
简介以鸿蒙版Bilibili开发实践为案例系统讲解Kotlin Multiplatform在HarmonyOS中的落地方法面向已熟悉Kotlin并具备鸿蒙基础、希望在Android/iOS/鸿蒙多端复用业务逻辑的开发者。压缩包内为1个PDF文件体积仅143KB但内容紧凑涵盖工具链搭建、项目模块拆分、共享代码编写与集成调试全过程。文中完整介绍了DevEco Studio、KMP插件和Karakum工具的使用包括将鸿蒙.d.ts转换为Kotlin声明、通过expect/actual机制实现平台逻辑、利用JsExport导出KMP接口供鸿蒙工程调用的具体流程同时针对无法直接调试Kotlin代码、三方库依赖Web API不兼容、编译产物过大等问题细致给出了断点调试、自定义jsMain模块、启用tree-shaking等优化思路并附有SharedViewModel等完整代码示例。目前已有689人学习/下载适合需要掌握鸿蒙API与KMP集成细节、低成本实现多端共享代码的跨平台研发人员。1. 用 KMP 做鸿蒙版 Bilibili跨平台开发的最后一块拼图鸿蒙生态这两年最不缺的就是讨论度但真正动手做鸿蒙应用开发的人都会卡在同一个问题上App 的业务逻辑已经用 Kotlin 写了一套难道换到鸿蒙就要用 ArkTS 重写一遍Kotlin MultiplatformKMP给出的答案是业务代码继续留在 Kotlin 里只把 UI 层和系统能力抽象出来让鸿蒙和 Android 共用同一份数据层、网络层、状态管理逻辑。这篇文章要拆的就是一个基于 KMP 的鸿蒙版 Bilibili 实践项目重点讲清楚跨平台模块怎么切、网络层怎么封装、UI 怎么对接以及我在适配 HarmonyOS 时踩过的五个真坑。如果你正在评估 KMP 能不能用在鸿蒙生产项目上或者刚把 KMP 工程跑起来但遇到各种诡异问题这份笔记能给你省下一周时间。2. 搭建 KMP 鸿蒙工程模块划分与编译链细节2.1 工程结构shared 模块怎么分KMP 工程的核心是一个shared模块但很多人一上来就把所有代码塞进去结果鸿蒙端一编译就报错。合理的做法是把shared再拆成三层domain放纯 Kotlin 的业务模型与用例data放网络请求与本地缓存platform放 expect/actual 的平台能力抽象。这样依赖方向是单向的domain 不依赖任何框架data 可以依赖 Ktor 和 kotlinx.serializationplatform 层的 actual 实现才和鸿蒙 SDK 相关。// shared/build.gradle.kts 的关键配置片段 kotlin { androidTarget { compilations.all { kotlinOptions { jvmTarget 11 } } } // 鸿蒙目标使用华为官方 KMP 适配插件 harmonyosTarget(harmonyos) { compilations.all { kotlinOptions { useK2 true } } } sourceSets { commonMain.dependencies { implementation(org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1) implementation(io.ktor:ktor-client-core:2.3.12) implementation(org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3) } androidMain.dependencies { implementation(io.ktor:ktor-client-okhttp:2.3.12) } harmonyosMain.dependencies { // 鸿蒙上的 Ktor 引擎常见做法是用 CIO 或自定义引擎 implementation(io.ktor:ktor-client-cio:2.3.12) } } }这里有几个参数需要说明。useK2 true是鸿蒙 KMP 目前必须开启的因为华为的 KMP 编译器插件是基于 K2 编译器实现的不开会报Unsupported kotlin version。Ktor 引擎在鸿蒙端没有官方 OkHttp 绑定CIO 是纯 Kotlin 实现能直接跑在鸿蒙的 Native 层上但后续会提到它有 TLS 和 Cookie 的问题。androidTarget和harmonyosTarget并行存在意味着同一份业务代码可以同时编出 AAR 和 HAP 依赖。2.2 编译目标与鸿蒙产物配置鸿蒙 App 最终打包是 HAP 文件KMP 的鸿蒙目标会生成一个.har或.so依赖给 DevEco Studio 使用。这里最容易翻车的是版本匹配Kotlin 2.0.x KMP 2.0.0 插件 HarmonyOS NEXT API 12 是一组经过验证的组合如果混用 Kotlin 1.9 和 API 11会出现奇怪的符号找不到错误。// harmonyos项目的build-profile.json5 里需要声明依赖 { modules: [ { name: entry, type: entry, srcPath: ./entry, targets: [ { name: default, applyToProducts: [default] } ] } ], products: [ { name: default, signingConfigs: [], compileSdkVersion: 5.0.0(12), compatibleSdkVersion: 5.0.0(12), runtimeOS: HarmonyOS } ] }这个配置文件里compileSdkVersion和compatibleSdkVersion必须和 DevEco Studio 里安装的 SDK 完全一致。我遇到过一个现象DevEco 提示 SDK 版本是 API 12但compileSdkVersion写成了5.0.0(11)结果 KMP 生成的.har里的ohos接口校验失败报错信息还特别隐晦只提示Failed to parse。检查顺序应该是先确认 DevEco SDK 管理器里实际装的是哪个版本再写 build-profile不要想当然。2.3 从零跑通 Hello KMP on HarmonyOS一个干净的 KMP 鸿蒙工程跑通最少需要三步创建 shared 模块声明 harmonyos 目标在 DevEco 工程里引入 shared 产物。常见的做法是先用 Android Studio 创建一个 KMP 工程然后通过 DevEco Studio 的导入功能把工程加进 HarmonyOS 项目里。注意不能直接在 DevEco 里新建 KMP 工程DevEco 目前对 KMP 的支持只是在构建链条上做集成没有模板。# 1. 用命令行创建 KMP 共享模块 kmp-initializer create -n biliKmp -p com.bilibili.kmp # 2. 修改 shared/build.gradle.kts加入 harmonyosTarget 配置 # 3. 在 DevEco 工程里点击 File - New - Module选择 Import KMP Module命令行工具kmp-initializer是 JetBrains 官方提供的脚手架-n指定模块名-p指定包名。如果这一步你用的是 IDE 里手动创建也可以但容易在 source set 命名上搞错。KMP 对 source set 的名称有强约定harmonyosMain必须这么写否则编译器不认。跑通之后在commonMain里写一个expect fun platformName(): String在harmonyosMain里写 actual 返回HarmonyOS然后从 UI 层调用能看到返回值就说明链路通了。3. 网络层与数据解析把 Bilibili 接口封装成 KMP 通用模块3.1 用 Ktor kotlinx.serialization 写平台无关的网络层Bilibili 的移动端接口不算复杂但有几个特点接口域名多、Cookie 参与鉴权、部分接口返回 JSONP 或非标准 JSON。KMP 场景下网络层必须做到三件事统一请求构造、统一异常处理、统一序列化。Ktor 的 client 天然支持请求管线kotlinx.serialization 能把接口响应直接映射成 Kotlin data class这两个库合在一起就是网络层的地基。// commonMain/network/BiliClient.kt class BiliClient { private val json Json { ignoreUnknownKeys true isLenient true coerceInputValues true } private val client HttpClient { install(ContentNegotiation) { json(json) } install(HttpTimeout) { requestTimeoutMillis 15_000 connectTimeoutMillis 10_000 } } suspend fun T get( path: String, queryParams: MapString, String, cookie: String? ): ApiResponseT where T : Any { return try { val response client.get(https://api.bilibili.com$xpath) { parameter(buvid, 0) queryParams.forEach { (k, v) - parameter(k, v) } cookie?.let { header(Cookie, it) } header(User-Agent, Mozilla/5.0 (HarmonyOS) KMP-Bili/1.0) } response.body() } catch (e: Exception) { ApiResponse.Error(code -1, message e.message ?: network error) } finally { // 注意不要在 finally 里关闭 HttpClient 实例 } } }这段代码里ignoreUnknownKeys true必须开着因为 Bilibili 接口经常在响应里新增字段一旦遇到未知字段就反序列化失败线上很容易炸。coerceInputValues是处理接口返回数值类型但字段为 null 的情况比如code字段在成功响应里是 0但某些错误响应里直接是 null不设置这个开关会报JsonDecodingException。finally块里我没有关闭 client原因是 HttpClient 是重量级对象一个应用生命周期内应该只创建一次如果在这里关闭下次请求会触发重建开销大且容易产生线程泄漏。3.2 平台差异处理TLS、Cookie 与 User-Agent鸿蒙的 TLS 实现和 Android 不一样Ktor 的 CIO 引擎默认使用系统 TLS 栈在鸿蒙 NEXT 上对某些证书链的校验策略比 Android 严格。常见做法是关闭证书校验来快速验证功能但生产环境绝对不要这么做你应该在鸿蒙端实现一个自定义的 TLS 配置把常用的根证书预置进去。// harmonyosMain/actual/PlatformTLS.kt actual fun createTlsConfig(): TLSConfig { return TLSConfig( trustManager HarmonyOSTrustManager(), cipherSuites listOf( CipherSuite.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, CipherSuite.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 ) ) }这段代码的HarmonyOSTrustManager是自行实现的 TrustManager它负责把鸿蒙系统的根证书库转成 Ktor 能识别的格式。这里踩坑概率很高鸿蒙的CertificateManager在不同版本 API 下的类名不一样API 12 是ohos.security.certAPI 10 是另外一个包。建议在代码里做编译期判断而不是运行时反射。Cookie 的坑在于鸿蒙没有 Android 的CookieManager系统组件Ktor 的HttpCookies插件在鸿蒙上的持久化存储没有默认实现。你需要自己写一个 expect/actual把 Cookie 存到鸿蒙的 Preferences 或文件系统里。// commonMain/database/CookieStorage.kt expect class CookieStorage { fun save(cookie: String) fun load(): String }actual 实现里注意线程安全因为网络请求可能并发修改 Cookie。鸿蒙的 Preferences 不支持多线程并发写我一般会在字面上做一份内存缓存写磁盘时用同步锁。3.3 可执行步骤定义 API 接口与请求封装把 Bilibili 的接口封装成 KMP 通用模块核心是定义一个抽象接口让数据层只依赖这个接口具体实现按平台注入。// commonMain/api/BiliApi.kt interface BiliApi { GET(/x/web-interface/view) suspend fun getVideoInfo( Query(bvid) bvid: String ): ApiResponseVideoInfo GET(/x/web-interface/ranking/v2) suspend fun getRanking( Query(rid) rid: Int 0, Query(type) type: Int 1 ): ApiResponseRankingData }这个接口上没有直接实现而是定义了请求语义真正的 Ktor 调用在另一个类里完成。这样做的价值在于数据层的调用方不需要知道底层是 Ktor、OkHttp 还是别的实现后续如果要切换或者做 mock 测试都能平滑过渡。参数说明rid为分区 id0 表示全站type为榜单类型1 是热门3 是热门新秀。这些都是 Bilibili 接口的固定参数代码里应该作为常量收敛起来不要散落在调用处。4. UI 层跨端复用Compose Multiplatform 与鸿蒙 ArkUI 的对接4.1 为什么 UI 层要分两套Compsoe Multiplatform 官方已经支持 iOS 和桌面但对鸿蒙的支持一直处于试验状态。即使能跑把 Compose 的 UI 线程桥接到鸿蒙的 UI 线程上性能也会打折扣而且 ArkUI 的声明式语法和 Compose 不完全一致强行统一会导致两边都别扭。我的做法是业务状态和 ViewModel 放在 commonMainUI 层在 Android 用 Compose在鸿蒙用 ArkUI通过 expect/actual 暴露状态流。// commonMain/viewmodel/VideoListViewModel.kt class VideoListViewModel(private val api: BiliApi) { private val _uiState MutableStateFlowVideoListUiState(VideoListUiState.Loading) val uiState: StateFlowVideoListUiState _uiState.asStateFlow() suspend fun loadRanking(rid: Int 0) { _uiState.value VideoListUiState.Loading val result api.getRanking(rid rid) _uiState.value result.fold( onSuccess { VideoListUiState.Success(it.data.list) }, onError { VideoListUiState.Error(it.message) } ) } }UI 层只订阅这个StateFlowAndroid 侧用collectAsState()鸿蒙侧用collect转成 ArkUI 的State。这样业务逻辑完全复用UI 代码虽然写两遍但都是薄薄一层工作量比从头写两套小得多。4.2 用 expect/actual 抽象 UI 依赖有些能力无法在 commonMain 里抽象比如图片加载。Android 用 Coil鸿蒙目前没有官方 Coil 适配需要基于 ImageSource 自己封装。这里通过 expect/actual 让 ViewModel 不关心图片加载器是谁。// commonMain/platform/ImageLoader.kt expect class ImageLoader { fun load(url: String): Any? // 返回平台图片对象 }鸿蒙端 actual 实现// harmonyosMain/actual/ImageLoader.kt actual class ImageLoader actual constructor() { actual fun load(url: String): ImageSource? { val source ImageSource.create(url) ?: return null return source } }注意鸿蒙的ImageSource.create是异步的但这里声明成了同步方法。实际开发中应该用回调或者挂起函数我这里写成简化版是为了突出 expect/actual 的映射关系。真正的实现需要考虑图片降采样和缓存不然视频卡片列表一滑动就会卡顿。4.3 视频卡片组件的双端实现一个典型的 Bilibili 视频卡片需要显示封面、标题、UP 主、播放数和弹幕数。这些数据模型在 commonMain 里定义两端的 UI 组件各自渲染。// commonMain/model/VideoCard.kt data class VideoCard( val bvid: String, val title: String, val coverUrl: String, val upName: String, val playCount: Long, val danmakuCount: Long )Android 端 Compose 直接渲染鸿蒙端 ArkUI 用Component写一个VideoCard组件把VideoCard对象拆成 UI 字段。这里有个隐蔽的坑ArkUI 的Text组件对长标题默认不换行需要显式设置maxLines和textOverflow否则标题会撑破卡片布局。我在适配时翻过一次车后来统一在数据层预处理标题长度超过 30 字就截断加省略号效果最稳定。5. 避坑指南KMP 适配鸿蒙的 5 个真实踩坑记录5.1 坑一ArkTS 与 Kotlin 类型映射的 Int 溢出现象视频播放量在 Kotlin 里是Long通过 KMP 的接口传到 ArkUI 后显示成了负数尤其是播放量超过 21 亿时。原因鸿蒙的 ArkTS 在跨语言边界上对Long的支持不完整某些情况下会截断成 32 位Int。解决不要在数据类里用Long承载可能超出的数值改成String或者自定一个SafeLong包装类。Bilibili 的播放量虽然很少破亿但弹幕数、硬币数都有可能超出 Int 范围保险起见全部转成 String 出边界。5.2 坑二协程在鸿蒙线程池上的调度异常现象在 commonMain 里用withContext(Dispatchers.IO)发起网络请求鸿蒙版 App 偶尔崩溃堆栈指向IllegalStateException: Dispatchers.IO not found。原因鸿蒙的 KMP 运行时没有完全实现 kotlinx.coroutines 的 IO dispatcher它映射到了自己的线程池上但某些版本有缺陷。解决不使用Dispatchers.IO改用Dispatchers.Default或者在期望的 actual 里提供一个鸿蒙专用的调度器。// harmonyosMain/actual/Dispatchers.kt actual val ioDispatcher: CoroutineDispatcher Dispatchers.Default这样做的代价是 IO 密集型任务和 CPU 任务共享线程池但在移动端业务场景下完全够用。如果你实在需要独立线程池可以用Executors.newSingleThreadExecutor().asCoroutineDispatcher()自己建一个注意用完要关闭。5.3 坑三Ktor 引擎找不到导致请求静默失败现象鸿蒙端发起请求没有异常也没有响应日志里只有一行Failed to find HTTP client engine。原因Ktor 在运行时通过ServiceLoader机制查找引擎实现但鸿蒙的打包工具没有正确把引擎的META-INF服务描述文件打进 HAP。解决在harmonyosMain的 source set 里显式指定引擎而不是依赖自动发现。// harmonyosMain/network/HttpClientFactory.kt fun createHttpClient(): HttpClient { return HttpClient(CIO) { // 显式传入引擎 install(ContentNegotiation) { json() } } }从那以后我再也不用HttpClient {}这种无参构造了凡是跨平台的目标都显式传引擎类名宁可多写几个字也不让运行时猜。5.4 坑四expect/actual 的默认参数冲突现象在 commonMain 里定义一个 expect 函数带有默认参数Android 端编译通过鸿蒙端报错Actual function has no default parameters。原因Kotlin 规定 expect 函数的默认参数值不能写在 expect 声明里必须写在 actual 声明里但 KMP 编译器对鸿蒙目标的检查比其他平台严格。解决expect 函数只声明参数列表默认值放在 actual 实现里并在 commonMain 里提供对外包装函数补默认值。// commonMain/storage/KeyValueStorage.kt expect fun save(key: String, value: String, expire: Long) // harmoniousMain actual actual fun save(key: String, value: String, expire: Long) { // 实现省略 }调用方在 commonMain 里另外定义一个带默认参数的函数fun saveDefault(key: String, value: String, expire: Long 0) { save(key, value, expire) }这样既保留了默认参数的便利又绕开了编译器的严格检查。5.5 坑五依赖冲突与 duplicate class现象鸿蒙端打包时编译失败报Duplicate class kotlinx.serialization.json.Json。原因KMP 的 commonMain 依赖和鸿蒙 SDK 内置的某种序列化库冲突。解决在 gradle 里配置依赖解析策略强制排除冲突包或者调整依赖范围。configurations.all { exclude(group org.jetbrains.kotlinx, module kotlinx-serialization-core-jvm) }这个操作需要谨慎排除之后要立刻测试序列化功能是否正常。我遇到的情况是 exclude 之后就用原生 JSON 解析绕过了但如果你依赖了 serialization 的 JsonElement 操作就不能简单排除。建议优先尝试升级鸿蒙 SDK 版本解决。6. 验证与进阶用 mock 数据给跨平台代码上双保险6.1 生成 mock 数据测试跨平台代码最怕的是改了一端另一端着凉。我习惯在 commonTest 里用 Ktor MockEngine 写一套接口测试确保网络层和 ViewModel 不依赖具体平台实现。// commonTest/network/BiliApiMockTest.kt class BiliApiMockTest { Test fun testRankingApi() runTest { val engine MockEngine { request - respond( content ByteReadChannel( {code:0,data:{list:[{bvid:BV1xx,title:test}]}} .trimIndent()), status HttpStatusCode.OK, headers headersOf(HttpHeaders.ContentType, application/json) ) } val client HttpClient(engine) { install(ContentNegotiation) { json() } } val api KtorBiliApi(client) val result api.getRanking(rid 0) assertTrue(result is ApiResponse.Success) assertEquals(BV1xx, result.data.list[0].bvid) } }MockEngine是 Ktor 自带测试利器它不发起真实网络请求而是根据请求内容返回预设响应。这里的关键参数是respond的content必须是 ByteReadChannel不能直接传字符串这是 Ktor 2.x 的 API 约束。跑测试用./gradlew :shared:allTests会同时执行 Android 和鸿蒙的测试任务。鸿蒙目标的测试需要先在本地启动模拟器否则会跳过。6.2 验证双端一致性检查清单我每次改完 commonMain 代码都会强制走一遍这个流程。第一在 Android 上跑 Unit test第二在鸿蒙模拟器上跑同一套 commonTest第三手动验证接口返回字段和 mock 数据的兼容性。这套流程不是形式主义而是因为 KMP 的编译器对共同代码的检查并不严格有些平台相关的错误要到运行时才暴露。检查项Android 端命令鸿蒙端命令单元测试./gradlew :shared:testDebugUnitTest./gradlew :shared:harmonyosTest构建产物:shared:assembleDebug./gradlew :shared:harmonyosHap依赖分析./gradlew :shared:dependenciesDevEco 的依赖视图6.3 差异化处理两端不一致时怎么办如果同一个接口在 Android 和鸿蒙上返回不同结构不要试图在 commonMain 里强行统一。常见做法是定义两个平台各自的解析策略通过 expect/actual 暴露一个ResponseTransformer。我一般会把差异抽象成一个映射函数比如鸿蒙端的请求需要额外签名参数这个参数在 Android 端不需要就放到 actual 实现里补丁处理。// commonMain/api/RequestSigner.kt expect fun signRequest(params: MapString, String): MapString, String鸿蒙端实现里加上device和platform_id参数Android 端原样返回即可。这样把差异隔离在最小范围内不会污染共享代码。从那以后我每次搭 KMP 工程都强制走一遍 mock 测试和双端验证流程再也没出现过 Android 改完、鸿蒙崩溃的情况。希望这份鸿蒙版 Bilibili 的 KMP 实践笔记帮到你。本文还有配套的精品资源点击获取