一、先讲一个真实的故事

我见过一个团队,用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 / DecomposeKMP导航库
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导入平台APIiOS链接阶段才报错用 expect/actual 抽象
过度使用expect/actual代码僵化运行时可替换的用接口+DI
预拆分模块不必要的复杂度从一个 shared 开始,按需拆分
共享UI中写平台判断耦合、难维护用接口隔离,DI注入
忘记配置AGP 9新结构构建失败共享模块+独立入口模块
依赖版本不兼容编译失败CMP 1.11+要求Kotlin 2.1+
DateTimeFormatter不缓存列表滚动卡顿用对象缓存formatter实例
在commonMain用java.timeiOS编译失败用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共享更简单,收益也更大。

Logo

一站式 AI 云服务平台

更多推荐