news 2026/10/17 16:33:11

KMP跨平台实战:鸿蒙版Bilibili信息流工程落地与避坑

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
KMP跨平台实战:鸿蒙版Bilibili信息流工程落地与避坑

简介:一份聚焦于Kotlin Multiplatform(KMP)在鸿蒙应用跨平台开发实践的PDF技术文档,面向已熟悉Kotlin并希望向鸿蒙扩展的开发者,用于解决Android、iOS与鸿蒙三端共享业务逻辑、降低重复开发成本的问题。文档为单个PDF文件,文件大小仅143KB,内容高度浓缩;目前已有689人学习或下载。内容以鸿蒙版Bilibili为案例,先梳理DevEco Studio、KMP插件与Karakum组成的工具链以及公共、Android、iOS、鸿蒙四模块项目结构,再详解通过Karakum将鸿蒙.d.ts声明转换为Kotlin声明、在commonMain编写共享逻辑、用expect/actual实现平台代码、借助@JsExport导出并集成到鸿蒙主工程的完整流程。同时针对无法直接调试Kotlin代码、三方库依赖Web环境API不兼容、JS产物体积过大等实战痛点给出解决方案,并附有可直接复用的代码示例,适合在实际工程中对照演练。

1. Kotlin Multiplatform跨平台实现杀进鸿蒙:鸿蒙版Bilibili到底新在哪里

鸿蒙应用开发圈子里有个反直觉的事实:Kotlin Multiplatform跨平台实现并不是让鸿蒙版Bilibili“复制”一份UI,而是把Bilibili式的业务内核——Feed流分页、缓存、搜索状态——从Android/iOS两个平台里抽出来,编译成鸿蒙能直接调用的本地har包。做中重度App的团队最清楚这笔账:UI在ArkUI上原生生写,业务逻辑三端复用,比另起炉灶写三套代码省一半工作量。这篇文章适合手里已有Kotlin代码、想低成本落地鸿蒙版的开发者,也适合正在评估KMP和Flutter到底谁靠谱的技术负责人。下面直接按最小工程到信息流的落地顺序讲,边做边排坑。

2. 鸿蒙上的KMP怎么跑:共享层到底放什么、不放什么

KMP进入鸿蒙之后,最容易踩的第一个坑是把它的定位理解错。它不是渲染引擎,不是UI框架,而是一个“业务逻辑打包器”。在鸿蒙应用开发里,UI仍然由ArkUI和ArkTS负责,KMP只负责那些不碰界面、却在三端高度重合的部分。把这层边界划清楚,后面所有工程决策都顺了;划不清楚,就会变成“哪哪都要expect/actual,最后跟重写一遍没区别”。

2.1 KMP不是Flutter:先搞清鸿蒙场景下的职责边界

我常跟团队说一句话:Flutter是连锅端,KMP是只端菜。Flutter用Dart自绘整套UI,在鸿蒙上跑起来,等于在ArkUI旁边多了一套渲染管线,交互手势、键盘、滚动惯性都得自己养;而KMP在鸿蒙落地时,UI走ArkTS原生组件,KMP只负责端上层的网络、数据模型、状态机、缓存和分页游标,这在做Bilibili这类信息流重场景时尤其舒服——Feed排序、缓存Key、搜索历史这些逻辑,不需要因为换了平台就推倒重来。

方案UI层业务逻辑层鸿蒙原生体验适合场景
FlutterDart自绘Dart中,受限于自绘控件不想碰ArkTS、团队全是Dart
uni-app转译到各端组件JS/TS中,鸿蒙适配晚于移动端Web前端团队,重H5迁移
纯ArkTSArkUIArkTS最高只做鸿蒙,不考虑Android/iOS
KMPArkUI原生Kotlin共享高已有Kotlin工程,要快速补鸿蒙

从这个表能看出,KMP在鸿蒙场景里抢的份额不是Flutter那种“一套UI三端跑”,而是“一套业务逻辑三端跑”。如果你现在评估的是一个全新项目,全员都会ArkTS,纯ArkTS可能更快;如果已经有Android版Kotlin代码,哪怕代码写得乱,KMP也比从零抄一遍便宜。

2.2 expect/actual:鸿蒙的“系统能力差异”在KMP里怎么落地

KMP在鸿蒙上的核心机制是expect/actual。它解决的问题很朴素:共享层里有一小部分功能必须依赖系统,比如取设备标识、拿当前时间戳、写日志、读本地缓存路径。这些在Android、iOS、鸿蒙上各有各的API,但上层业务代码只想调用一个统一函数。

// commonMain/kotlin/com/example/shared/Platform.kt expect fun deviceId(): String expect fun isUserLoggedIn(): Boolean expect fun logToPlatform(tag: String, message: String)
// androidMain/kotlin/com/example/shared/Platform.android.kt actual fun deviceId(): String { return Settings.Secure.getString( context.contentResolver, Settings.Secure.ANDROID_ID ).orEmpty() } actual fun isUserLoggedIn(): Boolean { return SessionManager.isLogin() } actual fun logToPlatform(tag: String, message: String) { android.util.Log.i(tag, message) }
// harmonyosMain/kotlin/com/example/shared/Platform.harmonyos.kt actual fun deviceId(): String { return DeviceInfo.getDeviceId() } actual fun isUserLoggedIn(): Boolean { return HarmonySessionCache.isLogin() } actual fun logToPlatform(tag: String, message: String) { // 在HarmonyOS端输出到hilog HiLog.info(HiLogLabel(0, 0, tag), message) }

实际项目中expect/actual不要滥用。所有对外接口尽量收敛成基础类型:String、Long、Boolean、ByteArray,不要在共享层定义“鸿蒙的Bitmap对象”或“Android的Context对象”。expect声明数量我一般控制在10个以内,日志、设备ID、网络状态、缓存目录这几个是必备平台桩,其余能做纯Kotlin做的,都留在commonMain里。这个约束的目的只有一个:让鸿蒙适配层的编译成本变得可预期。

2.3 最小KMP工程到鸿蒙har包:Gradle与构建链跑通

KMP共享层要变成鸿蒙能用的产物,现代常见做法是编译出一个har包,再由DevEco Studio侧用本地依赖方式接进工程。这个har包不是普通Kotlin库,而是包含鸿蒙Native ABI能力的包。用最朴素的结构说明这件事:

// shared/build.gradle.kts 示意 plugins { kotlin("multiplatform") kotlin("plugin.serialization") } kotlin { androidTarget() iosArm64() // KMP-For-HarmonyOS一类的适配插件会提供鸿蒙target harmonyosTarget() sourceSets { val commonMain by getting { dependencies { implementation("io.ktor:ktor-client-core:3.+") implementation("io.ktor:ktor-client-json:3.+") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.+") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.+") } } } }

这段配置里有两个关键点。第一,鸿蒙target和androidTarget、iosArm64并列存在,说明鸿蒙被当作一等目标接入,而不是靠什么“兼容层”奇技淫巧。第二,依赖里尽量使用纯Kotlin库,Ktor、kotlinx.serialization、kotlinx.coroutines都满足这一点;像Android的Room这种和平台绑定很深的库,不要放进来。

构建命令常见形如./gradlew :shared:assembleHarmonyosHar,不同插件版本任务名会不一致,以自己装的插件为准。构建成功后在shared/build/outputs下能找到shared.har。接着在鸿蒙应用工程里把它当作本地har引入:

// entry/oh-package.json5 { "dependencies": { "shared": "file:./libs/shared.har" } }

然后在ArkTS代码里import shared from 'shared',就能调用KMP暴露出来的入口类。这是最原始但最不容易出错的接入方式。后续要想让团队多人并行开发,就得把har发布到私有maven仓库,但那是另一个话题,初期用本地文件足够。

3. 搭建Bilibili式信息流:Ktor网络层 + ArkUI桥接的三个关键动作

把最小链路跑通后,真正有价值的是拿它做一个看得见的东西。这里以Bilibili风格的视频信息流为例:首页Feed流、视频卡片、搜索触发、分页加载。这些需求在Android版里通常已经沉淀过一版,在鸿蒙版里最理想的状态是:网络请求、分页游标、数据模型全部从旧代码复用,ArkTS只负责渲染和交互。

3.1 共享数据层:Ktor + kotlinx.serialization 建一个可分页的Feed

信息流的网络层我很推荐Ktor Client,因为它在JVM、Android、iOS原生、鸿蒙Native上表现一致,而且用协程原生支持异步。配合kotlinx.serialization做JSON解析,数据类可以直接序列化到Kotlin对象,不用像传统做法那样手写解析器。

// commonMain/kotlin/com/example/shared/model/FeedModels.kt @Serializable data class VideoCard( val id: Long, val title: String, val coverUrl: String, val author: String, val playCount: Long, val durationSec: Int, val tabType: Int ) @Serializable data class FeedPage( val items: List<VideoCard>, val nextCursor: Long, val hasMore: Boolean )
// commonMain/kotlin/com/example/shared/repo/FeedRepository.kt class FeedRepository( private val client: HttpClient, private val baseUrl: String ) { private val cursor = AtomicLong(0L) suspend fun loadNextPage(tabType: Int): FeedPage { val resp = client.get("$baseUrl/api/feed") { parameter("tabType", tabType) parameter("cursor", cursor.get()) } if (resp.status.value != 200) { throw IllegalStateException("feed load failed: ${resp.status}") } val page = resp.body<FeedPage>() cursor.set(page.nextCursor) return page } fun resetCursor() { cursor.set(0L) } }

参数说明:这里没用页码,而是游标。因为视频Feed流随时可能在首页插热门、做推荐排序,用“页码+pageSize”容易在列表变化时重复或漏掉条目;游标则是后端返回哪个位置,下次就从哪个位置继续取,这是Bilibili这类信息流更常见的分页语义。不过要注意,游标分页没有“总页数”的概念,所以hasMore字段必须由后端正确返回,否则很容易出现一直加载到底的假死。

演示阶段可以自己架一个MockServer,返回写死的FeedPage数据,主要目的是把“共享层逻辑”和“UI层渲染”的边界练出来。不要一上来就抓真实网站的接口,那不是开发实践,是在给运维添麻烦。

3.2 ArkTS消费KMP:har包导入与泛型类型落到List

KMP层的对象不能直接在ArkTS侧还原成强类型,这是鸿蒙KMP接入最现实的一个约束。我在前面强调对外暴露只走基础类型,落地时最省心的做法是:KMP层用一个Bridge对象,把FeedPage序列化成JSON字符串,ArkTS拿字符串后用JSON.parse解析,再转成显式声明的VO类。

// commonMain/kotlin/com/example/shared/bridge/FeedBridge.kt object FeedBridge { private val repository by lazy { buildRepository() } fun loadNextPageSync(tabType: Int): String { val page = runBlocking { repository.loadNextPage(tabType) } return Json.encodeToString(FeedPage.serializer(), page) } }

为什么用loadNextPageSync而不是直接暴露suspend fun?Kotlin协程的恢复机制和ArkTS的事件循环不是一套体系,挂起函数直接跨到鸿蒙侧,很容易出现回调丢失。所以Bridge这层先把协程结果用runBlocking收成同步返回值,ArkTS侧再自己处理线程。这个设计虽然算不上优雅,但胜在可预测、可排查。

// entry/src/main/ets/pages/FeedPage.ets import shared from 'shared'; interface VideoCardVO { id: number; title: string; coverUrl: string; author: string; playCount: number; durationSec: number; } @Entry @Component struct FeedPage { @State items: VideoCardVO[] = []; private tabType: number = 0; aboutToAppear(): void { // 注意:这个方法内部是同步阻塞的,不要直接放在UI线程反复调用 const json = shared.FeedBridge.loadNextPageSync(this.tabType); const page: { items: VideoCardVO[]; hasMore: boolean } = JSON.parse(json); this.items = page.items; } build() { List({ space: 12 }) { ForEach(this.items, (item: VideoCardVO) => { ListItem() { FeedCard({ title: item.title, coverUrl: item.coverUrl }) } }, (item: VideoCardVO) => item.id.toString()) } .padding(12) } }

这里有一个容易翻车的点:aboutToAppear里直接跑同步网络请求,如果放到UI线程会卡帧甚至触发系统无响应。常见做法是把loadNextPageSync放到鸿蒙侧的TaskPool或Worker里执行,执行完回到UI线程再用JSON.parse解析。这段ArkTS代码只是渲染骨架,完整的FeedCard组件需要单独实现,重点在于理解KMP产物和ArkTS的数据边界是JSON字符串,而不是对象。

3.3 状态机设计:Loading/Success/Error/Empty 在跨平台侧的契约

信息流页面不能只有“加载完渲染”这一种状态。首次进入要Loading,下拉刷新要保留旧列表,网络错误要给重试按钮,后端返回空列表要显示空态。这些状态如果在三个平台各自实现,很容易出现Android和鸿蒙的失败提示文案不一致、重试按钮位置不一致这类细节偏差。更好的方案是在共享层定一个状态模型,三端只是翻译。

// commonMain/kotlin/com/example/shared/state/LoadState.kt sealed interface LoadState<out T> { data object Loading : LoadState<Nothing> data class Success<T>( val data: T, val isRefresh: Boolean = false ) : LoadState<T> data class Error( val throwable: Throwable, val retryCount: Int = 0 ) : LoadState<Nothing> data object Empty : LoadState<Nothing> }

为什么Success里要放isRefresh标志?因为分页加载和下拉刷新都是成功,但UI处理逻辑不同:下拉刷新成功应该替换整个列表,分页加载成功应该追加列表。把isRefresh放共享层,UI侧就不需要自己猜“这次成功到底来自哪个动作”。

共享层再提供一个把状态序列化的方法,让Bridge可以把它带着数据一起变成JSON返回给ArkTS。这样ArkTS侧只维护一个@State viewState: string,收到后反序列化,按不同状态渲染不同子树。等于把最容易吵架的状态定义问题,从“三端各写一套”变成了“三端共看一份契约”。

4. 鸿蒙KMP避坑指南:5个真实翻车现场与排查方法

下面这些坑,是我在鸿蒙端用KMP做信息流项目时真实撞过的,有一些属于“不跑真机根本发现不了”的玄学问题。每一条都按现象、原因、解决三层写,方便你直接对照排查。

4.1 翻车现场:expect函数在ArkTS侧“查无此名”

现象:我在commonMain里声明了expect fun deviceId(): String,Android侧用得好好的,鸿蒙工程里调用shared.Platform.deviceId()编译报错,提示undefined或者找不到符号。

原因:expect只是声明,真正落地靠actual实现。我的harmonyosMain源集虽然建了,但里面的actual函数没有配置到导出列表,或者源集路径写错了,DevEco在构建har时没有把鸿蒙实现打包进去。更隐蔽的情况是:expect被做成了一个internal函数,没有public导出。

解决:先确认harmonyosMain源集在构建日志中确实参与编译,再检查actual函数的可见性是否为public,然后在Bridge层包一层facade,只暴露需要给ArkTS的那几个函数。千万不要让ArkTS直接引用一堆expect声明,那是给自己埋雷。

4.2 翻车现场:共享层返回List ,ArkTS侧直接跪

现象:KMP层某个Bridge方法返回List<String>,在Android里正常,在ArkTS里要么拿到一个无法遍历的对象,要么直接崩溃报“No implementation found”。

原因:KMP产物的泛型信息在跨过Native边界时会丢失,ArkTS看到的只是Kotlin集合的底层映射,不是它认识的Array或List。如果导出层用一个包含嵌套泛型的数据类,丢失得更明显。

解决:对外API统一收敛成String、Long、Boolean、ByteArray,复杂结构一律JSON。我在共享层专门写了一个ContractMapper,把FeedPage、List<VideoCard>映射成JSON字符串再交给Bridge,ArkTS侧JSON.parse后得到普通对象数组。这个额外步骤看起来啰嗦,但它能让跨端类型问题从“时不时玄学崩溃”变成“编译期就能确定”。

4.3 翻车现场:协程挂在Native层,ArkTS收不到回调

现象:共享层暴露了一个suspend fun fetchPage(),我在ArkTS里通过某种包装去调用,结果页面一直转圈,断点看不到Kotlin侧的日志。

原因:Kotlin协程的调度依赖自己的Dispatcher,在鸿蒙Native侧,它和ArkTS的TaskPool、主线程消息循环没有自动对应关系。挂起函数一旦在后台线程恢复,想回到UI线程更新状态,ArkTS那侧并不知道该往哪个队列回调。

解决:Bridge层不要把suspend函数直接开放出去。我采用的做法是内部runBlocking+一个固定的Dispatchers.IO线程池,把结果阻塞式返回;ArkTS侧再开启鸿蒙的TaskPool去调Bridge,真正回UI线程还是在ArkTS自己的主线程。共享层永远不知道ArkTS怎么处理线程,它只保证“你调用我这个同步函数,一定给你完整结果”。

4.4 翻车现场:DevEco Studio和Android Studio的Gradle抢版本

现象:同一个KMP工程在Android Studio里构建正常,拿到DevEco Studio打开后插件报错,或提示Gradle版本不匹配;或者反过来,昨天还能出har,今天更新了一个插件之后又不行了。

原因:KMP的鸿蒙适配插件对Kotlin版本、AGP版本、Gradle版本有依赖窗口。两个IDE默认使用的Gradle wrapper不同,一旦一边升级了gradle,另一边没同步,就会在所有机器上表现不一致。

解决:把KMP共享模块做成独立仓库,和Android App、鸿蒙App工程分开。共享模块用命令行gradlew wrapper --gradle-version xxx锁死版本,不要依赖某个IDE自动同步。发布har时走CI脚本,而不是靠开发者在本地手动assemble。这条是工程规范问题,但绝大多数鸿蒙KMP翻车都源自没把共享模块当独立产物对待。

4.5 翻车现场:真机跑通后模拟器崩溃,so库加载失败

现象:鸿蒙真机上运行正常,切到x86_64模拟器后启动即崩,日志里有一条dlopen failed,指向har包里的某个Native库。

原因:KMP的鸿蒙产物是按ABI分目录输出的,arm64-v8a通常会自动打。模拟器常见是x86_64,如果构建时没有单独声明模拟器架构,har里就缺少对应so,运行时自然加载失败。

解决:调试阶段明确目标设备架构,在构建脚本里打开abiFilters或架构配置,让模拟器包带上x86_64。但发布包不要无脑全架构,体积会变大,线上只保留arm64-v8a就好。我建议把模拟器当作“快速看UI”的工具,凡是涉及KMP共享层能力的验证,一律用真机,否则会被这类so问题浪费半天时间。

5. 进阶验证与性能调优:把共享层变成可信黑匣子

5.1 commonTest跨端单测:在Android与鸿蒙上测同一个数据分页

有了共享层,最大的优势是可以把核心业务逻辑放commonTest里跑,同一份测试代码在Android和鸿蒙两个目标上执行。越早把分页边界、游标重复、空列表这些场景纳入测试,后面UI层返工越少。

// commonTest/kotlin/com/example/shared/FeedRepositoryTest.kt class FeedRepositoryTest { @Test fun `游标分页第二次请求返回第二页数据`() = runTest { val repo = FeedRepository(MockEngine(), "https://mock.example") val first = repo.loadNextPage(tabType = 0) assertEquals(20, first.items.size) val second = repo.loadNextPage(tabType = 0) assertEquals(40, second.items.first().id) assertNotEquals(first.nextCursor, second.nextCursor) } }

注意这段测试里我用MockEngine替换了真实Ktor引擎,返回写死的页数据。这样跑一次就能确认分页游标、hasMore逻辑、tabType隔离这三件事没有在三端交叉污染。我的习惯是:任何一个共享层函数要是想在Bridge里被ArkTS调用,先写一个commonTest把它的行为钉死,再进UI联调。

5.2 缓存与性能日志:用文件JSON缓存加expect/actual埋点

Bilibili式信息流对冷启动加载速度很敏感。首屏最快见效的做法,不是上重型数据库,而是在共享层做一个文件缓存,把上一轮Feed存成JSON,App启动先读缓存,再异步刷新。配合expect/actual日志埋点,能直接对比共享层耗时和ArkUI渲染耗时。

// commonMain/kotlin/com/example/shared/cache/FeedCache.kt class FeedCache(private val cacheRoot: String) { fun save(tabType: Int, page: FeedPage) { val file = File(cacheRoot, "feed_${tabType}.json") file.writeText(Json.encodeToString(FeedPage.serializer(), page)) } fun load(tabType: Int): FeedPage? { val file = File(cacheRoot, "feed_${tabType}.json") return if (file.exists()) { Json.decodeFromString(FeedPage.serializer(), file.readText()) } else null } }

缓存Key里要带上tabType,因为首页、排行榜、分区页是三类不同数据,互相不能顶替。性能日志则用expect/actual统一入口,鸿蒙侧落到hilog,Android侧落到logcat,两端看到的字段完全一致。我一般在FeedRepository的loadNextPage前后各打一个时间戳,能很快判断慢是慢在网络、解析还是ArkUI渲染。

真机实测时,如果共享层整体耗时稳定在几十毫秒,但页面还是卡,问题基本不在KMP,而在ArkTS侧的错误线程或组件复用。如果共享层耗时上百毫秒,优先回头检查Ktor的HttpTimeout超时配置、JSON解析的ignoreUnknownKeys,这两件事是最常见的“黑匣子”来源。

我现在接跨平台项目,第一件事不是写UI,而是在共享层里把对外接口面缩到最小。能只暴露JSON字符串就不暴露对象,能同步就不要暴露挂起,能跑commonTest就在commonTest里钉死分页边界。这些教训是我在鸿蒙端三四个迭代里换来的。曾有版本我图省事把expect直接透传给ArkTS,结果某个真机型号上deviceId拿不到,崩溃率直接抬上去好几个点。后来我宁可多写一层facade,也要守住“跨端边界只过基础类型”这条线。这个习惯养成了,鸿蒙版信息流的联调周期能从两周压到三天。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/17 16:31:01

Kali 2024.2 中 curl 8.7.1 的协议安全升级与实战适配

1. 项目概述&#xff1a;为什么一个 curl 命令值得单独写一篇“最新 Kali”专题&#xff1f;在某高校网络安全实验室的日常教学中&#xff0c;我带过三届学生做渗透测试基础实训。每次讲到信息收集环节&#xff0c;总有人举手问&#xff1a;“老师&#xff0c;curl 和 wget 到底…

作者头像 李华
网站建设 2026/10/17 16:30:57

TRX与USDT链上原子兑换:多签托管智能合约方案

简介&#xff1a;这是一套面向区块链开发者与TRON生态项目实践者的TRX/USDT双向兑币机智能合约及配套机器人源码&#xff0c;解决去中心化场景下高频、可配置的跨链资产兑换需求。资源包含15个文件&#xff0c;以7个Go语言服务端模块&#xff08;如ScanBlock.go、chargeWithdra…

作者头像 李华
网站建设 2026/10/17 16:22:48

LabVIEW调用HCNetSDK实战:从CLN配置到多路抓图

简介&#xff1a;海康威视HCNetSDK V5.3.6.30库文件是一套面向LabVIEW开发者的摄像头控制接口&#xff0c;解决了在图形化编程环境中直接调用设备SDK的难题&#xff0c;适合需要快速集成监控功能的中级LabVIEW工程师。资源为ZIP压缩包&#xff0c;共45个文件&#xff0c;以DLL动…

作者头像 李华