【Jetpack Compose娓娓道来】 第22课:Kotlin Multiplatform深入实践——从“共享UI“到“共享一切“
一、先讲一个真实的故事
我见过一个团队,用Compose Multiplatform把Android应用移植到了iOS。移植完成后,他们发现一个尴尬的事实:共享的代码只有UI层。
网络请求用的是平台各自的库——Android用Retrofit,iOS用Alamofire。数据库各写各的——Android用Room,iOS用Core Data。连数据模型都各定义了一份。结果就是:改一个字段,要改两套代码;加一个接口,要写两遍逻辑。
他们确实“用了KMP”,但只用了KMP的一半能力。UI共享了,业务逻辑没共享。这就像两个人共用一套房子的装修风格,但水管、电线、地基各建各的——表面上看起来一样,底下全是重复劳动。
这就是这一课要解决的问题:如何从“共享UI”走到“共享一切”。
第14课我们认识了Compose Multiplatform,知道了它能让Compose代码在Android、iOS、桌面和Web上运行。但第14课更多是“介绍性”的——CMP是什么、各平台支持怎么样、工程结构长什么样。这一课,我们要深入KMP的工程实践,回答那些真正开始写多平台代码时才会遇到的问题:
expect/actual到底该怎么用?什么时候用函数级,什么时候用类级?- 共享代码到底该共享什么?UI和业务逻辑要不要分开模块?
- 我现有的Android Compose项目,怎么一步步迁移到KMP?
- KMP的依赖注入、网络、数据库怎么选?和Android生态怎么衔接?
- 多平台代码怎么测试?
二、KMP的工程配置:2026年的默认结构
2.1 新旧项目结构的区别
第14课我们看过一个简化的KMP项目结构,但那是“旧版”的。2026年5月,JetBrains发布了新的默认项目结构,与AGP 9.0对齐。
旧结构:一个 composeApp 模块包含所有共享代码和所有平台的入口点。所有平台共用同一个Gradle模块。
新结构:共享KMP库模块(shared)+ 各平台的独立应用模块。共享代码放在一个纯粹的Kotlin Multiplatform库模块中,每个平台有自己的入口模块依赖这个共享模块。
为什么这个变化很重要:AGP 9.0要求Android应用入口点与通用代码分离。如果你打算使用AGP 9或更高版本,必须采用新结构。
2.2 新结构的典型布局
my-app/
├── shared/ # 共享KMP库模块
│ ├── src/
│ │ ├── commonMain/ # 共享代码:Composable、ViewModel、业务逻辑
│ │ ├── androidMain/ # Android平台特定实现
│ │ ├── iosMain/ # iOS平台特定实现
│ │ ├── desktopMain/ # 桌面平台特定实现
│ │ └── commonTest/ # 共享代码的测试
│ └── build.gradle.kts
├── androidApp/ # Android应用入口模块
│ └── src/main/kotlin/MainActivity.kt
├── iosApp/ # iOS应用入口(Xcode项目)
├── desktopApp/ # 桌面应用入口模块
└── build.gradle.kts
共享模块的 commonMain 包含所有可共享的代码——Composable函数、ViewModel、Repository、UseCase、数据模型。各平台的入口模块只负责启动应用和提供平台特定的能力。
2.3 源集的依赖关系
KMP的源集之间有一种“继承”关系:
commonMain是所有平台共享的代码。androidMain继承自commonMain,可以使用Android SDK,但不能被commonMain引用。iosMain继承自commonMain,可以使用iOS API。desktopMain继承自commonMain,可以使用JVM/桌面API。
关键约束:commonMain 不能导入任何平台特定的包(如 android.*、platform.UIKit.*、java.awt.*)。如果 commonMain 里出现平台导入,会在iOS链接阶段报错,而且错误信息距离出问题的代码很远,非常难排查。
简单原则:默认放 commonMain,只有当代码必须依赖平台能力时才放到平台源集。每一行放在平台源集的代码,都应该是一个“有正当理由的例外”。
三、expect/actual深入:不只是“条件编译”
3.1 与前端条件编译的本质区别
如果你有前端背景,可能觉得 expect/actual 和 process.env.PLATFORM === 'web' 差不多。但它们有本质区别:
前端条件编译是运行时或打包时的分支判断。类型系统无法验证每个平台的实现是否齐全,遗漏某个平台的实现,往往要等到运行时才会暴露。
expect/actual是编译时强制约束。expect 定义跨平台的公共契约,actual 提供各平台的具体实现。遗漏任何一个平台的实现,编译就会失败。
这意味着:你不需要担心“某个平台忘了实现”的问题。编译器会逼着你实现所有平台。
3.2 函数级 vs 类级
expect/actual 可以用于函数、属性、类、接口等。但在实践中,优先使用函数级别的 expect/actual,粒度最细,最容易测试和维护。类级别的 expect/actual 会让代码导航复杂一些。
// 推荐:函数级
expect fun getPlatformName(): String
expect fun randomUUID(): String
// 也可以,但更重
expect class PlatformSpecificLogger {
fun log(message: String)
}
3.3 三种典型使用场景
场景一:平台标识
// commonMain
expect fun getPlatformName(): String
// androidMain
actual fun getPlatformName(): String = "Android"
// iosMain
actual fun getPlatformName(): String = "iOS"
// desktopMain
actual fun getPlatformName(): String = "Desktop"
场景二:时间格式化
不同平台的时间API不同。Android和桌面可以用 java.time,iOS需要 NSDateFormatter,Web需要JavaScript的 Date。
// commonMain
expect fun formatCurrentTime(): String
// androidMain / desktopMain
actual fun formatCurrentTime(): String =
java.time.LocalDateTime.now().toString()
// iosMain
actual fun formatCurrentTime(): String =
NSDateFormatter().apply {
dateFormat = "yyyy-MM-dd HH:mm:ss"
}.stringFromDate(NSDate())
场景三:UUID生成
// commonMain
expect fun randomUUID(): String
// androidMain / desktopMain
actual fun randomUUID(): String =
java.util.UUID.randomUUID().toString()
// iosMain
actual fun randomUUID(): String =
NSUUID().UUIDString()
3.4 什么时候不该用expect/actual
重要的架构建议:当平台差异是服务依赖而不是语言边界时,优先用普通接口+依赖注入,而不是 expect/actual。
// 不推荐:用expect/actual做依赖注入
expect fun createHttpClient(): HttpClient
// 推荐:定义接口,用DI注入
interface HttpClientProvider {
fun create(): HttpClient
}
// Android实现、iOS实现分别在各自模块中,通过Koin/Hilt注入
expect/actual 适合编译时就必须确定的平台差异。如果是运行时可以替换的服务,用接口+DI更灵活。
3.5 Koin Annotations:绕过expect/actual的新方式
Koin 4.x 引入了一个有趣的模式:用注解和Koin编译器完全绕过 expect/actual。
在 commonMain 中定义接口,在平台源集中用 @Factory 或 @Single 注解实现,Koin的 @ComponentScan 会自动扫描所有平台的模块,找到对应实现。
// commonMain
interface PlatformDomainProtocol {
fun description(): String
}
// androidMain
@Factory
class PlatformDomain : PlatformDomainProtocol {
override fun description() = "from android"
}
// iosMain
@Factory
class PlatformDomain : PlatformDomainProtocol {
override fun description() = "from ios"
}
commonMain 中完全不需要 expect/actual。这个方式适合平台差异是实现细节而不是编译时契约的场景。
四、共享逻辑与平台UI的权衡
4.1 三种共享策略
KMP项目有三种共享层次,选择哪种取决于你的目标:
策略一:只共享业务逻辑,UI全部原生。 Android用Compose,iOS用SwiftUI,桌面用各自的UI框架。共享模块只包含Repository、UseCase、数据模型。
策略二:共享业务逻辑+部分UI。 一些页面用CMP共享,一些页面用平台原生UI。共享模块分成 sharedLogic 和 sharedUI 两个模块。
策略三:共享业务逻辑+全部UI。 所有平台的UI都用CMP,只有极少数平台特定的组件用 expect/actual 或原生互操作。
4.2 模块拆分策略
官方的建议是:不要预先拆分,从一个 shared 模块开始,当某个平台真正需要原生UI时再拆。
如果你确定iOS端需要原生UI(比如用SwiftUI实现某些页面),那么应该:
sharedLogic模块:纯Kotlin,包含Repository、UseCase、数据模型。所有平台都依赖它。sharedUI模块:依赖sharedLogic,包含CMP的Composable。只有使用CMP的平台才依赖它。
// sharedLogic/build.gradle.kts
kotlin {
androidTarget()
iosX64(); iosArm64(); iosSimulatorArm64()
jvm("desktop")
// 不依赖Compose Multiplatform
}
// sharedUI/build.gradle.kts
kotlin {
sourceSets {
commonMain.dependencies {
implementation(project(":sharedLogic"))
implementation(compose.runtime)
implementation(compose.material3)
}
}
}
4.3 在共享UI中处理平台差异
CMP的哲学是“共享优先,平台例外”。在共享UI中,你不应该到处写平台判断。正确做法是用接口隔离平台差异,在平台层注入实现。
// commonMain — 定义接口
interface PlatformUIProvider {
@Composable fun PlatformSpecificComponent()
}
// androidMain — 提供实现
class AndroidUIProvider : PlatformUIProvider {
@Composable override fun PlatformSpecificComponent() {
AndroidView(factory = { ... })
}
}
然后在共享UI中通过DI注入 PlatformUIProvider。这样共享UI的代码完全干净,平台差异被隔离在平台层。
五、从Android Compose项目迁移到KMP
5.1 迁移的前置检查
JetBrains官方指南指出:如果你的项目已经用Kotlin和Jetpack Compose,迁移复杂度会大幅降低。
在开始迁移之前,检查以下几项:
Java代码:commonMain 不能包含Java代码。如果项目里有Java代码,要么隔离到 androidMain,要么转成Kotlin。用到的Java库(如RxJava)应该先迁移到 kotlinx-coroutines。
Android专属API:java.time、Uri、Objects.hash() 等需要替换为KMP兼容的替代品,或者隔离到平台源集。
Android资源管理:Android的 res/ 目录在 commonMain 中不可用。需要用CMP的资源管理方式(composeResources/)替代。
5.2 逐模块迁移策略
官方推荐的迁移方式是:从依赖树中最底层的模块开始,逐个迁移。
第一步:迁移数据层模块。 把网络请求、数据库访问、数据模型迁移到KMP。用Ktor替代Retrofit,用SQLDelight替代Room。
第二步:迁移Domain层模块。 UseCase和Repository接口是纯Kotlin,可以直接移到 commonMain。
第三步:迁移UI层。 把Composable函数逐个移到 shared 模块的 commonMain 中。Android的 MainActivity 变成只调用 App() 的薄入口。
第四步:创建其他平台入口。 添加iOS的 MainViewController、桌面的 main()。
5.3 资源迁移
Android的 res/values/strings.xml 需要移到 composeResources/values/strings.xml。图片从 res/drawable 移到 composeResources/drawable。字体从 res/font 移到 composeResources/font。
构建后,CMP会生成 Res.strings 和 Res.drawable 对象,提供类型安全的资源访问。
六、KMP生态工具链
6.1 依赖注入:Koin
KMP项目推荐用 Koin 替代Hilt(Hilt目前不直接支持KMP)。Koin是纯Kotlin的轻量级DI框架,基于DSL配置,运行时解析依赖。
// commonMain
val appModule = module {
single<ArticleRepository> { ArticleRepositoryImpl(get(), get()) }
viewModelOf(::ArticleListViewModel)
}
// 启动时初始化
startKoin {
modules(appModule)
}
Koin 4.x 还提供了注解和编译器,可以进一步简化配置。
6.2 网络:Ktor
Ktor Client 是KMP上最流行的网络库,设计上就支持多平台。
// commonMain
val client = HttpClient {
install(ContentNegotiation) {
json()
}
}
suspend fun getArticles(): List<Article> =
client.get("https://api.example.com/articles").body()
不同平台需要不同的HTTP引擎:Android用OkHttp,iOS用Darwin,桌面用OkHttp或CIO。Ktor的 HttpClient 会自动选择可用的引擎。
6.3 数据库:SQLDelight
SQLDelight 从SQL查询生成类型安全的Kotlin代码,是KMP上Room的替代方案。
-- shared/src/commonMain/sqldelight/com/example/Article.sq
CREATE TABLE article (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
content TEXT NOT NULL
);
selectAll:
SELECT * FROM article ORDER BY id DESC;
insert:
INSERT INTO article(id, title, content) VALUES (?, ?, ?);
SQLDelight会生成 ArticleQueries 类,提供类型安全的方法。不同平台用不同的SQLite驱动:Android用 AndroidSqliteDriver,iOS用 NativeSqliteDriver,桌面用 JdbcSqliteDriver。
6.4 其他KMP库
| 库 | 用途 |
|---|---|
| Coil | 图片加载,支持KMP |
| kotlinx-datetime | 跨平台日期时间处理 |
| kotlinx-serialization | 跨平台序列化,替代Gson/Moshi |
| Voyager / Decompose | KMP导航库 |
| Haze | 背景模糊效果 |
| Compose DND | 拖拽功能 |
七、测试与调试
7.1 共享代码的单元测试
KMP的 commonTest 源集可以写共享的单元测试,用 kotlin.test 库。测试会在所有平台上运行。
// commonTest
class ArticleRepositoryTest {
@Test
fun `getArticles returns list`() = runTest {
val fakeApi = FakeArticleApi()
val fakeDao = FakeArticleDao()
val repository = ArticleRepositoryImpl(fakeApi, fakeDao)
val result = repository.getArticles()
assertTrue(result.isSuccess)
}
}
kotlin.test 提供了跨平台的断言:assertEquals、assertTrue、assertContains 等。
7.2 平台特定测试
平台特定的测试放在各自的源集中。Android的仪器测试放在 androidInstrumentedTest,iOS的测试放在 iosTest。
7.3 调试技巧
commonMain的导入清洁:如果 commonMain 里出现了平台导入,编译错误会在iOS链接阶段才暴露,而且错误信息很模糊。建议在IDE中配置导入检查,尽早发现。
KMPify工具:这是一个自动化工具,帮助把Android Jetpack Compose项目迁移到KMP,自动替换Android特定的资源导入。
八、常见陷阱速查
| 陷阱 | 后果 | 解决方案 |
|---|---|---|
| commonMain导入平台API | iOS链接阶段才报错 | 用 expect/actual 抽象 |
| 过度使用expect/actual | 代码僵化 | 运行时可替换的用接口+DI |
| 预拆分模块 | 不必要的复杂度 | 从一个 shared 开始,按需拆分 |
| 共享UI中写平台判断 | 耦合、难维护 | 用接口隔离,DI注入 |
| 忘记配置AGP 9新结构 | 构建失败 | 共享模块+独立入口模块 |
| 依赖版本不兼容 | 编译失败 | CMP 1.11+要求Kotlin 2.1+ |
| DateTimeFormatter不缓存 | 列表滚动卡顿 | 用对象缓存formatter实例 |
| 在commonMain用java.time | iOS编译失败 | 用kotlinx-datetime |
九、综合实战:一个KMP新闻应用
我们把这一课的知识串起来,做一个KMP新闻应用。
9.1 共享模块结构
shared/
├── src/
│ ├── commonMain/
│ │ ├── kotlin/
│ │ │ ├── data/
│ │ │ │ ├── ArticleApi.kt # Ktor API
│ │ │ │ ├── ArticleDao.kt # SQLDelight DAO
│ │ │ │ └── ArticleRepository.kt # Repository
│ │ │ ├── domain/
│ │ │ │ ├── Article.kt # 数据模型
│ │ │ │ └── GetArticlesUseCase.kt
│ │ │ ├── ui/
│ │ │ │ ├── App.kt # 根Composable
│ │ │ │ ├── ArticleListScreen.kt
│ │ │ │ └── ArticleDetailScreen.kt
│ │ │ └── di/
│ │ │ └── AppModule.kt # Koin模块
│ │ └── composeResources/
│ │ ├── values/strings.xml
│ │ └── values-zh/strings.xml
│ ├── androidMain/
│ │ └── kotlin/
│ │ └── MainActivity.kt
│ ├── iosMain/
│ │ └── kotlin/
│ │ └── MainViewController.kt
│ └── desktopMain/
│ └── kotlin/
│ └── main.kt
9.2 数据层
// commonMain/data/Article.kt
@Serializable
data class Article(
val id: Long,
val title: String,
val content: String,
val publishedAt: Long
)
// commonMain/data/ArticleApi.kt
class ArticleApi(private val client: HttpClient) {
suspend fun getArticles(): List<Article> =
client.get("https://api.example.com/articles").body()
}
// commonMain/data/ArticleRepository.kt
class ArticleRepository(
private val api: ArticleApi,
private val dao: ArticleDao
) {
fun getArticlesFlow(): Flow<List<Article>> = dao.getAllFlow()
suspend fun refresh(): Result<Unit> = try {
val remote = api.getArticles()
dao.insertAll(remote)
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
9.3 UI层
// commonMain/ui/App.kt
@Composable
fun App() {
MaterialTheme {
val navController = rememberNavController()
NavHost(navController, startDestination = "list") {
composable("list") {
ArticleListScreen(
onArticleClick = { id ->
navController.navigate("detail/$id")
}
)
}
composable("detail/{id}") { backStackEntry ->
val id = backStackEntry.arguments?.getString("id")?.toLongOrNull()
ArticleDetailScreen(articleId = id)
}
}
}
}
9.4 各平台入口
// androidMain
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent { App() }
}
}
// iosMain
fun MainViewController(): UIViewController =
ComposeUIViewController { App() }
// desktopMain
fun main() = application {
Window(onCloseRequest = ::exitApplication) {
App()
}
}
十、KMP的思维模型
最后,用一张“思维模型”来总结这一课:
第一层:新项目结构。 共享KMP库模块+各平台独立入口模块,与AGP 9.0对齐。
第二层:expect/actual。 编译时强制约束,优先用函数级,适合必须编译时确定的平台差异。运行时可替换的服务用接口+DI。
第三层:共享策略。 从单一 shared 模块开始,按需拆分 sharedLogic 和 sharedUI。共享优先,平台例外。
第四层:迁移策略。 从依赖树底层向上迁移,先数据层,再Domain层,最后UI层。
第五层:生态工具。 Koin(DI)、Ktor(网络)、SQLDelight(数据库)、Coil(图片)、kotlinx-serialization。
第六层:测试。 commonTest 写共享单元测试,用 kotlin.test。
贯穿始终的原则:共享优先,平台例外。 能共享的放 commonMain,不能共享的用 expect/actual 或接口+DI隔离在平台层。
十一、小结与下一课预告
这一课我们深入了KMP的工程实践。关键点回顾:
- 新项目结构:共享KMP库模块+各平台独立入口模块,与AGP 9.0对齐。
- expect/actual:编译时强制约束,优先用函数级,适合必须编译时确定的平台差异。运行时可替换的服务用接口+DI。
- 共享策略:从单一
shared模块开始,按需拆分sharedLogic和sharedUI。 - 迁移策略:从依赖树底层向上迁移,先数据层,再Domain层,最后UI层。
- 生态工具:Koin(DI)、Ktor(网络)、SQLDelight(数据库)、Coil(图片)、kotlinx-serialization。
- 测试:
commonTest写共享单元测试,用kotlin.test。 - 常见陷阱:commonMain导入平台API、过度使用expect/actual、预拆分模块、共享UI中写平台判断、忘记配置AGP 9新结构。
KMP的价值不在于“写一次跑所有平台”,而在于“把可共享的共享,把必须分开的分开”。 共享优先,平台例外——这是KMP工程实践的第一性原理。
下一课,我们会讲Compose与AI的结合——把生成式AI能力集成到Compose应用中。内容包括:如何调用大模型API、如何设计AI对话界面、流式响应的处理、AI辅助UI生成、以及Compose在AI时代的机遇与挑战。这是把Compose从“UI框架”提升到“智能应用框架”的关键一课。
课后练习建议:找一个你之前写的Compose页面,试着把它的数据层和Domain层迁移到KMP的 commonMain。不用一开始就迁移UI,只迁移Repository和UseCase。你会发现,业务逻辑的共享比UI共享更简单,收益也更大。
更多推荐


所有评论(0)