Kotlin Multiplatform + 声明式 UI 三端实战:从工程结构到鸿蒙适配

Kotlin Multiplatform + 声明式 UI 三端实战:从工程结构到鸿蒙适配

面向读者:客户端开发者(Android/iOS/鸿蒙),希望用 Kotlin Multiplatform (KMP) 共享业务逻辑,并用一套声明式 UI(Compose 风格)尽可能复用 UI 与组件能力。

核心目标 :用"能跑起来、能扩展、能上线"的工程视角,把三端共享做到可维护,并重点讲清楚鸿蒙侧常见缺口(导航、生命周期、图片、原生控件嵌入)如何补齐。


1. 你真正要解决的问题:三端一致性 + 研发效率 + 可控的技术债

很多人第一次接触 KMP,会把它想成"写一次,到处跑"。但工程落地里更准确的目标是:

  1. 共享非 UI 的业务能力
    • 如:网络请求、数据层 (DTO / Repository)、业务规则、埋点策略、序列化、权限决策、状态机、播放器控制逻辑等。
  2. UI 尽量一致,但允许"平台特化"
    • 声明式 UI 能让三端 UI 复用率显著提升;但遇到 WebView、音视频、系统能力(相册/通知/后台任务)时,必须做平台桥接。
  3. 工程化可持续
    • 跨端不是"把代码搬过去",而是构建一条长期可演进的链路:模块边界、依赖注入、编译产物、调试与发布、监控与回滚。

你可以把三端项目抽象成一张分层图:

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
}

💡 核心规则科普

  1. 不是继承关系expectactual 不是父类与子类的关系,而是声明与实现的关系(类似 C/C++ 的头文件与源文件)。编译器会保证它们在最终产物中是同一个类。
  2. 同包同名 :必须保证 package 路径完全一致,类名/函数名完全一致。
  3. 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 核心组件详解

  1. State (状态) : 页面上所有能变的东西(加载圈、列表数据、错误提示),都必须定义在一个 data class 里。

  2. Intent (意图) : 用户在页面上所有能做的操作(点击刷新、点击列表项),都必须定义为一个 sealed interface。

  3. 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 再强,也不可能把所有原生能力都"纯跨端化"。正确姿势是:

  1. 共享层 只定义 接口 + 数据模型 + 调用协议
  2. 平台层 实现 原生组件容器
  3. 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 很容易让新人"兴奋过头":生成一大坨代码,跑是能跑,但完全不可维护。更好的方式是:

  1. AI 生成 页面布局骨架(组件树、约束关系、间距)
  2. 你来做 组件抽取 (Design System)
  3. 你来做 状态与事件收敛 (Intent/Action)
  4. 你来做 性能与可访问性(长列表、重组、图片、字体缩放)

一个可复制的落地流程

text 复制代码
+---------------------+
|     设计稿/草图      |
+----------+----------+
           |
           v
+---------------------------+
|   AI生成Compose布局骨架     |
+----------+----------------+
           |
           v
+---------------------------+
|        抽公共组件          |
|    (Button/Card/Cell)     |
+----------+----------------+
           |
           v
+---------------------------+
|       接入状态管理         |
|      (MVI/Reducer)        |
+----------+----------------+
           |
           v
+---------------------------+
|       接入平台能力         |
|    (Web/Audio/Image)      |
+----------+----------------+
           |
           v
+---------------------------+
|       性能&稳定性          |
|     (列表/缓存/监控)       |
+---------------------------+

底线:AI 代码只能当"初稿",工程质量要靠你用模块化、状态驱动、可观测性把它"收敛成产品"。


10. 长列表与性能:跨端 UI 复用的第一道门槛

你做资讯流/列表页,最容易踩的是:

  • Item 过度重组 (state 粒度太大)
  • 图片加载抖动、重复请求
  • 滚动性能:测不到、定位不了、无法对齐三端差异

建议你从第一天就加上三个"硬指标"

  1. 首屏渲染耗时 (TTI / First Frame)
  2. 列表滚动帧率(掉帧率、长帧)
  3. 图片命中率(内存缓存、磁盘缓存、网络请求次数)

并且把状态拆小,让列表 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 周内把三端打通,我建议优先做两件事:

  1. 列表页 + 详情页(共享 UI + 共享状态)
    • 验证:路由、状态管理、网络、图片、长列表性能。
  2. 一个原生嵌入能力(Web 或音频二选一)
    • 验证:桥接层的可维护性、生命周期管理、返回键/前后台处理。

这两个跑通后,再扩展其他 feature,成功率会高很多。


结语:跨端不是"省代码",而是"省协作成本"

KMP + 声明式 UI 的真正价值,往往不是"少写 30% 代码",而是:

  • 业务逻辑一致:减少三端对齐成本
  • 迭代节奏一致:减少"Android 先上线、iOS 追两周、鸿蒙再补一个月"
  • 质量体系一致:同一套埋点、同一套容错、同一套状态机

最后给一句非常工程化的建议 :先把"平台差异"收敛成接口,再谈复用率。

只要你的边界清晰,哪怕今天 UI 复用 50%,未来也能稳步涨到 80%;反过来,一开始就追求 90% 复用,很容易变成"跨端大泥球"。

相关推荐
世人万千丶2 小时前
鸿蒙跨端框架 Flutter 学习 Day 3:综合实践——多维数据流与实时交互实验室
学习·flutter·华为·交互·harmonyos·鸿蒙
世人万千丶2 小时前
鸿蒙跨端框架 Flutter 学习 Day 3:工程实践——数据模型化:从黑盒 Map 走向强类型 Class
学习·flutter·ui·华为·harmonyos·鸿蒙·鸿蒙系统
aqi002 小时前
FFmpeg开发笔记(九十九)基于Kotlin的国产开源播放器DKVideoPlayer
android·ffmpeg·kotlin·音视频·直播·流媒体
航Hang*3 小时前
Photoshop 图形与图像处理技术——第9章:实践训练3——图像修饰和色彩色调的调整
图像处理·笔记·学习·ui·photoshop·期末
移幻漂流3 小时前
Kotlin与Java共生之道:解密互操作底层原理与最佳实践
java·python·kotlin
大雷神4 小时前
Harmony App 开发中Flutter 与鸿蒙原生交互传参教程
flutter·交互·harmonyos
安卓理事人4 小时前
鸿蒙的“官方推荐”架构MVVM
华为·架构·harmonyos
小雨青年4 小时前
鸿蒙 HarmonyOS 6 | 逻辑核心 (06):本地 关系型数据库 (RDB) 的 CRUD 与事务处理
数据库·华为·harmonyos
小白阿龙4 小时前
鸿蒙+flutter 跨平台开发——鸿蒙版多功能计算器
flutter·华为·harmonyos