开源鸿蒙平台 CMP 三方库「richtext-compose」适配全流程
本文记录
richtext-compose接入 OpenHarmony 的完整过程,覆盖 Compose 依赖盘点、Compose-free 内核提取、ohosArm64/ohosX64目标、Kotlin/Native 动态库、C ABI/N-API 桥接、ArkUI 双区宿主、HAP 构建与真机验收,以及两个只有在真机上才会暴露的 ArkUI 渲染问题。
richtext-compose是一套 Compose Multiplatform 富文本编辑库:RichTextEditor(value, onValueChange)负责编辑,RichText(value)负责渲染,RichTextValue里存着「文本 + 一串带 tag 的区间」,所有加粗、斜体、颜色、字号、对齐、列表都是这些区间在文本上的代数运算结果。它和KStore那类纯数据层库有一个根本区别:价值有一半长在 UI 上——RichTextEditor用BasicTextField+VisualTransformation做所见即所得的样式编辑,RichText把AnnotatedString画出来,而AnnotatedString/SpanStyle/ParagraphStyle全部是androidx.compose.ui.text的类型,鸿蒙侧没有任何对应制品。因此本次适配走的不是「真编译整库」,而是社区批准的另一种路线:把 Compose-free 的样式代数内核提取出来,编译成ohosArm64/ohosX64动态库,渲染交给 ArkUI。内核 8 个文件 1627 行、零第三方依赖,按上游语义逐函数搬运;Compose UI 层 15 个文件原样停放、不参与编译;两者之间只隔一份扁平 JSON 契约。
项目地址: AtomGit/oh-tpc/richtext-compose-multiplatform.git
上游仓库: Wavesonics/richtext-compose-multiplatform
开发工具: 华为云码道
环境安装: 环境安装指南
一、背景
1.1 为什么做 OpenHarmony 平台 KMP/CMP 适配
richtext-compose 解决的是「一段带格式的文本怎么编辑、怎么存、怎么画」这一件事。它的数据模型很克制:一段纯文本,加上若干条 (start, end, tag) 区间。Bold 是区间,TextColor:#FF3366 是区间,AlignCenter 也是区间(只不过作用在段落上)。所有编辑行为都收敛成对这批区间的四种操作——插入时合并相邻同 tag、删除时按结构相等移除、文本变更时整体位移、清格式时在光标处切开。样式本身不存对象,只存 tag 字符串,tag 到具体属性的映射是一张显式表。上游还带了一套「长度差超过 10 才记一条历史」的撤销栈,以及一个可序列化的快照结构。
这套模型的价值在 UI 上兑现:RichTextEditor 把 VisualTransformation 挂在 BasicTextField 上,用户打字时光标所在位置实时套用样式;RichText 把区间解析成 AnnotatedString 再交给 Compose 画。OpenHarmony 应用同样需要富文本能力——批注、笔记、消息草稿都要——但上游构建(Kotlin 1.9.21 / AGP 8.2.0 / Compose 1.5.11,只有 androidTarget 与 jvm("desktop"))不会生成任何 OpenHarmony 变体。
如果只把示例页面改成 ArkTS,页面能显示按钮,却无法证明样式代数在 OpenHarmony ARM64 设备上真的跑起来了——而样式代数正是这个库的全部价值所在。适配必须同时验证区间模型、历史栈、渲染解析、Native 动态库、C ABI、N-API、ArkUI 页面与 HAP 交付链路。
| 障碍 | 具体问题 |
|---|---|
| 目标缺失 | 上游只有 androidTarget + jvm("desktop"),没有 ohosArm64() / ohosX64()。 |
| 依赖矩阵 | commonMain 的 api 依赖是 compose.runtime / compose.foundation / compose.material3,另有 kotlin-reflect 与 com.arkivanov.essenty:parcelable:1.3.0;这五件在鸿蒙侧都没有 ohos 变体,一个都解析不到。 |
| 语言边界不同 | ArkTS 不能持有 Kotlin 对象;AnnotatedString / SpanStyle / ParagraphStyle 在 ArkUI 里没有对应物,Span 只接受显式属性。 |
| 交互形态不同 | BasicTextField + VisualTransformation 在 ArkUI 没有等价组件;TextArea 不支持「一段文字里不同位置不同样式」。 |
| 语义跨不了桥 | 撤销栈、RichTextValue 的 copy-on-write、快照都是 Kotlin 侧概念,C ABI 只能同步一来一回。 |
| 交付链路复杂 | .so、CMake、N-API、HAP、签名和 hdc 都要单独验收。 |
因此,本项目的边界划在:Compose-free 内核的 ohos 编译 + 根构建替换(cmp_qrose 模式)+ C ABI/N-API 桥接 + ArkUI 重绘。样式语义完全由上游算法负责,ArkTS 只负责调用 Native、解析 JSON、把 blocks 画出来。
1.2 库提供的能力
Demo 提供一个真实的编辑会话,对应桥接的全部入口:
- 文本编辑(setText):整篇替换文本,区间按上游规则整体位移,等价于「一次性打字」;
- 选区(select / selectParagraph):显式设置
start/end;段落样式需要非折叠选区,所以额外有selectParagraph把光标扩到整段; - 字符样式(insertStyle):
Bold/Italic/Underline/Strikethrough/TextSize:1.5/TextColor:#E11D48,重复应用同一 tag 会撤销(上游语义); - 按族清除(clearStyles):
["TextColor","TextSize"]把选区覆盖到的这两族区间整条删掉; - 历史(undo / redo):与上游同一套栈与同一套阈值(长度差 > 10 才记),可用态随响应返回;
- 快照(snapshot):返回
text+selectionPosition+spanStyles+paragraphStyles,可直接持久化; - 渲染视图(blocks / runs):桥每次响应都带上解析好的段落与样式切分,以及一份 HTML 导出;
- 版本握手(richTextVersion):返回 fork 版本号,来自真实 Native 调用而非静态文字。
响应包含:
| 字段 | 作用 |
|---|---|
ok | 操作是否成功,失败时 error 带原文。 |
op / session | 回显本次操作与会话名(多会话互不干扰)。 |
text / length | 当前全文与长度;页面无条件用响应里的 text 覆盖输入框。 |
selection | {start, end},ArkTS 的样式按钮先同步它再发样式请求。 |
currentStyles | 选区命中的样式族名,用于高亮工具条。 |
spans / paragraphs | 原始区间(start / end / tag / style),快照页直接展示。 |
blocks | 解析后的段落:align、list、text、runs[],ArkTS 只负责画。 |
html | 最小 HTML 导出,用于核对与只读展示。 |
snapshot | 仅 op=snapshot 时出现。 |
引擎侧完整能力(区间合并/拆分/位移、历史栈、快照、渲染解析、HTML 导出)全部在 commonMain 里编译进 .so,JVM 侧保留 45 项交叉验证用例,证明 ohos .so 里执行的样式代数和 JVM 上跑的是同一份代码。
1.3 实现适配
| 维度 | 要求 |
|---|---|
| 代码复用 | 区间代数、历史栈、快照、渲染解析逐函数搬运到 core/,语义按上游注释与测试对齐;不重写算法,只去掉 Compose 类型。 |
| 平台目标 | 根构建声明 ohosArm64 + ohosX64,保留 jvm() 作交叉验证;库模块出 KLIB 且依赖表为空,example/nativeApp 出双 ABI 动态库。 |
| 构建隔离 | 根构建替换为极简 ohos 构建(cmp_qrose 模式);:android 与 :desktopExample 留盘不进 settings.gradle.kts。 |
| 桥接稳定 | 2 个 C ABI 符号 + UTF-8 JSON,不传 Kotlin 对象地址;异常永远不跨 FFI。 |
| 依赖纪律 | 库模块零依赖,不依赖任何 kotlinx / Compose 的 ohos 制品线——这条决定了工具链可以停留在已批准的 2.2.21 线。 |
| UI 完整 | 四页签覆盖全部桥接入口:编辑(文本 + 字符样式 + 字号 + 颜色)、段落(对齐 + 列表)、快照(区间 + HTML)、关于(版本握手)。 |
| 仓库规范 | 项目说明、文章、效果图和源码链接统一使用 AtomGit。 |
本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现。
RichTextEditor/RichText/RichTextField仍然在src/composeMain里,不参与 ohos 编译;ArkTS Demo 是「TextArea 输入 + blocks 重绘」的双区验收宿主,不是「Compose 跑在 ArkUI 上」。
二、实现路线图
第 1 阶段:项目初始化 ── 盘点 Compose 依赖边界、确认内核可提取、确定交付物
第 2 阶段:目标与依赖打通 ── 根构建替换、eazytec Maven 置前、零依赖内核、双 ohos 目标
第 3 阶段:桥协议建模 ── op 词表、选区/历史/快照字段、blocks 渲染视图、错误通道
第 4 阶段:原生桥接 ── @CName C ABI、C++ N-API、参数校验与 DisposeString
第 5 阶段:ArkUI 宿主 ── 双区结构、四页签、blocks 重绘与两个渲染坑
第 6 阶段:示例与验证 ── 45 项交叉验证、so 符号、HAP、签名、真机
三、逐步实现过程
第 1 阶段:项目初始化
1.1 工程边界
richtexteditor/ 上游库模块(进 ohos 构建,jvm + ohosArm64 + ohosX64)
richtexteditor/src/commonMain/.../core/ 新增:Compose-free 内核(8 文件 1627 行,零依赖)
richtexteditor/src/commonTest/ 新增:45 项交叉验证(jvmTest 跑)
richtexteditor/src/composeMain/ 上游 Compose 源码原样停放(15 文件,不参与编译)
android/ 上游 Android 示例,留盘不进 settings
desktopExample/ 上游桌面示例,留盘不进 settings
example/nativeApp/ Kotlin/Native 动态库(Bridge + MiniJson)
example/harmonyApp/ DevEco Stage、CMake N-API 和 ArkTS 四页签页面
README.OpenHarmony.md / _CN.md 鸿蒙适配说明(中英双份)
README.upstream.md 上游原文保存
构建拓扑走 cmp_qrose 模式(根构建即 ohos 构建):settings.gradle.kts 只 include :richtexteditor 与 :example:nativeApp,rootProject.name = "ohos_richtext"。上游的 org.jetbrains.compose、com.android.library、kotlin-parcelize、maven-publish、signing 五个插件全部随根构建替换剥离——本 fork 不发 Maven Central。
1.2 工具链矩阵
| 项目 | 配置 |
|---|---|
| Kotlin Multiplatform | HarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven) |
| JDK | 21(org.gradle.java.home 指向本机 Temurin 21) |
| OpenHarmony 目标 | ohosArm64 + ohosX64(jvm() 保留作交叉验证) |
| Gradle | 8.14.1(2.2.21 线对应版本,wrapper 随仓) |
| 库依赖 | 零依赖(不引 kotlinx、不引 Compose) |
| DevEco product | 5.1.0(18) 兼容 |
| DevEco Studio | 5.1.0 Release |
| ABI | arm64-v8a + x86_64 |
| Bundle | org.terminator.ohos.richtext |
export JAVA_HOME="/path/to/jdk-21"
java -version
1.3 源码依赖度盘点
| 层次 | 文件 | 依赖形态 | 处置 |
|---|---|---|---|
| UI 层 | ui/RichTextEditor.kt、ui/RichText.kt、ui/RichTextField.kt | Compose runtime / foundation / material3 | 停放 src/composeMain |
| 变换层 | transformations/*.kt | VisualTransformation(Compose 类型) | 停放 src/composeMain |
| 模型层 | model/RichTextValue.kt、RichTextValueImpl.kt | Compose TextRange / TextFieldValue / AnnotatedString | 提取到 core/,类型换成本地实现 |
| 区间层 | utils/AnnotatedStringBuilder.kt、utils/Extensions.kt | AnnotatedString.Builder | 提取到 core/ |
| 样式层 | model/Style.kt、mappers/StyleMapper.kt | SpanStyle / ParagraphStyle / Color | 提取,改为 tag 词表 + 属性解析 |
| 快照 | utils/RichTextValueSnapshot.kt | @Parcelize(essenty-parcelable) | 提取,去掉注解 |
| 平台 | common/platform.kt(expect/actual) | JVM / Android 各一份 | 停放,核心固定 "\n" |
提取结果:core/ 8 个文件、1627 行、依赖表为空。composeMain/ 里 15 个上游文件原样保留,目录结构与包名不变,将来 Compose 工具链可用时可以直接切回去。
core/ 的职责划分:
| 文件 | 职责 |
|---|---|
RichTextStyle.kt | tag 词表、toTag / fromTag / parse(spec)、ARGB 与十六进制互转、字号 clamp 0.5–2.0 |
RichTextRange.kt | RichSelection、RichStyleRange、段落边界函数(startOf / endOf / coerceParagraph) |
RichTextDocument.kt | 区间代数:addSpans / addParagraphs / removeSpans / removeParagraphs / updateStyles / shiftRanges / splitStyles |
RichTextSnapshot.kt | 快照结构与往返(toDocument() / from(document, selection)) |
RichTextEditorModel.kt | 选区与 composition、历史栈(阈值 10)、insertStyle / removeStyleFromSelection / clearStyles / undo / redo |
RichTextRender.kt | 解析成 blocks / runs、最小 HTML 导出 |
MiniJson.kt | 手写扁平 JSON 读写(不引 kotlinx-serialization) |
RichTextBridge.kt | op 分发、会话表、响应拼装 |
1.4 页面边界
标题区 richtext-compose · OpenHarmony / CMP 富文本引擎 · Kotlin/Native ohosArm64/ohosX64
Tab 切换 编辑 / 段落 / 快照 / 关于
原文区 多行 TextArea(直接回写引擎,三行示例文本 72 字符)
选区区 start / end 两个输入框 + 应用按钮 + 全选 / 整段 / 撤销 / 重做
字符样式区 B / I / U / S / 清除 + 五档字号 + 五个颜色
渲染区 诊断两行(blocks / runs / spans / rev / version,run0 的原始值与解析值)+ 重绘结果
状态行 最近一次结果(如「insertStyle TextColor:#E11D48 ok · 选区[0,72) · 样式 Bold+TextColor」)
日志区 带时间戳的调用记录(最新在前,上限 8 条)
段落页 对齐三键 + 列表两键 + 应用选区按钮
快照页 text / selectionPosition / spanStyles / paragraphStyles + HTML 导出
关于页 版本握手、架构分层、已知限制、仓库链接
页面启动时先调 richTextVersion() 做握手,再 reset() + setText() 注入示例文本并选中前 8 个字符。
第 2 阶段:目标与依赖打通
2.1 制品线验证:这一次结论是「没有」
先查社区 Maven(https://maven.eazytec-cloud.com)上有没有 Compose 与相关依赖的 ohos 制品。结论与 KStore 那次相反:
androidx.compose.runtime:runtime:* → 无 ohos 变体
androidx.compose.foundation:foundation:* → 无 ohos 变体
androidx.compose.material3:material3:* → 无 ohos 变体
org.jetbrains.kotlin:kotlin-reflect:* → 无 ohos 变体
com.arkivanov.essenty:parcelable:1.3.0 → 无 ohos 变体
也就是说,上游 commonMain 的五个 api 依赖一个都解析不到。richtexteditor 模块无法整库编译——不是工具链版本问题,是从 AnnotatedString 开始就不存在于鸿蒙生态。这条结论直接决定路线:
| 库的类型 | 价值落在哪一层 | 正确路线 |
|---|---|---|
| 纯数据层(KStore、配置、缓存、状态机) | commonMain,零 UI | 真编译整库 + 补 actual |
| UI 型(本库、图片处理、图表) | 一半在 Compose 绘制层 | 提取 Compose-free 内核 + 平台 UI 重写 |
路线选择必须基于当天制品是否可达,而不是历史结论——这正是 KStore 与本库得出相反答案的原因。
2.2 工具链钉版(为什么是 2.2.21-1.0.0)
内核零依赖带来一个额外好处:工具链不受 kotlinx / Compose 制品线的元数据约束,可以停在社区已批准的 2.2.21-1.0.0(qrose / haze 同款)。上游源码是 Kotlin 1.9.21 编的,2.2.21 读 1.9 元数据没有任何问题(向后兼容);反过来,2.4.x 线在这里没有必要,因为没有任何 2.4 元数据的依赖需要它。
Gradle 沿用 2.2.21 线对应的 8.14.1(2.4.20 线才用 9.x,两条工具链线的经验不能互相套用)。
settings.gradle.kts 把 eazytec 仓库写在最前:
pluginManagement {
repositories {
maven("https://maven.eazytec-cloud.com/nexus/repository/maven-public/")
maven("https://maven.eazytec-cloud.com/nexus/content/groups/harmonyos")
gradlePluginPortal()
google()
mavenCentral()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.PREFER_PROJECT)
repositories {
maven("https://maven.eazytec-cloud.com/nexus/repository/maven-public/")
maven("https://maven.eazytec-cloud.com/nexus/content/groups/harmonyos")
google()
mavenCentral()
}
}
rootProject.name = "ohos_richtext"
include(":richtexteditor")
include(":example:nativeApp")
两个细节:PREFER_PROJECT 不能省,否则模块里声明的仓库会被全局仓库覆盖;:android 与 :desktopExample 故意不 include——它们的 AGP 插件没有 ohos 线,进来只会在配置期报错。
2.3 编译引擎核心
richtexteditor/build.gradle.kts 收敛为纯 KMP 模块:
plugins {
alias(libs.plugins.kotlin.multiplatform)
}
kotlin {
jvmToolchain(21)
jvm() // 交叉验证用
ohosArm64()
ohosX64()
sourceSets {
commonMain.dependencies {
// 刻意留空:内核零依赖,不引 kotlinx、不引 Compose
}
commonTest.dependencies {
implementation(kotlin("test"))
}
}
}
实测:
./gradlew :richtexteditor:compileKotlinOhosArm64 :richtexteditor:compileKotlinOhosX64
# BUILD SUCCESSFUL
根构建只有三件事:声明插件、统一版本、禁用 ohos 测试任务。
plugins {
alias(libs.plugins.kotlin.multiplatform) apply false
}
allprojects {
group = "com.darkrockstudios"
version = providers.gradleProperty("version").orElse("1.5.0-ohos.1").get()
}
subprojects {
tasks.matching { it.name.contains("Ohos", true) && it.name.contains("Test") }
.configureEach { enabled = false }
}
2.4 测试依赖的处置
本项目没有第三方测试依赖(不引 turbine、不引 coroutines-test),commonTest 只有 kotlin("test"),所以不存在「测试依赖没有 ohos 制品」的问题。但 ohos 测试任务仍然要禁——commonTest 的依赖表对 ohos 测试编译同样可见,而 kotlin("test") 的 ohos 变体在部分工具链版本上并不完整,跑起来只会卡在 compileTestKotlinOhosArm64。
处置是两条纪律的组合:测试统一在 JVM 跑(jvmTest),根构建全局禁用 Ohos*Test*。这样 ohos 侧只承担编译与链接,测试文件不用改一行。
第 3 阶段:桥协议建模
3.1 JSON 契约
com.darkrockstudios.richtexteditor.core/**(Compose-free 内核,原样编译)
↓ RichSelection / RichStyleRange / RichTextEditorModel
Bridge.kt(@CName × 2,扁平 JSON 进出)
↓ UTF-8 JSON
N-API richTextVersion() / richTextDispatch()
↓
ArkUI Index.ets(四页签)
请求与响应:
请求 : {"session":"main","op":"setText","text":"...","caret":72}
{"op":"select","start":0,"end":72}
{"op":"insertStyle","style":"Bold"}
{"op":"insertStyle","style":"TextColor:#E11D48"}
{"op":"clearStyles","styles":["TextColor","TextSize"]}
{"op":"snapshot"}
响应 : {"ok":true,"op":"...","text":"...","length":72,
"selection":{"start":0,"end":72},"undo":true,"redo":false,
"currentStyles":["Bold","TextColor"],
"spans":[{"start":0,"end":72,"tag":"Bold/","style":"Bold"}],
"paragraphs":[...],
"blocks":[{"index":0,"start":0,"end":24,"align":"left","list":"none",
"text":"...","runs":[{"start":0,"end":24,"text":"...",
"bold":true,"italic":false,"underline":false,
"strikethrough":false,"color":"#E11D48","size":null}]}],
"html":"<p><b><span style=\"color:#E11D48;\">...</span></b></p>"}
{"ok":false,"error":"..."}
三个刻意的设计:
op缺省是state,只读——页面初始化时可以直接打一个{}看当前状态;session缺省是main,不同 session 各自持有模型,桥内部是一张表;- 每次响应都带完整的
blocks/html,不是增量——Demo 文本短,全量最简单,代价写进 8.2。
3.2 为什么桥返回解析好的 blocks,而不是裸 spans
把 spans 直接丢给 ArkTS,页面要做的事是:按位置把重叠区间切片、对每个切片算出「是否 bold / 是否 italic / 颜色取哪个 / 字号乘几 / 属于哪个段落 / 段落是左中右」。这些正是 AnnotatedString + ParagraphStyle 在 Compose 里替我们做的事——在 ArkTS 里重写一遍,等于维护第二份样式代数,两份实现迟早会分叉。
所以桥在 Kotlin 侧就把区间解析成 blocks(一段一 block,带 align / list)与 runs(同样式的最大连续段),ArkTS 只做一件事:把 run 映射成 Text 的属性。这和已过审的 CMP 移植里「模型留在 Kotlin,渲染层保持笨」是同一条原则。
spans / paragraphs 仍然原样返回,因为快照页要展示原始区间、排查时也需要看到未解析的数据。
3.3 错误通道
每个 op 整体 try/catch,Kotlin 异常永远不跨 FFI:
object RichTextBridge {
fun dispatch(request: String): String = try {
/* op 分发 + 响应拼装 */
} catch (t: Throwable) {
"""{"ok":false,"error":${MiniJson.quote(t.message ?: t.toString())}}"""
}
}
非法 JSON、缺字段、未知 op、未知样式名、insertStyle 落在折叠选区上,全部走同一个 ok:false 通道,ArkTS 侧统一渲染成状态行的红字。
第 4 阶段:原生桥接(技术难点)
4.1 三层桥接
ArkTS
│ JSON string
▼
C++ N-API entry(librichtext_napi.so)
│ const char*
▼
Kotlin/Native C ABI(libohosrichtext.so)
│ RichTextDispatch / RichTextVersion
▼
UTF-8 JSON + DisposeString
直接导出 Kotlin 对象会暴露 ABI 与生命周期问题;只导出整数又会让页面复制样式语义,所以选择 C ABI + JSON。
4.2 Kotlin/Native 导出函数
example/nativeApp 里只有三行实质代码——桥的全部逻辑都在 commonMain,所以 JVM 测试覆盖的就是真机上跑的那份:
@CName("RichTextDispatch")
fun richTextDispatch(request: String): String = RichTextBridge.dispatch(request)
@CName("RichTextVersion")
fun richTextVersion(): String = RichTextBridge.VERSION // "1.5.0-ohos.1"
| C ABI 符号 | ArkTS 方法 | 覆盖内核 |
|---|---|---|
RichTextDispatch | richTextDispatch(req) | 全部 10 个 op:state / reset / setText / select / selectParagraph / insertStyle / clearStyles / undo / redo / snapshot |
RichTextVersion | richTextVersion() | fork 版本握手(真机版本行的数据来源) |
动态库由 sharedLib 产出,双 ABI 分别 link,链接完成后由 finalizedBy 自动回填到 DevEco 工程的 entry/libs:
kotlin {
listOf(ohosArm64(), ohosX64()).forEach { target ->
target.binaries.sharedLib {
baseName = "ohosrichtext"
// ...
}
// cxxShared(triple) 定位 libc++_shared.so;copyKnSo* 把两件 so 拷进 libs/<abi>
}
}
4.3 C++ N-API 方法分发
napi_property_descriptor desc[] = {
{"richTextDispatch", nullptr, RichTextDispatch, nullptr, nullptr, nullptr,
static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
{"richTextVersion", nullptr, RichTextVersion, nullptr, nullptr, nullptr,
static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
};
每个回调统一三步:napi_typeof 校验必须是 string、OH_LOG_INFO 打字节数、调 KN 函数并包装返回。三个容易踩的细节:
- API 26 的 NAPI 头把
napi_get_value_string_utf8的缓冲区标成非 const,std::string::data()是const char*,直接传编不过——先量长度,再开std::vector<char>,拷完构造std::string; - KN 返回的字符串必须经
libohosrichtext_symbols()->DisposeString归还 Kotlin 运行时,绝不free(); OH_LOG_INFO的格式串不保证支持z长度修饰,size_t要static_cast<int>后用%{public}d。
模块名必须与 CMake target、类型声明目录、oh-package.json5、ArkTS import 四处对齐:
static napi_module demoModule = {
.nm_version = 1,
.nm_flags = 0,
.nm_filename = nullptr,
.nm_register_func = Init,
.nm_modname = "richtext_napi", // → librichtext_napi.so
.nm_priv = nullptr,
.reserved = {0},
};
4.4 CMake 和类型声明
# OHOS_ARCH 为空、或 libs/${OHOS_ARCH}/libohosrichtext.so 不存在,直接 FATAL_ERROR
add_library(ohosrichtext SHARED IMPORTED)
set_target_properties(ohosrichtext PROPERTIES
IMPORTED_LOCATION ${KN_SO}
IMPORTED_NO_SONAME TRUE)
add_library(richtext_napi SHARED napi_init.cpp)
target_link_libraries(richtext_napi PUBLIC
libace_napi.z.so
libhilog_ndk.z.so
ohosrichtext)
IMPORTED_NO_SONAME 与 $ORIGIN rpath 一起用:前者防止 CMake 把 IMPORTED 的绝对路径写进 DT_NEEDED(否则 so 的依赖里会出现 Windows 盘符),后者让同目录三件套在 dlopen 时互相找得到。POST_BUILD 再把 KN so 与 libc++_shared.so 拷到输出目录。
export const richTextDispatch: (request: string) => string;
export const richTextVersion: () => string;
第 5 阶段:ArkUI 宿主
5.1 双区结构
ArkUI 的 TextArea 不支持「一段文字里不同位置不同样式」,Span 只接受显式属性、不接受任意嵌套结构。因此页面是上下两块:
┌───────────────────────────────┐
│ 原文(TextArea 直接回写引擎) │ ← 输入与选区入口,纯文本
├───────────────────────────────┤
│ 选区 start / end + 应用 │
│ B I U S 清除 | 字号 | 颜色 │ ← 工具条,全部走桥
├───────────────────────────────┤
│ 渲染结果(blocks / runs) │ ← 引擎解析结果重绘,只读
└───────────────────────────────┘
编辑体验是「改文本在上、看效果在下」,不是光标级所见即所得——这是本适配最大的形态差异,写进 8.2。
5.2 页面交互
所有样式按钮都是同一个动作序列:先 select(把输入框里的 start / end 同步给引擎),再 insertStyle。段落样式多一步 selectParagraph,因为上游对折叠光标不处理:
private applyStyle(style: string): void {
this.applySelection(); // select(start, end)
const res = this.dispatch(JSON.stringify({ op: 'insertStyle', style: style }));
this.apply(res, 'insertStyle ' + style);
}
private applyParagraph(style: string): void {
this.dispatch(JSON.stringify({ op: 'selectParagraph', caret: this.selStart }));
const res = this.dispatch(JSON.stringify({ op: 'insertStyle', style: style }));
this.apply(res, 'insertStyle ' + style);
}
dispatch() 整体 try/catch:Native 抛错、返回空串、JSON 解析失败,都变成 {ok:false, error} 交给状态行,绝不让页面静默空白。
5.3 渲染器的两个真机坑
这两个问题在 JVM 测试里完全看不见——引擎数据全对,界面就是不动。它们的排查过程本身值得记下来。
坑一:null 被当成字号,15 * null === 0
引擎对「没有颜色」的 run 输出 "color":null,对「没有字号」输出 "size":null。JSON.parse 得到的是 null,不是 undefined,而页面原来写的是:
.fontSize(15 * (run.size !== undefined ? run.size : 1))
null !== undefined 为真,于是 15 * null === 0——字号 0,每个字形都被布局成零尺寸。现象极其迷惑:诊断行 blocks=3 · runs=3 · spans=2 全部正常,渲染区一片空白。
修复是把「没有值」的判断收敛到一处,null、undefined、非正数一起兜住:
private sizeOf(run: Run): number {
const value: number | null | undefined = run.size;
if (value === null || value === undefined) { return 1; }
if (!(value > 0)) { return 1; } // 顺带挡掉 NaN 与 0
return value;
}
private colorOf(run: Run): string {
const value: string | null | undefined = run.color;
if (value === null || value === undefined || value.length === 0) { return '#111827'; }
return value;
}
并且加了一条契约测试:无样式 run 必须输出显式 null。这条测试的意义不在断言本身,而在把「桥的字段可能为 null」这件事钉进回归——否则下次换个页面又会被同一块石头绊倒。
坑二:ForEach 按 key 复用子组件,样式改了不重绘
文字出来之后,点 B、换颜色、改字号,渲染区一动不动,但诊断行和状态行都会更新。原因是 ArkUI 的 ForEach 按 key 做 diff,key 不变就直接复用旧子组件、不重新执行 item builder;而原来的 key 里只有位置信息:
(block) => `b-${block.index}-${block.start}-${block.end}`
(run, index) => `${block.index}-${index}-${run.start}`
加粗、换色、改字号时,index / start / end 一个都不变,key 字符串完全相同,于是 fontWeight / fontColor / fontSize 永远不会重新求值。
修复是给状态一个自增版本号,并折进每一层 key:
@State renderRev: number = 0; // apply() 里每次响应 +1
(run, index) => `${this.renderRev}-b${block.index}-r${index}`
(block) => `${this.renderRev}-b${block.index}`
代价是每次响应全量重建 3 个 block(Demo 文本很短,无所谓);换来的是「数据变了界面一定跟着变」这条确定性。
为了不再靠猜,渲染区顶部固定两行诊断:
blocks=3 · runs=3 · spans=2 · rev=9 · v1.5.0-ohos.1
run0 bold=true size=null color=#E11D48 → fontSize=15
第一行说明「有没有拿到新响应」(rev 涨不涨),第二行说明「拿到的是什么值」(原始 size / color 与最终字号)。这两个数字一摆,是数据问题还是布局问题就不用再猜了。
第 6 阶段:示例与验证
6.1 工程结构
example/
├── nativeApp/
│ ├── build.gradle.kts 双 ABI sharedLib(baseName=ohosrichtext)+ cxxShared + copyKnSo*
│ └── src/ohosMain/kotlin/.../Bridge.kt @CName × 2(三行包装,逻辑全在 commonMain)
└── harmonyApp/
├── build-profile.json5 signingConfigs 留空(签名由 DevEco 自动生成回填)
├── AppScope/app.json5 bundleName org.terminator.ohos.richtext、versionName 1.5.0-ohos.1
└── entry/
├── build-profile.json5 externalNativeOptions + abiFilters [arm64-v8a, x86_64]
├── libs/{arm64-v8a,x86_64}/ linkDebugShared* 经 copyKnSo* 回填的三件套(gitignore)
├── src/main/cpp/CMakeLists.txt IMPORTED 引入 KN so、$ORIGIN rpath
├── src/main/cpp/napi_init.cpp N-API 导出、参数检查、DisposeString
├── src/main/cpp/include/libohosrichtext_api.h
├── src/main/cpp/types/librichtext_napi/{index.d.ts, oh-package.json5}
└── src/main/ets/pages/Index.ets ArkUI 验收页面(916 行)
6.2 构建、签名和安装
./gradlew :richtexteditor:jvmTest # 45 项交叉验证
./gradlew :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64
cd example/harmonyApp
hvigorw assembleHap -p product=default -p buildMode=debug --no-daemon
签名在 DevEco Studio 的 File → Project Structure → Signing Configs 中勾选自动生成,证书写入本机 ~/.ohos/config,不进入仓库(仓库内 build-profile.json5 的 signingConfigs 保持空数组,勾选自动签名后由 IDE 回填带 material 的完整配置)。
hdc list targets -v
hdc install -r entry/build/default/outputs/default/entry-default-signed.hap
hdc shell aa start -a EntryAbility -b org.terminator.ohos.richtext -m entry
hdc shell hilog | grep RichText
四、完整代码对照
4.1 整体架构
上游 richtexteditor/src/commonMain(15 个 Compose 文件)
│ 停放 src/composeMain,不参与 ohos 编译
│
└── 提取 ──▶ core/(8 文件 1627 行,零依赖)
│ RichSelection / RichTextEditorModel / RichTextRender
Bridge.kt
│ RichTextDispatch / RichTextVersion
libohosrichtext.so(arm64-v8a / x86_64)
│ richTextDispatch / richTextVersion
librichtext_napi.so(CMake)
│ JSON parse + @State 更新
Index.ets
▼
ArkUI 真机四页签
4.2 文件清单
| 文件 | 职责 |
|---|---|
settings.gradle.kts | 极简 ohos 构建、eazytec Maven 置前、ohos_richtext、android/desktopExample 移出 |
gradle/libs.versions.toml | Kotlin 2.2.21-1.0.0 + multiplatform 插件别名 |
build.gradle.kts(根) | 版本 1.5.0-ohos.1、全局禁用 Ohos*Test* 任务 |
gradle.properties | org.gradle.java.home(JDK 21)、ignoreDisabledTargets、Maven 分发开关 |
richtexteditor/build.gradle.kts | 纯 KMP:jvm + ohosArm64 + ohosX64,依赖表为空 |
core/RichTextStyle.kt | tag 词表、parse(spec)、ARGB、字号 clamp |
core/RichTextRange.kt | 选区与区间结构、段落边界函数 |
core/RichTextDocument.kt | 区间代数(增删改位移切分) |
core/RichTextEditorModel.kt | 选区/composition、历史栈、样式增删、undo/redo |
core/RichTextRender.kt | blocks / runs 解析、HTML 导出 |
core/MiniJson.kt | 手写扁平 JSON |
core/RichTextBridge.kt | op 分发、会话表、响应拼装 |
example/nativeApp/build.gradle.kts | 双 ABI sharedLib、cxxShared 定位 libc++、copyKnSo* 回填 |
nativeApp/.../Bridge.kt | 2 个 C ABI(三行包装) |
harmonyApp/.../cpp/napi_init.cpp | N-API 导出、参数检查、DisposeString |
harmonyApp/.../cpp/CMakeLists.txt | IMPORTED 引入 KN so、$ORIGIN rpath、POST_BUILD 三件套 |
harmonyApp/.../ets/pages/Index.ets | ArkUI 四页签验收页面(916 行) |
README.OpenHarmony_CN.md | 鸿蒙适配主文档(654 行 / 17 节) |
4.3 关键 API 对照
| 层次 | 上游 | 本 fork | 作用 |
|---|---|---|---|
| 样式词表 | Style.Bold 等 | RichTextStyle sealed class | 词表一致,TextSize 保留 0.5–2.0 clamp |
| tag 映射 | StyleMapper.toTag / fromTag | RichTextStyle.toTag / fromTag / fromTagOrNull | 格式不变:SimpleName/ + 可选参数 |
| 区间代数 | AnnotatedStringBuilder | RichTextDocument | 方法一一对应 |
| 选区工具 | utils/Extensions.kt | RichSelection / RichTextParagraph | coerceNotReversed / intersects / coerceParagraph 语义按 Compose 抄 |
| 模型 | RichTextValue / RichTextValueImpl | RichTextEditorModel | 历史栈、阈值 10、clearRedoStack 全部照搬 |
| 快照 | RichTextValueSnapshot(@Parcelize) | RichTextSnapshot | 四字段不变,去掉注解 |
| 渲染 | toSpanStyle / toParagraphStyle | RichTextRender.blocks/runs | 不生成 Compose 对象,直接解析成属性 |
| Native | — | RichTextDispatch / RichTextVersion | C ABI JSON 导出 |
| N-API | — | richTextDispatch(req) / richTextVersion() | 向 ArkTS 暴露能力 |
| ArkTS | RichTextEditor | dispatch(op) + @State | 触发操作并重绘 |
4.4 ArkTS 与 Kotlin 的边界
const res = JSON.parse(richTextDispatch(JSON.stringify({
op: 'insertStyle', style: 'TextColor:#E11D48'
})));
// res.blocks[0].runs[0].color === '#E11D48'
// res.spans[0].tag === 'TextColor/#E11D48'
"insertStyle" -> {
val style = RichTextStyle.parse(json.str("style")) // 未知样式抛异常 → ok:false
model.insertStyle(model.selection, style)
}
两者之间只传输扁平 JSON 字符串,不传输 Kotlin 对象,也不传输未校验的动态结构。
五、关键决策说明
决策 1:提内核而不是真编译整库
KStore 那次能真编译整库,是因为它零 UI、依赖全在 commonMain。本库的 commonMain 里写着 compose.runtime / foundation / material3 与 essenty-parcelable,这五件在鸿蒙侧一个变体都没有——不是版本不对,是生态里不存在。继续等或换线都解决不了「AnnotatedString 不是 ArkTS 类型」这个事实。
所以边界划在「样式代数」与「绘制」之间:代数留在 Kotlin 并编译成 so,绘制交给 ArkUI。代价是 UI 层要重写一份宿主,收益是样式语义没有第二份实现——这是本适配唯一能保住的底线。
决策 2:工具链停在 2.2.21-1.0.0 + Gradle 8.14.1
因为内核零依赖,工具链不受任何 kotlinx / Compose 制品线的元数据约束,可以停在社区已批准线(qrose / haze 同款)。上游源码是 Kotlin 1.9.21 编的,2.2.21 向后兼容读取没有问题。
这与 KStore 的结论相反,但并不矛盾:KStore 必须跟着 coroutines 1.11.0-0.1.0-07 的 stdlib 2.4.20 元数据走;本库没有任何依赖要跟,跟了反而多一层风险。工具链版本永远跟着依赖走,而不是跟着习惯走。
决策 3:根构建替换(cmp_qrose 模式),android/desktopExample 留盘
上游根构建的五个插件(Compose、AGP Library、kotlin-parcelize、maven-publish、signing)对鸿蒙没有保留价值,整个替换为极简 ohos 构建。android/ 与 desktopExample/ 是示例工程、依赖 AGP,留盘不进 settings.gradle.kts——它们的源文件仍在树里,但不参与任何一次 ohos 构建。
决策 4:tag 词表取代 SpanStyle 对象
上游 MutableRange<SpanStyle> 里存的是真对象,看起来「只存 tag」是丢信息。实际上:SpanStyle 完全由 tag 决定(Bold/ → SpanStyle(fontWeight = Bold),TextSize/1.5 → SpanStyle(fontSize = 1.5.em)),存对象只是缓存;而上游快照里本来就只存 tag——上游自己也承认 tag 是持久化形态。
去掉对象后,区间代数、历史栈、快照全部变成纯 Kotlin,不碰 Compose,一个文件就能编到 ohos。代价是自定义 StyleMapper 用不了(上游允许业务方注册自己的 tag 与 SpanStyle 映射),词表固定为内置样式。
决策 5:交叉验证是新写的 45 项,不是上游测试
上游的测试依赖 Compose runtime,在 ohos 侧跑不了;本仓的 45 项是按上游语义重写的:42 项覆盖 tag 往返、区间增删合并、编辑位移、撤销重做、blocks/runs 解析、HTML 导出与桥协议,3 项原样回放 ArkTS 页面发出的报文。
这不是「上游测试零改动」那种强证明,但至少钉住了三件事:同一输入同一序列得到同一区间、渲染切分与手算一致、桥字段(含 null)不被悄悄改掉。能复现「同一序列得到同一区间」才算验收,点一遍按钮扫一眼不算。
六、测试与验证
6.1 测试环境
本次验证使用 Windows(x64)、JDK 21(Temurin)、Gradle 8.14.1、Kotlin 2.2.21-1.0.0、DevEco Studio 5.1.0 Release(product 5.1.0(18))与 OpenHarmony 设备(arm64)及其真机(x86_64)。
java -version # openjdk version "21"
./gradlew --version # Gradle 8.14.1
6.2 JVM 交叉验证
./gradlew :richtexteditor:jvmTest
# BUILD SUCCESSFUL — tests="42" + tests="3",failures="0"
45 项用例分八组:
| 测试组 | 用例数 | 钉住什么 |
|---|---|---|
| tag 与词表 | 6 | tag 往返、未知 tag 拒绝、字号 clamp、颜色解析、parse(spec) |
| 区间代数 | 10 | 选区加样式、相邻合并、二次点击撤销、多样式叠加、按族清除、段落覆盖整行、折叠光标不处理 |
| 历史 | 3 | undo/redo、撤销回到上一组样式、clearStyles 不可撤销 |
| 文本编辑 | 4 | 前方插入位移、后方插入不动、末尾输入扩张、删除回收区间 |
| 渲染解析 | 8 | run 切分、重叠颜色后者覆盖、段落对齐 / 列表、HTML 嵌套 / 转义 / 对齐 / 列表 / 颜色字号 |
| 段落函数 | 1 | startOf / endOf / paragraphCount |
| 桥协议 | 10 | setText + insertStyle 契约、selectParagraph、undo/redo 可用态、未知 op、未知样式、clearStyles 数组、snapshot、多会话、reset、脏输入 |
| Demo 报文回放 | 3 | 页面启动的四条报文原样重放(含 \n 转义与 CJK)、颜色字号 spec、无样式 run 必须输出显式 null |
RichTextBridge 只有一份,@CName 只是三行包装——所以这 45 项测的就是真机 .so 里执行的那份代码,不是「看起来差不多的另一份」。
6.3 HAP 验证
本机(Gradle 侧)实测:
:richtexteditor:jvmTest tests=45, failures=0
:richtexteditor:compileKotlinOhosArm64 / OhosX64 BUILD SUCCESSFUL
:example:nativeApp:linkDebugSharedOhosArm64 / OhosX64 BUILD SUCCESSFUL
copyKnSoArm64 / copyKnSoX64 已执行(回填 entry/libs)
llvm-nm -D libohosrichtext.so RichTextDispatch / RichTextVersion 均为全局 T 符号
DevEco 侧(CMake 与 HAP 由 IDE / hvigor 完成,效果图即运行证据):
BuildNativeWithCmake finished(双 ABI 编出 librichtext_napi.so)
CompileArkTS finished
PackageHap finished → entry-default-signed.hap
install bundle successfully
start ability successfully
| 产物 | 体积 |
|---|---|
arm64-v8a/libohosrichtext.so | ≈ 4.05 MB(≈ 3.86 MiB,含 Kotlin/Native 运行时) |
x86_64/libohosrichtext.so | ≈ 3.76 MB(≈ 3.59 MiB) |
librichtext_napi.so | 数十 KB(只含 N-API 包装层) |
每个 ABI 目录下必须是三件套:librichtext_napi.so / libohosrichtext.so / libc++_shared.so,缺一个就会在 dlopen 或安装阶段失败。
6.4 功能用例
- 启动后「关于」页显示
1.5.0-ohos.1——来自richTextVersion()的真实 Native 调用,不是静态文字; - 示例文本注入后渲染区显示三行,诊断
blocks=3 · runs=3(一个换行一个 block),启动时默认选中[0,8); - 不动选区直接点
B:前 8 个字符变粗,状态行样式 Bold、spans=1; - 选区改成
0–72点「应用」再点红色#E11D48:整段变红并保留加粗,状态行样式 Bold+TextColor、spans=2; - 渲染区诊断
run0 bold=true size=null color=#E11D48 → fontSize=15,与界面一致; - 点「清除」后颜色与加粗一起消失,
currentStyles回到-; - 「段落」页居中只影响光标所在行(
selectParagraph扩到整段后插入AlignCenter); - 「快照」页
spanStyles的 tag 区间与 HTML 导出对得上,[start,end)与界面选区一致; - 撤销 / 重做按钮的可用态随响应变化;每操作一次
rev加一,渲染区每次都跟着重建。
6.5 验证结论
样式代数(区间增删合并、位移、历史、快照)、渲染解析(blocks / runs / HTML)、Native 动态库、C ABI、N-API、ArkUI 页面与 HAP 已完成链路验证。应用在鸿蒙设备上安装并进入前台,页面展示了真实的编辑会话:版本握手、选区、加粗、换色、渲染重绘与日志留痕(见下节效果图)。
需要单独说明的是:渲染相关的两个问题只在真机上暴露,JVM 侧 45 项用例全绿时页面依然是空白。这也是为什么本文把 5.3 单独拎出来写——跨端适配里,「引擎对不对」和「界面会不会画」是两条独立的验收线。
七、运行效果
7.1 真机截图

7.2 命令速查
git clone https://atomgit.com/oh-tpc/richtext-compose-multiplatform.git
cd richtext-compose-multiplatform
export JAVA_HOME="/path/to/jdk-21"
./gradlew :richtexteditor:jvmTest # 45 项交叉验证
./gradlew :richtexteditor:compileKotlinOhosArm64 :richtexteditor:compileKotlinOhosX64 # klib
./gradlew :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64
cd example/harmonyApp
hvigorw assembleHap -p product=default -p buildMode=debug --no-daemon
hdc install -r entry/build/default/outputs/default/entry-default-signed.hap
hdc shell aa start -a EntryAbility -b org.terminator.ohos.richtext -m entry
hdc shell hilog | grep RichText
八、遗留问题与改进方向
8.1 踩坑复盘
- 只改 ArkTS 页面不算 CMP 适配,必须让样式代数真正编译成
ohosArm64/ohosX64动态库; settings.gradle.kts里 eazytec 双仓必须排在pluginManagement与dependencyResolutionManagement最前,否则配置期直接Unresolved reference: ohosArm64;RichTextStyle.all不能写成伴生对象里的val all = listOf(...):嵌套object的INSTANCE还没赋值,取到的全是null——必须写成get()计算属性;- 核心不能用
sortedSetOf:那是 JVM 专属 API,Kotlin/Native 的 common stdlib 没有,改成mutableSetOf+sorted(); insertStyle点不动,多半是选区折叠且没有 composition——段落样式先selectParagraph,字符样式必须给非折叠选区;blocks只按段落区间切分不够:多行文本必须在每个换行符处也切,否则三行只会得到一个 block,"one\ntwo\nthree"的用例就是钉这个;- 引擎对无样式 run 输出
"color":null/"size":null,JSON.parse得到的是null而非undefined,页面用!== undefined判断会放行,15 * null === 0直接让字号归零、整片空白——判断必须收敛到sizeOf/colorOf; ForEach按 key 复用子组件:key 里只有index/start/end时,样式变化不改变 key,Text永远不重建——必须把自增renderRev折进 key;- 颜色芯片第一个
#111827与渲染区默认字色相同,点它本来就看不出变化——验证请点红 / 蓝 / 绿 / 橙,别拿默认色试; Text()里直接嵌ForEach生成Span不属于Text文档化的子组件集合(只有Span/ImageSpan/SymbolSpan/ContainerSpan)——本仓没有赌这个未文档化行为,改成「一 run 一个Text」;它与第 7 条同期出现,最终确认的根因是null,这一条属于规避而不是确证;build-profile.json5的signingConfigs条目缺material字段会被 hvigor schema 拒绝(报00303038 must have required property 'material')——仓库内留空数组,勾选自动签名后由 IDE 回填;- HAP 缺当前 ABI 的 so 会报安装失败
9568347,abiFilters必须双 ABI,每个 ABI 三件套缺一不可; - CMake target 名、
nm_modname、types/目录、oh-package.json5、ArkTSimport五处名字必须一致,错一处就是does not provide an export named 'richTextDispatch'; IMPORTED_NO_SONAME不能省:省了之后 CMake 会把 IMPORTED 的绝对路径写进DT_NEEDED,so 的依赖里出现 Windows 盘符,装机后dlopen必失败;- KN 返回字符串必须
DisposeString归还运行时,N-API 层不能free();OH_LOG_INFO的格式串不保证支持z长度修饰,size_t要转int; - 根构建的
/build/是根锚定模式,盖不住嵌套目录——example/harmonyApp/{.hvigor,oh_modules,entry/build,entry/libs}与example/nativeApp/build/要单独追加 gitignore; - 签名材料由 DevEco 自动写入本机配置,证书绝不进入 AtomGit。
8.2 已知问题
- 编辑不是所见即所得。 鸿蒙侧是「TextArea 输入 + blocks 重绘」双区,不是
BasicTextField+VisualTransformation的光标级样式编辑;功能可达,交互形态不同; - 渲染区的内联排版是近似的。 每个 run 是一个独立
Text,装在Flex({ wrap: Wrap })里:跨 run 的自动换行不如单个Text+Span精确(run 边界处可能提前换行)。样式、颜色、字号、对齐、列表标记都是准的; - 段落样式需要非折叠选区。 上游行为,页面用
selectParagraph补上这一步; clearStyles不切分区间、不推历史。 上游行为,测试钉住,不在本 fork 修正;- 自定义
StyleMapper不支持。 tag 词表固定为内置样式,要扩展得同时改RichTextStyle与RichTextRender; TextColor的 tag 参数与 Android 侧不通用。 上游存 ComposeColor.value(含色彩空间位),本 fork 存 plain ARGB,跨端同步颜色要先换算;- 列表样式是本 fork 补完的。 上游
toParagraphStyle对列表是TODO(),也就是 Compose 侧其实没实现;本 fork 把列表解析成真实段落属性,所以鸿蒙 Demo 的列表可用——这是补完,不是漂移; - HTML 导出是最小实现。 没有 CSS 类、没有内联样式表、没有
<div>,只够核对与只读展示;扫描/复制要以text/snapshot为数据源; - 跨 source set 的期望落空。
composeMain不挂任何 target、不编译,它的存在只是「上游源码原样留档」,不是「将来一定能编」; - android / desktopExample 目标剥离(源文件留在树里),发布、dokka、kover 一并剥离,本 fork 不发 Maven Central。
8.3 未来优化方向
- 用
StyledString+TextController替换逐 run 的Text,恢复精确的行内续排(当前用Flex是为了先稳住基线); - 给
TextArea补光标回传:目前onChange只回文本,选区靠两个输入框显式同步,接上TextAreaController的 caret 位置后可以做到「选中即同步」; - 把
RichText(只读渲染)单独封成一个 ArkTS 组件,让业务方不必自己写blocks → Text的映射; - 评估
.har形态发布,让 ArkTS 工程直接依赖而不是走 so 三件套; - 在 CI 中加入 ohos 编译、ELF 符号检查与 HAP 构建,避免「本地能编、CI 不能编」。
九、总结
9.1 核心难点回顾
上游 richtexteditor + Compose UI(15 文件)
→ 验证 Compose 制品线:五件依赖全部没有 ohos 变体
→ 提内核:core/ 8 文件 1627 行、零依赖、逐函数对齐上游语义
→ 工具链停在 2.2.21-1.0.0(Gradle 8.14.1)
→ 根构建替换(cmp_qrose 模式)
→ ohosArm64 / ohosX64 KLIB + 双 ABI sharedLib
→ C ABI(@CName × 2)+ 扁平 JSON
→ C++ N-API(DisposeString 纪律)
→ ArkTS 双区宿主(TextArea 输入 + blocks 重绘)
→ 真机四页签验收(加粗 / 换色 / 字号 / 对齐 / 快照 / 撤销)
9.2 封装层次
- 样式层:上游 tag 词表与
parse(spec)语法,改为显式 sealed class; - 区间层:上游区间代数(合并、删除、位移、切分),一行算法都没重写;
- 模型层:上游历史栈、阈值 10、撤销/重做语义、快照结构;
- 渲染层:新增
blocks/runs解析与最小 HTML 导出,替代 Compose 的toSpanStyle; - Native 层:双 ABI 动态库 + 2 个 C ABI +
DisposeString; - N-API 层:参数检查、UTF-8 转换、错误兜底;
- ArkTS 层:页面状态、选区同步、JSON 分发、重绘、日志;
- DevEco 层:CMake、HAP、签名、安装与真机运行。
9.3 三条经验
- 路线选择先看制品,再看库的类型。 同样是 CMP 库,
KStore该真编译整库,本库必须提内核——差别不在工具链,而在「价值是否长在 Compose 绘制层」。判断依据只有一条:把 Compose 类型去掉之后,这个库还剩不剩下可验证的逻辑; - 「引擎对」和「界面画得对」是两条独立验收线。 45 项 JVM 用例全绿的时候,真机页面可以是全白:
null被乘成 0 号字、ForEach按 key 复用子组件,两个问题都在 ArkTS 侧、都不在桥的数据里。所以诊断必须做进页面(rev+run0原始值),而不是靠猜; - 跨端移植里,最贵的不是算法而是语义细节。 区间合并、位移、阈值、段落逃逸,上游注释里一句话,落地时要一条用例钉一次;
clearStyles不切分、折叠光标不处理这类「看起来像 bug 的行为」也必须原样保留——改掉它们,测试全绿也没意义。
9.4 适配成果
当前适配已完成:
richtext-compose1.5.0 的 Compose-free 内核提取与双 ABI Kotlin/Native 编译(core/8 文件 1627 行,零依赖,上游 Compose 源码 15 文件原样停放);- 区间代数、历史栈、快照、渲染解析、HTML 导出在 ohos
.so内运行,语义与上游逐函数对齐; - 2 个 C ABI 符号与 C++ N-API 包装层(
llvm-nm验证全局 T 符号); - DevEco Stage ArkUI 四页签验收页面(编辑 / 段落 / 快照 / 关于,916 行);
- 45 项 JVM 交叉验证全绿(42 项引擎 + 3 项页面报文回放);
- 双 ABI 动态库(arm64 ≈4.05 MB / x86_64 ≈3.76 MB)+ HAP 构建、签名、设备安装与真机验证;
- AtomGit 项目文档、OpenHarmony README(中英双份)与本地效果图。
适配过程 FAQ
以下问题来自
ohosArm64目标接入、NAPI 桥接、HAP 构建与真机验收的实际流程,按出现频率排序。
Q1:DevEco Studio Sync 失败或 ohpm 依赖下载超时,怎么处理?
- 确认 DevEco Studio 版本与工程
build-profile.json5的compatibleSdkVersion一致; - 在
File > Settings > Build, Execution, Deployment > Ohpm配置可用的 ohpm 仓库镜像或代理; - 删除
oh_modules、oh-package-lock.json5与.hvigor缓存后重新 Sync; - 公司网络需要代理时,同时给 ohpm 与 hvigor 配置
http.proxy。
Q2:CMake 报 Missing .../libxxx.so 或找不到 Kotlin/Native 动态库?
- 先在
example目录执行./gradlew :nativeApp:linkDebugSharedOhosArm64(模拟器用linkDebugSharedOhosX64); - 确认
libxxx.so与libc++_shared.so已复制到ohos/entry/libs/<abi>/; - 检查
entry/build-profile.json5的abiFilters与设备 ABI 是否一致(真机arm64-v8a,模拟器x86_64); - 清理
entry/.cxx与entry/build后重试。
Q3:ArkTS 报 Cannot find module 'libxxx_napi.so' 或调用返回 undefined?
- 确认 CMake 的
add_library(<target> SHARED ...)名称与import ... from 'libxxx_napi.so'完全一致; - 解包 HAP,确认
libs/<abi>/libxxx_napi.so已打包; - 删除
entry/build、entry/.cxx与oh_modules后重新构建; - 检查
module.json5的srcEntry与页面路径是否正确。
Q4:真机安装报 signature verification failed 或 install failed?
- 在 DevEco Studio 的
Signing Configs配置证书、Profile 与密码; bundleName必须与签名 profile 中的 bundle name 一致;- 设备需加入 profile 的
deviceIds,调试证书有效期需覆盖当前时间; - 用
hdc list targets确认设备在线,再执行hdc install -r xxx-signed.hap。
Q5:应用启动白屏、闪退或 NAPI 调用崩溃?
- 用
hdc hilog | grep -i "napi\|kotlin"查看原生日志; - 确认
libc++_shared.so与 Kotlin/Native 工具链版本匹配,且 ABI 与设备一致; - 检查 C ABI 返回的字符串是否由 Native 侧分配、ArkTS 侧复制后是否调用
XxxFree释放; - 在 DevEco 的 Native 调试配置中附加
lldb,定位空指针与越界访问。
Q6:Gradle 报 ohosArm64 目标不存在或 Kotlin/Native 编译失败?
- 使用鸿蒙发行版 Kotlin(如
2.2.21-1.0.0),并在settings.gradle.kts配置 eazytec 仓库; - Gradle 建议 8.x、JDK 21,版本不匹配时先统一工具链;
- 确认
kotlin { ohosArm64() }已应用,且local.properties的 SDK 路径有效; - 清理
~/.gradle/caches中对应版本的 Kotlin/Native 缓存后重试。
Q7:HAP 构建报 SDK 版本不匹配或 compatibleSdkVersion 错误?
- 对齐 DevEco Studio、HarmonyOS SDK 与
build-profile.json5中的compatibleSdkVersion; - 升级 DevEco Studio 后同步升级 hvigor 与 SDK,避免
modelVersion与 IDE 不匹配; - 命令行构建时设置
DEVECO_SDK_HOME指向 DevEco 的sdk目录。
Q8:命令行 hvigor 构建失败,但 DevEco 里能构建?
- 使用 DevEco 自带的 node 与 hvigor:
"$DEVECO/Contents/tools/node/bin/node" "$DEVECO/Contents/tools/hvigor/bin/hvigorw.js" ...; - 设置
JAVA_HOME、NODE_HOME、DEVECO_SDK_HOME; - 加上
--no-daemon排除守护进程缓存影响,并清理entry/build与.hvigor。
参考文档
更多推荐

所有评论(0)