打破单仓迷思!Kotlin Multiplatform 双端双仓库(Polyrepo)实战:Xcode 到底看到了什么?

在 Kotlin Multiplatform (KMP) 的各种官方教程或入门 Demo 中,开发者通常会被带入两种理想化的设定:
- 单仓库(Monorepo):Android 和 iOS 的代码,以及共享的 Kotlin 模块,都整整齐齐地躺在同一个 Git 仓库里。
- Compose Multiplatform UI:UI 完全用 Kotlin 写,iOS 的底层细节被包装得严严实实,甚至不需要碰 Swift 桥接。
然而,理想很丰满,现实很骨感。对于拥有成熟移动端团队的大中型企业来说,Android 和 iOS 往往是两个庞大、独立的“历史帝国”:
- 各自拥有数百个源文件;
- 运行着完全隔离的 CI/CD 流水线;
- 遵循着不同的发布节奏、分支管理策略和历史惯例。
如果要把这两个仓库强行合并成一个,无异于一场跨越数个季度的“架构大迁徙”,成本高到难以在业务层面获得支持。
本文将带你跳出“单仓”和“全栈跨端(Compose UI)”的舒适区,分享一套**双端、双仓库(Polyrepo)**的 KMP 实战方案。我们将剥开 KMP 的外壳,看清在双仓库的物理区隔下,Xcode 到底是如何看待 KMP 产物的,并一步步解决那些官方文档从未提及的构建、生命周期及协同痛点。
一、 双端双仓的物理连接:平行宇宙的握手
在双仓(Polyrepo)架构下,我们的目录结构通常是这样的:
📁 Git / Development Root(共同的开发根目录)
├── 📁 Android ← Android 主工程 + KMP 共享模块
│ └── 📁 shared ← 我们的 commonMain Kotlin 代码就在这里
└── 📁 ios-app ← 兄弟仓库,iOS 独立主工程(消费编译后的 KMP 二进制)
黄金物理法则:相同的父目录
为了让这个架构跑起来,团队内部需要确立一条唯一的物理约束:每位开发者都必须将这两个仓库克隆到同一个父目录下。
这是因为 Xcode 在引用 KMP 编译出的 Framework 时,使用的是相对路径。我们在 iOS 工程中会通过 ../../Android 先跳出当前仓库,再进入兄弟仓库读取编译产物。一旦有开发者放错了目录,Xcode 就会在寻找 Framework 时直接“抓瞎”,甚至报出一些极其隐蔽的路径错误。
二、 心智模型的转变:Xcode 眼中只有“黑盒二进制”
这是决定双仓 KMP 体验最核心的一个认知,如果能第一天就想通这一点,能帮你省下大把折腾环境的时间:
Xcode 并不在乎你的 Kotlin 源码。在它眼里,KMP 编译出的
SharedKMP.xcframework就是一个已经封装好的、由第三方 Swift Package Manager (SPM) 或 CocoaPods 提供的闭源 SDK,比如 Alamofire。
因为这两个仓库是平行的,Xcode 不会像在单仓里那样,自动把 Kotlin 代码当作“子项目(Gradle sub-project)”来追踪。这意味着:
- Android 切分支,iOS 变“静态”:
当 Android 同学切到一个新功能分支,并在 Kotlin 里给一个共享 Model 加了新字段,你在 iOS 端兴高采烈地打开 Xcode 想调用它,却发现 Swift 的自动补全里压根没有这个字段。因为 Xcode 默认根本不知道 Kotlin 源码变了,它依然在读取 SPM 缓存里那个陈旧的二进制。 - “重置包缓存(Reset Package Caches)”成为日常仪式:
只要 KMP 的 Framework 结构发生了变化(新增了模块、重命名了类型),Xcode 的 SPM 缓存就会开始“撒谎”。
你会迅速熟练掌握这套 Xcode 拯救三连击:File → Packages → Reset Package Caches,接着Cmd + Shift + K(清理构建),最后Cmd + B(重新构建)。 - API 签名的改变成为强契约:
在单仓里,Kotlin 侧改了方法签名,iOS 编译会当场报错,你可以顺手改掉。但在双仓里,这成了跨仓库的“API 契约变更”。一旦在 Android 侧修改了共享库的初始化签名,iOS 仓在未同步代码前就会直接编译失败。
核心心智:请将共享的 Kotlin 模块视为一个版本化的第三方 SDK 依赖,只是这个 SDK 恰好住在你隔壁的文件夹里。
三、 两行核心动作:Xcode 的“跨仓即时构建”
既然 Xcode 默认不会自动重构 Kotlin,我们就必须在 Xcode 的构建生命周期中塞入“钩子”,让它在编译 Swift 源码之前,强制跑去兄弟仓库“敲敲门”,把最新的 Kotlin 代码编译成最新的二进制。
在 iOS 工程的 Build Phases 中,我们需要配置以下两个关键脚本:
脚本 1:JIT(即时)Framework 编译
- 放置位置:在
Compile Sources之前,在Check Pods Manifest.lock之后。
# 1. 从当前 iOS 目录向上跳两级,进入平行的 Android 目录
cd "${SRCROOT}/../../Android"
# 2. 调用 Gradle,让它根据当前 Xcode 的构建环境,编译出对应的 Apple Framework
./gradlew :shared:embedAndSignAppleFrameworkForXcode
妙处解析:
${SRCROOT}指向的是Git/ios-app/myapp-ios。../../Android帮助我们无缝跨越了 Git 仓库的边界。:shared:embedAndSignAppleFrameworkForXcode是 KMP 自带的黑科技任务。它能自动读取 Xcode 传入的各种环境变量(当前是真机还是模拟器$ARCHS、是 Debug 还是 Release$CONFIGURATION,以及当前的 SDK$SDK_NAME),从而只编译当前 Xcode 这一次构建所需要的那一部分切片(Slice)。这极大地保证了 Xcode 端 Incremental Build(增量编译)的速度。
配置完这个脚本后,“Android 切分支,iOS 代码滞后”的问题将彻底成为历史。每次你在 Xcode 里点按下 Cmd + B,它都会顺手把隔壁仓库最新鲜的 Kotlin 源码编译热乎,供 Swift 享用。
四、 跨仓资源捆绑:让 Moko Resources 找到回家的路
如果你在 KMP 中使用了 Moko Resources 来共享多语言字符串、图片等静态资源,你很快会遇到一个崩溃:项目能顺利编译通过,但一旦 iOS 运行到读取共享字符串的界面,APP 就会瞬间闪退。
这是因为,虽然共享资源被编译进了 Framework,但 Xcode 的构建系统默认并不知道需要把这些 .bundle 资源文件拷贝到最终的 iOS .app 安装包里。
脚本 2:跨仓资源拷贝
- 放置位置:在
[CP] Embed Pods Frameworks之前。
# 跨仓执行 Gradle 任务,将共享模块的资源强行注入 Xcode 的最终 App 包中
"$SRCROOT/../../Android/gradlew" -p "$SRCROOT/../../Android" \
:shared:copyFrameworkResourcesToApp \
-Pmoko.resources.PLATFORM_NAME="$PLATFORM_NAME" \
-Pmoko.resources.CONFIGURATION="$CONFIGURATION" \
-Pmoko.resources.ARCHS="$ARCHS" \
-Pmoko.resources.BUILT_PRODUCTS_DIR="$BUILT_PRODUCTS_DIR" \
-Pmoko.resources.CONTENTS_FOLDER_PATH="$CONTENTS_FOLDER_PATH"
妙处解析:
注意 "$SRCROOT/../../Android/gradlew" -p "$SRCROOT/../../Android" 这个路径。虽然我们是在 iOS 仓库的上下文里执行这个 Gradle 包装器(Gradle wrapper),但我们通过 -p(Project Root)将 Gradle 的运行根目录指定为了隔壁的 Android 工程。
通过这种“隔空喊话”,Xcode 将其原生的构建上下文(PLATFORM_NAME、BUILT_PRODUCTS_DIR 等)作为参数塞给了 Android Gradle。Gradle 在收到指令后,会精准地将 Kotlin 中的图片和字符串 Bundle 拷贝到 Xcode 正在临时打包的 .app 目录中。
五、 Koin 依赖注入:双仓之下的 API 契约
为了让共享的业务代码在运行时能够调用底层的平台特性(如 iOS 端的原生 HttpClient 引擎 Darwin、原生的 Firebase Token 提供者,或原生的埋点 Logger),我们通常会选择 Koin 作为依赖注入(DI)框架。
在共享模块中,我们会定义一个初始化函数:
// commonMain 中的 Koin 初始化入口
fun initKoin(
engine: HttpClientEngine,
tokenProvider: FirebaseTokenProvider,
analyticsLogger: AnalyticsLogger,
) {
/* 将这些平台特性的实现注册为单例,并启动 Koin */
}
在双仓的设定下,initKoin 的参数列表就成为了两端无可逃避的“物理契约”。
如果在 Android 侧(Kotlin 共享库)需要新增一个平台能力注入(例如加一个 featureFlagProvider),一旦在 Kotlin 侧改写了 initKoin 签名并推送到远程,iOS 仓的 KoinSetup.swift 在同步之前就会瞬间编译崩溃。
架构建议:将 initKoin 视同于向外发布的 SDK 接口。任何对该函数签名的修改,都应该提前告知 iOS 侧的队友,共同协调合并时机。
避坑小贴士:静态依赖检查报错 (KOIN-D001)
Koin 的编译器插件默认会在编译期对依赖图(Dependency Graph)进行静态合法性校验。
由于我们的 HttpClientEngine 等参数是在运行时(Runtime)通过 initKoin 动态塞进来的,Koin 的静态校验插件找不到它们的注册来源,会在编译时抛出 KOIN-D001 Missing dependency 错误。
不要一怒之下用 KOIN_CONFIG_CHECK=false 关闭静态检查!你只需要在 Kotlin 构造函数中,对这些运行时传入的参数使用 @Provided 注解:
class BadgeService(
@Provided private val httpClient: HttpClient,
) { … }
这等于给编译器打了个招呼:“放心吧,这个依赖我会在运行时手动填进去,你不用在静态期瞎操心了。”
六、 SwiftUI 的无声陷阱:被遗忘的协程生命周期与内存泄漏
作为 Android 开发者,我们对 viewModelScope 习以为常。在 Android 中,当 Activity 或 Fragment 销毁时,Jetpack ViewModel 的 ViewModelStoreOwner 会默默在后台帮你把这个协程作用域(Scope)取消掉,干干净净,不留痕迹。
然而,SwiftUI 根本没有这一套生命周期挂钩!
如果你直接在 SwiftUI 的 View 里初始化了一个共享的 Kotlin ViewModel:
- 页面打开,ViewModel 初始化,启动了一个 Ktor 轮询请求或者订阅了一个 Flow;
- 用户点击返回,页面退栈销毁;
- 灾难发生:SwiftUI 销毁了它的 View,但那个共享的 Kotlin ViewModel 依然苟活在内存中,它的
viewModelScope保持活跃。Ktor 依然在后台悄悄发送网络请求,Flow 依然在占用内存。
这是一个无声的内存泄漏,如果不做专门的内存监控,它会像幽灵一样在后台吞噬用户的电量和流量。
为了解决这个痛点,我们需要在 iOS 侧搭建三套精妙的“生命周期铁轨”:
第一步:构建 Swift 侧的“墓碑监听器” IOSViewModelStoreOwner
这个 Swift 类负责强引用 Kotlin 的 ViewModelStore。
利用 Swift 对象的 ARC 释放机制,我们在 deinit(析构函数)中,主动、显式地去调用 Kotlin ViewModel 的 .clear()。
class IOSViewModelStoreOwner {
let store = ViewModelStore()
deinit {
// 当 Swift 侧判定没有 View 引用我时,主动清理 Kotlin ViewModel,释放协程
store.clear()
}
}
第二步:创建 SwiftUI 的生命周期容器 ViewModelStoreOwnerProvider
这是一个 SwiftUI 视图,利用 SwiftUI 的视图层级,管理生命周期。当这个 View 退出层级时,促使 Swift 的 ARC 机制去释放 IOSViewModelStoreOwner,进而触发析构和 KMP VM 清理。
ViewModelStoreOwnerProvider {
BadgesView() // 任何需要消费共享 ViewModel 的 SwiftUI 视图
}
第三步:铺设响应式桥梁 ObservableValueWrapper
因为 SwiftUI 无法原生监听 Kotlin 的 StateFlow,我们需要这个包装类。它将 Kotlin 的 Flow 数据流转换并映射成 Swift 能够原生识别的 @Published 属性,从而实现数据的双向绑定和界面自动刷新。
当然,如果你选择 Compose Multiplatform,UI 完全写在 Kotlin 侧,这个生命周期问题会被框架自身屏蔽。但对于坚持追求 iOS 原生 SwiftUI 极致体验、坚持写“原生 Swift”而非“Kotlin 腔 Swift”的团队,这三驾马车是必须搭建的 load-bearing 基础设施。
七、 那些年 SKIE 留下的小尾巴
SKIE 是个伟大的 KMP 增强插件。它能将 StateFlow 优雅地转换为 Swift 的 AsyncSequence,将 Kotlin 的密封类(Sealed Class)翻译成 Swift 优雅的 enum,还能支持默认参数。
但它也有翻车的时候。
如果你的 Kotlin 数据类(Data Class)的构造函数中,包含了一个复杂类型或者未导出到 iOS 的类型,并且你为其设置了默认值:
// Kotlin
data class BadgeUIState(
val items: List<Badge> = emptyList(),
val isLoading: Boolean = false
)
SKIE 的默认参数桥接器在遇到这种复杂类型时,可能会在 Swift 侧“摆烂”,导致 Xcode 抛出 init() is unavailable 编译错误。
避坑工作流:伴生对象工厂函数
如果遇到了这种编译错误,一个屡试不爽的 Workaround 是在 Kotlin 侧提供一个伴生对象的工厂方法:
data class BadgeUIState(
val items: List<Badge> = emptyList(),
val isLoading: Boolean = false
) {
companion object {
// 显式提供一个不依赖 Swift 桥接默认参数的初始化工厂
fun default() = BadgeUIState()
}
}
在 Swift 侧,通过调用 BadgeUIState.companion.default() 替代直接调用 BadgeUIState() 构造函数。虽然有些“不修边幅”,但它非常稳定,且易于在团队中推广。
八、 苦尽甘来:双仓 KMP 带来的高光时刻
当你在 Xcode 里成功配置了这两行构建脚本,在 SwiftUI 里封装好了生命周期容器,你会发现,所有的前期折腾都是值得的。
你的日常开发将变得无比丝滑:
- 逻辑只写一次,Review 只做一轮:无论是复杂的缓存淘汰策略,还是繁琐的 API 数据解析、业务状态流转,都在 Kotlin 共享层搞定并完成单元测试。
- 真正的原生 UI:Android 开发用 Compose,iOS 开发用 SwiftUI。两个平台的工程师都可以使用自己最熟悉、最新潮的声明式 UI 框架,却共享着 100% 相同的业务状态机和接口数据。
- 各自美丽,互不打扰:两个工程继续保留原有的 CI/CD。如果某天 iOS 端不需要 KMP 的新功能,它甚至可以直接锁死在某一个 Framework 的 Git Commit 版本上,继续独立发版。
在双仓(Polyrepo)模式下,KMP 展现出了它极高的数据资产复用价值和无侵入性的工程灵活性。它不仅仅是技术栈的融合,更是一次对跨端工程化协作模式的优雅升级。
更多推荐



所有评论(0)