资讯动态

鸿蒙 KMP 跨平台实战:Bilibili 应用数据层复用与 NAPI 桥接

发布时间:2026/10/20 17:46:19 来源:尧图企业网站定制
简介这份PDF资料面向已熟悉Kotlin、具备一定鸿蒙开发经验的研发人员聚焦Kotlin Multiplatform在HarmonyOS上的跨平台落地难题以鸿蒙版Bilibili开发实践为主线讲解如何让业务逻辑在鸿蒙、Android与iOS之间复用从而降低多端开发成本。资源包仅1个PDF文件约143KB篇幅紧凑但技术密度较高适合作为KMP与鸿蒙集成的案头参考。内容覆盖工具链准备DevEco Studio、KMP插件、Karakum、公共模块与Android/iOS/鸿蒙模块的项目结构划分并重点拆解将鸿蒙.d.ts声明转换为Kotlin声明、编写共享业务逻辑、通过expect/actual实现平台特定代码、以JsExport导出并集成到鸿蒙工程等核心步骤。针对无法直接调试Kotlin代码、三方库兼容性冲突、编译产物体积过大等常见痛点文中也给出了断点调试、API替换与tree-shaking等排错与优化思路并附完整代码示例。目前已有692人学习适合希望系统掌握KMP跨端方案并动手实践的开发者参考。1. 鸿蒙上的 KMP 跨平台为什么值得把 Bilibili 这类应用当试验田鸿蒙应用开发走到 2025 年纯 ArkTS 写业务已经能跑通大部分场景但一旦涉及多端复用团队就会面临一个很现实的问题同一套业务逻辑Android 写一遍、iOS 写一遍、鸿蒙再写一遍维护成本直接翻三倍。Kotlin MultiplatformKMP给出的思路是把数据层、网络层、领域逻辑抽到 commonMain 里各平台只写 UI 和平台能力适配。拿鸿蒙版 Bilibili 这类视频应用做实践是因为它同时踩中了 KMP 最擅长的部分网络请求、JSON 解析、数据缓存、状态管理和最不擅长的部分播放器、弹幕渲染、系统分享边界清晰适合验证 KMP 在鸿蒙生态里的真实可用度。这篇文章面向的是已经会写 ArkTS、想评估 KMP 能不能进生产环境的鸿蒙开发者以及正在做多端架构选型的技术负责人。2. 把 KMP 工程接进鸿蒙从 Gradle 配置到第一个 commonMain 函数2.1 为什么选 KMP 而不是纯 ArkTS 或 Flutter纯 ArkTS 的问题在于复用天花板低鸿蒙和 Android 之间只能靠手动同步代码。Flutter 在鸿蒙上的适配依赖社区维护的引擎分支版本跟进有延迟且 Dart 生态和现有 Kotlin 后端服务对不齐。KMP 的优势是 Kotlin 本身和 Android 同源鸿蒙侧通过 Kotlin/Native 编译成静态库或动态库再用 ArkTS 的 NAPI 机制调用。代价是构建链路变长调试不能像纯 ArkTS 那样直接断点进 Kotlin 代码需要靠日志和单元测试兜底。选型时我一般看三个指标commonMain 能覆盖多少业务代码、平台特定实现有多少、构建产物在鸿蒙设备上的启动耗时。以 Bilibili 类应用为例视频列表分页、用户信息、评论树、搜索建议这些纯数据逻辑可以做到 80% 以上进 commonMain播放器控制、弹幕引擎、推送接收必须留在 ArkTS 侧。2.2 工程结构shared 模块怎么切一个能落地的 KMP 工程通常长这样project-root/ shared/ build.gradle.kts src/ commonMain/kotlin/ # 业务逻辑、数据模型、网络层 androidMain/kotlin/ # Android 平台 actual 实现 iosMain/kotlin/ # iOS 平台 actual 实现 ohosMain/kotlin/ # 鸿蒙平台 actual 实现 ohos-app/ # ArkTS 宿主工程 android-app/ ios-app/ohosMain 是鸿蒙侧的平台源集里面放 expect/actual 的 actual 实现比如文件路径获取、网络引擎初始化、日志输出。这个目录名不是固定的但社区里用 ohosMain 比较统一方便和 HarmonyOS 的构建插件对接。2.3 Gradle 配置Kotlin/Native 目标与鸿蒙产物shared 模块的 build.gradle.kts 核心配置如下plugins { kotlin(multiplatform) version 2.0.20 kotlin(plugin.serialization) version 2.0.20 } kotlin { // Android 目标 androidTarget() // iOS 目标 iosX64(); iosArm64(); iosSimulatorArm64() // 鸿蒙目标通过 Kotlin/Native 编译为共享库 // 注意鸿蒙目前没有官方 KMP target // 常见做法是编译成 linuxArm64 或 ohosArm64 自定义 target linuxArm64 { binaries { sharedLib { baseName shared } } } sourceSets { val commonMain by getting { dependencies { implementation(io.ktor:ktor-client-core:2.3.12) implementation(io.ktor:ktor-client-content-negotiation:2.3.12) implementation(io.ktor:ktor-serialization-kotlinx-json:2.3.12) implementation(org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1) implementation(org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.1) } } val commonTest by getting { dependencies { implementation(kotlin(test)) } } val ohosMain by creating { dependsOn(commonMain) dependencies { implementation(io.ktor:ktor-client-okhttp:2.3.12) } } } }这段配置的关键点KMP 官方没有 ohos target所以鸿蒙侧通常走 linuxArm64 交叉编译或者用社区维护的 ohos 插件。编译产物是 .so 共享库放到 ArkTS 工程的 libs 目录下通过 NAPI 暴露接口。参数上baseName 决定产物文件名sharedLib 表示动态库如果要做静态链接就换成 staticLib。Ktor 的引擎在 ohosMain 里选 OkHttp 是因为鸿蒙底层兼容 POSIX 网络接口OkHttp 的 Kotlin/Native 版本能跑通但要注意 DNS 解析在部分鸿蒙设备上需要手动指定。2.4 第一个 commonMain 函数Bilibili 视频列表请求在 commonMain 里定义一个视频列表接口// commonMain/kotlin/data/VideoRepository.kt package data import io.ktor.client.* import io.ktor.client.call.* import io.ktor.client.request.* import io.ktor.client.plugins.contentnegotiation.* import io.ktor.serialization.kotlinx.json.* import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json Serializable data class VideoItem( val aid: Long, val title: String, val cover: String, val duration: Int, val upName: String ) Serializable data class VideoListResponse( val code: Int, val message: String, val data: ListVideoItem ) class VideoRepository(private val client: HttpClient) { suspend fun fetchRecommend(page: Int, pageSize: Int 20): ListVideoItem { val response: VideoListResponse client.get(https://api.example.com/video/recommend) { parameter(page, page) parameter(pageSize, pageSize) }.body() if (response.code ! 0) { throw IllegalStateException(API error: ${response.message}) } return response.data } } // 工厂函数各平台传入不同引擎 fun createHttpClient(engine: HttpClientEngine): HttpClient { return HttpClient(engine) { install(ContentNegotiation) { json(Json { ignoreUnknownKeys true isLenient true }) } } }逻辑说明VideoRepository 只依赖 HttpClient 抽象不关心底层是 OkHttp 还是 Darwin。createHttpClient 接收平台传入的引擎这样 Android 传 OkHttp、iOS 传 Darwin、鸿蒙传 OkHttp 的 Native 版本。参数上ignoreUnknownKeys 必须开因为 Bilibili 类接口经常加字段不开会导致反序列化直接崩。isLenient 处理一些非标准 JSON 格式。2.5 鸿蒙侧 actual 实现与 NAPI 桥接在 ohosMain 里提供 actual 实现// ohosMain/kotlin/platform/HttpEngine.ohos.kt package platform import io.ktor.client.engine.HttpClientEngine import io.ktor.client.engine.okhttp.OkHttp actual fun platformHttpEngine(): HttpClientEngine OkHttp.create()然后在 ArkTS 侧通过 NAPI 调用编译好的 .so// ohos-app/src/main/cpp/napi_init.cpp #include napi/native_api.h #include string // 假设 shared 库导出了 fetchRecommend 的 C 接口 extern C const char* fetch_recommend(int page, int pageSize); static napi_value FetchRecommend(napi_env env, napi_callback_info info) { size_t argc 2; napi_value args[2]; napi_get_cb_info(env, info, argc, args, nullptr, nullptr); int page, pageSize; napi_get_value_int32(env, args[0], page); napi_get_value_int32(env, args[1], pageSize); const char* result fetch_recommend(page, pageSize); napi_value jsonResult; napi_create_string_utf8(env, result, NAPI_AUTO_LENGTH, jsonResult); return jsonResult; }这一步是整个链路里最容易翻车的地方。Kotlin/Native 导出的 C 接口需要加 CName 注解字符串要手动管理内存ArkTS 侧拿到的 JSON 字符串还要再解析一次。我一般会在 Kotlin 侧直接返回 JSON 字符串ArkTS 用 JSON.parse 处理避免在 C 层做复杂对象映射。3. 数据层与状态管理commonMain 里怎么组织 Bilibili 的业务逻辑3.1 用 Repository UseCase 分层隔离平台差异commonMain 里的业务逻辑要经得起三端复用分层必须清晰。我一般分三层Repository 负责数据来源网络、缓存、数据库UseCase 负责业务规则比如“先读缓存再请求网络网络失败返回缓存”ViewModel 负责状态暴露。ViewModel 这层在 KMP 里比较特殊因为 Android 有 Jetpack ViewModel、iOS 有 ObservableObject、鸿蒙有 ArkTS 的 State所以 commonMain 里只定义状态数据类和状态流转逻辑不继承任何平台基类。// commonMain/kotlin/domain/VideoListUseCase.kt package domain import data.VideoItem import data.VideoRepository import kotlinx.coroutines.flow.* class VideoListUseCase( private val repository: VideoRepository, private val cache: VideoCache ) { private val _state MutableStateFlow(VideoListState()) val state: StateFlowVideoListState _state.asStateFlow() suspend fun loadFirstPage() { _state.update { it.copy(isLoading true, error null) } try { val cached cache.getRecommend() if (cached.isNotEmpty()) { _state.update { it.copy(videos cached, isLoading false) } } val remote repository.fetchRecommend(page 1) cache.saveRecommend(remote) _state.update { it.copy(videos remote, isLoading false) } } catch (e: Exception) { _state.update { it.copy(isLoading false, error e.message) } } } } data class VideoListState( val videos: ListVideoItem emptyList(), val isLoading: Boolean false, val error: String? null )参数说明StateFlow 是冷流转热流适合做 UI 状态。cache 是 expect/actual 接口Android 用 Room、iOS 用 SQLite、鸿蒙用关系型数据库。loadFirstPage 里先读缓存再请求网络这是视频类应用的标准做法能显著降低首屏白屏时间。3.2 鸿蒙侧状态订阅ArkTS 怎么消费 Kotlin FlowKotlin 的 Flow 不能直接给 ArkTS 用需要在 ohosMain 里包一层回调接口// ohosMain/kotlin/bridge/StateBridge.kt package bridge import domain.VideoListState import kotlinx.coroutines.* import kotlinx.coroutines.flow.* class StateBridge(private val scope: CoroutineScope) { fun observeVideoList( useCase: VideoListUseCase, onState: (VideoListState) - Unit ): Job { return useCase.state .onEach { onState(it) } .launchIn(scope) } }ArkTS 侧通过 NAPI 注册回调函数Kotlin 侧每次状态变更就调一次。这里要注意线程切换Kotlin/Native 的协程默认跑在后台线程回调到 ArkTS 必须切回主线程否则 UI 更新会出问题。常见做法是在 onState 回调里用 Dispatchers.Main 包一层鸿蒙的 Main 调度器需要自己实现因为 Kotlin/Native 没有内置鸿蒙的主线程 Dispatcher。3.3 缓存策略内存 磁盘两级缓存视频列表的缓存我一般做两级内存缓存用 LruCache 思路磁盘缓存用 SQLite 或文件。commonMain 里定义接口// commonMain/kotlin/data/VideoCache.kt package data expect class VideoCache { suspend fun getRecommend(): ListVideoItem suspend fun saveRecommend(items: ListVideoItem) suspend fun clearExpired(maxAgeMs: Long) }ohosMain 的实现用鸿蒙的关系型数据库// ohosMain/kotlin/data/VideoCache.ohos.kt package data import ohos.data.relationalStore.* import ohos.app.Context actual class VideoCache(private val context: Context) { private val store: RdbStore by lazy { val config StoreConfig( SecurityLevel.S1, video_cache.db, null ) RdbStore(context, config) } actual suspend fun getRecommend(): ListVideoItem { val predicates RdbPredicates(recommend) predicates.orderByDesc(cached_at) predicates.limitAs(50) val resultSet store.query(predicates, arrayOf(aid, title, cover, duration, up_name)) val list mutableListOfVideoItem() while (resultSet.goToNextRow()) { list.add( VideoItem( aid resultSet.getLong(resultSet.getColumnIndex(aid)), title resultSet.getString(resultSet.getColumnIndex(title)), cover resultSet.getString(resultSet.getColumnIndex(cover)), duration resultSet.getInt(resultSet.getColumnIndex(duration)), upName resultSet.getString(resultSet.getColumnIndex(up_name)) ) ) } resultSet.close() return list } actual suspend fun saveRecommend(items: ListVideoItem) { store.beginTransaction() try { items.forEach { item - val values ValuesBucket().apply { putLong(aid, item.aid) putString(title, item.title) putString(cover, item.cover) putInt(duration, item.duration) putString(up_name, item.upName) putLong(cached_at, System.currentTimeMillis()) } store.insert(recommend, values, ConflictResolution.ON_CONFLICT_REPLACE) } store.commit() } catch (e: Exception) { store.rollBack() throw e } } actual suspend fun clearExpired(maxAgeMs: Long) { val expireTime System.currentTimeMillis() - maxAgeMs val predicates RdbPredicates(recommend) predicates.lessThan(cached_at, expireTime) store.delete(predicates) } }参数说明SecurityLevel.S1 是鸿蒙数据库的安全等级视频列表这种非敏感数据用 S1 就够。ON_CONFLICT_REPLACE 保证 aid 重复时覆盖旧记录。clearExpired 的 maxAgeMs 一般设 24 小时视频推荐列表更新频率高缓存太久反而影响体验。4. 避坑与排查KMP 接鸿蒙时最容易翻车的五个点4.1 坑一Kotlin/Native 编译产物在鸿蒙设备上加载失败现象ArkTS 侧调用 NAPI 接口时报 “module not found” 或直接闪退日志里看到 dlopen 失败。原因Kotlin/Native 编译出的 .so 依赖 libc 版本和鸿蒙设备上的不一致或者产物架构不对。鸿蒙设备主流是 arm64但模拟器可能是 x86_64如果只编了 linuxArm64 就会在模拟器上挂。解决在 build.gradle.kts 里同时编 linuxX64 和 linuxArm64产物按架构分目录存放。ArkTS 侧加载时根据设备架构选对应目录。另外用readelf -d shared.so检查依赖库缺什么补什么。4.2 坑二协程在鸿蒙主线程调度失效现象Kotlin 侧回调更新状态后ArkTS 的 UI 不刷新或者报 “not on UI thread”。原因Kotlin/Native 没有鸿蒙的主线程 Dispatcher默认跑在后台线程ArkTS 的 State 必须在主线程更新。解决在 ohosMain 里实现一个 MainDispatcher用鸿蒙的 EventHandler 或 TaskDispatcher 把回调 post 到主线程。代码大致是// ohosMain/kotlin/platform/MainDispatcher.kt package platform import kotlinx.coroutines.* import ohos.eventhandler.EventHandler import ohos.eventhandler.EventRunner actual fun mainDispatcher(): CoroutineDispatcher object : CoroutineDispatcher() { private val handler EventHandler(EventRunner.getMainEventRunner()) override fun dispatch(context: CoroutineContext, block: Runnable) { handler.postTask(block) } }然后在 commonMain 里用withContext(mainDispatcher())包住 UI 回调。4.3 坑三JSON 反序列化在鸿蒙上遇到字段类型不一致现象同样的接口Android 上解析正常鸿蒙上抛 SerializationException。原因Kotlin/Native 的 kotlinx-serialization 对数字类型更严格接口返回的 duration 有时是 Int 有时是 StringAndroid 的 OkHttp 引擎可能做了隐式转换Native 引擎不会。解决在 Json 配置里加coerceInputValues true或者把 duration 定义成 JsonElement 再手动转。更稳妥的做法是给每个字段加 SerialName 并写自定义 serializer。4.4 坑四内存泄漏——Kotlin 对象被 ArkTS 长期持有现象页面退出后内存不释放反复进出页面内存持续增长。原因NAPI 回调注册后没有反注册Kotlin 侧的 StateBridge 持有 useCaseuseCase 持有 repositoryrepository 持有 HttpClient整条链被 ArkTS 的全局回调引用。解决在 ArkTS 页面的 aboutToDisappear 里调用反注册接口Kotlin 侧 cancel 对应的 Job。StateBridge 的 observeVideoList 返回 JobArkTS 侧保存这个 Job 的句柄页面销毁时调 cancel。4.5 坑五构建缓存导致 commonMain 改动不生效现象改了 commonMain 的代码重新编译后鸿蒙侧行为没变。原因Kotlin/Native 的编译缓存和 Gradle 缓存叠加增量编译有时不识别 commonMain 的变更。解决执行./gradlew clean后重新编译或者在 gradle.properties 里关掉 Kotlin/Native 的增量编译kotlin.native.cacheKindnone。这个开关会拖慢构建速度只在排查阶段用。5. 进阶技巧用 KMP 的 expect/actual 把播放器控制也抽象出来播放器是视频应用里最平台相关的部分但它的控制接口其实可以抽象。commonMain 里定义// commonMain/kotlin/player/VideoPlayer.kt package player expect class VideoPlayer { fun setDataSource(url: String) fun prepare() fun play() fun pause() fun seekTo(positionMs: Long) fun release() val currentPosition: Long val duration: Long val isPlaying: Boolean }鸿蒙侧的 actual 实现包装 ArkTS 的 AVPlayer// ohosMain/kotlin/player/VideoPlayer.ohos.kt package player import ohos.media.player.AVPlayer import ohos.media.common.Source actual class VideoPlayer { private var player: AVPlayer? null actual fun setDataSource(url: String) { player AVPlayer().apply { setSource(Source(url)) } } actual fun prepare() { player?.prepare() } actual fun play() { player?.play() } actual fun pause() { player?.pause() } actual fun seekTo(positionMs: Long) { player?.seekTo(positionMs.toInt()) } actual fun release() { player?.release() player null } actual val currentPosition: Long get() player?.currentTime?.toLong() ?: 0L actual val duration: Long get() player?.duration?.toLong() ?: 0L actual val isPlaying: Boolean get() player?.isPlaying ?: false }这样 commonMain 里的播放逻辑比如“播放到 80% 时预加载下一集”就能复用。但要注意播放器的状态回调onPrepared、onCompletion在 KMP 里需要用回调接口桥接ArkTS 侧注册监听Kotlin 侧转发到 commonMain 的 Flow。验证 KMP 方案是否值得投入我一般跑一个最小闭环commonMain 写一个带分页和缓存的列表逻辑鸿蒙侧只写 UI 和播放器看开发耗时和纯 ArkTS 比是多了还是少了。如果 commonMain 覆盖率能到 60% 以上且构建链路稳定就值得继续。我自己的习惯是每接一个新平台先花半天把 NAPI 桥接和主线程调度跑通这两个通了后面就是填业务代码。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑