在这里插入图片描述

在 Kotlin Multiplatform (KMP) 的各种官方教程或入门 Demo 中,开发者通常会被带入两种理想化的设定:

  1. 单仓库(Monorepo):Android 和 iOS 的代码,以及共享的 Kotlin 模块,都整整齐齐地躺在同一个 Git 仓库里。
  2. 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)”来追踪。这意味着:

  1. Android 切分支,iOS 变“静态”
    当 Android 同学切到一个新功能分支,并在 Kotlin 里给一个共享 Model 加了新字段,你在 iOS 端兴高采烈地打开 Xcode 想调用它,却发现 Swift 的自动补全里压根没有这个字段。因为 Xcode 默认根本不知道 Kotlin 源码变了,它依然在读取 SPM 缓存里那个陈旧的二进制。
  2. “重置包缓存(Reset Package Caches)”成为日常仪式
    只要 KMP 的 Framework 结构发生了变化(新增了模块、重命名了类型),Xcode 的 SPM 缓存就会开始“撒谎”。
    你会迅速熟练掌握这套 Xcode 拯救三连击:File → Packages → Reset Package Caches,接着 Cmd + Shift + K(清理构建),最后 Cmd + B(重新构建)。
  3. 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_NAMEBUILT_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

  1. 页面打开,ViewModel 初始化,启动了一个 Ktor 轮询请求或者订阅了一个 Flow;
  2. 用户点击返回,页面退栈销毁;
  3. 灾难发生: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 展现出了它极高的数据资产复用价值和无侵入性的工程灵活性。它不仅仅是技术栈的融合,更是一次对跨端工程化协作模式的优雅升级。

Logo

一站式 AI 云服务平台

更多推荐