ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

KMP + Compose Multiplatform 双端实战避坑指南

KMP + Compose Multiplatform 双端实战避坑指南 1. 为什么我把 KMP Compose 这套组合重新捡了起来做移动端这些年跨平台方案我基本都试过一遍。早几年团队里同时维护 Android 和 iOS 两套原生代码业务逻辑复制粘贴一个订单状态机在两个仓库里长出不同的分支修一个 bug 得开两次评审改完还得对齐发版节奏那种消耗是很实在的。后来试过 Web 容器、试过 Flutter也试过只共享网络层和模型层各有各的舒服和不舒服。直到KMPKotlin Multiplatform配合Compose Multiplatform这套组合成熟到能在生产里跑我才觉得找到了一个比较平衡的落点业务逻辑、数据层、状态管理写一遍UI 层可以选择共享也可以选择各写各的主动权在自己手里而不是被框架绑架。这篇东西偏向实战入门不讲虚的。我会从一个能真正在 Android 和 iOS 上跑起来的骨架开始把工程结构、Gradle 配置、平台差异下沉、Compose UI 的共享边界、以及我踩过的那几个能让人卡一整天的坑一条条拆开说。适合已经有 Kotlin 基础、写过 Jetpack Compose、想把手伸到 iOS 那边的 Android 同学也适合 iOS 出身、想看看共享层到底怎么落地的人。我更倾向于把它写成一个施工日志而不是一份 API 手册——手册你查官方文档就行我讲的是文档里不会写、但你一定会遇到的那些事。先给个心理预期这套组合的上手成本不算低第一天你大概率会卡在 Xcode 和 Gradle 的联调上会怀疑人生。但只要骨架搭通一次后面加页面、加接口、加平台能力的效率会有质变。我现在的体感是一个中等复杂度的 App共享层能覆盖七成以上的代码量UI 共享能做到九成以上剩下的就是平台特有的那点东西。注意KMP 和我们常说的字符串匹配算法 KMP 完全是两码事搜索资料的时候别被带偏认准 Kotlin Multiplatform 这个关键词。1.1 三套代码同时维护问题到底出在哪很多人把跨平台理解成少写代码这个理解偏了。真正的痛点不是代码行数而是一致性成本。举个我遇到过的真实场景一个优惠券的计算规则满减叠加门槛、四舍五入的位置、时间边界的判断Android 和 iOS 各实现一遍。上线三个月后iOS 那边因为浮点精度处理不同出现了 0.01 元的差异用户截图发到社区我们花了整整两天去定位。这类问题不是靠 Code Review 能拦住的因为两边的实现看起来都对只是细节漂移了。共享层解决的就是这件事。把规则算法收敛到 commonMain 里一份 Kotlin 代码两个平台编译出来的是同一套逻辑浮点处理、边界判断、精度策略全部统一。UI 层可以继续用各自的原生实现也可以交给 Compose Multiplatform 统一渲染。这里有个判断标准可以给你参考如果一个模块的行为需要两端结果完全一致它就应该下沉到共享层如果一个模块更多是呈现风格的问题放在平台侧反而更灵活。我一般会把网络、存储、模型、业务规则、状态机全部下沉把系统级的权限弹窗、通知栏、桌面小组件这类强平台绑定的东西留在各自那边。1.2 Compose Multiplatform 帮你省掉的到底是哪一层Compose Multiplatform 的定位要说清楚它不是把 Android 的 Compose 搬过去而是基于同一个 Compose 编译器和运行时在多个平台上提供渲染后端。Android 上走的是原有的 Skia 加原生控件体系iOS 上走的是 Skia 渲染到 Metal 的路径桌面端走 Skia 到 OpenGL 或 MetalWeb 端走 CanvasKit 或者 DOM。这意味着什么你在 commonMain 里写的Column、Row、LazyColumn、TextField在 iOS 上是真实渲染出来的不是 WebView 套壳。手势、滚动惯性、动画曲线这些Compose 有自己的实现观感上和原生有细微差别但已经足够日常使用。我实测下来列表页、表单页、详情页这类常规界面的还原度很高用户基本感知不到差异。真正需要注意的是一些平台特有的交互惯性比如 iOS 的边缘返回手势、文本选择菜单的样式、键盘上方的工具栏这些需要单独处理或者接受一定的差异。如果你的产品对原生质感有极致要求我的建议是核心流程用 Compose个别强平台属性的页面用原生写再嵌进去。1.3 这套方案适合谁又该劝退谁说点实话不是所有项目都该上这套。适合的已经有 Kotlin 技术栈积累的团队两端功能高度重合的产品需要快速验证又不想欠下两套代码债务的创业项目工具类、内容类、后台管理类的 App。这些场景下 KMP 的收益非常明确。要慎重的团队里完全没有 Kotlin 经验且项目周期极短产品重度依赖平台特有的硬件能力比如 AR、复杂的蓝牙协议栈、深度定制的相机管线团队维护能力有限出了问题没人能查 Kotlin/Native 的编译错误。我见过有团队在只有两周排期的活动页项目上引入 KMP结果光环境搭建就耗掉一周得不偿失。跨平台方案的收益曲线是前期投入高、后期摊薄成本项目周期太短的话你根本走不到回本那个点。2. 动手之前先把工程结构和版本矩阵定死工程结构这件事我强烈建议动手写代码之前花半天想清楚。因为源集Source Set的划分一旦确定后面所有代码文件的位置、依赖的声明方式、乃至 IDE 的索引速度都会被影响。改结构比改代码痛苦得多尤其是当你已经写了五十个文件之后。KMP 的核心概念就是源集。默认层级模板Default Hierarchy Template在较新的 Kotlin 版本里已经会自动帮你生成中间源集比如iosMain会自动聚合iosX64、iosArm64、iosSimulatorArm64三个目标。你别手动去声明这些容易和自动模板打架。2.1 commonMain 里该放什么不该放什么这条线划得清不清楚直接决定你后面会不会把共享层写成一个四不像。该放的数据模型、序列化逻辑、网络请求定义、Repository 层、UseCase 层、状态管理、业务规则算法、时间日期处理、字符串处理、校验逻辑、常量配置。不该放的任何直接引用android.*或platform.UIKit.*的代码、平台专属的权限请求、通知调度、文件系统路径拼接、系统级 UI 组件。有个我自己的判断小技巧你写这段代码的时候如果脑子里在想着Android 上怎么怎么样那它就大概率不该放在 commonMain。反过来如果你在描述的是业务上应该怎么怎么样那它就该在共享层。被这条线卡住的时候正确的做法不是硬塞而是定义一个expect声明或者一个接口把平台实现推到对应的源集里去。这个后面第 4 节会详细讲。2.2 三种目录组织方案我最后选了哪种实际项目里我见过三种常见结构各有取舍。方案结构描述优点缺点适用场景单模块扁平一个shared模块装下所有共享代码配置简单上手快代码一多就乱编译全量触发Demo、验证、小项目单模块分包一个模块内按data/domain/ui分层结构清晰还是单模块好维护层与层之间无编译隔离中等规模、团队 2 到 5 人多模块拆分core-model、core-network、feature-*独立模块编译隔离好增量构建快配置复杂度上升明显大型项目、多团队协作我现在的中型项目基本用第二种按包分层模块保持一个。原因是 KMP 的多模块配置坑比纯 Android 多不少expect/actual跨模块传播的时候经常出现一些莫名其妙的声明不匹配团队里没专人维护的话容易把时间耗在构建配置上。等到项目规模真的需要拆了再拆也不迟。具体的包结构我是这么分的model放纯数据类和序列化声明network放 Ktor 客户端配置和接口定义repository放数据聚合和缓存策略domain放业务规则这个包里的代码应该接近纯函数ui放 Compose 界面和状态管理platform放expect声明对应的actual写在各平台源集2.3 版本矩阵别在这里给自己埋雷这套技术栈最让人头疼的就是版本耦合。Kotlin 编译器版本、Compose Multiplatform 版本、Compose 编译器插件版本、AGP 版本、Gradle 版本、JDK 版本、Xcode 版本任意两个对不上都可能给你一屏幕的报错。我用下来比较稳的一组搭配是这样的组件版本范围说明JDK17别用 21某些 AGP 版本仍有兼容问题Gradle8.7 及以上支持配置缓存构建速度提升明显Kotlin2.0.20 及以上2.0 之后 K2 编译器稳定编译速度有改善Compose Multiplatform1.7.0 及以上iOS 侧进入稳定阶段AGP8.5 及以上低于这个版本和 Kotlin 2.0 配合容易出问题Xcode15.3 及以上低于这个版本对新架构模拟器支持不好用版本目录libs.versions.toml管理这些依赖别在build.gradle.kts里写死字符串。跨平台项目里同一个库的 Android 和 iOS 构件经常需要版本一致集中管理能省掉大量排查时间。提示第一次搭环境的时候把所有版本先降到这套矩阵的下限跑通之后再逐个升级。别一上来就用最新的出问题你分不清是配置错了还是版本不兼容。3. 从零搭一个能在双端跑起来的骨架这一段是全文最实操的部分我会把关键配置逐行拆开讲。如果你之前完全没接触过 KMP跟着走一遍应该能拿到一个能跑的结果。3.1 别迷信向导手写脚本反而更快现在 IDE 里有 KMP 项目向导也能用在线模板生成。但我的建议是第一次手动搭一遍。原因很直接向导生成的配置你如果不理解每一行在干什么后面出一丁点问题就完全没法排查只能推倒重来。手动搭的话需要的文件不多project/ ├── settings.gradle.kts ├── build.gradle.kts ├── gradle.properties ├── gradle/libs.versions.toml ├── shared/ │ ├── build.gradle.kts │ └── src/ │ ├── commonMain/kotlin/ │ ├── androidMain/kotlin/ │ └── iosMain/kotlin/ ├── androidApp/ └── iosApp/shared模块承载所有共享代码androidApp是一个普通的 Android 应用模块iosApp是 Xcode 工程。这套结构和你可能见过的其他模板不太一样的地方在于我把 Android 应用壳单独拆出来了而不是让shared直接产出 APK。这样职责更清晰shared就是一个纯粹的库。3.2 Gradle 配置逐行拆解先看shared/build.gradle.kts这是整个工程最核心的文件plugins { alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.androidLibrary) alias(libs.plugins.composeMultiplatform) alias(libs.plugins.composeCompiler) alias(libs.plugins.kotlinSerialization) } kotlin { androidTarget { compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) } } listOf( iosX64(), iosArm64(), iosSimulatorArm64() ).forEach { iosTarget - iosTarget.binaries.framework { baseName Shared isStatic true } } sourceSets { commonMain.dependencies { implementation(compose.runtime) implementation(compose.foundation) implementation(compose.material3) implementation(compose.ui) implementation(compose.components.resources) implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.serialization.json) implementation(libs.ktor.client.core) implementation(libs.koin.core) } androidMain.dependencies { implementation(libs.ktor.client.okhttp) } iosMain.dependencies { implementation(libs.ktor.client.darwin) } } } android { namespace com.example.shared compileSdk 35 defaultConfig { minSdk 24 } compileOptions { sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 } }几处值得单独说的三个 iOS target 一个都不能少。iosArm64对应真机iosSimulatorArm64对应 M 系列芯片 Mac 上的模拟器iosX64对应 Intel Mac 上的模拟器。现在 Intel Mac 少了但保留着不占什么成本反而能避免团队里有人用老机器打不开项目。isStatic true是有意为之。静态框架在启动时不需要动态加载冷启动能省一点时间而且链接期的符号解析更严格能提前暴露一些问题。代价是包体积会略大但对绝大多数应用来说这点差异可以忽略。如果你在做的 SDK 需要被其他框架动态引用那再考虑改成动态。androidTarget和android块要同时存在。前者是 KMP 的 target 声明后者是 AGP 的配置新手很容易只写一个然后报错找不到命名空间。再补充一点关于 Ktor 引擎的选择逻辑。commonMain里只依赖ktor-client-core这是接口层具体的引擎实现必须在平台源集里提供Android 用 OkHttpiOS 用 Darwin。为什么这么设计因为底层网络实现完全依赖平台的网络栈Android 侧的 OkHttp 在 iOS 上根本编译不过这是硬性的物理隔离。你如果偷懒想在 commonMain 里直接指定引擎会直接编译失败。3.3 iOS 侧的接线三种姿势选一种这一步是新手最容易翻车的地方。让 Xcode 工程能够链接到 Kotlin 编译出来的框架常见的有三种做法。做法一CocoaPods 集成。在 Kotlin 侧启用cocoapods插件生成 Podspec然后 iOS 工程用 Podfile 引入。这条路的好处是和现有 Pods 生态融合好坏处是构建链路长出问题排查困难而且 Cordova 那套依赖管理的坑会叠加进来。我现在基本不用这个方案了。做法二手动指定框架路径。在 Kotlin 侧配置好框架输出目录在 Xcode 的 Build Settings 里把FRAMEWORK_SEARCH_PATHS和OTHER_LDFLAGS指过去。简单粗暴但每次切换 Debug/Release 或者模拟器/真机都可能需要调路径很烦。做法三Gradle 任务自动嵌入签名。这是我现在用的方案也是最省心的一个。在 Xcode 工程的 Build Phases 里加一个 Run Script放在 Compile Sources 之前cd $SRCROOT/.. ./gradlew :shared:embedAndSignAppleFrameworkForXcode这个任务会读取 Xcode 传入的环境变量自动判断当前是哪个架构、哪种构建配置把对应的框架编译好、拷贝到正确位置、并且完成签名。整个过程对开发者透明切真机切模拟器都不用改配置。但这里有个必须知道的坑我在第 6 节会详细讲如果你的 Xcode 是 15 及以上版本必须把 Build Settings 里的ENABLE_USER_SCRIPT_SANDBOXING设为 NO否则脚本会因为沙箱限制无法写文件报一个和权限相关的错误而这个错误信息完全不会提示你问题出在沙箱上。iOS 侧的入口代码如果是 Compose 全量共享 UIKotlin 端长这样import androidx.compose.ui.window.ComposeUIViewController import platform.UIKit.UIViewController fun MainViewController(): UIViewController ComposeUIViewController { App() }Swift 侧用一个UIViewControllerRepresentable包一层就可以塞进 SwiftUI 的视图树里import SwiftUI import Shared struct ComposeView: UIViewControllerRepresentable { func makeUIViewController(context: Context) - UIViewController { MainViewControllerKt.MainViewController() } func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} } struct ContentView: View { var body: some View { ComposeView() .ignoresSafeArea(.keyboard) } }.ignoresSafeArea(.keyboard)这一句别漏。不写的话Compose 内部的键盘避让和 SwiftUI 的键盘避让会打架输入框被顶飞两次那个效果相当灾难。4. expect/actual 与平台能力下沉的正确姿势共享层写得顺不顺很大程度上看你会不会把平台能力干净地漏出来。KMP 提供了expect/actual这个机制但它不是万能的用错了反而会让代码更乱。4.1 网络、存储、日志三类能力的具体实现网络引擎是我最常用的一个例子。定义在 commonMainexpect fun createHttpEngine(): HttpClientEngineAndroid 侧actual fun createHttpEngine(): HttpClientEngine OkHttp.create()iOS 侧actual fun createHttpEngine(): HttpClientEngine Darwin.create()然后共享层的 HttpClient 就用createHttpEngine()来构造两端自动拿到各自的原生引擎。这个模式的好处是调用方完全不需要知道底下用的是哪个引擎接口干净。键值存储也是类似思路。Android 用SharedPreferencesiOS 用NSUserDefaults声明一个KeyValueStore接口两端各实现一份。这里要注意 iOS 侧NSUserDefaults的读写是同步的但性能并不理想高频写入的场景建议在共享层加一层内存缓存批量落盘。日志稍微特殊一点。Android 有 LogcatiOS 有println或者os_log。我一般会用expect定义一个platformLog函数Android 侧转发到Log.diOS 侧用NSLog。这样在两端调试的时候都能看到一致的日志输出。4.2 什么时候该用接口什么时候该用 expect/actual这是很多人纠结的点我给出一个比较明确的分界线。用expect/actual的场景一个功能在所有平台上都必须存在而且只有一个实现。比如当前平台名称、平台版本号、网络引擎工厂。这种情况下expect/actual是最直接的表达。用普通接口加注入的场景一个功能在不同平台上可能有多套实现或者你需要在测试里替换成假实现。比如数据存储Production 用真实数据库测试用内存实现。这种时候定义一个interface在平台入口处注入具体实现扩展性更好。还有一点值得注意的跨模块声明expect/actual会比较麻烦。如果你把expect声明放在core-model模块actual实现放在app模块构建配置上要做额外处理。我现在的偏好是expect/actual全部放在共享模块内部不给别的模块添麻烦。4.3 把 Android 的 Context 藏起来这是必修课新手最常犯的错误就是在 commonMain 里直接引用Context然后编译不过。正确的做法是在 Android 的 Application 或 Activity 启动时把Context交给共享层的一个持有者对象然后共享层的代码通过这个持有者间接访问平台能力。// commonMain object PlatformContextHolder { var appContextProvider: (() - Any)? null }// androidMain fun initPlatform(context: Context) { PlatformContextHolder.appContextProvider { context.applicationContext } }看着有点绕但这是必须付出的代价。共享层不能有任何平台类型的直接引用否则 iOS 侧就是编译不过。一个更优雅的替代方案是用依赖注入比如 Koin 的expect/actual模块声明在平台入口处启动不同的模块。这样共享层完全不知道Context的存在只知道自己需要的那几个接口被提供了。我现在大部分项目走的是这条路线。注意绝对不要在 commonMain 里 import 任何android.开头的包。IDE 的自动补全很容易帮你加进去写完记得扫一眼 import 列表。5. Compose Multiplatform UI 层的实战细节UI 层是最能体现这套方案价值的地方也是最容易踩坑的地方。共享 UI 写起来爽但要处理不少平台差异。5.1 一次编写两端渲染边界在哪里先说能放心共享的部分布局结构、状态驱动的界面、列表、表单、卡片、弹窗Compose 自己实现的 Dialog、动画、主题配色、字体排版。需要谨慎处理的部分我列个表能力共享情况我的处理方式页面路由可以共享用 Compose Navigation 的 KMP 版本系统返回手势部分支持iOS 侧需要在 SwiftUI 层包裹处理安全区适配需要处理用 WindowInsets 系列 API键盘避让需要处理参照 3.3 节的 ignoresSafeArea 写法底部弹窗可以共享Compose 自己实现的 BottomSheet 观感尚可系统分享面板不共享定义 expect 声明两端各实现权限请求不共享放在平台侧通过回调通知共享层文本长按菜单部分支持观感有差异需要接受安全区适配这块我踩过坑。Android 的边到边显示和 iOS 的刘海、灵动岛、底部横条处理方式不一样。Compose Multiplatform 提供了WindowInsets.safeDrawing这类 API但两端的默认行为有差别。我的做法是在根节点统一下 padding把安全区一次性处理掉各个页面就不需要各自操心了。5.2 资源管理和字体别等上线才发现问题Compose Multiplatform 有一套资源管理机制放在commonMain/composeResources目录下会自动生成Res对象供代码引用。图片、字符串、字体都走这套。import org.jetbrains.compose.resources.painterResource Image( painter painterResource(Res.drawable.logo), contentDescription null )字体这里有个坑不同平台对字体文件的解析能力不完全一致。我遇到过某个可变字体在 Android 上渲染正常在 iOS 上字重错乱的情况。建议优先用静态字体文件每个字重单独一份别偷懒用可变字体。另外中文字体的体积很大如果产品对包体积敏感考虑按需下载或者用系统字体。图片资源还有个细节iOS 上没有 Android 那套密度分级的概念Compose 会按 1x 处理。如果你的图片资源只有一套在高分屏上可能会放大模糊。稳妥的做法是提供足够分辨率的资源让 Compose 在各平台按需缩放。5.3 状态管理和导航选型别太激进状态管理这块共享层用什么方案都行因为它是纯 Kotlin 代码。我目前用的是官方推荐的ViewModel类加StateFlow这套组合Compose Multiplatform 提供了跨平台版本。class HomeViewModel( private val repository: HomeRepository ) : ViewModel() { private val _state MutableStateFlow(HomeState()) val state: StateFlowHomeState _state.asStateFlow() init { viewModelScope.launch { val data repository.loadFeed() _state.update { it.copy(items data, loading false) } } } }导航这块Compose Multiplatform 有对应的 Navigation 库但我要提醒一句不要把 Android 侧的导航写法照搬过来。iOS 上的导航栈语义和 Android 不同尤其是返回手势和导航栏的联动完全靠 Compose 处理的话体验会有落差。我的做法是让 Compose 内部维护自己的导航状态然后在 SwiftUI 外层处理手势拦截两边约定好通信协议。另外viewModelScope在 iOS 上的生命周期绑定需要确认清楚。有些版本里 ViewModel 的清除时机和 Compose 的 DisposableEffect 不同步会导致协程泄漏。排查方法是在 iOS 上打开内存检测工具反复进出页面看对象数量。6. 常见问题与排查实录这一节是我最想写的内容因为这些问题我在文档里基本看不到全是自己一晚上一晚上熬出来的。6.1 编译期报错速查报错信息关键词大概率原因解决方式Shared not foundXcode 找不到框架检查脚本是否执行、沙箱是否关闭、搜索路径是否正确Unresolved reference: platform在 commonMain 引用了平台 API把代码移到对应的平台源集This API is internalCompose 版本和编译器插件版本不匹配对齐两者版本号Duplicate class kotlin.collections依赖重复引入检查是否有 kotlin-stdlib 的手动声明Konan needs XcodeXcode 命令行工具未配置执行xcode-select --install并确认路径Out of memoryKotlin/Native 编译内存不足在gradle.properties里调大kotlin.native.jvmArgsSandbox: deny file-writeXcode 脚本沙箱限制关闭ENABLE_USER_SCRIPT_SANDBOXING内存不足这个我要单独说。Kotlin/Native 编译单个 iOS 目标是会吃掉大量内存的默认配置在 M1 的 8G 机器上几乎必然失败。在gradle.properties里加这一行kotlin.native.jvmArgs-Xmx4g如果是 16G 以上的机器可以给到 6G。这个参数不设你会看到编译进程被系统杀掉然后 Gradle 报一个很含糊的失败信息完全看不出是内存问题。6.2 iOS 运行期的那些崩溃编译通过只是第一关跑起来之后的问题更隐蔽。第一类基础类型转换。Kotlin 的Long映射到 iOS 是Int64Int是Int32边界值处理不当很容易溢出。涉及 ID、时间戳这类字段我建议统一用Long并在共享层做好边界检查。第二类主线程约束。iOS 上所有 UI 操作必须在主线程执行。共享层的协程默认调度可能在后台线程回调到 UI 之前必须切回主线程。Compose 内部会帮你处理大部分情况但你自己写的expect实现如果抛回调出去一定要记得切。第三类内存管理。Kotlin/Native 从 1.7.20 开始默认使用新内存管理器跨线程共享对象不再是问题但循环引用的检测手段比 JVM 弱。共享层里如果有大量相互引用的对象图泄漏了不一定能在第一时间发现。我的做法是核心页面的状态对象尽量做成不可变的减少引用链的复杂度。6.3 构建速度优化这个真的值得花时间KMP 项目的构建速度是可以做出明显改善的我总结了几条实际有效的开启配置缓存。在gradle.properties里org.gradle.configuration-cachetrue org.gradle.cachingtrue org.gradle.paralleltrue org.gradle.jvmargs-Xmx6g -XX:MaxMetaspaceSize1g配置缓存对 KMP 的加速效果比纯 Android 项目更明显因为 iOS 目标的配置计算很耗时。但要注意有些自定义 Gradle 任务不兼容配置缓存开启后如果报错先定位是哪个任务的问题。Kotlin/Native 编译缓存。默认情况下 Kotlin/Native 的编译产物会复用但缓存位置如果在临时目录里可能会被系统清理。可以通过konan.data.dir指定一个持久化目录。减少不必要的 target。如果你的机器是 M 系列芯片iosX64这个目标其实可以暂时移除只在 CI 上保留。本地构建能省下不少时间。当然前提是团队里没有 Intel Mac 的开发者。增量构建的合理预期。改一个 commonMain 里的文件两端都要重新编译这个时间躲不掉。我实测下来改一行共享代码到 Android 侧看到效果大概 20 到 40 秒到 iOS 侧跑起来要 1 到 3 分钟机器性能不同差异很大。所以我的习惯是把纯 UI 的调试放在 Android 侧做逻辑稳定了再去 iOS 上验证能省下大量等待。7. 几个我踩过之后才明白的经验写到这里核心的东西基本说完了。再补几条零散的体会都是踩坑换来的。第一条别急着共享 UI。我一开始就想把所有界面都共享掉结果遇到一堆平台差异改起来比写两套还累。后来调整了策略先把数据层和业务逻辑共享UI 层从最简单的页面开始共享一个一个来遇到强平台属性的就退回原生实现。这个渐进的过程让团队的接受度高很多。第二条iOS 开发者模式这个事记得提醒团队里的 iOS 同学。iOS 16 之后真机调试需要在设备的设置里手动打开开发者模式这个开关藏得比较深在隐私与安全性下面。之前有同事拿到新手机折腾了一下午以为是签名问题。第三条CI 上的 CocoaPods 和 Ruby 环境能省则省。如果用了 Gradle 的自动嵌入签名方案构建机上就不需要装 CocoaPods 和 Ruby能减少不少环境依赖问题。我们的构建脚本改造完之后CI 的失败率下降了一大截。第四条共享层的测试要跟上。commonMain 里的代码可以用kotlin.test写单元测试在 JVM 上跑速度快。这部分测试覆盖住了两端的行为一致性基本就有保障了。我现在的要求是 domain 层的代码测试覆盖率不低于七成这个投入非常值。第五条别在版本升级上贪快。这套技术栈的版本迭代很快新版本经常会引入一些行为变化尤其是 Kotlin 编译器和 Compose 编译器插件的配合关系。我的做法是跟一个稳定版本攒够两三个小版本再统一升一次升之前先在分支上跑通完整回归。最后一个小的技巧gradlew加上--no-daemon调试构建问题时很有用能排除守护进程状态污染带来的干扰。而日常开发则一定要保留守护进程不然每次构建都要重新预热慢得让人崩溃。
返回列表