先说清楚要做什么
用 Compose Multiplatform 写一个双端待办:一份 Compose UI、一份业务与存储逻辑,产出 Android APK 和 iOS App。开山篇讲过”逻辑写一份”是 KMP 的第一卖点,本篇把它做成实物——”双端”这个约束让每个设计决策都变重:任何一行平台 API 都意味着它在另一端不存在,任何一处先读后写都意味着并发丢数据。工程选最简形态,版面留给”共享是怎么发生的”。
| 指标 | 目标 | 怎么测 |
|---|---|---|
| 双端功能一致 | 同一操作序列结果一致 | 跑文末验收清单 |
| 平台代码占比 | 平台目录合计约 20 行 | cloc 或 wc -l 统计各源集 |
| 修改只落一处 | 改规则只动 commonMain | 双端重装行为同时变化 |
整体架构:一个模块承载三层
composeApp(单模块)
├── commonMain App() UI + TaskRepository + Task ← 全部业务,100% 共享
└── androidMain / iosMain 各端入口 + 文件路径,合计约 20 行
| 层 | 选择 | 为什么 |
|---|---|---|
| UI | Compose Multiplatform(1.8 线) | iOS 自 1.8.0 稳定;Compose 语法不重讲,见Compose 框架入门 |
| 存储与序列化 | DataStore Preferences + kotlinx-serialization | 小数据键值场景;编译期生成无反射也能跑;Room/SQLDelight 要按目标配驱动,没到不上 |
| 状态 | Flow + collectAsState | 共享层不依赖 UI 框架;基础见协程与 Flow 基础 |
难点一:工程骨架——目标声明决定一切
用官方脚手架 kmp.jetbrains.com 生成工程(勾 Android + iOS),关键配置。克制建议:不要一上来拆多模块——复杂度用包结构表达,模块边界留给”共享产物要单独分发”的场景:
kotlin {
androidTarget()
val ios = listOf(iosArm64(), iosSimulatorArm64(), iosX64()) // 真机 + 两种模拟器
ios.forEach {
it.binaries.framework { baseName = "ComposeApp"; isStatic = true }
// baseName 是 Swift 侧 import 的模块名;isStatic 静态链接产物最小
}
}
// commonMainImplementation(libs.androidx.datastore.preferences) // 1.1 起官方多平台
// commonMainImplementation(libs.kotlinx.serialization.json)
⚠️ 反模式:把平台目录当第二套工程——”先在 androidMain 写好再移植”,业务逻辑出现两份副本。commonMain 从第一个文件起就承载全部业务,平台目录只配得上入口与路径。
难点二:数据层——写一份,落两端
存储选 DataStore:1.1 起官方多平台,唯一需要平台介入的只有文件路径,路径从平台入口传进来,业务代码不知道文件系统存在。
// commonMain/kotlin/todo/Task.kt
@Serializable
data class Task(val id: Long, val title: String, val done: Boolean = false)
// commonMain/kotlin/todo/TaskRepository.kt —— 不含一行平台判断
class TaskRepository(private val dataStore: DataStore<Preferences>) {
private val json = Json { ignoreUnknownKeys = true }
private val writeScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
private val KEY = stringPreferencesKey("tasks_json")
val tasks: Flow<List<Task>> = dataStore.data
.catch { emit(emptyList()) } // 首次启动文件不存在会抛 IO 异常
.map { p -> p[KEY]?.let { json.decodeFromString<List<Task>>(it) } ?: emptyList() }
fun add(title: String) = launchWrite { it + Task(nextId(it), title.trim()) }
fun toggle(id: Long) = launchWrite { l -> l.map { if (it.id == id) it.copy(done = !it.done) else it } }
fun remove(id: Long) = launchWrite { it.filterNot { t -> t.id == id } }
// 读-改-写必须在 edit 事务里完成:原子性挡住并发丢更新,自己 get 再 set 必丢
private fun launchWrite(block: (List<Task>) -> List<Task>) = writeScope.launch {
dataStore.edit { p ->
val cur = p[KEY]?.let { json.decodeFromString<List<Task>>(it) } ?: emptyList()
p[KEY] = json.encodeToString(block(cur))
}
}
private fun nextId(list: List<Task>) = (list.maxOfOrNull { it.id } ?: 0L) + 1L
}
一处取舍:写操作 fire-and-forget(UI 不等写盘,状态由 Flow 回推),小应用合理;要可靠写确认就改成 suspend 由调用方 await。⚠️ 反模式:把 nextId 算在 edit 事务之外——两次快速点击拿到同一个 id,勾选与删除互相误伤,id 自增必须依赖事务内读到的 current。
难点三:UI 与状态——单一数据源
// commonMain/kotlin/todo/App.kt —— Compose 语法与 Android 端同源
@Composable
fun App(repository: TaskRepository) {
val tasks by repository.tasks.collectAsState(initial = emptyList())
val scope = rememberCoroutineScope()
var input by remember { mutableStateOf("") }
Column(Modifier.fillMaxSize().systemBarsPadding().padding(16.dp)) {
Row {
OutlinedTextField(input, { input = it }, Modifier.weight(1f))
Button(onClick = {
if (input.isNotBlank()) scope.launch { repository.add(input) }
input = ""
}) { Text("添加") }
}
LazyColumn {
items(tasks, key = { it.id }) { task ->
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(task.done, { scope.launch { repository.toggle(task.id) } })
Text(task.title, Modifier.weight(1f))
Text("删", Modifier.clickable { scope.launch { repository.remove(task.id) } })
}
}
}
}
}
⚠️ 反模式:给 tasks 再存一份 mutableStateOf 副本——制造双源真相,界面和数据各走各的。UI 只 collect 不缓存,改数据只能走仓库方法。
难点四:两端入口——每端十行的接线
// androidMain/kotlin/todo/MainActivity.kt
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent { App(taskRepository(this)) }
}
}
private fun taskRepository(context: Context) =
TaskRepository(PreferenceDataStoreFactory.createWithPath(
produceFile = { File(context.filesDir, "tasks.preferences_pb").toPath() }))
// iosMain/kotlin/MainViewController.kt —— iOS 侧的全部 Kotlin 代码
fun MainViewController(): UIViewController =
ComposeUIViewController { App(taskRepository()) }
private fun taskRepository() =
TaskRepository(PreferenceDataStoreFactory.createWithPath(
produceFile = { (documentsDir() + "/tasks.preferences_pb").toPath() }))
private fun documentsDir(): String =
NSFileManager.defaultManager.URLForDirectory(
NSDocumentDirectory, NSUserDomainMask, null, true, null
)?.path ?: error("无法定位 Documents 目录")
Xcode 两步(官方 direct integration):加 Run Script 阶段放 Compile Sources 之前,内容 ./gradlew :composeApp:embedAndSignAppleFrameworkForXcode;Build Settings 关掉 User Script Sandboxing。Swift 侧包一层:
// ContentView.swift —— ComposeApp 是 framework 的 baseName
struct ContentView: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> UIViewController {
ComposeApp.Main_iosKt.MainViewController() // ComposeApp = baseName
}
func updateUIViewController(_ vc: UIViewController, context: Context) {}
}
注意 expect/actual 在这个工程里一处都没写:平台差异(入口与路径)前移到各端入口的组装函数。它服务的是”commonMain 内部必须调平台能力”,本工程业务层对平台零感知——开山篇”expect 克制”的极端兑现。
验证:验收清单与排障表
| 操作 | 两端一致的预期 |
|---|---|
| 添加两条待办 | 列表即时出现,id 递增 |
| 勾选一条 | 划线样式即时切换 |
| 删除后杀进程重开 | 被删的仍消失,剩余的原样还原(落盘验证) |
| 现象 | 根因在哪 | 怎么改 |
|---|---|---|
| iOS Run Script 失败 | User Script Sandboxing 未关 | 关掉 ENABLE_USER_SCRIPT_SANDBOXING |
| 找不到 ComposeApp 模块 | baseName 与 Swift import 不一致 | 对齐两个名字 |
| 改了 Kotlin 界面没变 | Xcode 缓存旧 framework | 手动跑 embedAndSign 再构建 |
| commonMain 编译报错 | 平台 API 泄漏 | 按语法要点对照表换库 |
真实数字
本篇未实测启动耗时与帧率,不提供性能数字。可数的事实只有结构性的:两端平台目录合计约 20 行,其余全在 commonMain——共享率是自己数出来的,不是宣传口径。公开口径:CMP 的 iOS 目标自 1.8.0(2025-05)起稳定。
总结
教会了什么:双端一致不是测出来的,是 commonMain 编译出来的;平台代码越少越好;expect/actual 在纯 CMP 应用可以一处不写;edit 事务是数据不丢的底座。什么时候不用:深用系统能力(相机、推送)或团队没 Mac 的,回各端原生;UI 要跟系统手感的,用纯 KMP 共享逻辑(开山篇路线一)。下一步:KMP 学习路线串全系列,配置细节查参考字典。
版本核实(以官方文档为准):CMP 1.8 线(截至 2025-07-15,官方发布页口径),iOS 自 1.8.0(2025-05)稳定;DataStore 多平台自 1.1 起,createWithPath 为官方 KMP 文档现行 API;embedAndSignAppleFrameworkForXcode 为官方 direct integration 文档现行内容。安装与构建命令不钉版本号。