实战:将 Android 多模块应用迁移到 Kotlin Multiplatform + Compose Multiplatform
最近把自己的 NBA 数据应用 HoopsNow 从纯 Android 多模块架构迁移到了 KMP + CMP,实现了 Android/iOS 共享一套代码。这篇文章记录整个迁移过程中的思路、踩坑和最终方案。
项目背景
HoopsNow 是一个 NBA 数据展示应用,功能包括比赛比分、球队信息、球员搜索和收藏管理。迁移前的架构参考了 Google 的 Now in Android 项目,是一个标准的 Android 多模块架构:
1 | hoopsnow/ |
技术栈:Hilt + Navigation3 + Room + ViewModel + Coil
这套架构在纯 Android 场景下很好用,模块边界清晰,构建并行度高。但当我想把应用扩展到 iOS 时,这些 Android 专属的库就成了障碍。
为什么选择 KMP + CMP
考虑过几个方案:
| 方案 | 优点 | 缺点 |
|---|---|---|
| Flutter | 生态成熟,热重载 | 需要重写全部代码,Dart 语言 |
| React Native | Web 开发者友好 | 性能开销,桥接复杂 |
| KMP + 原生 UI | 共享逻辑,原生体验 | 需要写两套 UI |
| KMP + CMP | 共享逻辑 + UI,Kotlin 全栈 | CMP iOS 端相对年轻 |
最终选了 KMP + CMP,原因很简单:现有代码是 Kotlin + Compose,迁移成本最低,UI 也能共享。
技术栈替换
迁移的核心就是把 Android 专属库替换为 KMP 兼容方案:
| 功能 | 迁移前 | 迁移后 | 迁移难度 |
|---|---|---|---|
| 依赖注入 | Hilt | Koin 4.0 | ⭐⭐ |
| 导航 | Navigation3 | Voyager 1.1.0-beta03 | ⭐⭐⭐ |
| 数据库 | Room | SQLDelight 2.0 | ⭐⭐⭐ |
| 状态管理 | ViewModel | Voyager ScreenModel | ⭐ |
| 图片加载 | Coil | Coil 3 (KMP) | ⭐ |
| 网络 | Ktor (Android) | Ktor 3.0 (KMP) | ⭐ |
| UI | Jetpack Compose | Compose Multiplatform 1.7 | ⭐ |
下面逐个说说迁移细节。
一、创建 shared 模块
第一步是创建 KMP 共享模块。shared/build.gradle.kts 的核心配置:
1 | plugins { |
二、数据库迁移:Room → SQLDelight
这是迁移中工作量最大的部分。Room 不支持 KMP,必须换成 SQLDelight。
定义 .sq 文件
SQLDelight 用 .sq 文件定义表结构和查询,放在 commonMain/sqldelight/ 目录下:
1 | -- Team.sq |
平台 Driver
通过 expect/actual 为不同平台提供数据库驱动:
1 | // commonMain |
踩坑:SQLDelight 属性名
SQLDelight 生成的 Queries 属性名基于 .sq 文件名,不是表名。比如 Game.sq 生成 database.gameQueries,不是 database.gameEntityQueries。这个坑让我排查了好一会儿。
踩坑:Kotlin 类型推断
SQLDelight 的链式 mapper 调用会让 Kotlin 的类型推断犯迷糊。解决方案是写显式的扩展函数:
1 | fun TeamEntity.toTeam(): Team = Team( |
三、依赖注入:Hilt → Koin
Hilt 依赖 Android 的注解处理器(KSP),不支持 KMP。Koin 是纯 Kotlin 实现,天然跨平台。
1 | // commonMain - KoinModules.kt |
平台模块只需要提供 HTTP 引擎和数据库驱动:
1 | // androidMain |
迁移体验:Hilt 的 @HiltViewModel + @Inject constructor 全部删掉,换成 Koin 的 factory { } 声明。代码量反而少了。
四、导航:Navigation3 → Voyager
导航是迁移中设计决策最多的部分。Voyager 提供了 TabNavigator + Navigator 的组合,很适合底部 Tab + 页面栈的场景。
Tab 定义
1 | object GamesTab : Tab { |
每个 Tab 内嵌独立的 Navigator,Tab 切换时各自的导航栈互不影响。
Screen 定义
1 | class GamesListScreen : Screen { |
页面间传参
Voyager 通过构造函数传参,简单直接:
1 | class GameDetailScreen(private val gameId: Int) : Screen { ... } |
Koin 端用 parametersOf 传递:
1 | // 定义 |
主入口
1 |
|
五、状态管理:ViewModel → ScreenModel
这是最简单的一步。Voyager 的 ScreenModel 和 ViewModel 几乎一模一样:
1 | // 迁移前 |
改动点:
- 删除
@HiltViewModel和@Inject constructor ViewModel()→ScreenModelviewModelScope→screenModelScopecollectAsStateWithLifecycle()→collectAsState()(CMP 中没有 AndroidX Lifecycle)
六、Android 入口精简
迁移后 app 模块只剩两个文件:
1 | // HoopsNowApplication.kt |
七、iOS 接入
iOS 端更简单,只需要一个 SwiftUI 壳:
1 | // iOSApp.swift |
shared 模块中提供 iOS 入口(最终落地版本):
1 | // iosMain - MainViewController.kt |
就这样,iOS 端就能跑起来了。整个 Compose UI 通过 ComposeUIViewController 嵌入 SwiftUI。
最终 iOS 工程入口路径:
1 | iosApp/iosApp/iosApp.xcodeproj |
常用构建命令:
1 | # Apple Silicon 模拟器 |
八、清理旧代码
迁移完成后建议清理:
core/— 9 个旧 Android 模块feature/— 4 个功能模块app/navigation/— 旧 Navigation3 代码build-logic/中的 6 个 Convention Plugin(Hilt、Room、Feature、Library 等)libs.versions.toml中的 Hilt、KSP 相关声明
当前仓库为了迁移对照,仍保留了部分 core/、feature/ 历史代码,但它们不在 settings.gradle.kts 中参与构建。构建维度已经是 2 个模块(app + shared)。
迁移后的项目结构
1 | hoopsnow/ |
踩坑总结
1. SQLDelight 属性名
生成的 Queries 属性名基于 .sq 文件名(gameQueries),不是 CREATE TABLE 的表名(gameEntityQueries)。
2. collectAsStateWithLifecycle 不可用
这是 AndroidX Lifecycle 的扩展,CMP 中用 collectAsState() 替代。ScreenModel 会在 Screen dispose 时自动取消 scope,不用担心泄漏。
3. Kotlin 类型推断与 SQLDelight
链式 mapper 调用时类型推断可能失败,写显式的 toModel() 扩展函数解决。
4. Material Icons Extended
Icons.Default.StarBorder、Icons.Default.OpenInNew 等图标需要额外添加 compose.materialIconsExtended 依赖。
5. Koin ScreenModel 参数传递
带参数的 ScreenModel 需要用 factory { params -> } 定义,使用时通过 koinScreenModel { parametersOf(...) } 传入。
6. iOS Framework 编译
每次修改 shared 代码后需要重新编译 Framework。Xcode 工程路径如果是 iosApp/iosApp/iosApp.xcodeproj,Framework Search Paths 和 Run Script 的相对路径要按两级目录配置,否则容易出现 Framework not found Shared。
迁移收益
| 指标 | 迁移前 | 迁移后 |
|---|---|---|
| 模块数量 | 20+ | 2 (app + shared) |
| 支持平台 | Android | Android + iOS |
| UI 代码共享 | 0% | 100% |
| 业务逻辑共享 | 0% | 100% |
| build.gradle 文件 | 20+ | 3 |
| Convention Plugins | 7 | 2 |
最大的收益是 iOS 端几乎零成本接入 — 只需要两个 Swift 文件就能跑起完整的应用。
依赖版本参考
| 库 | 版本 |
|---|---|
| Kotlin | 2.0.21 |
| Compose Multiplatform | 1.7.3 |
| Ktor | 3.0.3 |
| SQLDelight | 2.0.2 |
| Koin | 4.0.0 |
| Voyager | 1.1.0-beta03 |
| Coil 3 | 3.0.4 |
| kotlinx-serialization | 1.7.3 |
| kotlinx-datetime | 0.6.1 |
| Coroutines | 1.9.0 |
总结
整个迁移花了大约一周时间,其中数据库迁移(Room → SQLDelight)和导航迁移(Navigation3 → Voyager)占了大部分工作量。网络层(Ktor)和序列化(kotlinx-serialization)本身就是 KMP 库,基本不用改。
如果你的 Android 项目已经在用 Kotlin + Compose,迁移到 KMP + CMP 的成本比想象中低很多。最大的障碍是 Room 和 Hilt 这两个 Android 专属库的替换,但 SQLDelight 和 Koin 都是成熟的替代方案。
项目源码:GitHub - laibinzhi/hoopsnow(cmp 分支)