ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

鸿蒙KMP开发实战:三条落地路径与NAPI桥接避坑指南

鸿蒙KMP开发实战:三条落地路径与NAPI桥接避坑指南 简介面向熟悉Kotlin并有一定鸿蒙开发经验的开发者这份资源以鸿蒙版Bilibili为案例系统讲解Kotlin MultiplatformKMP在鸿蒙应用中的跨平台实现帮助读者在鸿蒙、Android、iOS等多端复用业务逻辑有效降低开发与维护成本。压缩包内为1个PDF文档大小仅143KB内容高度浓缩便于快速通读与随时查阅。目前已有689人浏览学习。文档从环境准备讲起覆盖DevEco Studio、KMP插件与Karakum工具链的配置清晰划分公共模块、Android、iOS与鸿蒙模块的工程结构核心部分完整演示了用Karakum将鸿蒙API声明转换为Kotlin文件、在commonMain中编写共享逻辑、通过expect/actual实现平台特定代码以及利用JsExport将KMP代码导出为.js和.d.ts并集成进鸿蒙项目的关键步骤。调试与优化章节则针对无法直接调试Kotlin代码、三方库兼容性、编译产物体积过大等真实痛点给出具体解决思路并附完整代码示例非常适合边读边动手实践。1. 用KMP做鸿蒙版Bilibili真正能共享的是哪一层先给结论再谈方案做鸿蒙应用开发的团队现在普遍卡在同一个问题上手头有Android或iOS的成熟代码换到HarmonyOS NEXT上要重写UI能不能把底层那套网络、缓存、解析、签名逻辑原封不动搬过去Kotlin MultiplatformKMP恰好是拿来干这个的——一套Kotlin业务逻辑编译到Android、iOS、JVM甚至Native库UI层各端自己写。Bilibili这类以信息流列表和视频播放为主的App架构上最能被复用的恰恰是接口封装、WBI签名、推荐流解析这部分脏活ArkUI这边只要写页面和交互。但先说透一个反直觉的结论KMP目前并没有官方OpenHarmony target所以“鸿蒙版KMP”有三种实现路径省事程度和长期收益完全不同。这篇文章按我实际做项目的顺序把路径选型、共享层搭建、NAPI桥接到高频翻车点一次讲完。适合已有KMP代码、或正打算用一套业务逻辑同时覆盖鸿蒙和Android的团队参考。2. 鸿蒙KMP三条落地路径兼容APK、Native库透传与社区OHOS插件怎么选2.1 先判断你的鸿蒙是“兼容安卓”还是“纯血鸿蒙”动手前第一件事不是写代码是用hdc确认目标设备的鸿蒙版本。HarmonyOS 4及更早的机型普遍保留双框架能直接安装APK格式的应用HarmonyOS NEXT 5.0开始走向纯血不兼容安卓APK只认HAP/HAR包。这个区别直接决定KMP的产物能不能被鸿蒙加载。hdc shell param get const.product.software.version hdc shell param get const.build.characteristics输出里如果能看到5.0.0这类版本号并且const.build.characteristics包含phone基本可以认定是HarmonyOS NEXT。想进一步确认是否支持APK用hdc install xxx.apk试装一次一分钟内会有明确报错。很多开发者在网上搜“鸿蒙开发教程”一上来就抄KMP的Android目标构建HAP结果在NEXT设备上装不上就是因为没先做这一步判断。2.2 路径A双框架期直接用Android target产物这是三条路里最省事的适合手上还是HarmonyOS 4存量设备、或者产品要求快速上验证版的情况。做法很直白KMP工程只保留androidTarget()把共享逻辑打成AAR再放进鸿蒙工程里当普通安卓依赖用。鸿蒙应用在双框架期本身就是以APK格式运行的所以你在Android上验证通过的KMP业务层在鸿蒙上能直接跑。整个过程不需要新增任何鸿蒙专用target也不需要NAPI桥接。kotlin { androidTarget { compilations.all { kotlinOptions.jvmTarget 17 } } }退出这个模式的条件也很简单一旦HarmonyOS NEXT成为主力机这个APK就装不上去了。很多团队把这条路径当成“占坑方案”先用最快速的KMP共享逻辑把Bilibili的接口层跑通、确认产品在鸿蒙上的形态再投入精力往Native库方向迁移。这条路本身的坑在构建配置鸿蒙APK的签名必须用鸿蒙发布证书不要沿用之前安卓的签名文件否则上架审核会卡在签名校验上。2.3 路径BKotlin/Native编译出so库给ArkTS走NAPI纯血鸿蒙上要真正复用KMP代码常见做法是用Kotlin/Native把共享模块编成Linux aarch64的动态库再用鸿蒙NDK的NAPI接口包一层C函数最后让ArkTS通过import方式加载so调用。这样UI是ArkUI业务逻辑是KMP共享比例最高。kotlin { linuxArm64(ohosArm64) { binaries { sharedLib { baseName bili_shared linkerOpts( --sysroot${ohosSdkPath}/native/sysroot, -L${ohosSdkPath}/native/libs/aarch64-linux-ohos/, -lohos ) } } } }这里的坑非常具体Kotlin/Native自带的linuxArm64 target默认面向glibc环境而OpenHarmony使用musl libc。直接编出来的so在鸿蒙里加载时会报undefined symbol或cannot open shared object file。解决思路是在linkerOpts里把OpenHarmony NDK的sysroot和lib路径传给lld让Kotlin/Native的编译链用鸿蒙自己的C库做链接。这个配置我通常在gradle.properties里单独维护一个ohosSdkPath变量方便不同机器切换NDK路径。2.4 路径C社区OpenHarmony target插件省胶水代码但运维成本高社区里一直有团队在做KMP的OpenHarmony target支持思路是在Gradle插件里为KMP工程注册一个ohostarget让Kotlin源码直接参与鸿蒙工程的HAR打包。这条路如果跑通连so库和NAPI胶水层都不用写KMP共享模块以原生依赖的形式进入鸿蒙工程体验上接近Android里的AAR。但我的实际感受是这条路目前更适合尝鲜不太适合生产。原因有三个。第一社区插件的版本跟随鸿蒙SDK和Kotlin版本漂移每次升级OpenHarmony SDK都可能要等适配。第二插件内部的产物可能是通过kotlin native再到so链路长出了问题很难分清是KMP还是鸿蒙SDK还是插件本身的问题。第三文档明显落后于代码很多参数要靠读源码猜。团队里如果没有人能在一周内啃下这个插件的构建逻辑不建议在核心业务上赌它。2.5 三条路径的选型对比表路径业务共享比例工作量主要风险适合场景AAndroid target产物高仅限双框架期低鸿蒙NEXT不兼容APK快速验证、存量设备BNative库NAPI高中高musl链接、so体积、桥接层维护纯血鸿蒙长期产品C社区OHOS插件理论上高低插件成熟度、SDK升级适配内部原型、技术预研如果产品判断半年内HarmonyOS NEXT会铺开我一般建议直接走B路径不要走A再迁移。路径A到B的迁移不是改个target那么简单网络引擎、日志、协程调度全都要为鸿蒙环境重新适配等于把共享层再调试一轮。与其到时候返工不如一开始就把Native库的编译链搭好哪怕先用模拟器跑通再接真机。3. 共享业务层搭建用Ktor与kotlinx.serialization把B站接口封装成跨端仓库类3.1 创建KMP工程的build.gradle.kts与目录结构KMP共享模块的工程结构是固定的关键是把源码集映射安排好。以Android、iOS、鸿蒙三端为目标时我在src下建commonMain、androidMain、iosMain、ohosArm64Main四个源码集。commonMain放纯Kotlin业务逻辑三个平台源码集只放expect声明和平台actual实现绝不放业务。plugins { kotlin(multiplatform) version 2.0.20 kotlin(plugin.serialization) version 2.0.20 } kotlin { androidTarget() listOf( iosX64(), iosArm64(), iosSimulatorArm64() ).forEach { it.binaries.framework { baseName BiliShared; isStatic true } } linuxArm64(ohosArm64) { binaries { sharedLib { baseName bili_shared } } } sourceSets { commonMain.dependencies { implementation(io.ktor:ktor-client-core:2.3.10) implementation(io.ktor:ktor-client-content-negotiation:2.3.10) implementation(io.ktor:serialization-kotlinx-json:2.3.10) implementation(org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1) } androidMain.dependencies { implementation(io.ktor:ktor-client-okhttp:2.3.10) } iosMain.dependencies { implementation(io.ktor:ktor-client-darwin:2.3.10) } } }这段配置里有两个参数值得说。linuxArm64(ohosArm64)会创建一个名为ohosArm64的Kotlin/Native targetsharedLib让产物流出为so而不是framework。如果我们的鸿蒙设备是32位arm这里要改成linuxArm64对应的32位配置但现在市场上的鸿蒙手机基本都是64位可以不用考虑32位。sourceSets里的依赖要尽量避免在commonMain里塞Platform-specific库比如ktor-client-okhttp如果写在commonMain会导致iosMain和ohosArm64Main编译期直接报找不到类。3.2 expect/actual拆分日志与平台差异点共享层里最常碰到的差异是日志打印。Android上有Log.diOS有os_log鸿蒙有hilog三者的API完全不一样。如果只在commonMain里写println发布到真机上日志会丢失或无法分级。用expect/actual按平台接一个日志函数是KMP跨端的第一步。// commonMain expect fun logDebug(tag: String, message: String) expect fun devicePlatform(): String // androidMain actual fun logDebug(tag: String, message: String) { android.util.Log.d(tag, message) } actual fun devicePlatform(): String android // ohosArm64Main actual fun logDebug(tag: String, message: String) { // 在Native层无法直接调hilog把日志写到全局返回给上层 } actual fun devicePlatform(): String ohos鸿蒙对应ohosArm64Main里没有hilog的Kotlin绑定我常用的处理是在NAPI桥接层里把日志字符串通过回调函数抛给ArkTS侧由ArkTS调hilog打印。这样做的代价是日志在Native侧有一点缓冲延迟但好处是共享层代码不用为日志单独写一套平台分支。expect/actual还有一个容易忽视的地方函数签名里的参数类型必须全部在kotlin标准库或expect声明里能表示不能直接引用Android的Context或鸿蒙的Context否则iosMain编译不过。3.3 封装B站视频列表接口从请求签名到JSON解析Bilibili的推荐流、视频详情、弹幕接口都有固定签名要求不能直接拼好URL发请求。这类签名逻辑非常适合放在KMP共享层因为Android端和鸿蒙端必须保持一致如果两端各写一份时间长了必然一个改了另一个忘改。共享层里我用Ktor做HTTP客户端kotlinx.serialization做JSON解析把接口封装成一个仓库类。Serializable data class VideoItem( val bvid: String, val title: String, val pic: String, val owner: Owner ) Serializable data class Owner(val mid: Long, val name: String) class BiliRepository(private val client: HttpClient) { private val signer WbiSigner() suspend fun recommendFeed(page: Int): ListVideoItem { val params mapOf( pn to page.toString(), ps to 20, platform to android ) val signedParams signer.sign(params) val response client.get(https://api.bilibili.com/x/web-interface/wbi/index/top/feed/rcmd) { url { parameters.appendAll(signedParams) } header(User-Agent, userAgent) header(Referer, https://www.bilibili.com/) }.bodyApiResponseListVideoItem() return response.data } }WbiSigner是这个共享层的核心它负责从配置接口拉取wbi key、缓存、再对请求参数做签名。签名函数必须放进expect/actual之外——这段逻辑在所有平台完全一样签名算法不会因为底层是OkHttp还是curl就变化。我特别强调一点签名后的参数键值对在传给Ktor之前不要预先拼到URL字符串里。Ktor的parameters.appendAll会自己对键值做URL编码如果你先把参数拼成字符串再传某些带特殊字符的值会被二次编码导致签名校验失败。3.4 协程与状态管理共享仓库类怎么设计Bilibili的页面是典型的分页信息流共享仓库类里要处理下拉刷新、上拉加载更多、请求竞态三个基本问题。我的设计是把仓库类做成无状态的数据源每次请求返回不可变的数据对象页面状态由各端的UI层自己持有。这样KMP共享层只负责“给什么参数、拿什么数据”不负责“当前是什么加载状态”。class FeedPager(private val repository: BiliRepository) { private var currentPage 1 private val mutex Mutex() suspend fun next(): ListVideoItem mutex.withLock { val items repository.recommendFeed(currentPage) currentPage items } suspend fun refresh(): ListVideoItem mutex.withLock { currentPage 1 repository.recommendFeed(currentPage) } }这里用Mutex而不是volatile是因为KMP共享代码在Native和JVM上的内存模型不完全一致。在Android上volatile还能用但在Kotlin/Native的iOS和鸿蒙上只靠volatile保证不了跨线程的可见性尤其是协程切换线程池之后。使用Mutex的一个显性收益是如果把请求过程中出现的错误直接抛出来调用方只需要处理异常不需要关心分页状态是否已经被污染。分页状态永远在锁内更新失败时状态不变下次重试还能从同一页开始。4. 鸿蒙侧对接Native库过NAPI桥接ArkUI以及不等桥接的过渡方案4.1 把共享模块编译成鸿蒙可加载的产物在KMP工程里跑./gradlew :shared:linkOhosArm64DebugSharedLib会在build/bin/ohosArm64/debugSharedLib下生成libbili_shared.so和对应的头文件。这个过程不需要鸿蒙IDE参与但产物要放进鸿蒙工程时需要放在entry/src/main/cpp/libs/arm64-v8a/目录下或者通过CMake在构建时从外部路径拷贝。cd shared ./gradlew :shared:linkOhosArm64DebugSharedLib ls build/bin/ohosArm64/debugSharedLib/拿到so之后先不要急着写ArkTS用鸿蒙NDK里自带的readelf检查so的依赖和架构。readelf -h libbili_shared.so readelf -d libbili_shared.so | grep NEEDEDMachine字段必须是AArch64NEEDED列表里如果出现libc.so.6或ld-linux-aarch64.so.1这种glibc特征明显的库名说明编译链没指向OpenHarmony的musl环境这个so在鸿蒙真机上必崩。出现这种情况回到build.gradle.kts检查linkerOpts里的sysroot路径是否正确然后清理~/.konan里的缓存重新编译。4.2 编写NAPI桥接层C接口与Napi Moduleso本身只暴露Kotlin/Native的C导出函数ArkTS不能直接调用需要一薄层C代码用NAPI把C函数包装成JS函数。这个桥接层放在鸿蒙工程的entry/src/main/cpp/下。#include napi/native_api.h #include string #include libbili_shared_api.h static napi_value FetchRecommend(napi_env env, napi_callback_info info) { size_t argc 1; napi_value args[1]; napi_get_cb_info(env, info, argc, args, nullptr, nullptr); int32_t page 1; napi_get_value_int32(env, args[0], page); const char* jsonCStr bili_shared_fetch_recommend(page); napi_value result; napi_create_string_utf8(env, jsonCStr, NAPI_AUTO_LENGTH, result); return result; } static napi_value Init(napi_env env, napi_value exports) { napi_property_descriptor desc { fetchRecommend, nullptr, FetchRecommend, nullptr, nullptr, nullptr, napi_default, nullptr }; napi_define_properties(env, exports, 1, desc); return exports; } NAPI_MODULE(NODE_GYP_MODULE_NAME, Init)这段代码的逻辑很直接NAPI的FetchRecommend先接收ArkTS传进来的页码把它转成C的int32_t然后调用KMP导出的bili_shared_fetch_recommend把返回的JSON字符串变成JS字符串返回。需要注意bili_shared_fetch_recommend返回的const char*是从Kotlin侧分配的内存如果KMP侧没有用memScoped管理释放这里每次调用都会泄漏。常见做法是在KMP侧用一个全局的allocator统一分配并在NAPI层调完后手动释放。我在实际项目里用了一个更省事的方案KMP侧把JSON写入一块固定的静态缓冲区NAPI层拿到地址后立即复制到napi_value整个过程没有动态分配也就不存在泄漏。4.3 ArkTS侧调用promise化的异步封装桥接层暴露的NAPI函数是同步的如果Bilibili的推荐流接口响应超过500msArkTS主线程会被卡住。因此ArkTS侧不能直接调fetchRecommend要包装成Promise并在真正调用前用setTimeout切到异步执行避免阻塞UI渲染。import biliShared from libbili_shared.so; const TAG BiliKMP; export function fetchRecommend(page: number): Promisestring { return new Promisestring((resolve, reject) { try { const jsonStr biliShared.fetchRecommend(page); resolve(jsonStr); } catch (err) { console.error(${TAG} fetchRecommend failed: ${JSON.stringify(err)}); reject(err); } }); }写这段时最容易被忽略的一点是KMP侧如果内部已经用了协程那么biliShared.fetchRecommend实际上是在Kotlin协程的线程池里执行的数据回来后回到ArkTS这个Promise里已经是非主线程。对Bilibili这种接口返回JSON后要立即更新List组件的场景建议在Promise的then回调里用鸿蒙的ohos.router或在页面组件里再包一层TaskPool确保setState在主线程执行。千万别在Promise回调里直接改State变量鸿蒙NEXT对线程内UI更新的检查比Android严格得多卡死和渲染错乱往往就是这个原因。4.4 不写桥接的变通方案AAR直引与接口隔离如果团队暂时抽不出人力维护NAPI层还有一个介于路径A和路径B之间的过渡做法KMP先编译Android AAR鸿蒙工程通过兼容框架加载AAR里的Java类。这个方案只适用于还有APK兼容能力的设备并且AAR里不能调用Android特定API否则在鸿蒙上运行时类查找直接失败。一般我会在KMP工程里为鸿蒙单独建一个harmonyCompat源码集把所有Android依赖替换成接口隔离的版本Java层只做字符串和JSON的传递不碰android.content.Context。androidTarget { compilations.all { kotlinOptions.jvmTarget 17 } }这个方案的长期问题是一旦鸿蒙完全脱离APK兼容这些AAR就是废代码。但它有一个隐性价值——可以提前把KMP的业务接口收敛成纯数据进出让后续Native化改造时NAPI层只需要映射函数而不是改写业务。我建议把它当作过渡方案不要当作最终架构写进技术规划里。5. 鸿蒙KMP避坑清单链接失败、内存黑匣子与URL编码五个高频翻车点5.1 现象so加载时直接闪退日志只有dlopen failed原因glibc与musl不匹配解决补sysroot和libohos这个问题几乎是每个第一次做鸿蒙KMP的人都会踩的。Kotlin/Native编译出来的so默认是按Linux桌面环境的c库链接的鸿蒙用的是musl二者在动态符号表上差异很大。真机上会看到dlopen failed: library libbili_shared.so not found但so明明就放在libs目录下。用readelf -d查看后发现NEEDED里引用了glibc的libc.so.6这就是根源。解决方向是把OpenHarmony NDK的sysroot传给KMP的链接器让所有C运行时符号都解析到鸿蒙的musl库。注意linkerOpts里的-lohos不能少否则NAPI相关的线程调度接口会链接失败。5.2 现象KMP请求线程卡死或一直无响应原因没有为鸿蒙提供Ktor引擎解决写一个最简HttpURLConnection actualKtor在JVM上用OkHttp在iOS上用Darwin偏偏在鸿蒙的Kotlin/Native target下没有现成的引擎。如果commonMain里直接调用HttpClient()编译可能通过但运行时一发起请求就挂起因为底层引擎根本没有实现网络读写。我在这里的解决思路是不依赖Ktor的高阶特性自己写一个基于POSIX socket的轻量引擎或者用HttpURLConnection在JVM兼容期先跑通。有一点要注意Ktor引擎是expect/actual机制的核心体现宁可给鸿蒙单独写一个只支持GET和POST的极简引擎也不要为了省事把整个网络层误入平台代码。5.3 现象生成的so有几十MB打进HAP后安装包急剧膨胀原因Kotlin/Native带了一套完整运行时解决strip 关闭不需要的特性Kotlin/Native编出来的so本来就比C的大默认带GC、协程调度、字符串处理等运行时组件。一个只封装了B站推荐流接口的共享库Debug版轻松超过50MBRelease版也可能在10MB到20MB之间。我在项目里做了两个处理第一用llvm-strip去掉符号表体积能降40%左右第二在共享模块的gradle.properties里关掉不需要的二进制特性比如kotlin.native.binary.optimizationModefast改为release。处理完后HAP体积基本回到可接受范围。$OHOS_NDK/toolchains/llvm/bin/llvm-strip -s libbili_shared.so5.4 现象所有请求都返回签名错误Android端同样代码正常原因URL编码不一致导致w_rid校验失败Bilibili的WBI签名是对参数键值对做编码后再计算的Android上OkHttp对参数值里特殊字符的编码规则和Ktor Native下默认的编码规则存在差异。比如标题里的中文、视频描述里的emoji、链接里的两端的编码结果不一样服务端算出的签名自然对不上。解决方式是在KMP共享层统一一个encodeParam函数在签名前和请求前都走同一个编码逻辑绝不允许参数先拼URL再去签名。这个函数在commonMain里实现用Kotlin标准库的encodeURLParameter即可。5.5 现象列表接口的数据已经返回但鸿蒙UI一直卡在loading原因协程从Native线程池回到ArkTS主线程的链路断了解决在NAPI回调里主动切线程KMP协程的调度在Native上跑自己的线程池ArkTS的UI更新又必须在主线程。很多人在NAPI层把KMP返回的JSON塞给ArkTS后就结束了结果发现数据明明拿到了页面却一直转圈。真正可靠的方案是让KMP侧返回CompletableDeferred然后在NAPI桥接层用napi_create_async_work创建异步任务把Kotlin协程的结果切换到ArkTS主线程后再调用JavaScript回调。这个桥接代码比较绕我一般直接封装成一个模板放在项目里新接口只需要复制粘贴改函数名。6. 验证与进阶三端复用率、首帧耗时与签名接口的日常维护技巧6.1 复用率度量用脚本统计共享代码行数占比KMP方案值不值得继续投入要看共享模块相对于三端UI代码的占比。我每个月跑一次统计脚本把commonMain下的Kotlin代码行数和Android、iOS、鸿蒙三端各自的UI层代码做对比。如果共享层占比低于20%说明业务逻辑渗出到各端UI层太多架构需要收敛。find shared/src/commonMain -name *.kt -print0 | xargs -0 wc -l find entry/src/main/ets -name *.ets -print0 | xargs -0 wc -l对比Android侧的app/src/main/java和iOS侧源码后你会清楚地看到哪些接口请求、数据模型、签名逻辑还散落在端侧。把这些端侧代码往commonMain里迁移的过程就是每次版本迭代顺手做的重构。6.2 性能验证首帧耗时与滑动帧率两个基准Bilibili这类信息流产品KMP共享层最怕引入的性能问题是请求串行化和JSON解析卡顿。我定两个硬性基准冷启动时推荐流首帧数据从发起请求到ArkTS拿到JSON耗时不超过800ms列表快速滑动时帧率不低于50fps。如果某个版本超出这个阈值优先检查共享层是不是在主线程做了JSON解析。hdc shell ps -ef | grep bili hdc shell hidumper -s RenderService -a fps在鸿蒙模拟器上测帧率经常不准模拟器的GPU和真机差异很大。我一般用真机跑hidumper抓FPS并且固定同一台设备做横向对比。别忘了把当前鸿蒙系统的省电模式关掉省电策略会限制CPU频率导致帧率数据失真。6.3 一份KMP代码三端跑的维护节奏维护到后期我习惯把Bilibili接口的响应结构约束在一份私有的JSON Schema文件里每次服务端接口有变动先改Schema再跑生成器同步到三端的数据类。KMP的数据类是kotlinx.serialization的Android端和iOS端直接用同一份鸿蒙侧通过so返回的也是同一份JSON字符串三端不会出现字段名大小写不一致的老问题。最后说一个我自己的教训第一次做鸿蒙KMP时我为了追求“纯正”的KMP三端共享硬啃社区插件花了两周才把编译链跑通结果性能还不如过渡方案。后来想明白KMP的价值在于业务逻辑的确定性共享不在于UI层也共用。现在遇到类似项目我都是先确认设备的鸿蒙版本再决定走哪条路径不再迷信某一种绝对方案。希望帮到你。本文还有配套的精品资源点击获取
返回列表