本文记录 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 MultiplatformHarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven)
JDK21(org.gradle.java.home 指向本机 Temurin 21)
OpenHarmony 目标ohosArm64 + ohosX64(jvm() 保留作交叉验证)
Gradle8.14.1(2.2.21 线对应版本,wrapper 随仓)
库依赖零依赖(不引 kotlinx、不引 Compose)
DevEco product5.1.0(18) 兼容
DevEco Studio5.1.0 Release
ABIarm64-v8a + x86_64
Bundleorg.terminator.ohos.richtext
export JAVA_HOME="/path/to/jdk-21"
java -version
1.3 源码依赖度盘点
层次文件依赖形态处置
UI 层ui/RichTextEditor.kt、ui/RichText.kt、ui/RichTextField.ktCompose runtime / foundation / material3停放 src/composeMain
变换层transformations/*.ktVisualTransformation(Compose 类型)停放 src/composeMain
模型层model/RichTextValue.kt、RichTextValueImpl.ktCompose TextRange / TextFieldValue / AnnotatedString提取到 core/,类型换成本地实现
区间层utils/AnnotatedStringBuilder.kt、utils/Extensions.ktAnnotatedString.Builder提取到 core/
样式层model/Style.kt、mappers/StyleMapper.ktSpanStyle / 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.kttag 词表、toTag / fromTag / parse(spec)、ARGB 与十六进制互转、字号 clamp 0.5–2.0
RichTextRange.ktRichSelection、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.ktop 分发、会话表、响应拼装
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":"..."}

三个刻意的设计:

  1. op 缺省是 state,只读——页面初始化时可以直接打一个 {} 看当前状态;
  2. session 缺省是 main,不同 session 各自持有模型,桥内部是一张表;
  3. 每次响应都带完整的 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 方法覆盖内核
RichTextDispatchrichTextDispatch(req)全部 10 个 op:state / reset / setText / select / selectParagraph / insertStyle / clearStyles / undo / redo / snapshot
RichTextVersionrichTextVersion()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 函数并包装返回。三个容易踩的细节:

  1. API 26 的 NAPI 头把 napi_get_value_string_utf8 的缓冲区标成非 const,std::string::data() 是 const char*,直接传编不过——先量长度,再开 std::vector<char>,拷完构造 std::string;
  2. KN 返回的字符串必须经 libohosrichtext_symbols()->DisposeString 归还 Kotlin 运行时,绝不 free();
  3. 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.tomlKotlin 2.2.21-1.0.0 + multiplatform 插件别名
build.gradle.kts(根)版本 1.5.0-ohos.1、全局禁用 Ohos*Test* 任务
gradle.propertiesorg.gradle.java.home(JDK 21)、ignoreDisabledTargets、Maven 分发开关
richtexteditor/build.gradle.kts纯 KMP:jvm + ohosArm64 + ohosX64,依赖表为空
core/RichTextStyle.kttag 词表、parse(spec)、ARGB、字号 clamp
core/RichTextRange.kt选区与区间结构、段落边界函数
core/RichTextDocument.kt区间代数(增删改位移切分)
core/RichTextEditorModel.kt选区/composition、历史栈、样式增删、undo/redo
core/RichTextRender.ktblocks / runs 解析、HTML 导出
core/MiniJson.kt手写扁平 JSON
core/RichTextBridge.ktop 分发、会话表、响应拼装
example/nativeApp/build.gradle.kts双 ABI sharedLib、cxxShared 定位 libc++、copyKnSo* 回填
nativeApp/.../Bridge.kt2 个 C ABI(三行包装)
harmonyApp/.../cpp/napi_init.cppN-API 导出、参数检查、DisposeString
harmonyApp/.../cpp/CMakeLists.txtIMPORTED 引入 KN so、$ORIGIN rpath、POST_BUILD 三件套
harmonyApp/.../ets/pages/Index.etsArkUI 四页签验收页面(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 / fromTagRichTextStyle.toTag / fromTag / fromTagOrNull格式不变:SimpleName/ + 可选参数
区间代数AnnotatedStringBuilderRichTextDocument方法一一对应
选区工具utils/Extensions.ktRichSelection / RichTextParagraphcoerceNotReversed / intersects / coerceParagraph 语义按 Compose 抄
模型RichTextValue / RichTextValueImplRichTextEditorModel历史栈、阈值 10、clearRedoStack 全部照搬
快照RichTextValueSnapshot(@Parcelize)RichTextSnapshot四字段不变,去掉注解
渲染toSpanStyle / toParagraphStyleRichTextRender.blocks/runs不生成 Compose 对象,直接解析成属性
Native—RichTextDispatch / RichTextVersionC ABI JSON 导出
N-API—richTextDispatch(req) / richTextVersion()向 ArkTS 暴露能力
ArkTSRichTextEditordispatch(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 与词表6tag 往返、未知 tag 拒绝、字号 clamp、颜色解析、parse(spec)
区间代数10选区加样式、相邻合并、二次点击撤销、多样式叠加、按族清除、段落覆盖整行、折叠光标不处理
历史3undo/redo、撤销回到上一组样式、clearStyles 不可撤销
文本编辑4前方插入位移、后方插入不动、末尾输入扩张、删除回收区间
渲染解析8run 切分、重叠颜色后者覆盖、段落对齐 / 列表、HTML 嵌套 / 转义 / 对齐 / 列表 / 颜色字号
段落函数1startOf / endOf / paragraphCount
桥协议10setText + 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. 启动后「关于」页显示 1.5.0-ohos.1——来自 richTextVersion() 的真实 Native 调用,不是静态文字;
  2. 示例文本注入后渲染区显示三行,诊断 blocks=3 · runs=3(一个换行一个 block),启动时默认选中 [0,8);
  3. 不动选区直接点 B:前 8 个字符变粗,状态行 样式 Bold、spans=1;
  4. 选区改成 0–72 点「应用」再点红色 #E11D48:整段变红并保留加粗,状态行 样式 Bold+TextColor、spans=2;
  5. 渲染区诊断 run0 bold=true size=null color=#E11D48 → fontSize=15,与界面一致;
  6. 点「清除」后颜色与加粗一起消失,currentStyles 回到 -;
  7. 「段落」页居中只影响光标所在行(selectParagraph 扩到整段后插入 AlignCenter);
  8. 「快照」页 spanStyles 的 tag 区间与 HTML 导出对得上,[start,end) 与界面选区一致;
  9. 撤销 / 重做按钮的可用态随响应变化;每操作一次 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 踩坑复盘

  1. 只改 ArkTS 页面不算 CMP 适配,必须让样式代数真正编译成 ohosArm64 / ohosX64 动态库;
  2. settings.gradle.kts 里 eazytec 双仓必须排在 pluginManagement 与 dependencyResolutionManagement 最前,否则配置期直接 Unresolved reference: ohosArm64;
  3. RichTextStyle.all 不能写成伴生对象里的 val all = listOf(...):嵌套 object 的 INSTANCE 还没赋值,取到的全是 null——必须写成 get() 计算属性;
  4. 核心不能用 sortedSetOf:那是 JVM 专属 API,Kotlin/Native 的 common stdlib 没有,改成 mutableSetOf + sorted();
  5. insertStyle 点不动,多半是选区折叠且没有 composition——段落样式先 selectParagraph,字符样式必须给非折叠选区;
  6. blocks 只按段落区间切分不够:多行文本必须在每个换行符处也切,否则三行只会得到一个 block,"one\ntwo\nthree" 的用例就是钉这个;
  7. 引擎对无样式 run 输出 "color":null / "size":null,JSON.parse 得到的是 null 而非 undefined,页面用 !== undefined 判断会放行,15 * null === 0 直接让字号归零、整片空白——判断必须收敛到 sizeOf / colorOf;
  8. ForEach 按 key 复用子组件:key 里只有 index / start / end 时,样式变化不改变 key,Text 永远不重建——必须把自增 renderRev 折进 key;
  9. 颜色芯片第一个 #111827 与渲染区默认字色相同,点它本来就看不出变化——验证请点红 / 蓝 / 绿 / 橙,别拿默认色试;
  10. Text() 里直接嵌 ForEach 生成 Span 不属于 Text 文档化的子组件集合(只有 Span / ImageSpan / SymbolSpan / ContainerSpan)——本仓没有赌这个未文档化行为,改成「一 run 一个 Text」;它与第 7 条同期出现,最终确认的根因是 null,这一条属于规避而不是确证;
  11. build-profile.json5 的 signingConfigs 条目缺 material 字段会被 hvigor schema 拒绝(报 00303038 must have required property 'material')——仓库内留空数组,勾选自动签名后由 IDE 回填;
  12. HAP 缺当前 ABI 的 so 会报安装失败 9568347,abiFilters 必须双 ABI,每个 ABI 三件套缺一不可;
  13. CMake target 名、nm_modname、types/ 目录、oh-package.json5、ArkTS import 五处名字必须一致,错一处就是 does not provide an export named 'richTextDispatch';
  14. IMPORTED_NO_SONAME 不能省:省了之后 CMake 会把 IMPORTED 的绝对路径写进 DT_NEEDED,so 的依赖里出现 Windows 盘符,装机后 dlopen 必失败;
  15. KN 返回字符串必须 DisposeString 归还运行时,N-API 层不能 free();OH_LOG_INFO 的格式串不保证支持 z 长度修饰,size_t 要转 int;
  16. 根构建的 /build/ 是根锚定模式,盖不住嵌套目录——example/harmonyApp/{.hvigor,oh_modules,entry/build,entry/libs} 与 example/nativeApp/build/ 要单独追加 gitignore;
  17. 签名材料由 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 侧不通用。 上游存 Compose Color.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 三条经验

  1. 路线选择先看制品,再看库的类型。 同样是 CMP 库,KStore 该真编译整库,本库必须提内核——差别不在工具链,而在「价值是否长在 Compose 绘制层」。判断依据只有一条:把 Compose 类型去掉之后,这个库还剩不剩下可验证的逻辑;
  2. 「引擎对」和「界面画得对」是两条独立验收线。 45 项 JVM 用例全绿的时候,真机页面可以是全白:null 被乘成 0 号字、ForEach 按 key 复用子组件,两个问题都在 ArkTS 侧、都不在桥的数据里。所以诊断必须做进页面(rev + run0 原始值),而不是靠猜;
  3. 跨端移植里,最贵的不是算法而是语义细节。 区间合并、位移、阈值、段落逃逸,上游注释里一句话,落地时要一条用例钉一次;clearStyles 不切分、折叠光标不处理这类「看起来像 bug 的行为」也必须原样保留——改掉它们,测试全绿也没意义。

9.4 适配成果

当前适配已完成:

  • richtext-compose 1.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。

参考文档

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐