如果你最近在移动端技术社区里逛,肯定没少看到“KMP”这个词。先给刚接触的人泼盆冷水:这里说的KMP不是面试题里那个字符串匹配算法,而是 Kotlin Multiplatform。它解决的问题非常直接:你不想给 Android 和 iOS 各写一套网络请求、数据解析、本地存储、业务校验逻辑,但又不想像 Flutter 那样把整个 UI 都交出去。KMP 的思路是“共享逻辑,保留原生”,把和界面无关的代码塞进一个公共模块,App 两端各写各的界面,但底层数据流全都走同一份 Kotlin 代码。这篇文章我从项目搭建、expect/actual 机制、iOS 集成、数据层选型到常见坑位,完整梳理一遍。适合刚打算入坑 KMP、或者已经在项目里用到一半卡住的开发者参考。
1. 整体设计思路:KMP 到底在跨平台开发里扮演什么角色
1.1 跨平台方案演进与 KMP 的定位
先说清楚 KMP 在跨平台版图里的位置。我们手头常见的跨平台路子基本有三类:第一类是 Web 套壳,比如 Cordova、Capacitor,写一套 HTML/CSS/JS 塞进 WebView,成本和体验都有天花板;第二类是自绘 UI 引擎,Flutter 是典型,用 Dart 写完整业务和界面,靠引擎自绘跨端渲染,一致性很好,但你很难脱离它的 UI 体系;第三类是共享逻辑型,React Native 算是这一类的产物,JS 作逻辑层,UI 桥接到原生,但 RN 的桥接性能和类型安全一直是小心结。
KMP 走的是第三条路里更原教旨的一条:共享的单元不只是逻辑代码,还包括数据模型、网络层、持久化策略,并且全部用 Kotlin 这种编译型语言直接编译到 iOS 的二进制上,而不是跑在解释器或虚拟机上。Android 端直接用,iOS 端编译成 Framework 给 Xcode 引用,两端没有中间层解释器,几乎零运行时开销。这意味着你在 Android 上测试过的 Repository 逻辑,在 iOS 上表现完全一致,因为它们是同一套代码编译出来的。
从团队角度来算账,KMP 的收益也很清晰:服务端接口定义、Json 模型、本地缓存策略、权限计算规则这类偏底层偏重头的代码,能写一份就写一份。业务界面仍需两端各自实现,但界面逻辑里常见的 VM 状态机、事件流转换、表单校验等也能沉淀到共享模块,真正留给 UI 层的工作量就大幅缩水。很多人问 KMP 是不是要取代 Flutter,我觉得这个问法本身就有问题——Flutter 抢的是整个 App 的渲染层,KMP 乐得把 UI 留给你做原生,它只管底层一切可以统一的东西。
1.2 为什么共享逻辑层比共享 UI 更稳妥
我见过太多团队一上来就想着让 UI 也一套代码跑两端,结果项目越往后维护成本越高。KMP 核心思路的反直觉之处在于:它刻意不碰 UI,反而让边界更清晰。UI 有一个天然属性——它是最容易跟随平台走的部分。iOS 的 Navigation 手势、Android 的返回逻辑、两端各自的 SafeArea 行为、字体渲染差异,这些你想一套代码统一到丝般顺滑,投入产出比会差到怀疑人生。
但业务逻辑不一样。你 App 里的搜索排序规则、积分体系计算、接口加密签名、埋点事件上报格式,这些既不关心你在 iOS 上是用 SwiftUI 还是 UIKit,也不关心 Android 端是 Compose 还是 View 体系。它们只在乎输入参数和输出结果。把这类代码下沉到 shared 模块,用 Kotlin 写一遍,两端各自调用,界面想怎么做就怎么做,这类代码天然适合共享。
这里有个点值得展开:KMP 的共享不是“源码级别复制一份到两端”,而是编译期就分出产物。Android 端打成 AAR,iOS 端通过 Kotlin/Native 编译成 apple framework。所以 App 运行时没有任何“跨端框架运行时”,你在 Android 能看到真实的 Kotlin 类,在 iOS 栈回溯里也能看到真实的 Kotlin 函数名。这对线上问题排查非常重要,即使是你写成 Kotlin 的模块崩溃了,堆栈依然能精确到文件和行号,而不是只在某层桥上留下模糊提示。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析:expect/actual 机制与边界处理
2.1 expect/actual 到底怎么用
expect/actual 是 KMP 最常被拿出来说的机制,也是新手最容易踩坑的地方。它的工作方式非常直白:你声明一个“期望”的 API,比如获取当前设备时区,然后为各个平台分别提供“真实”实现。编译器会保证:只要某个 expect 的声明没有匹配到对应平台的 actual,编译直接报错。
kotlin复制// commonMain
expect fun getPlatformName(): String
expect fun currentTimeMillis(): Long
expect class PlatformDevice() {
val model: String
}
kotlin复制// androidMain
actual fun getPlatformName(): String = "Android"
actual fun currentTimeMillis(): Long = System.currentTimeMillis()
actual class PlatformDevice {
actual val model: String = android.os.Build.MODEL
}
kotlin复制// iosMain
actual fun getPlatformName(): String = "iOS"
actual fun currentTimeMillis(): Long = NSDate().timeIntervalSince1970
actual class PlatformDevice {
actual val model: String = UIDevice.currentDevice.model
}
这套机制看起来简单,真正项目里要注意几个关键点:
expect 声明的可见性、类型、参数名必须和 actual 完全一致。比如你在 commonMain 写 expect fun fetchUser(id: String),那么 Android 和 iOS 里的 actual 的函数名、参数类型、数量、返回类型都不能变,参数名其实也要对齐,否则某些场景会编译不过或者调用起来非常别扭。这是编译器强约束的,不像接口实现那样还能放宽。
actual 类型不一定非得是一对一的关系。expect class 在 Android 端可以映射成普通的 class,在 iOS 端可以直接映射成 Objective-C 的类。这里我们经常会用 typealias 绕一层:
kotlin复制// iosMain
actual typealias PlatformDevice = UIDevice
因为 iOS 端可能本来就有现成的类能满足你的需求,直接用 typealias 一把梭省得包一层。这个技巧在对接系统 API 时特别爽,不过要注意 typealias 指向的类必须是公共可用的,构造方式的差异也得处理。
构造器也是可以声明成 expect 的。KMP 支持 expect class 的构造函数被 actual 实现,但这里有个非常隐蔽的坑:你在 commonMain 里调用构造器时,expect 类必须声明成 class 而不是 interface。Kotlin 编译器对 expect class 的构造器要求极严格,如果 actual 类里有额外的初始化逻辑(比如 Android 端构造时需要 Context),推荐用一个独立的 create() 工厂函数来处理,不要让构造器签名复杂化,否则 shared 模块的 API 会越来越难用。
之前还有个小坑:expect 声明不能定义在 internal 级别的可见性下。因为 Kotlin/Native 在编译 iOS framework 时对 internal 的符号处理有限制,简而言之你的 expect 函数必须是 public 或 protected,否则 iOS framework 导出符号表里找不到对应项,就会出现"declaration is not visible"这类莫名其妙的编译错误。官方文档写的是“expect/actual declarations cannot be visible only internally”,但很多人第一次看到这个报错还是懵的,这里提前排雷。
2.2 UI 层选择:Compose Multiplatform 还是原生 UI
现在入坑 KMP 的人一多半是冲着 Compose Multiplatform(CMP)来的,因为界面也能写一份跑两端,看起来轻松很多。我的建议是:如果你的 App 对交互复杂度不高,且团队 Kotlin 能力足够强,CMP 可以试;但如果已经有成熟的两端 UI 层,或者目标 App 涉及大量地图、视频、自定义手势之类强交互模块,先老老实实共享逻辑层,UI 用原生。
CMP 的核心价值是把 Compose 的声明式 UI 模型带到了 iOS,和 Flutter 的思路有点接近。它跑在 Skia 渲染之上,iOS 端的列表滚动、点击事件、动画表现基本都能自绘出来。但问题是,Kotlin 团队把 Compose 搬到 iOS 属于相对年轻的方向,第三方生态的成熟度远比原生差。你需要原生地图,CMP 很难直接接入苹果地图 SDK;你需要支付,iOS 上的 StoreKit 走起来也要桥接一堆胶水代码。
如果决定只共享逻辑层,那么 UI 和 ViewModel 的边界怎么划就成了关键决策。我的做法是:把 ViewModel 也放进共享模块,UI 状态用 StateFlow 暴露,两端的 UI 层只做“状态渲染 + 事件派发”。ViewModel 里不出现任何 Android 或 iOS 专属类型,这能让状态管理和业务逻辑一次写清:
kotlin复制class ProductListViewModel(
private val repository: ProductRepository
) {
private val _uiState = MutableStateFlow(ProductListUiState())
val uiState: StateFlow<ProductListUiState> = _uiState.asStateFlow()
fun onSearchKeywordChanged(keyword: String) {
// 业务逻辑全在这里
}
}
Android 端用 Jetpack Compose 的 collectAsState 收集,iOS 端用 SwiftUI 的 onReceive 订阅。ViewModel 只依赖抽象的 repository 接口,和 androidx.lifecycle.ViewModel 完全脱钩,这样 iOS 端就不用引入任何 androidx 依赖。这一点很关键——很多人会把 ViewModel 类直接继承 androidx.lifecycle.ViewModel,结果 iOS 端编译不过才发现 shared 模块不能依赖 Android 框架库。
3. 从零搭建一个 KMP 项目的完整实操
3.1 Gradle 工程结构与依赖配置
现在 KMP 官方推荐用 Kotlin Multiplatform Wizard 生成工程骨架,也可以手写。手写一遍能帮你理解整个构建结构。一个标准的 KMP 项目分为 shared 模块和 androidApp、iosApp 两个壳工程:
text复制MyKmpProject/
├── shared/
│ ├── build.gradle.kts
│ └── src/
│ ├── commonMain/kotlin/
│ ├── androidMain/kotlin/
│ └── iosMain/kotlin/
├── androidApp/
│ ├── build.gradle.kts
│ └── src/main/kotlin/
└── iosApp/
├── iosApp.xcodeproj
└── iosApp/
shared 模块的 build.gradle.kts 核心配置长这样(以 Kotlin 2.x 为例):
kotlin复制plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidLibrary)
alias(libs.plugins.kotlinSerialization)
}
kotlin {
androidTarget()
listOf(
iosX64(),
iosArm64(),
iosSimulatorArm64()
).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "Shared"
isStatic = true
}
}
sourceSets {
commonMain.dependencies {
implementation(libs.kotlinx.coroutines.core)
implementation(libs.ktor.client.core)
implementation(libs.kotlinx.serialization.json)
implementation(libs.koin.core)
}
androidMain.dependencies {
implementation(libs.ktor.client.okhttp)
}
iosMain.dependencies {
implementation(libs.ktor.client.darwin)
}
}
}
这里的几个配置从原理上讲很值得琢磨。
iOS 三种 target 的含义:iosX64 对应模拟器(Intel Mac 时代),iosArm64 对应真机,iosSimulatorArm64 对应 Apple Silicon 上的模拟器。现在新 Mac 基本都是 arm64,模拟器编译必须走 iosSimulatorArm64。baseName = "Shared" 决定了最终生成的 framework 名以及你在 Swift 里要 import Shared。
isStatic = true 静态库很重要。Kotlin/Native 生成动态库时需要处理各种链接符号问题,静态库体积大一点但链接省心。如果你刚开始接触,强烈建议直接 isStatic = true,能避免掉大量“framework not found”的坑;等工程大了再评估要不要换动态库。
androidTarget() 必须和某个 Android Gradle Plugin 版本匹配。shared 模块本质是一个 Android library,所以还需要在 android {} 块里配置 compileSdk、minSdk。注意现在新版 Kotlin Multiplatform 插件把 androidTarget 和 com.android.library 插件一起用,namespace 必须声明,否则 AGP 会直接报错。
3.2 核心代码实现:共享模块的网络层与仓储层
一个没有网络层的 KMP 项目是没有灵魂的。网络层我一般用 Ktor Client,配 ContentNegotiation 插件和 kotlinx.serialization。这套组合是 KMP 生态里最成熟的数据链路。
kotlin复制// commonMain
val httpClient = HttpClient {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
isLenient = true
})
}
}
@Serializable
data class UserInfo(
val id: String,
val name: String,
val avatarUrl: String? = null
)
class UserRepository(
private val client: HttpClient = httpClient
) {
suspend fun fetchUser(id: String): UserInfo {
return client.get("https://api.example.com/users/$id").body()
}
}
这代码看起来和纯 Android 项目没什么区别,但你要清楚它跑在 iOS 上时,底层 Http 引擎是 Darwin 的 NSURLSession,而 Android 上是 OkHttp。正因为 Ktor 给各端提供了 Engine 适配,HttpClient 这个顶层类才能写出完全一致的调用代码。这也是 KMP 最打动我的一点:网络库层面的能力差异被完美抹平,你在 iOS 端不需要为了一个 GET 请求单独引 AlamoFire。
仓储层喜欢再加一层缓存:
kotlin复制expect class KeyValueStorage {
fun put(key: String, value: String)
fun get(key: String): String?
}
Android 端用 SharedPreferences 实现,iOS 端用 NSUserDefaults。这样一个简单的 expect/actual 就能把轻量级 KV 缓存写进共享模块。如果是更复杂的关系型数据,后面第 5 章会说 SQLDelight 的方案。
3.3 iOS 端集成:Framework 导出与 Xcode 脚本
iOS 端接垃圾 KMP 框架最容易卡住的环节是 Gradle 和 Xcode 工程之间的联动。KMP 工程模板里通常会给你准备一个 Embed Frameworks 的 Build Phase 脚本:
bash复制cd "$SRCROOT/.."
./gradlew :shared:embedAndSignAppleFrameworkForXcode
这条命令核心作用是为当前 Xcode 配置的 SDK 和架构构建对应的 framework,并自动嵌入到 App 包中。它读取 Xcode 传过来的环境变量,比如 SDK_NAME、ARCHS、CONFIGURATION,然后选择要编译的 Kotlin/Native target。
刚入坑时这个脚本会让你体验一把“第一遍编译十分钟”的酸爽,因为在没有增量缓存时 Kotlin/Native 要完整跑一遍 LLVM 编译流程,确实慢。如果你遇到 framework 一直找不到、module not found 这种问题,优先检查 Build Phase 里的脚本是否放在了 Compile Sources 之后、Copy Bundle Resources 之前。
另外还有个小细节:模拟器调试和真机运行分别会构建不同架构的 framework。每次切换目标运行设备,embedAndSignAppleFrameworkForXcode 都会自动识别环境变量并重新构建。这就导致你经常在 Xcode 里点一个 Run,等一两分钟看 Gradle 重新打包。后来我把 Gradle 的 daemon JVM 参数调大了些,配置了磁盘缓存,编译增量快了很多,具体下文第 4 章再展开。
4. 常见问题与排查技巧实录
4.1 编译失败类问题
KMP 的编译失败有一大堆是因为平台符号差异造成的,下面这几个是我项目里出现频率最高的。
报错:expect declaration has no actual declaration in module ... for ...
这个错误要不就是 expect 写好后忘记在对应平台目录创建 actual,要不就是源集目录放错位置。检查 src/androidMain/kotlin 和 src/iosMain/kotlin 的路径是否和 Gradle sourceSets 匹配。另一个隐蔽点是,如果你在共享模块里写了 iosMain 的 actual,但没有注册 iosX64/iosArm64 对应的编译 target,那么非 host 平台上的 actual 检查不一定触发。比如在 Mac 上开发时 iosX64 target 构建没问题,但 iosArm64 真机编译时才发现缺少 actual,这种情况经常让人措手不及。解决方法是定期用 ./gradlew :shared:compileKotlinIosArm64 做一次真机编译预检。
报错:Unresolved reference: NSDate / UIDevice 找不到
Kotlin/Native 里访问 iOS 系统 API,一般不直接用 Foundation 的类全名,而是要 import platform.Foundation.NSDate 或者 import platform.UIKit.UIDevice。很多人不写 import,编译器报找不到符号。Kotlin/Native 的 Objective-C 互操作对大小写和包路径非常敏感,platform.Foundation 和 platform.UIKit 是系统默认提供的,但 IDE 有时不会自动导入,手写也很快。
报错:CocoaPods was not able to continue 或 pod install 卡住
如果你用 CocoaPods 集成 KMP,需要维护 Podfile 和共享模块的 podspec。KMP 官方插件也提供了 cocoapods 配置块,需要设置 podfile = project.file("../iosApp/Podfile")。实际经验是 CocoaPods 集成时务必保证 pod install 和 Xcode 脚本中的 embedAndSignAppleFrameworkForXcode 配合好,不然 Kotlin 生成的 framework 没有打进 App 包。优先推荐直接手动 embed,少用 CocoaPods,因为 CocoaPods 对不同 target 的配置管理容易出幺蛾子。
4.2 运行时崩溃与崩溃排查
KMP 最常见的运行时崩溃是 Kotlin 协程在 iOS 主线程调度问题。如果你在 iOS 端调用共享模块里的 suspend 函数,协程默认会往 Dispatchers.Main 上调度,而 iOS 主线程调度器在 KMP 里实现是 Darwin 派发队列,某些版本需要初始化 Kotlin/Native 运行时才能工作。如果 App 启动时序不对,一进页面就调用共享模块的网络请求,可能会直接抛异常。
解决方法是 在 iOS 端 App 启动时预热 Kotlin 运行时,简单点就在 AppDelegate.swift 里调一次共享模块导出的初始化函数:
swift复制@main
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
SharedSDK.shared().doInit()
return true
}
}
另一个高发崩溃是把 Kotlin 的 List 转成 Swift 数组时的类型问题。Kotlin List<String> 在 Swift 里映射成 [String],大部分场景没问题;但一旦你共享模块返回了 List<Any?>? 这种泛型边界比较粗的类型,Swift 侧就得小心 as? 的强制转换。建议共享模块的公共 API 尽可能不要暴露 Any、Map<String, Any>,尽量定义明确的 data class 和枚举,否则所有调用方都要做一层脏活。
4.3 构建性能相关坑
iOS 编译慢是 KMP 绕不开的话题。Kotlin/Native 编译器要生成 LLVM 位码然后链接成 framework,所以第一遍构建耗时比 Android gradle 编译高出不少。实际项目中我的优化方案是:
- 开启 Kotlin/Native 增量编译缓存。在
~/.gradle/gradle.properties里增加kotlin.native.cacheKind=none默认其实不太好,建议用官方默认的通用缓存。同时注意磁盘空间,Kotlin/Native 缓存有时能到十几 GB 甚至更多。 - 调整 Gradle JVM 参数。把
org.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=2g写字进去,减少 GC 停顿。Kotlin/Native 链接阶段非常吃内存和 CPU。 - 减少 iOS target 数量。如果不需要支持 Intel 模拟器,可以把
iosX64注释掉。多一个 target 就多一份全量编译和链接,即使缓存命中也会增加扫描时间。 - 善用
--configuration-cache。现在 Gradle 配置缓存对 KMP 项目支持已经很好了,常用命令后面加--configuration-cache提升明显,但第一次配置缓存时会做全量检测,别被那次慢给吓到。
我这里有个排查大表,直接收藏起来遇到问题翻一翻:
| 现象 | 可能原因 | 解决方式 |
|---|---|---|
Xcode 编译提示 Shared.framework not found |
脚本执行失败或 target 架构不匹配 | 检查 Build Phase 顺序,运行 ./gradlew :shared:linkDebugFrameworkIosSimulatorArm64 验证 |
Gradle 构建成功但 Swift 里 import Shared 报错 |
framework 没有加入 Xcode 项目的 Framework Search Paths | 在 Build Settings 里添加 $(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME) |
| 模拟器运行正常,真机编译报 Undefined symbol | 多 target 配置缺失 iosArm64 | 真机构建前先跑 ./gradlew :shared:linkDebugFrameworkIosArm64 |
| 首次 Build 后 binary 很大 | Kotlin/Native 默认 strip 不足 | 在 framework 配置块里 freeCompilerArgs += "-Xbinary=bundleId=com.example.shared" 并设置 binaries.framework { binaryOption("bundleId", "...") } |
| 协程切换线程异常 | iOS 主线程调度器初始化晚 | 在 App 启动入口调用一次共享模块初始化方法 |
| Android 端 lint 报 “obsolete dependency” | Kotlin 标准库重复依赖 | 使用 implementation(project(":shared")) 并确保 AGP 和 Kotlin 版本兼容 |
5. 数据层与依赖注入实践
5.1 用 Ktor 统一跨平台网络栈
Ktor Client 应该是 KMP 生态里最成熟的第三方库了。除了基础的 GET/POST,它还带了 WebSocket、SSE、表单上传等功能。关键优势是每个平台有专门的 Engine:Android 用 OkHttp,iOS 用 Darwin,JVM 用 CIO 或 OkHttp,Web 用 JS。这意味着你可以在 commonMain 写一套网络拦截器、认证逻辑、错误重试机制,两个 App 都用同一份策略。
要注意的是 Ktor 版本升级偶尔会有 API 不兼容,比如旧版 HttpClient() 的无参构造配置在 2.x 有了调整。我建议直接锁定官方最新稳定版,不要用 beta。另外网络层如果做统一的 Token 刷新逻辑,可利用 Ktor 的 HttpSend 插件在 Pipeline 里插入拦截:
kotlin复制class AuthPlugin(
private val tokenProvider: TokenProvider
) {
fun create(): PluginBuilder = PluginBuilder("Auth")
}
// 在 HttpClient 初始化时安装
val client = HttpClient {
install(ContentNegotiation) { json(...) }
// 自定义插件
}
不过说实话,刚开始跑通一个 GET 请求就足够了,过度设计网络层会让新手被 Ktor pipeline 概念劝退,先简单用,等业务量大了再引入重试、Reauth 等高级玩法。
5.2 SQLDelight 与本地存储方案
跨平台本地数据库,优先看 SQLDelight。它把 SQL 表结构定义写在 .sq 文件里,自动生成 Kotlin 类型安全的查询 API,Android 端用 SQLite Driver,iOS 端用 Native SQLite Driver。这个方案的好处是:SQL 语法完全一致,生成的代码在两端表现一致,还能拿到编译期校验过的 SQL 正确性。
sql复制-- src/commonMain/sqldelight/com/example/db/User.sq
CREATE TABLE user (
id TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL,
avatar TEXT
);
selectById:
SELECT * FROM user WHERE id = ?;
kotlin复制// commonMain
class UserLocalDataSource(context: DriverContext) {
private val database = AppDatabase(DriverFactory(context))
suspend fun getUser(id: String): User? = withContext(Dispatchers.Default) {
database.userQueries.selectById(id).executeAsOneOrNull()
}
}
SQLDelight 唯一需要注意的是 Driver 的创建需要 expect/actual。Android 上用 AndroidSqliteDriver,iOS 上用 NativeSqliteDriver,这两个 Driver 构造方式不一样,所以通常包一层 DriverFactory:
kotlin复制expect class DriverFactory {
fun createDriver(): SqlDriver
}
实际使用时每次数据库版本升级会要求你写 migration 脚本,这块要比 Room 更手动一点。但如果只是单表或几张小表的轻量缓存,SQLDelight 反而比 Room 灵活,生成的查询没有反射,启动快。
5.3 依赖注入:Koin 还是手动注入
KMP 项目里的依赖注入,社区常见选择是 Koin 或 Kodein,它们的共同点是纯 Kotlin 实现,不依赖 JVM 反射注入框架(Hilt 那种只在 Android 跑得动)。我自己的取舍是:项目不大,用手动构造注入;项目上了规模,用 Koin 的 KMP 支持。
手动注入省不了多少代码,但能让 shared 模块的边界非常清楚。你在 shared 模块里定义好 AppContainer:
kotlin复制class AppContainer {
val userRepository: UserRepository by lazy {
UserRepository(httpClient)
}
val productRepository: ProductRepository by lazy {
ProductRepository(httpClient, userLocalDataSource)
}
private val userLocalDataSource by lazy {
UserLocalDataSource(DriverFactory(...))
}
}
iOS 端拿到 AppContainer 后,直接作为单例传给 SwiftUI 环境,连依赖注入框架都不需要。如果项目模块多、依赖图复杂,Koin 在 KMP 里的 koin-core 支持已经相当完善,module {} 那段 DSL 在 commonMain 写起来几乎和在 Android 里一样。但我建议不要上来就铺满 Koin,因为 KMP 调试依赖注入关系时,编译报错和运行时报错往往比常规 JVM 项目更隐蔽,手动注入能帮你先排清基础问题。
6. 从实际项目里沉淀下来的经验
如果让我用一句话总结 KMP 的实战心得,那就是:永远把共享模块当做一个“无 UI 的独立 SDK”来设计,而不是当一个 Android library 来设计。这个心态转换非常重要。你写的每个类都要问一句:这个类如果暴露给 Swift 调用方,API 设计得够不够友好?类型有没有可能变成 [Any]?默认参数在 Swift 里能不能用?一旦从这个视角出发,你就会自然地用 data class、枚举、接口和纯函数去组织共享代码,避免把 Android 特有的技巧带进 commonMain。
第二个体会是 KMP 的生态已经不是早期那个“demo 能跑,生产不敢用”的状态了。我团队用 KMP 把一套包含登录、搜索、下单的电商逻辑完全搬进 shared 模块,线上跑了半年多,iOS 和 Android 的崩溃率并没有因为逻辑共享而上升,反而因为 bug 修复只改一处、两端同步上线,运营和测试的返工量少了很多。真正拖后腿的反而不是技术,而是团队里有人对“共享逻辑”产生错觉,以为所有东西都能共享,于是把 UI 层也没边界地塞进来——这种项目后期回天乏力,一拗就崩。
最后分享一个小技巧:在 shared 模块里写一个 BuildConfig 等价物,用一个 expect val isDebug: Boolean,Android 端映射到 BuildConfig.DEBUG,iOS 端映射到 #if DEBUG 的判断结果。这样你在 commonMain 里就能统一做日志开关、模拟数据开关,不用在两端各维护一套常量。类似的这种小 API 边界会越积越多,但它们都是值得的投资——因为 KMP 项目真正的核心资产,是你慢慢沉淀出来的这套共享模块 API,它决定了你的团队能在这套方案上走多远。
