Kotlin Multiplatform 三方库 multiplatform-markdown-renderer 的 OpenHarmony 适配实战(三库源码收编 206 文件 + CAdapterCodegen NPE 排查 + 独立 klib 模块拆分解法)
库版本:com.mikepenz:multiplatform-markdown-renderer-m3 0.46.0(源码收编)|org.jetbrains:markdown(源码收编,GFM 解析器)|org.jetbrains.kotlinx:kotlinx-collections-immutable 0.3.8(源码收编)|验证环境:Kotlin 2.2.21-0.4.0(ovCompose OpenHarmony 定制工具链)|Compose Multiplatform 1.9.2-0.4.0|JDK 21.0.12|DevEco Studio 26.0.0|DevEco 模拟器 x86_64|OpenHarmony API 26
multiplatform-markdown-renderer(社区常称 markdown-compose)是 KMP 生态做 Markdown 渲染的主流库,用 JetBrains 的markdown包做语法解析,再用 Compose 把 AST 画出来,官方支持 Android / iOS / Desktop。它至今没有发布 OpenHarmony 切片,而它依赖的另外两个库——解析器org.jetbrains:markdown和集合库kotlinx-collections-immutable——同样只有android/ios/jvm/native变体。想在 OpenHarmony 上跑 Markdown 渲染,三个库得一起解决。
本文记录把这三个库完整搬上 OpenHarmony 的全过程:源码收编 206 个.kt文件、写一个四 Tab 的渲染 Demo(基础 / GFM / 代码高亮 / 流式),以及排查一个极具迷惑性的崩溃——compileKotlinOhosArm64全程绿灯,linkDebugSharedOhosArm64却在 K/N 的CAdapterCodegen里抛 NPE。这个坑试遍了-Xbinary开关都没用,最后靠把源码从composeApp拆到独立markdown-lib模块才解决,解法反直觉但逻辑闭环。
一、先看清楚:Markdown 渲染链路由三个库拼成
翻开源码分布就明白适配面有多大。这不是一个库的事,是三层协作:
| 层 | 库 | 职责 | OpenHarmony 切片 |
|---|---|---|---|
| 解析层 | org.jetbrains:markdown | Lexer + Parser,产出 AST;含 GFM 扩展(表格 / 删除线 / 任务列表) | 无 |
| 渲染层 | com.mikepenz:multiplatform-markdown-renderer | 遍历 AST,映射成 Composable(MarkdownComponent/MarkdownElements) | 无 |
| 基础层 | kotlinx.collections.immutable | 渲染层大量使用PersistentList/PersistentMap做不可变状态 | 无 |
markdown包内部还分层:commonMain是全部词法/语法规则,nativeMain只需要补少量expect/actual(CommonDefs那套 HTML 实体转义)。markdown-renderer的markdown-m3子模块提供 Material 3 风格的Markdown(content = "...")composable。
关键观察:三个库都没有 OpenHarmony 变体,且都不是「纯 common 代码」——每个都带 platform 相关点。所以适配不是改版本号就能解决,只能把源码收编进工程。
二、工程结构
cmp-markdown-demo/ ├── markdown-lib/ # 【新增】独立 KMP 库模块,收编三个库的全部源码 │ ├── build.gradle.kts # ohosArm64/ohosX64 + androidTarget,compose api 暴露 │ └── src/ │ ├── commonMain/kotlin/ # 206 个 .kt │ │ ├── org/intellij/markdown/ # 101 个(JetBrains 解析器 + GFM) │ │ ├── com/mikepenz/markdown/ # 60 个(markdown-core + m3 渲染层) │ │ └── kotlinx/collections/ # 45 个(immutable 集合) │ └── ohosMain/kotlin/ # 4 个 actual(CommonDefs / Url / MarkdownLogger / commonFunctions) ├── composeApp/ # CMP 应用模块,只放 Demo UI │ └── src/commonMain/kotlin/com/example/cmp_pure/ │ ├── App.kt # 四 Tab Demo(全部调用 Markdown(content = ...)) │ └── Platform.kt # expect/actual ├── harmonyApp/ # DevEco 工程 │ └── entry/ # ArkTS 入口,NAPI 加载 libkn.so └── _ref/ # 三库原始源码副本(markdown-core-src / jetbrains-markdown-src / ...)markdown-lib:本次适配的核心决策。三库源码全部收在这里,对外只暴露 Compose API 面(Markdown/MarkdownColors/MarkdownTypography);composeApp:以implementation(project(":markdown-lib"))的klib 依赖方式引用,而不是把源码并进来。这个区别是解决后面 NPE 的关键;harmonyApp:DevEco 侧标准工程,libentry.so通过 NAPI 把libkn.so的渲染结果上屏。
三、适配过程:六道坎
三个库都只发android/ios/jvm/native,没有 OpenHarmony 切片。做法是把源码整体拷进markdown-lib/src/commonMain,然后给 OpenHarmony 补上expect的actual。整个适配真正要手写的平台代码只有 4 个文件:
markdown-lib/src/ohosMain/kotlin/ ├── org/intellij/markdown/html/CommonDefsImplNative.kt # HTML 实体表(commonMain 是 expect) ├── org/intellij/markdown/html/Url.kt # URL 编解码 actual ├── com/mikepenz/markdown/utils/MarkdownLogger.native.kt # 日志 actual(native 侧无 android.util.Log) └── kotlinx/collections/immutable/internal/commonFunctions.kt # 集合内部 actualCommonDefs那套在 Kotlin 2.x 仍标注为 Beta,需要在所有 native 编译上显式打开开关,否则满屏告警:
// markdown-lib/build.gradle.ktskotlin{ohosArm64()// 真机 arm64ohosX64()// 模拟器/开发机 x64// org.intellij.markdown 的 CommonDefs 使用 expect/actual 类(Kotlin 2.x 仍标注为 Beta)targets.withType<org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget>().configureEach{compilations.configureEach{compilerOptions.options.freeCompilerArgs.add("-Xexpect-actual-classes")}}sourceSets{commonMain.dependencies{// 对外暴露的 Compose API 面(Markdown / MarkdownColors 出现在 public 签名中)api(compose.runtime);api(compose.foundation);api(compose.animation)api(compose.ui);api(compose.material3)implementation(libs.atomicFu)}// default hierarchy 不含 ohos,显式声明中间层valohosMain=create("ohosMain").apply{dependsOn(commonMain.get())}valohosArm64Mainbygetting{dependsOn(ohosMain)}valohosX64Mainbygetting{dependsOn(ohosMain)}}}kotlinx-collections-immutable也走同一套:commonMain是expect声明,ohosMain补actual。
3.2 第一道小坎:早期 stub 与完整源码撞车
收编之前我先写过一个ImmutableStubs.kt占位(少量接口声明)让 Demo 能编译。等完整源码收进来,立刻报重复声明 + 重载歧义:
e: Conflicting declarations: PersistentList, PersistentList e: Conflicting overloads: persistentListOf解法很直接——删掉 stub,ImmutableStubs.kt是过渡产物,不该和完整源码共存。删完compileKotlinOhosArm64一次通过。
3.3 核心坎:编译全绿,链接崩溃(CAdapterCodegen NPE)
这是本次适配最值得记录的一段。stub 删掉后:
.\gradlew.bat :composeApp:compileKotlinOhosArm64# BUILD SUCCESSFUL.\gradlew.bat :composeApp:linkDebugSharedOhosArm64# FAILED编译期完全不报错,链接期崩溃:
e: java.lang.NullPointerException at org.jetbrains.kotlin.backend.konan.CAdapterCodegen.buildCAdapter(...)CAdapterCodegen是 K/N 生成 C 适配器的模块——把 Kotlin 声明导出成 C 符号,给libentry.so通过 NAPI 调用。它在遍历导出面时对某个声明取到了 null。
第一反应是怀疑 export。composeApp里配了export(libs.compose.multiplatform.export),注释掉再试——照样崩,与 export 无关。
然后怀疑 C 适配器的各个子开关,逐个加:
// composeApp/build.gradle.kts(试过,全部无效)freeCompilerArgs+=listOf("-Xbinary=objcExportDisableMethodBridges=true","-Xbinary=objcExportGenerateDsym=false","-Xbinary=objcExportDisableInterfaceExtensions=true","-Xbinary=objcExportDisableSwiftUI=true")四个开关加满,依旧 NPE。说明问题不在 C 适配器的具体生成策略,而在它处理的声明集合本身出了问题。
接着怀疑 typealias。MarkdownComponents.kt里有两个 composable 函数类型的 typealias,形如:
typealiasMarkdownComponent=@Composable(MarkdownComponentModel)->UnittypealiasCustomMarkdownComponent=@Composable(IElementType,MarkdownComponentModel)->Unit先试低风险改法——改成internal,把它们从 public 导出面摘掉:
internaltypealiasMarkdownComponent=@Composable(MarkdownComponentModel)->UnitKotlin 接受了(编译通过),但链接还是 NPE。typealias 也不是触发源。
激进改法失败。接着尝试把 typealias 机械展开成完整函数类型。这一步翻车两次:
- 正则替换把
Unit也误伤,写出的@Composable (...) -> Unit?中?绑定到了内部Unit(应为(@Composable (...) -> Unit)?); - 修正括号后,FIR 编译器直接崩溃。
回退原文件,确认侵入式改不可行。
模块级二分也走不通。想靠「暂存部分目录」定位触发声明,但compose依赖model、annotator又依赖compose,模块间有向边,拆不开干净的二分单元。
解法:把源码搬出 composeApp,独立成markdown-lib模块。
// composeApp/build.gradle.kts(修改后)kotlin{sourceSets{commonMain.dependencies{// Markdown 渲染库(jetbrains-markdown + markdown-core/m3 + kotlinx-collections-immutable)// 以 klib 依赖方式引入,源码收拢在 :markdown-lib 模块implementation(project(":markdown-lib"))}}}// settings.gradle.ktsinclude(":composeApp")include(":markdown-lib")// 新增改完再链接:
> Task :composeApp:linkDebugSharedOhosArm64 BUILD SUCCESSFUL反直觉,但逻辑闭环:三库源码放在composeApp里时,它们的 206 个 public 声明全部进入composeApp的 C API 导出面,CAdapterCodegen要逐个处理;拆成独立模块后,这些声明属于markdown-lib的 klib,composeApp只以依赖身份引用,导出面里只剩 Demo 自己的App()。CAdapterCodegen 不再触碰那些声明,NPE 自然消失。
这也解释了为什么前面所有尝试都无效——objcExport*系列开关控制的是「怎么生成」,而问题是「生成的对象集合里混进了不该出现的东西」。模块边界才是这里的正解,不是编译器开关。
3.4 环境坎:Gradle 9.1 + JBR 25 跑不了 K/N
排查 NPE 期间顺手验证了一件事:把 JDK 换成 DevEco 自带的 JBR 25、Gradle 用 9.1,cmp-settings-demo(一个此前正常的工程)也崩了。堆栈显示 Kotlin 编译器解析不了 JBR 的版本串25.0.2。
结论:这套工具链组合本身不兼容,与项目无关。最终采用 JDK 21(Temurin 21.0.12)+ Gradle 8.9。另一个收获:命令行构建 HAP 时hvigorw在DevEco Studio/tools/hvigor/bin/(不是sdk/下),且需要显式注入三个环境变量,否则分别报NODE_HOME is not set、spawn java ENOENT:
$env:DEVECO_SDK_HOME ="C:\Program Files\Huawei\DevEco Studio\sdk"$env:NODE_HOME ="C:\Program Files\Huawei\DevEco Studio\tools\node"$env:JAVA_HOME ="C:\Program Files\Huawei\DevEco Studio\jbr"$env:PATH ="$env:NODE_HOME;$env:JAVA_HOME\bin;$env:PATH".\gradlew.bat :composeApp:linkDebugSharedOhosArm643.5 harmonyApp 坎:modelVersion 写错,DevEco 直接不识别工程
K/N 侧跑通后,DevEco 打开harmonyApp却没有自动生成运行配置,模块树也是空的。翻build.log看到真正的报错:
The modelVersion in hvigor-config.json5 is 26.0.0, and the modelVersion in oh-package.json5 is 6.0.0hvigor/hvigor-config.json5里的modelVersion被写成了26.0.0——这是 OpenHarmony 的 API 版本号,不是 hvigor 的模型版本。两者必须是同一个值。改成6.0.0后工程立刻被识别。
顺带清掉两个同类问题:
// build-profile.json5(修改前) "signingConfigs": [], // 空数组 = 显式声明"无签名配置",会阻止 DevEco 的 debug 自动签名 "signingConfig": "default", // 悬空引用,指向上面那个空数组 // 修改后:两个字段都删掉,让 DevEco 走默认的自动签名路径3.6 部署坎:89.6MB 的 HAP 传不进模拟器
工程能跑了,一部署又卡住。日志显示mkdir成功后整整 10 分钟没有进展,最后FileTransfer Failed:
$ hdc shell mkdir data/local/tmp/efb3079a... FileTransfer Failed: (10 分钟后) $ hdc shell rm -rf data/local/tmp/efb3079a...两个原因叠加。
其一,HAP 太大。libkn.so编译出来 82MB(arm64)+ 80MB(x86_64),abiFilters配了双 ABI,HAP 达到 89.6MB。而abiFilters只控制 CMake 编哪些 ABI,打包时ProcessLibs会把libs/目录下所有 so 全部塞进 HAP——所以只改abiFilters不够,还得把不用的 ABI 目录删掉:
// entry/build-profile.json5 "externalNativeOptions": { "path": "./src/main/cpp/CMakeLists.txt", "abiFilters": ["x86_64"] // 模拟器调试只留 x86_64 }# 同时删除 arm64 产物,否则照样打进 HAPRemove-Item-Recurse harmonyApp\entry\libs\arm64-v8aHAP:89.6MB → 47MB。
其二,hdc 的文件传输通道单独卡死。这是更隐蔽的一层。单独诊断:
| 测试 | 结果 |
|---|---|
hdc list targets | 模拟器在线 |
hdc shell "df -h /data" | 4.1G 可用,空间充足 |
hdc shell "echo alive" | 正常,秒回 |
hdc file send7 字节小文件 | 卡死超时 |
hdc shell通、hdc file send不通——两者走不同的 hdc 通道,说明传输通道单独挂了。重启 hdc 并重连设备即恢复:
hdckillGet-Processhdc|Stop-Process-Force# 有残留进程,kill 不够hdc tconn 127.0.0.1:5555# DevEco 模拟器的固定端口恢复后实测传同一个 47MB HAP:
FileTransfer finish, Size:49314657, File count = 1, time:375ms rate:131505.75kB/s0.5 秒传完,131 MB/s。之前 10 分钟传不完,纯粹是通道卡死,和 HAP 大小无关。
四、运行效果(DevEco 模拟器实测)
以下截图均为模拟器实拍。模拟器是OpenHarmony API 26(x86_64),跑的是只含 x86_64 ABI 的 47MB HAP。启动日志:ohos.cmp.markdown successfully launched within 1 s 721 ms。
基础渲染:标题层级、斜体/粗体、链接、行内代码、引用块,全部由Markdown(content = ...)渲染。
GFM 扩展:表格 + 任务列表(- [x]/- [ ])走的是markdown包的 GFM 扩展规则,不是手写 UI。
代码高亮:Kotlin / ArkTS / JSON 三个代码块,验证 GFM 围栏代码块 +MarkdownColors主题联动。
流式渲染:LLM 输出场景 Demo,展示rememberStreamingMarkdownState()的逐段追加用法。
四个 Tab 覆盖了渲染器的主要能力面:AST 解析、GFM 扩展、代码块、流式状态。
五、FAQ
Q1:Unresolved reference: markdown/mikepenz?
三个库都进了markdown-lib模块,composeApp必须显式implementation(project(":markdown-lib")),且settings.gradle.kts要有include(":markdown-lib")。
Q2:编译成功、链接时CAdapterCodegenNPE?
就是 3.3 那个坑。先确认三库源码是不是直接放在composeApp/src/commonMain里——如果是,搬到独立 KMP 模块,用 klib 依赖引用。不要指望-Xbinary=objcExport*开关能救。
Q3:Conflicting declarations: PersistentList?
早期 stub(如ImmutableStubs.kt)没删,和收编进来的完整源码重复。删 stub。
Q4:DevEco 打开工程没有运行配置、模块树为空?
查harmonyApp/.hvigor/outputs/build-logs/build.log,大概率是hvigor-config.json5的modelVersion和oh-package.json5不一致。两者要相同(这里是6.0.0)。
Q5:HAP 装不上或部署超时?
① 看 HAP 体积,双 ABI 会到 89.6MB,模拟器只留x86_64并删掉entry/libs/arm64-v8a;② 若日志卡在mkdir之后,是 hdc 传输通道卡死,hdc kill+ 杀残留进程 +hdc tconn 127.0.0.1:5555;③build-profile.json5别留空的signingConfigs: []。
Q6:命令行构建 HAP 报NODE_HOME is not set/spawn java ENOENT?hvigorw.bat要用DevEco Studio/tools/hvigor/bin/下那份,并注入DEVECO_SDK_HOME(指向sdk)、NODE_HOME、JAVA_HOME三个变量,见 3.4。
Q7:markdown-m3目录出现m3/m3/双层嵌套?
源码拷贝时把子模块目录整个塞进了同名父目录。m3下的文件应直接在com/mikepenz/markdown/m3/,多套一层会导致包路径对不上。
六、总结与参考
把multiplatform-markdown-renderer搬上 OpenHarmony,和之前适配 SQLDelight、Paging 3、Settings 最大的不同是:这次要一次收编三个库、206 个源文件,且三个库都没有 OpenHarmony 切片。真正手写的平台代码只有 4 个actual文件(HTML 实体、URL、日志、集合内部函数),工作量集中在源码管理和工程结构。
而整个适配最有价值的收获,是 3.3 那个编译全绿、链接 NPE的坑。它教会的一件事是:当CAdapterCodegen崩溃时,先看导出面里混进了什么,而不是先调编译器开关。-Xbinary=objcExportDisableMethodBridges这类开关控制的是「怎么导出」,而这里是「导出的对象集合本身不该包含这些声明」——把源码移到独立的markdown-lib模块、让composeApp以 klib 依赖引用,导出面立刻干净,问题消失。
顺带记下的三条环境经验也都有复用价值:Gradle 9.1 + JBR 25 跑不了 K/N(要 JDK 21);hvigor-config.json5的modelVersion写错会让 DevEco 静默不识别工程(报错只在 build.log 里);hdc 的 shell 和 file send 是两个独立通道,前者通不代表后者通。
三个库搬完之后,Demo 侧就是教科书式的 KMP 代码了——Markdown(content = "...")全 common,业务零改动。这意味着 OpenHarmony 上的 CMP 开发者可以直接用multiplatform-markdown-renderer处理任何 Markdown 渲染场景,包括文档浏览、LLM 流式输出、富文本编辑预览——这是 KMP 生产刚需。
还没做的:接真实网络源(把 Demo 的静态字符串换成拉取远端 Markdown 文件)、图片加载(需配合 Coil 的 OpenHarmony 适配)、MarkdownColors的深色模式定制。但最难的部分——三个库的源码收编 + 在 OpenHarmony 上驱动 Compose 完成 Markdown 全特性渲染——已经打通,后面都是业务封装。
- 上游仓库:https://github.com/mikepenz/multiplatform-markdown-renderer
- 解析器源码:https://github.com/JetBrains/markdown
- OpenHarmony 定制仓库:https://maven.eazytec-cloud.com/nexus/repository/maven-public/
- OpenHarmony 三方库社区:https://atomgit.com/oh-tpc
- 本项目适配地址:https://atomgit.com/oh-tpc/mikepenz