Kotlin Multiplatform + 声明式 UI 三端实战:从工程结构到鸿蒙适配
面向读者:客户端开发者(Android/iOS/鸿蒙),希望用 Kotlin Multiplatform (KMP) 共享业务逻辑,并用一套声明式 UI(Compose 风格)尽可能复用 UI 与组件能力。
核心目标 :用"能跑起来、能扩展、能上线"的工程视角,把三端共享做到可维护,并重点讲清楚鸿蒙侧常见缺口(导航、生命周期、图片、原生控件嵌入)如何补齐。
1. 你真正要解决的问题:三端一致性 + 研发效率 + 可控的技术债
很多人第一次接触 KMP,会把它想成"写一次,到处跑"。但工程落地里更准确的目标是:
- 共享非 UI 的业务能力
- 如:网络请求、数据层 (DTO / Repository)、业务规则、埋点策略、序列化、权限决策、状态机、播放器控制逻辑等。
- UI 尽量一致,但允许"平台特化"
- 声明式 UI 能让三端 UI 复用率显著提升;但遇到 WebView、音视频、系统能力(相册/通知/后台任务)时,必须做平台桥接。
- 工程化可持续
- 跨端不是"把代码搬过去",而是构建一条长期可演进的链路:模块边界、依赖注入、编译产物、调试与发布、监控与回滚。
你可以把三端项目抽象成一张分层图:
text
+---------------------------------------------------------+
| 共享 UI/组件层 (声明式 UI) |
+----------------------------+----------------------------+
|
v
+---------------------------------------------------------+
| 状态管理 (MVI/Reducer/ViewModel-like) |
+----------------------------+----------------------------+
|
v
+---------------------------------------------------------+
| 共享业务层 (UseCase/Interactor) |
+----------------------------+----------------------------+
|
v
+---------------------------------------------------------+
| 共享数据层 (Repository/Cache/Network) |
+----------------------------+----------------------------+
|
v
+---------------------------------------------------------+
| 平台能力 (存储/网络栈差异/多媒体/WebView/系统API) |
+---------------------------------------------------------+
越往下越"不可共享",越往上越"可共享"。 你真正要做的是:把"不可共享"的部分收敛成少数、稳定、可测试的接口。
2. 推荐的工程结构:Common + Platform Main 的"最小正确姿势"
2.1 目录与模块建议
一个对新人友好、对大团队也能扩展的结构是:
text
shared/ (KMP)
├── commonMain/ # 共享核心(路由、网络封装、状态管理、通用组件、资源管理抽象)
├── androidMain/ # Android 实现(文件系统、日志、原生控件适配)
├── iosMain/ # iOS 实现
└── harmonyMain/ # 鸿蒙实现(ArkTS/系统 API / C API 适配点)
app-android/ # Android 壳工程(入口、权限、打包、发布)
app-ios/ # iOS 壳工程
app-harmony/ # 鸿蒙壳工程
在 commonMain 再做一次模块拆分(强烈建议):
core/:路由、网络、图片加载抽象、基础组件库、日志与埋点接口feature-xxx/:按业务域拆(列表页、详情页、播放器页、Web 页......)foundation/:工具与基础设施(协程、序列化、时间、格式化)
这样做的核心价值是:跨端不是一个大泥球,每个 feature 都能独立演进、独立测试。
3. KMP 基础:expect/actual + 能力注入,让平台差异"可控"
3.1 用 expect/actual 处理最底层平台差异
例如日志、设备信息、文件读写这些"不可共享"的能力:
kotlin
// commonMain
expect object PlatformLog {
fun i(tag: String, msg: String)
fun e(tag: String, msg: String, tr: Throwable? = null)
}
expect class PlatformDevice {
fun osName(): String
fun osVersion(): String
}
// androidMain
actual object PlatformLog {
actual fun i(tag: String, msg: String) = android.util.Log.i(tag, msg)
actual fun e(tag: String, msg: String, tr: Throwable?) =
android.util.Log.e(tag, msg, tr)
}
actual class PlatformDevice {
actual fun osName() = "Android"
actual fun osVersion() = android.os.Build.VERSION.RELEASE ?: "unknown"
}
// iosMain (示意)
import platform.Foundation.NSLog
import platform.UIKit.UIDevice
actual object PlatformLog {
actual fun i(tag: String, msg: String) = NSLog("I/%s: %s", tag, msg)
actual fun e(tag: String, msg: String, tr: Throwable?) =
NSLog("E/%s: %s, %s", tag, msg, tr?.message ?: "")
}
actual class PlatformDevice {
actual fun osName() = "iOS"
actual fun osVersion() = UIDevice.currentDevice.systemVersion
}
💡 核心规则科普:
- 不是继承关系 :
expect和actual不是父类与子类的关系,而是声明与实现的关系(类似 C/C++ 的头文件与源文件)。编译器会保证它们在最终产物中是同一个类。- 同包同名 :必须保证
package路径完全一致,类名/函数名完全一致。- Typealias 黑魔法 :
actual甚至可以直接是一个typealias,比如actual typealias MyUUID = java.util.UUID,这样能零成本复用平台既有类库,无需由你重新实现一遍 wrapper。
鸿蒙侧同理,关键是:让"平台能力"集中在少数文件里,而不是散落在业务代码里。
3.2 服务注入 (Service Locator / DI) 更适合大工程的渐进迁移
expect/actual 适合"少量底层能力"。但当你要桥接 WebView、播放器、图片解码、埋点 SDK 等复杂能力时,更推荐"接口 + 注入"。
kotlin
// commonMain
interface WebContainer {
fun open(url: String)
}
object Services {
lateinit var web: WebContainer
}
class ArticleOpenUseCase {
fun openInWeb(url: String) {
Services.web.open(url)
}
}
三端在启动时注入实现:
- Android:用 Activity / Fragment / WebView 容器实现
- iOS:用 WKWebView 容器实现
- 鸿蒙:用 ArkWeb / 自己的 Web 组件实现
kotlin
// 1. Android (在 Application.onCreate 中注入)
Services.web = object : WebContainer {
override fun open(url: String) {
val intent = Intent(applicationContext, WebViewActivity::class.java)
intent.putExtra("url", url)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
applicationContext.startActivity(intent)
}
}
// 2. iOS (在 AppDelegate.didFinishLaunching 中注入)
// Kotlin 的 object 在 Swift 中对应 shared 单例
Services.shared.web = IosWebContainer()
class IosWebContainer: WebContainer {
func open(url: String) {
// 调用 SFSafariViewController 或自定义 WKWebView
}
}
// 3. HarmonyOS (在 EntryAbility.onCreate 中注入)
// 鸿蒙 Next 目前主流通过 C-Interop (NAPI) 与 KMP (Native) 交互
// 假设我们在 C++ 层暴露了注册函数
nativeBridge.registerWebImpl((url: string) => {
router.pushUrl({ url: 'pages/WebPage', params: { path: url } });
})
优点:你可以在不动共享业务代码的情况下,替换平台实现;而且易于 Mock,利于单测。
4. 共享 UI 的关键:状态驱动 + 可替换的路由
声明式 UI 的核心是:UI = f(state)。一旦你把状态管理统一,三端 UI 复用会顺滑很多。
这里我们使用 MVI (Model-View-Intent) 模式。如果这听起来很抽象,你可以把它理解为**"更严格、更单向的 MVVM"**。
核心思想图解:
text
+--------+ (1) User Action (Intent) +---------+
| | -----------------------------------> | |
| UI | | Store |
| (View) | <----------------------------------- | |
+--------+ (3) New State (StateFlow) +---------+
|
| (2) reduce(oldState, intent)
v
+---------+
| Reducer |
+---------+
4.1 核心组件详解
-
State (状态) : 页面上所有能变的东西(加载圈、列表数据、错误提示),都必须定义在一个 data class 里。
-
Intent (意图) : 用户在页面上所有能做的操作(点击刷新、点击列表项),都必须定义为一个 sealed interface。
-
Reducer (状态机) : 一个纯函数,输入"当前状态"和"用户意图",输出"新状态"。它是逻辑的核心,且完全不依赖 Android/iOS SDK,纯 Kotlin 实现,极易测试。
💡 为什么要叫 Reducer?
这个名字来自函数式编程中的
reduce(归约)操作。想象你有一堆数字
[1, 2, 3],你想把它们归约 成一个和。你会怎么做?0 + 1 = 1,1 + 2 = 3,3 + 3 = 6。在这里是一样的:你有一堆用户操作 (Intent 1, Intent 2...),你想把它们归约 成一个当前状态 。
公式是:
新状态 = reduce(旧状态, 用户操作)。它不代表状态本身,它代表**"如何通过操作算出新状态"的规则**。
kotlin
// commonMain
// 1. 定义页面长什么样 (State)
data class HomeState(
val loading: Boolean = false,
val items: List<String> = emptyList(),
val error: String? = null
)
// 2. 定义用户能做什么 (Intent)
sealed interface HomeIntent {
data object Refresh : HomeIntent
data class ClickItem(val id: String) : HomeIntent
}
// 3. 定义状态如何变化 (Reducer)
class HomeReducer {
// 纯逻辑:给我旧状态和用户意图,我算出一个新状态给你
fun reduce(state: HomeState, intent: HomeIntent): HomeState {
return when (intent) {
HomeIntent.Refresh -> state.copy(loading = true, error = null)
// 点击事件可能只需跳转,不改变当前页数据,这里原样返回
is HomeIntent.ClickItem -> state
}
}
}
4.2 状态容器 (Store)
Reducer 只是逻辑,我们需要一个"容器"来持有状态,并通知 UI 刷新。这类似于 Android 的 ViewModel。
- MutableStateFlow : 类似于
MutableLiveData,它持有当前值,改变时会通知订阅者。 - StateFlow : 类似于
LiveData(只读版),暴露给 UI 层监听。
kotlin
// commonMain
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
class Store<S, I>(
initial: S,
private val reducer: (S, I) -> S,
private val scope: CoroutineScope
) {
// 内部持有的可变状态 (就像 MutableLiveData)
private val _state = MutableStateFlow(initial)
// 暴露给外部的只读状态 (就像 LiveData)
// UI 层订阅它:store.state.collect { state -> updateUI(state) }
val state: StateFlow<S> = _state.asStateFlow()
// UI 层唯一能做的事:派发意图 (Intent)
fun dispatch(intent: I) {
val oldState = _state.value
val newState = reducer(oldState, intent)
_state.value = newState // 更新状态,StateFlow 会自动通知所有观察者
}
}
为什么这么折腾?
- 消除"平台分裂":Android Activity 和 iOS ViewController 不再处理逻辑,只负责"画图"和"监听"。
- 逻辑百分百复用 :
Reducer里的逻辑(比如刷新时清空 error)写一次,三端生效。 - Debug 神器 :只要打印出
Intent的序列,你就能完美复现任何 Bug(时间旅行调试)。
5. 鸿蒙常见挑战 1:没有现成导航组件?自己做一个"可用路由栈"
某些平台/框架环境下,你可能拿不到成熟的导航组件(或功能不完整)。这时不要纠结"完美导航",先实现一版 可维护 + 状态可保留 的路由栈。
5.1 路由数据结构
kotlin
// commonMain
sealed class Screen(val key: String) {
data object Home : Screen("home")
data class Detail(val id: String) : Screen("detail:$id")
data class Web(val url: String) : Screen("web:$url")
}
class Router(initial: Screen) {
private val stack = MutableStateFlow(listOf(initial))
val backStack: StateFlow<List<Screen>> = stack
fun push(s: Screen) { stack.value = stack.value + s }
fun pop(): Boolean {
val cur = stack.value
if (cur.size <= 1) return false
stack.value = cur.dropLast(1)
return true
}
}
5.2 用 remember + zIndex 做"页面保留"
很多同学一开始会"只渲染栈顶页面",结果栈底页面状态丢失(列表滚动、输入框内容、播放器状态)。
更可用的做法是:栈内页面都渲染,但只显示栈顶,用 zIndex 控制显示层级。
下面是 Compose 风格的伪代码(表达思想,不依赖具体实现细节):
kotlin
@Composable
fun AppRoot(router: Router) {
val stack by router.backStack.collectAsState()
Box {
stack.forEachIndexed { index, screen ->
val visible = index == stack.lastIndex
Box(
modifier = Modifier
.fillMaxSize()
.zIndex(index.toFloat())
.alpha(if (visible) 1f else 0f) // 或者 Offscreen + pointerInput 拦截
) {
ScreenHost(screen, router)
}
}
}
}
要点:
- 保留页面:页面状态不丢;
- 栈顶可交互:用 alpha / hitTest 控制;
- 后退 :
router.pop()即可。
当你后续拿到更成熟的导航方案,也可以无痛替换,因为你的业务代码只依赖 Router。
6. 鸿蒙常见挑战 2:没有"自动绑定生命周期"的 ViewModel?
有些平台环境里,你不一定能直接使用"与页面生命周期自动绑定"的 ViewModel(或能力不一致)。此时建议用组合式封装:
remember(key):创建并缓存对象DisposableEffect:在 Composable 离开时做清理(取消协程、释放资源)
6.1 一个跨端"Presenter/VM-like" 模板
kotlin
// commonMain
interface Presenter {
fun onStart()
fun onStop()
fun onDispose()
}
@Composable
fun <T : Presenter> rememberPresenter(
key: Any?,
factory: () -> T
): T {
val presenter = remember(key) { factory() }
DisposableEffect(presenter) {
presenter.onStart()
onDispose {
presenter.onStop()
presenter.onDispose()
}
}
return presenter
}
业务页使用:
kotlin
@Composable
fun DetailScreen(id: String) {
val p = rememberPresenter(key = id) { DetailPresenter(id) }
val state by p.state.collectAsState()
// UI = f(state)
}
工程收益:你在三端都用同一套 "Presenter 生命周期模型",不依赖平台特定的 ViewModel 管理机制。
7. 鸿蒙常见挑战 3:原生能力交互(WebView / 音视频)怎么做?
跨端 UI 再强,也不可能把所有原生能力都"纯跨端化"。正确姿势是:
- 共享层 只定义
接口 + 数据模型 + 调用协议 - 平台层 实现
原生组件容器 - UI 层 通过
Composable 包装器把原生组件嵌进来
7.1 Web 容器接口(共享层)
kotlin
// commonMain
data class WebRequest(val url: String, val headers: Map<String, String> = emptyMap())
interface WebEngine {
fun open(req: WebRequest)
fun close()
}
object Capabilities {
lateinit var webEngine: WebEngine
}
7.2 UI 层的跨端入口
kotlin
@Composable
fun WebPage(url: String) {
// 你可以在这里放一个统一的 Loading/错误处理 UI
Button(onClick = { Capabilities.webEngine.open(WebRequest(url)) }) {
Text("打开网页")
}
}
平台侧实现真正的 WebView / ArkWeb / WKWebView,并处理生命周期、返回键、Cookie 同步等。
7.3 需要跨语言互调?优先走"声明式桥接协议"
如果鸿蒙侧存在 Kotlin 与 ArkTS 双语言互调,你会遇到两类问题:
- 方法签名、线程模型、对象生命周期、异常传递
- 数据结构跨语言序列化 (Map/List/自定义对象)
推荐做法是:让跨语言调用"可声明、可审计、可测试",例如:
- 用注解/IDL 描述可调用 API(生成胶水代码)
- 数据统一走 JSON/Protobuf(明确字段与版本)
- 统一线程:UI 线程/后台线程切换封装在桥接层
一个简化示意(不绑定具体框架):
kotlin
// commonMain - 定义桥接服务
interface NativeBridge {
fun openWeb(url: String)
fun playAudio(id: String)
fun stopAudio()
}
object Bridges {
lateinit var native: NativeBridge
}
平台侧把 ArkTS/Swift/Java 的实现注入进来即可。共享层只关心"要做什么",不关心"怎么做"。
8. 鸿蒙常见挑战 4:图片加载库缺口?先做"能用的 AsyncImage"
当某个平台暂时没有成熟的异步图片加载库(或你暂时不想引入大依赖),你至少需要:
- 内存缓存 (LRU)
- 并发控制(避免同一 URL 重复下载)
- 解码与渲染分离(解码在后台,渲染在主线程)
- 失败重试与占位图
8.1 共享层:最小图片加载接口
kotlin
// commonMain
data class ImageKey(val url: String)
interface ImageLoader {
suspend fun load(url: String): ByteArray // 简化:返回原始 bytes,平台侧解码
}
object Images {
lateinit var loader: ImageLoader
}
8.2 一个简单 LRU 缓存(共享层)
kotlin
// commonMain
class LruCache<K, V>(private val maxSize: Int) {
private val map = LinkedHashMap<K, V>(16, 0.75f, true)
fun get(key: K): V? = map[key]
fun put(key: K, value: V) {
map[key] = value
if (map.size > maxSize) {
val it = map.entries.iterator()
if (it.hasNext()) {
it.next()
it.remove()
}
}
}
}
8.3 Compose 风格 AsyncImage(示意)
kotlin
@Composable
fun AsyncImage(
url: String,
placeholder: @Composable () -> Unit,
content: @Composable (bytes: ByteArray) -> Unit
) {
val cache = remember { LruCache<String, ByteArray>(maxSize = 50) }
var bytes by remember(url) { mutableStateOf<ByteArray?>(null) }
var error by remember(url) { mutableStateOf<String?>(null) }
LaunchedEffect(url) {
cache.get(url)?.let { bytes = it; return@LaunchedEffect }
runCatching { Images.loader.load(url) }
.onSuccess { data ->
cache.put(url, data)
bytes = data
}
.onFailure { e ->
error = e.message ?: "load failed"
}
}
when {
bytes != null -> content(bytes!!)
error != null -> placeholder() // 你也可以做 error UI
else -> placeholder()
}
}
平台侧负责把 ByteArray 解码成可渲染的图片对象(Android Bitmap / iOS UIImage / 鸿蒙 PixelMap)。
等后续你引入成熟图片库(或做 C API 适配)时,替换 ImageLoader 实现即可,UI 层不用动。
9. AI 辅助 UI:正确用法是"生成骨架 + 人来做工程收敛"
AI 做 UI 很容易让新人"兴奋过头":生成一大坨代码,跑是能跑,但完全不可维护。更好的方式是:
- AI 生成 页面布局骨架(组件树、约束关系、间距)
- 你来做 组件抽取 (Design System)
- 你来做 状态与事件收敛 (Intent/Action)
- 你来做 性能与可访问性(长列表、重组、图片、字体缩放)
一个可复制的落地流程:
text
+---------------------+
| 设计稿/草图 |
+----------+----------+
|
v
+---------------------------+
| AI生成Compose布局骨架 |
+----------+----------------+
|
v
+---------------------------+
| 抽公共组件 |
| (Button/Card/Cell) |
+----------+----------------+
|
v
+---------------------------+
| 接入状态管理 |
| (MVI/Reducer) |
+----------+----------------+
|
v
+---------------------------+
| 接入平台能力 |
| (Web/Audio/Image) |
+----------+----------------+
|
v
+---------------------------+
| 性能&稳定性 |
| (列表/缓存/监控) |
+---------------------------+
底线:AI 代码只能当"初稿",工程质量要靠你用模块化、状态驱动、可观测性把它"收敛成产品"。
10. 长列表与性能:跨端 UI 复用的第一道门槛
你做资讯流/列表页,最容易踩的是:
- Item 过度重组 (state 粒度太大)
- 图片加载抖动、重复请求
- 滚动性能:测不到、定位不了、无法对齐三端差异
建议你从第一天就加上三个"硬指标":
- 首屏渲染耗时 (TTI / First Frame)
- 列表滚动帧率(掉帧率、长帧)
- 图片命中率(内存缓存、磁盘缓存、网络请求次数)
并且把状态拆小,让列表 item 只订阅它自己的状态切片:
kotlin
// 不要:整个列表订阅一个巨大 state,导致任何变更都重组全列表
// 建议:列表只订阅 items,item 内订阅 itemState
11. 发布与调试:别等"最后一周"才发现跨端工程不可发
跨端项目最常见的"翻车点"不是功能,而是工程链路:
- 产物怎么出 (framework/aar/so)
- 版本怎么管 (shared 版本、平台壳版本)
- 符号化怎么做 (崩溃堆栈、映射文件)
- 多仓合入怎么排队、怎么灰度、怎么回滚
给你一份可直接抄的工程 Checklist:
11.1 发布前 Checklist
- shared 模块版本号与三端壳工程版本号解耦(避免强绑定)
- 每次发布产物包含:版本、commit、构建参数、依赖锁定信息
- 崩溃符号化链路打通:iOS dSYM、Android mapping、鸿蒙侧符号文件
- 可回滚:壳工程支持降级 shared(或灰度切换实现)
11.2 线上问题 Checklist
- 崩溃归因:共享层 / 平台层 / 桥接层 三段定位
- 性能卡顿:渲染、GC、图片解码、Web/音视频线程切换
- 兜底开关:关闭新路由 / 关闭图片新实现 / 关闭原生嵌入
12. 一个"从 0 到 1"的最小闭环:建议你先做这两个功能
如果你要在 2~4 周内把三端打通,我建议优先做两件事:
- 列表页 + 详情页(共享 UI + 共享状态)
- 验证:路由、状态管理、网络、图片、长列表性能。
- 一个原生嵌入能力(Web 或音频二选一)
- 验证:桥接层的可维护性、生命周期管理、返回键/前后台处理。
这两个跑通后,再扩展其他 feature,成功率会高很多。
结语:跨端不是"省代码",而是"省协作成本"
KMP + 声明式 UI 的真正价值,往往不是"少写 30% 代码",而是:
- 业务逻辑一致:减少三端对齐成本
- 迭代节奏一致:减少"Android 先上线、iOS 追两周、鸿蒙再补一个月"
- 质量体系一致:同一套埋点、同一套容错、同一套状态机
最后给一句非常工程化的建议 :先把"平台差异"收敛成接口,再谈复用率。
只要你的边界清晰,哪怕今天 UI 复用 50%,未来也能稳步涨到 80%;反过来,一开始就追求 90% 复用,很容易变成"跨端大泥球"。