本文记录 Combo Breaker 接入 OpenHarmony 的完整过程,覆盖 Jetpack Compose 文字绕排库盘点、纯算法源码集拆分、ohosArm64 / ohosX64 目标、Kotlin/Native 动态库、C ABI/N-API 桥接、桥内文本度量引擎(TTF 解析 + 贪心断行 + 对齐数学移植)、ArkTS Canvas 验收页面、HAP 构建和真机全 Tab 验收。

Combo Breaker 是 Romain Guy 的 Jetpack Compose 文字布局库:TextFlow 像 Box 一样承载子元素,文字自动绕开子元素的形状(默认矩形,或任意 Path)在多栏中流动。它的核心资产是两层算法——几何层(红黑区间树 + 线段裁剪 + 槽位归约,算出每一行"哪些矩形区域可以落字")与排版层(槽位逐行断行、跨栏续排、可拉伸空格两端对齐)。本次适配与社区已过审的 qrose 路线同构但有一步之差:qrose 是条码库,桥输出像素(Kotlin/Native 光栅化 + PNG);combo-breaker 是排版库,桥输出坐标(每段文字的落点),因此多出一个 qrose 没有的阶段——Kotlin/Native for ohos 没有任何平台文本栈(无 LineBreaker / MeasuredText / TextPaint),桥内必须自带一套文本度量:TTF 解析(cmap 4/12 + hmtx + hhea)给出逐字符前进宽度,贪心断行器(空格 / 连字符 / CJK 逐字断点)配合上游 justify 数学的完整移植完成排版,ArkTS 用 Canvas + 同一份注册字体逐段绘制。

项目地址: AtomGit/oh-tpc/combo-breaker.git

上游仓库: romainguy/combo-breaker

开发工具: 华为云码道

环境安装: 环境安装指南

一、背景

1.1 为什么做 OpenHarmony 平台 CMP 适配

Combo Breaker 给 Compose 应用的是"杂志级排版"能力:多栏、绕开任意形状、形状边距、两端对齐、六种 FlowType(OutsideLeft / OutsideRight / Outside / OutsideStart / OutsideEnd / None,后两者按排版方向解析,屏障语义由上游 applyFlowShapeExclusions 保证)。这套能力对阅读类、资讯类鸿蒙应用同样成立,但上游是纯 Android 库——几何算法长在 android.graphics.RectF/PointF/Path 上,断行深度耦合 android.text.LineBreaker + MeasuredText + TextPaint,UI 层是 @Composable BasicTextFlow。

如果只把示例页面改成 ArkTS,页面可以显示文字,却无法证明区间树、槽位计算与对齐数学在 OpenHarmony ARM64 设备上完成编译、链接和执行。适配需要同时验证几何内核、文本度量替代件、Native 动态库、C ABI、N-API、ArkTS Canvas 绘制和 HAP 交付链路。

障碍具体问题
目标缺失必须增加 ohosArm64() / ohosX64() 才能生成 OpenHarmony KLIB。
工具链不一致上游 AGP 8.10 / Kotlin 2.1.20 / Compose BOM 2025.05 的 Android 构建与鸿蒙工具链无法共存于一个 Gradle 构建。
几何类型耦合区间树是纯 Kotlin,但裁剪与槽位计算挂在 android.graphics 的 RectF / PointF / Path 上。
文本栈缺失Kotlin/Native for ohos 没有 LineBreaker / MeasuredText / TextPaint,断行与度量没有平台件可用。
语言边界不同ArkTS 不能直接持有 Kotlin 对象,需要 C ABI、C++ N-API 和 JSON。
绘制语义跨不了桥TextPaint 的 wordSpacing / hyphenEdit、AnnotatedString 的 span 合并都是 Android 侧概念,C ABI 只能同步一来一回。
交付链路复杂.so、CMake、N-API、HAP、签名和 hdc 都要单独验收。

因此,本项目把边界放在:几何与排版算法的 ohos 编译(IntervalTree 逐字保留,Clipping / FlowSlots 仅替换几何类型来源)、根构建替换(cmp_qrose 模式)和 C ABI/N-API 桥接层。绕排语义完全由上游原版实现负责,桥内只补"度量 + 断行"这一个平台缺失件,ArkTS 只负责调用 Native、解析 JSON 坐标和 Canvas 绘制。

1.2 库提供的能力

Demo 提供一个真实的绕排排版会话,对应桥接的全部入口:

  • 多栏排版:栏数 1–8、栏间距,段落可跨栏续排(与上游 layoutTextFlow() 控制流一致);
  • 绕排形状:矩形(x/y/w/h)与任意多边形(平铺坐标点列,自动闭合),坐标一律物理像素;
  • 六种 FlowType:OutsideStart / OutsideEnd 在桥构造 FlowShape 时按 direction 解析(与上游 buildFlowShapes() 同位),屏障语义原样生效;
  • 形状边距 margin:多边形按绕向做尖角外扩(miter 限长 4×margin,近似上游 Path.op);
  • 两端对齐:上游 justify() 的完整移植——可拉伸空格(仅 U+0020)分布、过宽行负收缩、无空格跳槽 NaN 语义、段落末行不拉伸;
  • 断行策略:空格断词、显式连字符后断行、CJK 逐字断行(中文无空格也能绕排);
  • 排版调试:debugSlots 返回每行槽位矩形,ArkTS 画成蓝色半透明叠加——上游 debugOverlay 的等价物。

响应包含:

字段作用
ok排版是否成功,失败时 error 带原文。
segments[]{t 文本, x 左端, y **基线**, w 宽度};未对齐的行 1 段,对齐行逐词一段(对齐增量已算进每个词的 x)。
slots[]仅 debugSlots:true 时返回的每行槽位矩形 {l,t,r,b}。
textHeight / lastOffset / truncated排到的文本高度、源文本停止偏移、是否因区域不足截断。
ascent / descent / lineHeight / fontSize度量回显(物理像素)。

1.3 实现适配

维度要求
代码复用IntervalTree.kt 与上游逐字一致(仅 internal→public);Clipping.kt / FlowSlots.kt 仅把 android.graphics 几何类型换成自带 FlowGeometry.kt;TextFlowSupport.kt 三个字符串工具逐字提取;算法零漂移。
平台目标根构建直接声明 ohosArm64 + ohosX64,保留 jvm 作交叉验证(7 项 commonTest 单测),库模块出 KLIB,example/nativeApp 出双 ABI 动态库。
构建隔离根构建替换为极简 ohos 构建(cmp_qrose 模式);上游 combo-breaker-material3 / combo-breaker-demo / Android 发布链留盘不进构建。
桥接稳定单个 C ABI(@CName("ComboBreakerLayout"))+ UTF-8 JSON,不传递 Kotlin 对象地址;异常永远不跨 FFI。
度量自洽桥内 TTF 解析器与 ArkTS font.registerFont 用同一份字体文件(88 KB 子集),量的就是画的。
UI 完整三 Tab 覆盖全部桥接入口:矩形形状 / 任意形状 / 调试视图(槽位可视化 + hyphenate 占位说明)。
仓库规范项目说明、文章、效果图和源码链接统一使用 AtomGit。

本项目的 ArkTS 页面不是 Compose Multiplatform UI 实现。上游 Compose 层(BasicTextFlow / TextLayout / TextPaint)归档进不编译的 composeMain 源码集,等社区 CMP 工具链与上游对齐后原样接回;ohos 侧暴露的是纯算法。

二、实现路线图

第 1 阶段:项目初始化     ── 盘点模块与依赖边界、拆分 commonMain / composeMain 源码集、确定交付物
第 2 阶段:目标与依赖打通 ── 根构建替换、eazytec Maven 置前(含镜像 init 脚本同名去重坑)、Kotlin 2.2.21-1.0.0 钉版
第 3 阶段:桥协议建模     ── 坐标 JSON 契约(形状/断行/对齐/调试全字段)、错误通道
第 4 阶段:文本度量引擎   ── TTF 解析(cmap/hmtx/hhea)、贪心断行(空格/连字符/CJK)、justify 数学移植、多边形 margin 外扩
第 5 阶段:原生桥接       ── @CName C ABI、C++ N-API、参数校验和内存释放
第 6 阶段:ArkUI 宿主     ── 字体子集引导、Canvas 坐标系判定绘制、HAP、真机全 Tab 验收

三、逐步实现过程

第 1 阶段:项目初始化

1.1 工程边界
combo-breaker/                        纯逻辑库(commonMain 移植 + commonTest 单测,进 ohos 构建)
combo-breaker/src/composeMain/        上游 Compose 实现归档(BasicTextFlow/TextLayout/TextPaint 等,不编译)
combo-breaker-material3/              上游 Material3 包装,留盘不进 settings
combo-breaker-demo/                   上游 Android Demo,留盘不进 settings
example/nativeApp/                    Kotlin/Native 动态库(Json / TtfFont / FlowTextEngine / Bridge)
example/harmonyApp/                   DevEco Stage、CMake N-API、ArkTS 三 Tab 页面与字体工具链
README.OpenHarmony.md / _CN.md        鸿蒙适配说明(中英双份,中文主文档 22 节)
README.upstream.md                    上游原文保存

构建拓扑走 cmp_qrose 模式(根构建即 ohos 构建):settings.gradle.kts 只 include :combo-breaker 与 :example:nativeApp,rootProject.name = "ohos_combo-breaker"。上游的 vanniktech / dokka 发布 classpath、AGP 插件、Android POM 发布段全部随根构建替换剥离——本 fork 不发 Maven Central。

1.2 工具链矩阵
项目配置
Kotlin MultiplatformHarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven)
JDK21(Temurin,org.gradle.java.home 钉路径)
OpenHarmony 目标ohosArm64 + ohosX64(jvm 保留作交叉验证)
Gradle8.14.1(wrapper 从上游 8.11.1 升级,2.2.21 线的标准搭配)
DevEco product5.1.0(18) 兼容,modelVersion 5.1.0
DevEco Studio5.1.0 Release
ABIarm64-v8a + x86_64
Bundleorg.terminator.ohos.combobreaker
export JAVA_HOME="/path/to/jdk-21"   # Windows 下 gradlew.bat 读 gradle.properties 前就要找到 java
java -version
1.3 源码依赖度盘点
上游文件依赖形态处置
IntervalTree.kt100% 纯 Kotlin(仅 kotlin.math)逐字编译进 commonMain(internal→public 供跨模块调用)
Clipping.kt / FlowSlots.kt纯算法,仅挂 android.graphics.PointF/RectF 作容器移入 commonMain,几何类型换自带 FlowGeometry.kt(同包同名最小面)
Geometry.ktPathSegment 纯;Path.toIntervals() 依赖 Path.approximatePathSegment 保留;新增 polygonToIntervals()——桥只喂平铺多边形,无需路径展平
BasicTextFlow.kt 内三个枚举FlowType / TextFlowJustification / TextFlowHyphenation提取到 commonMain(qrose 处理 Code128Type 同款先例),FlowType.resolve() 公有化
TextLayout.kt 字符串工具isLineEndSpace / countStretchableSpaces / trimEndSpace 纯函数逐字提取为 TextFlowSupport.kt,两侧共用防语义漂移
BasicTextFlow.kt / TextLayout.kt / TextPaint.kt / Locale.kt / MeasuredText.ktCompose / Android 深耦合归档 composeMain,不编译

上游 TextLayoutState 的段落切分、span 合并、LineBreaker 调用不移植——ohos 侧由桥内引擎按同一控制流重写(见第 4 阶段),几何内核则原样调用。

1.4 页面边界
标题区          Combo Breaker · OpenHarmony / 副标题(CMP 文本绕排库 · Kotlin/Native ohosArm64/ohosX64)
状态行          排版结果摘要(段落 N · 槽位 N · 文本高 Npx · lastOffset N · 已截断)或错误原文
画布卡片        白色圆角 Canvas:形状幽灵(半透明灰)→ 槽位叠加(可选蓝)→ 文字段落
Tab 切换        矩形形状 / 任意形状 / 调试视图
共享控件        栏数、两端对齐、字号、示例文本(EN/CN);各 Tab 另有 margin / 栏间距 / debugSlots / hyphenate
字体引导        rawfile 子集字体拷沙箱 + font.registerFont,就绪后触发首次排版

第 2 阶段:目标与依赖打通

2.1 settings 钉版与镜像坑

eazytec 双仓必须排在 pluginManagement 与 dependencyResolutionManagement 最前(ohos 版 Kotlin 插件只发在社区 Maven)。本机还叠了一层真实坑:全局 ~/.gradle/init.d/china-mirrors.gradle 会向 pluginManagement / 每个工程注入阿里云镜像,eazytec 仓库自动生成的名字(maven / maven2)与镜像同名,被 Gradle 的同名仓库去重规则静默丢弃;且注入的工程级仓库在 PREFER_PROJECT 模式下会整体屏蔽 settings 仓库。两剂解药都固化在 settings.gradle.kts 注释里:

pluginManagement {
    repositories {
        // 显式命名:自动名 maven/maven2 与镜像 init 脚本同名,会被 Gradle 去重丢弃
        maven("https://maven.eazytec-cloud.com/nexus/repository/maven-public/") {
            name = "eazytec-public"
        }
        maven("https://maven.eazytec-cloud.com/nexus/content/groups/harmonyos") {
            name = "eazytec-harmonyos"
        }
        gradlePluginPortal()
        google()
        mavenCentral()
    }
}

dependencyResolutionManagement {
    // PREFER_SETTINGS:注入镜像的 init 脚本会给工程加仓库,PREFER_PROJECT 会让 settings 仓库整体失效
    repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS)
    repositories { /* 同上两条 eazytec */ }
}

rootProject.name = "ohos_combo-breaker"

include(":combo-breaker")
include(":example:nativeApp")
2.2 编译真实算法

combo-breaker/build.gradle.kts 收敛为纯 KMP 模块:

plugins {
    alias(libs.plugins.kotlin.multiplatform)
}

kotlin {
    applyDefaultHierarchyTemplate()
    jvmToolchain(21)
    jvm()          // 交叉验证
    ohosArm64()
    ohosX64()

    sourceSets {
        // src/composeMain 是上游 Compose 实现的归档,故意不是编译源码集
        commonMain.dependencies { }
        commonTest.dependencies { implementation(kotlin("test")) }
    }
}

实测:

./gradlew :combo-breaker:compileKotlinOhosArm64 :combo-breaker:compileKotlinOhosX64   # BUILD SUCCESSFUL
./gradlew :combo-breaker:jvmTest                                                      # 7/7 通过

commonMain 里 FlowShape 换成多边形版(构造即建区间树,intervals / bounds 维持 internal),几何类型从自带的 FlowGeometry.kt 来——上游算法文件里除 import 外一行未动。

2.3 nativeApp 双 ABI 动态库

example/nativeApp/build.gradle.kts:两个 ohos 目标各声明 binaries.sharedLib { baseName = "ohoscombobreaker" },linkDebugSharedOhos* 被 finalizedBy 两个 Copy 任务——把 libohoscombobreaker.so 连同 ~/.konan/dependencies/cpf-llvm-19-*/lib/<triple>/libc++_shared.so 一起回填 example/harmonyApp/entry/libs/<abi>/(gitignore,生成物)。根构建全局禁用 Ohos*Test* 任务(qrose 同款纪律)。

第 3 阶段:桥协议建模

3.1 JSON 契约
dev.romainguy.text.combobreaker/**(上游几何内核,原样编译)
        ↓ findFlowSlots / IntervalTree / FlowType
FlowTextEngine.kt(桥内排版引擎:TTF 度量 + 贪心断行 + justify 移植)
Bridge.kt(@CName × 1,嵌套 JSON 进出)
        ↓ UTF-8 JSON
N-API comboBreakerLayout()
        ↓
ArkUI Index.ets(Canvas 逐段绘制)

请求与响应:

请求 : {"width":1080,"height":1600,"text":"…","columns":2,"columnSpacing":24,
        "justify":true,"hyphenate":false,"direction":"Ltr","fontSize":30,
        "fontPath":"/data/…/files/cbdemo_font.ttf","debugSlots":false,
        "shapes":[{"type":"rect","x":16,"y":24,"w":220,"h":340,
                   "flowType":"OutsideEnd","margin":12},
                  {"type":"polygon","points":[x0,y0,…],"flowType":"Outside","margin":8}]}
响应 : {"ok":true,"width":…,"ascent":…,"lineHeight":…,"textHeight":…,
        "lastOffset":77,"truncated":true,"segmentCount":14,"slotCount":0,
        "segments":[{"t":"The","x":368.2,"y":97.44,"w":…}],
        "slots":[{"l":…,"t":…,"r":…,"b":…}]}
       {"ok":false,"error":"…"}

三个关键设计:其一,segments[].y 是基线(Canvas fillText 语义),ArkTS 零换算;其二,对齐行逐词分段——ArkTS Canvas 没有 wordSpacing 旋钮,对齐增量(每空格 +justifyWidth)在 Kotlin 侧算进每个词的 x,渲染端不参与对齐数学;其三,形状坐标与排版区域全部物理像素,ArkTS 按 density 换算后发出,绘制时坐标系判定后 1:1 落点(见第 6 阶段)。

3.2 错误通道
@CName("ComboBreakerLayout")
fun comboBreakerLayout(request: String): String = try {
    handleRequest(request)   // 校验 + 引擎 + 响应拼装
} catch (t: Throwable) {
    val sb = StringBuilder()
    sb.append("{\"ok\":false,\"error\":")
    MiniJson.escapeTo(sb, t.message ?: t.toString())
    sb.append('}')
    sb.toString()
}

非法 JSON、width/height 越界、缺 fontPath、字体读不了、未知 flowType / type,全部走同一个 ok:false 通道;Throwable 永远不跨 FFI。

第 4 阶段:文本度量引擎(技术难点,本库特有)

4.1 为什么需要它

上游排版层的断行是 LineBreaker.computeLineBreaks(measuredText, constraints),度量来自 TextPaint。Kotlin/Native for ohos 没有等价物——这是 qrose 路线里"KN 光栅化"的对应缺失件。桥内自建三件套:

  • TtfFont.kt:sfnt 目录 → head(unitsPerEm)/ hhea(ascender/descender/lineGap/numberOfHMetrics)/ maxp / hmtx(前进宽度)/ cmap(优先 format 12 全 Unicode,退 format 4;查不到回退 .notdef 宽度)。posix fopen/fread 读 ArkTS 拷进沙箱的字体,按路径单槽缓存。
  • FlowTextEngine.kt 断行:贪心扫描,断点机会 = 空格之后、显式连字符之后、CJK 字符之间(U+2E80–9FFF、U+F900–FAFF)——中文无空格逐字断行是硬需求。维护 nonSpaceEnd 与 pendingSpacesWidth,行尾空格按上游 trimEndSpace 语义剔除不入测量;溢出时回退最近断点,无可断点则整块强排(对应 Android LineBreaker 的 desperate mode,保证布局必然前进)。
  • justify 移植:justifyWidth = (maxWidth - lineWidth) / stretchableSpaces;justification=None 时正值清零(保留负收缩);无空格且过宽返回 NaN → 跳槽换下一个;段落末行不拉伸——与上游 justify() 的条件逐条对齐,countStretchableSpaces 只数 U+0020。

控制流照抄上游 layoutTextFlow():栏宽收敛循环、findFlowSlots(行带, 全宽容器, shapes) 逐行调上游内核、同行 slots 从左到右依次填、段落跨栏续排、空段落记空行。

4.2 margin 外扩

上游用 Paint.getFillPath + Path.op 做圆角外扩,KN 无 Path。桥内 expandPolygon():有符号面积定绕向 → 每条边沿外法线平移 → 相邻偏移线求交(miter join),交距超 4×margin 截断防尖刺,平行退化取平均法线——视觉近似,差异记录在已知限制。

4.3 字体子集

example/harmonyApp/tools/make_font_subset.py 用 fontTools 把 DevEco SDK 自带的 HarmonyOS_Sans_SC.ttf(20.6 MB)子集化到双语示例文本 + 全 ASCII + 常用中文标点,产物 rawfile/cbdemo_font.ttf(88 KB,保 cmap + hmtx + glyf);tools/verify_texts.py 从 Index.ets 反抽文本对子集做覆盖校验(实测 missing glyphs: NONE)。度量(KN)与渲染(ArkTS)用同一份文件,“量的就是画的”。

第 5 阶段:原生桥接

5.1 三层桥接
ArkTS
  │ JSON string
  ▼
C++ N-API entry(libcombobreaker_napi.so)
  │ const char*
  ▼
Kotlin/Native C ABI(libohoscombobreaker.so)
  │ ComboBreakerLayout
  ▼
UTF-8 JSON + DisposeString
5.2 C ABI 符号
@CName("ComboBreakerLayout")
fun comboBreakerLayout(request: String): String
C ABI 符号ArkTS 方法覆盖内核
ComboBreakerLayoutcomboBreakerLayout(req)全部:形状解析 + findFlowSlots + 断行 + 对齐 + 段落/槽位输出

llvm-nm -D 实测 ComboBreakerLayout 与 libohoscombobreaker_symbols 均为全局 T 符号。

5.3 C++ N-API 与 CMake
napi_property_descriptor desc[] = {
    {"comboBreakerLayout", nullptr, NapiComboBreakerLayout, nullptr, nullptr, nullptr,
     static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
};
// nm_modname = "combobreaker_napi",与 import 'libcombobreaker_napi.so'、types 目录三名一致

回调统一三步:napi_typeof 校验 string、OH_LOG_INFO 打请求字节数、调 C ABI 并包装返回;KN 字符串必须经 libohoscombobreaker_symbols()->DisposeString 归还运行时,绝不 free()。CMake 用 IMPORTED 引入 KN so(IMPORTED_NO_SONAME + $ORIGIN rpath,避免 Windows 绝对路径进 RUNPATH),OHOS_ARCH 为空或 KN so 缺失直接 FATAL_ERROR,POST_BUILD 把 libohoscombobreaker.so + libc++_shared.so 拷到 NAPI so 旁边——HAP 内三件套同目录,dlopen 互见。

export const comboBreakerLayout: (request: string) => string;

第 6 阶段:ArkUI 宿主

6.1 字体引导与坐标系统判定
aboutToAppear(): void {
  this.bootstrapFont();      // async:rawfile → 沙箱 → registerFont('cbdemo')
  this.scheduleRender();    // Promise 完成前必提前返回——bootstrap 末尾必须补发一次
}

private draw(): void {
  // ArkUI Canvas 实测为 vp 坐标空间(ctx.width == 324 对应 324vp 画布),
  // 桥返回物理像素:按接近度判定坐标系后换算,而不是赌单位假设
  const vpSpace = Math.abs(ctx.width - this.canvasW) <= Math.abs(ctx.width - this.canvasW * this.density);
  const unitPerPx = vpSpace ? 1 / this.density : 1;
  ctx.save();
  ctx.scale(unitPerPx, unitPerPx);
  /* 白卡 → 形状幽灵 → 槽位 → segments(字号同乘 unitPerPx)*/
  ctx.restore();
}

两处必须点名的实战修复:画布卡片原本排在很高的 Tab 控件区之下、首屏完全看不到(被误判为"没渲染"),改为状态行 + 画布放在 Tabs 之前;ctx.scale(density) 与物理像素坐标叠加会造成双重放大(形状膨胀成巨块、文字出界),坐标系判定后 scale(1/density) 一劳永逸。

6.2 请求构造与绘制
const body = `{"width":${px(wPx)},"height":${px(hPx)},"text":"${escapeJson(this.activeText())}",` +
  `"columns":${this.columns},"columnSpacing":${px(sp)},"justify":${this.justify},` +
  `"hyphenate":${this.hyphenate},"direction":"Ltr","fontSize":${px(fs)},` +
  `"fontPath":"${escapeJson(this.fontPath)}","debugSlots":${debug},"shapes":[${this.shapesJson()}]}`;
const res = JSON.parse(comboBreakerLayout(body)) as LayoutResponse;

形状"幽灵"(半透明灰填充 + 描边,“T” 位画大号字母、图片位写 landscape 标签)先画,槽位叠加可选,最后逐段 fillText(seg.t, seg.x, seg.y)——y 即基线。hyphenate 开关照发但恒无效,配一行说明文字(上游依赖 Android API 33+)。

四、完整代码对照

4.1 整体架构

dev.romainguy.text.combobreaker/**(上游几何内核,commonMain 原样编译)
    │ IntervalTree / clipSegment / findFlowSlots / FlowType
example/nativeApp FlowTextEngine + TtfFont(平台文本栈替代件)
    │ 贪心断行 + justify 移植 + expandPolygon
Bridge.kt + MiniJson(嵌套 JSON,单 @CName)
    │ ComboBreakerLayout
libcombobreaker_napi.so
    │ comboBreakerLayout
Index.ets
    │ JSON.parse + Canvas 逐段绘制
    ▼
ArkUI 验收页面(三 Tab)

4.2 文件清单

文件职责
settings.gradle.kts极简 ohos 构建、eazytec 双仓显式命名置前、PREFER_SETTINGS、ohos_combo-breaker
gradle/libs.versions.tomlKotlin 2.2.21-1.0.0 单一版本源
build.gradle.kts(根)版本 0.9.0-ohos.1、全局禁用 Ohos*Test* 任务
combo-breaker/build.gradle.kts纯 KMP:jvm + ohosArm64 + ohosX64,composeMain 不编译
combo-breaker/src/commonMain/**IntervalTree(逐字)/ Clipping / FlowSlots(换几何类型)/ FlowGeometry / Geometry / FlowShape / FlowType / TextFlowEnums / TextFlowSupport
combo-breaker/src/commonTest/**JVM 交叉验证 7 用例
example/nativeApp/build.gradle.kts双 ABI sharedLib、cxxShared 定位 libc++、copyKnSo* 回填
nativeApp/.../Json.kt嵌套 JSON 手写解析 / 转义(不引 kotlinx.serialization)
nativeApp/.../TtfFont.ktcmap(4/12)/hmtx/hhea/head/maxp 解析 + posix 读文件
nativeApp/.../FlowTextEngine.kt断行 / 对齐引擎 + expandPolygon
nativeApp/.../Bridge.kt@CName("ComboBreakerLayout") + 参数校验 + 响应拼装
harmonyApp/.../cpp/napi_init.cppN-API 导出、参数检查、DisposeString
harmonyApp/.../cpp/CMakeLists.txtIMPORTED 引入 KN so、$ORIGIN rpath、POST_BUILD 三件套
harmonyApp/.../ets/pages/Index.etsArkUI 三 Tab 验收页面(坐标系判定绘制)
harmonyApp/tools/*.py字体子集生成 / 覆盖校验
README.OpenHarmony_CN.md鸿蒙适配主文档(22 节,含桥协议全字段与踩坑表 19 条)

4.3 关键 API 对照

层次API作用
内核findFlowSlots(box, container, shapes, state)一行文字的可落字矩形列表(上游原件)
内核IntervalTree.findOverlaps形状线段的垂直区间查询(上游原件)
内核FlowType.resolve(direction)OutsideStart/End 按方向解析(上游原件,公有化)
内核countStretchableSpaces / trimEndSpace对齐与行尾空格语义(上游逐字提取)
引擎greedyBreak / justifyWidth贪心断行 + 上游 justify 移植
引擎TtfFont.metrics(fontSize)逐字符前进宽度 / ascent / lineHeight
引擎expandPolygonmargin 尖角外扩(miter 限长)
NativeComboBreakerLayoutC ABI JSON 导出(单入口)
N-APIcomboBreakerLayout(req)向 ArkTS 暴露能力
ArkTSscheduleRender() / draw()请求构造 / 坐标系判定绘制

4.4 ArkTS 与 Kotlin 的边界

const res = JSON.parse(comboBreakerLayout(JSON.stringify({
  width: 1080, height: 1600, text: article,
  columns: 2, columnSpacing: 24, justify: true,
  direction: 'Ltr', fontSize: 30, fontPath: sandboxFont,
  debugSlots: false,
  shapes: [{ type: 'rect', x: 16, y: 24, w: 220, h: 340, flowType: 'OutsideEnd', margin: 12 }]
})));
for (const seg of res.segments) ctx.fillText(seg.t, seg.x, seg.y);   // y 即基线
val slots = findFlowSlots(                                   // 上游内核
    RectF(column.left, y, column.right, y + lineHeight),
    RectF(0f, y, width, y + lineHeight), flowShapes, state)
for (slot in slots) {                                        // 引擎按上游控制流填槽
    val br = greedyBreak(paragraph, breakOffset, slot.right - slot.left)
    val jw = justifyWidth(paragraph, breakOffset, br.end, br.width, maxWidth, justification)
    if (jw.isNaN()) continue                                 // 过宽且不可收缩 → 换槽
    emitSegments(paragraph, breakOffset, br.end, jw, slot.left, y + ascent, segments)
}

两者之间只传输 JSON 字符串:坐标、文本段、槽位矩形,不传输 Kotlin 对象或未校验的动态结构。

五、关键决策说明

决策 1:坐标过桥而不是像素

qrose 出 PNG 是因为条码的下游消费是"显示";排版库的下游消费是可交互的文本——出位图会把文字选择、无障碍、动态字号全堵死。桥返回 segments[{t,x,y,w}],ArkTS 用注册字体逐段绘制,文字仍是"活的"。

决策 2:Compose 归档 composeMain,等工具链对齐后接回

上游 API(BasicTextFlow)与 Android LineBreaker 深度耦合,硬编进 ohos 没有意义。归档不删(上游修复可 diff 同步),三个枚举按 qrose 的 Code128Type 先例提取到 commonMain——恢复 Android 构建时不产生重复声明。

决策 3:桥内自带文本度量,与渲染端共用同一份字体

没有平台文本栈就补最小件:TTF 解析只读 5 张表,断行只做贪心 + CJK 断点。关键约束是度量与渲染同源——KN 解析的与 ArkTS registerFont 的是同一份 88 KB 子集,前进宽度与 glyph 完全一致,坐标落点不会漂。

决策 4:对齐行逐词分段

ArkTS Canvas 没有 wordSpacing。与其在渲染端模拟,不如把对齐数学完整留在 Kotlin 侧(上游 countStretchableSpaces 原语义),每个词带着算好的 x 过桥——渲染端零排版逻辑。

决策 5:JVM 交叉验证钉住"算法没漂"

几何内核在 commonMain,JVM 与 ohos 跑同一份代码。7 项 commonTest 钉住:多边形→区间数、居中矩形 Outside=双槽 / OutsideRight=单槽、行不交形状=整行槽、整宽形状=全空槽、FlowType.resolve 双方向六映射、空格工具语义。上游 IntervalTree 逐字一致是验收红线,改算法先过 jvmTest 再链 so。

六、测试与验证

6.1 测试环境

本次验证使用 Windows(x64)、JDK 21(Temurin)、DevEco Studio 5.1.0 Release(API 18 SDK)、OpenHarmony 命令行工具(hvigor / hdc / uitest)与 DevEco 真机(x86_64)。

6.2 JVM 交叉验证

./gradlew :combo-breaker:jvmTest    # 7/7 通过
测试钉住什么
polygon produces one interval per edge显式闭合环不产生重复线段
centered rectangle with Outside yields two slots左右双槽边界值精确到浮点相等
centered rectangle with OutsideRight yields a single right slot屏障语义
line above the shape keeps the full width无区间命中则整行可用
two stacked shapes produce the middle band整宽形状 → 全空槽,不触发整行回退
flow type resolution follows layout directionLtr/Rtl 六方向解析
stretchable spaces only count U+0020对齐语义与行尾空格剔除

6.3 HAP 验证

:combo-breaker:compileKotlinOhosArm64 / OhosX64: BUILD SUCCESSFUL
linkDebugSharedOhosArm64 / linkDebugSharedOhosX64: BUILD SUCCESSFUL
llvm-nm -D: ComboBreakerLayout / libohoscombobreaker_symbols 均为全局 T 符号
copyKnSoArm64 / copyKnSoX64: 三件套回填 entry/libs/<abi>/
BuildNativeWithNinja: finished(libcombobreaker_napi.so)
CompileArkTS / PackageHap: finished
BUILD SUCCESSFUL → entry-default-unsigned.hap 约 7.1 MB
install bundle successfully / start ability successfully

KN so 实测 arm64 约 3.9 MB、x86_64 约 3.6 MB(debugShared 含 Kotlin/Native 运行时);每个 ABI 三件套(libcombobreaker_napi.so / libohoscombobreaker.so / libc++_shared.so)缺一不可。

6.4 功能用例(真机,PC 侧 hdc 闭环)

  1. hilog 出现 comboBreakerLayout request bytes=2150/2155 且 layout ok=true segs=14 rects=2——真实 Native 调用;
  2. 矩形形状 Tab:文字绕开左上 “T” 位与居中 landscape 位,margin 滑杆 0→24 文字整体外退,栏数 1→4 重排,justify 打开后右缘平齐(末行除外);
  3. 任意形状 Tab:文字沿心形 / 五角星轮廓绕排;切中文示例逐字断行无溢出;
  4. 调试视图 Tab:打开"显示槽位"后状态行变 段落 17 · 槽位 17 · 文本高 1108px · lastOffset 113 · 已截断,蓝色槽位矩形经像素级检测确认(采样 550 个 #2196F3 邻域像素);
  5. truncated/lastOffset 随栏数减少单调变化,与文本量一致;
  6. 清空文本 → 段落 0;字体未就绪时渲染 → 状态行提示"正在准备字体…",不崩。

6.5 验证结论

几何内核、文本度量引擎、Native 动态库、C ABI、N-API、ArkTS Canvas 绘制和 HAP 已完成链路验证。应用在真机成功安装进入前台,三个 Tab 展示真实绕排会话:矩形 / 任意形状绕排、两端对齐、槽位可视化(见下节效果图)。PC 侧全程用 hdc shell snapshot_display 截屏 + uitest dumpLayout / uiInput click|swipe 遍历控件完成验收——真机允许 hdc install 未签名 HAP,闭环不依赖 IDE。

七、运行效果

7.1 运行截图

在这里插入图片描述

7.2 命令速查

git clone https://atomgit.com/oh-tpc/combo-breaker.git
cd combo-breaker
export JAVA_HOME="/path/to/jdk-21"
./gradlew :combo-breaker:jvmTest                                            # 7 项交叉验证
./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-unsigned.hap   # 真机可装未签名
hdc shell aa start -a EntryAbility -b org.terminator.ohos.combobreaker -m entry
hdc shell hilog | grep ComboBreaker

八、遗留问题与改进方向

8.1 踩坑复盘

  1. 只改 ArkTS 页面不算适配,必须让几何内核真正编译成 ohosArm64 / ohosX64 动态库;
  2. eazytec 双仓必须置前且显式命名:本机镜像 init 脚本注入的阿里云仓库会抢占 maven/maven2 自动名,同名仓库被 Gradle 静默去重——插件解析直接找不到 2.2.21-1.0.0;
  3. repositoriesMode 用 PREFER_SETTINGS:镜像脚本给工程注入仓库后,PREFER_PROJECT 会让 settings 里的 eazytec 整体失效(报 Could not find kotlin-stdlib);
  4. Windows 下 gradlew.bat 在读 gradle.properties 之前就要 JAVA_HOME,命令行先 set 再跑;
  5. ArkTS getRawFileContent 在当前 SDK 返回 Promise<Uint8Array>,同步赋值报类型错——方法改 async 并 await,且异步完成后必须补发一次重绘(Canvas 是命令式绘制,@State 变更不会自动重画);
  6. Canvas 的 textAlign / textBaseline 是字符串字面量联合类型,用 TextAlign 枚举赋值编译报 not assignable to type 'CanvasTextAlign';TextBaseline 干脆不存在;
  7. ctx.roundRect 要 API 20,compatible 5.1.0(18) 上是隐患——手写 moveTo + arcTo×4 + closePath(arcTo 自 API 11 可用);
  8. ArkUI Canvas 坐标系实测是 vp(ctx.width 返回 324 对应 324vp 画布):桥的物理像素坐标若再 scale(density) 会双重放大——形状膨胀成巨块、文字整体出界。解法是运行时按 ctx.width 与 canvasW / canvasW*density 的接近度判定坐标系;
  9. 画布卡片别排在很高的控件区下面——首屏看不到会被误判为"没渲染",画布是主角必须放 Tabs 之前;
  10. HAP 缺当前 ABI 的 so 报安装失败 9568347,abiFilters 双 ABI、每 ABI 三件套缺一不可;
  11. KN 返回字符串必须 DisposeString 归还运行时,N-API 层不能 free();
  12. DevEco 打开工程生成的 example/harmonyApp/.idea/ 与 clangd 数据库要追加 gitignore,否则混进提交;hilog 没有 %b 占位符,布尔先转模板串再用 %s。

8.2 已知问题

  • 鸿蒙侧无 Compose BasicTextFlow / TextFlow composable;composeMain 等社区 CMP ohos 制品与上游对齐后接回;
  • 无 BiDi(上游本就标注未测试);桥只做栏序镜像(Rtl 时右栏先行);
  • 无自动连字符(上游依赖 Android API 33+),hyphenate 参数仅为前向兼容占位;
  • 单一样式:无 AnnotatedString span(上游 span 合并算法在 composeMain),全文字号统一;
  • 贪心断行 ≠ LineBreaker 的高质量断行;超长不可断词触发绝境强排时会视觉溢出槽位;
  • margin 外扩是尖角近似(miter 限长),与上游圆角 join 有细微视觉差;
  • 补充平面字符(emoji 等)按代理对近似度量(cmap 有 format 12 时可查到,否则 .notdef 宽度);
  • .github 的 Android CI 是上游遗留,新结构下不可用(保留原样,接回上游后自然恢复)。

8.3 未来优化方向

  • 多样式支持:桥接受 span 区间(粗体/颜色),字体子集扩重介质;
  • 断行升级:knuth-plass 或引入 ohos 侧 ICU 断行数据,替换贪心;
  • 在 CI 中加入 ohos 编译、ELF 符号检查和 HAP 构建;
  • 评估 .har 形态发布,让 ArkTS 工程直接依赖;
  • 补充真机(arm64)与其他设备 ABI 的验收矩阵和自动化截图脚本。

九、总结

9.1 核心难点回顾

上游 combo-breaker(几何内核 + Android 排版层 + Compose UI)
    → 拆源码集:commonMain(算法)/ composeMain(归档)
    → 工具链对齐 Kotlin 2.2.21-1.0.0 / Gradle 8.14.1(含镜像 init 脚本双坑)
    → ohosArm64 / ohosX64 KLIB + 双 ABI 动态库
    → 桥内文本度量引擎(TTF 解析 + CJK 贪心断行 + justify 移植)
    → C ABI(@CName × 1)+ 坐标 JSON(segments / slots)
    → C++ N-API(DisposeString 纪律)
    → ArkTS Canvas 坐标系判定绘制 + 字体子集同源
    → 真机三 Tab 验收(hdc + uitest 全程 PC 侧闭环)

9.2 封装层次

  • 内核层:上游原版 IntervalTree、clipSegment、findFlowSlots、FlowType 屏障语义;
  • 引擎层:TTF 度量、贪心断行(CJK)、上游 justify 数学移植、margin 尖角外扩;
  • Native 层:双 ABI 动态库并通过单 C ABI 输出坐标 JSON;
  • N-API 层:参数检查、字符串转换和 Native 内存释放;
  • ArkTS 层:字体引导、请求构造、坐标系判定、Canvas 逐段绘制与三 Tab 状态管理;
  • DevEco 层:CMake、HAP、签名、安装和真机运行。

9.3 三条经验

  1. 桥的形态由下游消费决定:条码库下游要"显示",出像素(qrose 的 KN 光栅化 + PNG);排版库下游要"活文本",出坐标。先想清楚 ArkTS 侧拿到什么再设计契约,比套上一个库的现成模式更重要——本次与 qrose 唯一的结构差异(多一个度量引擎阶段)就是被这一问逼出来的;
  2. 平台缺什么就补最小件:ohos 没有文本栈,补的是 5 张表的 TTF 解析 + 一个贪心断点函数,而不是引入 ICU/Skia 级依赖;度量与渲染共用同一份字体文件,是"最小件不漂移"的关键约束;
  3. PC 侧闭环验收是效率倍增器:hdc install 未签名 HAP + snapshot_display 截屏 + uitest dumpLayout / uiInput 遍历控件 + hilog 插桩,让"改一版→装→看→定位"全在命令行完成——本文 8.1 的第 5–9 条坑,没有这套闭环不可能定位。

9.4 适配成果

当前适配已完成:

  • combo-breaker 0.9.0 几何内核的双 ABI Kotlin/Native 编译(IntervalTree 逐字一致,Clipping / FlowSlots 仅换几何类型来源);
  • 桥内文本度量引擎(TTF cmap/hmtx/hhea 解析 + 空格/连字符/CJK 贪心断行 + 上游 justify 完整移植);
  • 1 个 C ABI 符号和 C++ N-API 包装层(llvm-nm 验证全局 T 符号);
  • DevEco Stage ArkTS 三 Tab 验收页面(矩形 / 任意形状绕排、两端对齐、槽位可视化、中英双语文本);
  • 7 项 JVM 交叉验证全绿 + HAP 构建(双 ABI 六件套 so,约 7.1 MB)+ 真机安装与全 Tab 功能验收(含槽位像素级检测);
  • AtomGit 项目文档、OpenHarmony README(中英双份,中文主文档 22 节)和本地效果图。

适配过程 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开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐