做鸿蒙化适配的时候我遇到的第一道坎不是什么底层能力而是一个看起来毫不起眼的 Flutter 三方库——marker。当时某项目里要做一个阅读器批注功能用户在文章上长按选中一段文字可以高亮、加下划线、画删除线还能加笔记。Flutter 端用 marker 库非常顺手几行代码就能把标注状态管理得明明白白导出 HTML 也是现成的。但到了鸿蒙端事情就完全不一样了Flutter 代码没法直接跑ArkUI 的 Text 组件又不支持现成的标注模型。这篇文章就围绕 marker 库的鸿蒙化适配把我这一路踩过的坑、拆过的源码、总结出的方案完整记录下来。无论你是要处理文本标记、富文本转换还是要做一个真正“精密”的标注工具这篇指南的核心思路都适用。我会先讲清楚 marker 到底解决了什么问题再拆两条适配路线接着深入到数据模型迁移、HTML 转换链路、踩坑实录最后聊一聊这套方案还能怎么用。内容偏实操适合正在做鸿蒙迁移、或者需要在鸿蒙上实现富文本标注功能的朋友。1. marker 到底是个什么库为什么值得费劲做鸿蒙化很多朋友第一次接触 marker是把它当作一个“文本高亮库”来用的这其实是把它用窄了。marker 的核心价值在于它构建了一套完整的“文本标注模型”不只是视觉上改个颜色而是把标注本身当成有身份、有类型、有样式、可序列化的数据对象来管理。1.1 核心能力拆解它不只是高亮marker 里一条标注通常由这么几个要素组成标注类型高亮、下划线、删除线、链接、备注等。作用范围以字符为单位的起始 offset 和长度 length而不是简单的“从第几行到第几行”。样式数据颜色、线型、背景色、字体样式等展示层信息。附加数据比如备注文本、链接地址、用户信息等业务字段。这种“数据与展示分离”的设计是它区别于普通高亮组件的关键。实际操作里你可以对同一条文本同时叠加一条高亮和一条删除线它们互不干扰因为各自是独立的对象分别维护自己的 offset、length 和样式。1.2 实际业务场景哪些项目需要这种能力我在某阅读类项目里需要支持用户在正文上做笔记。底层文本有几千字用户可以任意选择一段话加高亮并在旁边写一段点评。后来这个数据要同步到 Web 端和移动端需要把标注内容导出成 HTML 再给其他端渲染。换成用 marker 来做导出过程几乎不用自己写解析器它内建的 HTML 序列化规则可以覆盖大部分需求。另一个典型场景是编辑器的批注功能。比如某培训教材的协作审阅系统审阅人可以对某段文字做“建议删除”或“重点强调”的标注作者端收到的是结构化的标注数据而不是一张截图。这种场景对标注的“可计算性”要求很高必须能精确到字符级。marker 的做法是把标注范围建成一个闭区间通过 offset 和 length 精确定位实际使用中这样设计非常顺手。1.3 为什么鸿蒙化不是“翻译代码”这么简单如果只是在 Flutter 里继续用 marker那确实没什么好写的。但鸿蒙端的应用无法直接加载 Flutter 插件跑到鸿蒙上要么走 Flutter 兼容层要么把核心逻辑用 ArkTS 重写一遍。这里要注意重写并不是把 Dart 代码逐行翻译成 TypeScript 就行背后有三个层次的问题渲染层不同Flutter 的 TextSpan 有自己的一套样式合并规则ArkUI 的 Text 组件和 Span 子组件也有自己的规则两者并不一一对应。命中测试不同Flutter 的文本布局是自绘引擎算的ArkUI 用的是系统排版引擎同样的 offset 算出的字符位置可能会因为字体度量差异产生偏移。序列化标准不同marker 默认导出的 HTML 标签在 ArkUI 的 RichEditor 里不一定直接支持需要做标签映射。所以鸿蒙化的本质是把 marker 的“模型层”和“序列化层”吸收过来再把“渲染层”替换成 ArkUI 的实现。理解了这一点整个适配过程就不会被细节绕晕。2. 鸿蒙化适配的两条路线兼容层迁移还是原生 ArkUI 重写做适配方案之前先得想清楚一个问题这一步是临时过渡还是要长期在鸿蒙生态里发展这个答案直接决定了选哪条路线。2.1 Flutter 兼容层什么时候可以无脑选如果你们的鸿蒙应用是拿现成的 Flutter 工程做编译适配而且业务上不打算单独维护一套 ArkTS 版本的页面那最简单的方式就是继续在 Flutter 层使用 marker只是在构建鸿蒙版本时选用兼容 Flutter 引擎的运行时。好处是代码完全复用标注模型、导出逻辑、状态管理全都不用动。但走这条路有一个前提你的应用对“原生质感”要求不高而且不需要和鸿蒙原生的系统能力深度联动。比如项目里只是把 marker 的标注结果当成一种内部数据结构来用展示层是自绘的那兼容层方案完全够用。实测中marker 本身不依赖平台通道纯 Dart 实现在 Flutter 兼容层上跑基本没有阻碍。2.2 原生 ArkUI 重写什么时候必须自己来如果你的鸿蒙应用是一个独立的原生应用或者功能上需要和系统组件深度集成——比如标注内容要直接在系统输入法的候选词里显示、要支持跨应用拖拽、要接入系统级的无障碍朗读——那还是要走原生 ArkUI 重写。我遇到的情况就是后者。某项目要求阅读器在鸿蒙上拥有原生手感滚动流畅度、翻页动画、手势响应都必须跟系统应用保持一致。这种情况下 Flutter 兼容层会有明显的性能损耗而且标注层要叠加在系统组件上操作路径太长。最后我们选择了用 ArkTS 重写 marker 的核心模型渲染层完全基于 ArkUI 的 Text 和 Span 组件来搭建。2.3 我的选型结论这里直接给结论省得大家自己再试一遍。判断标准有两条如果 marker 在你的项目里只是“辅助功能”比如偶尔导出个带批注的富文本其他页面仍然是 Flutter 主导那直接留在 Flutter 层用兼容层跑最省事。如果标注功能是核心体验需要常驻页面、高频交互、深度联动系统组件那就必须原生重写而且越早动手越好不要等到功能迭代到一半再回头改。我当时选择的是原生 ArkUI 重写方案后面所有内容都基于这个前提展开。但模型的建模方式是从 marker 里吸收过来的序列化规则也尽量保持兼容这样即使后续某个页面的功能又切回 Flutter数据层面依然能无缝对接。3. 核心模型迁移文本标注这一套数据模型怎么在 ArkTS 里落重写工作我习惯从数据模型开始因为模型定了渲染和序列化都只是围绕它做转换。marker 的模型层设计得很干净把“文本内容”和“标注集合”分开管理迁移到 ArkTS 后依然保持这个思路。3.1 标注模型拆解Annotation、Marker 与样式我这边在 ArkTS 里定义的核心实体主要有三个分别是文本容器、标注对象、和标注类型枚举。第一个是文本容器本质就是一个字符串加一套标注列表。字符串是纯文本不掺入任何标记符号这样所有样式都由标注列表来驱动。第二个是标注对象包含 id、type、startOffset、endOffset 和 style 属性。第三个是标注类型枚举包括 highlight、underline、strikethrough、link、note 等。实际编码时标注对象我使用了一个类关键字段如下class MarkerAnnotation { id: string; type: MarkerType; startOffset: number; endOffset: number; style: AnnotationStyle; constructor(id: string, type: MarkerType, start: number, end: number, style: AnnotationStyle) { this.id id; this.type type; this.startOffset start; this.endOffset end; this.style style; } get length(): number { return this.endOffset - this.startOffset; } }这里有一个容易踩的细节差异marker 的表示方式里区间用的是“开始位置长度”而我在鸿蒙重写时改成了“开始位置结束位置”。原因是后续做标注合并、拆分、重叠判断时闭区间的表达更直观也少一步加减运算。两者只差一个换算序列化到外部时再转回来就行。3.2 文本位置标定offset 与 length 在 ArkUI 上的对应如果说模型定义是地基那“文本位置标定”就是最容易出问题的承重墙。ArkUI 的 Text 组件里有一个 Span 子组件用来构建富文本片段它可以设置 TextStyle包括文字颜色、背景色、装饰线等。但它接收的不是“offsetlength”而是要求你直接给出一个富文本片段。这跟 marker 的模型有个本质区别marker 是在“同一份纯文本”上叠加多个标注渲染时动态合并而 ArkUI 的 Span 是“把文本切碎了再拼起来”。所以我在重写时做了一个中间层先把纯文本按标注边界切成若干段每一段继承它所覆盖的所有标注样式然后生成对应的 Span 列表。这个切分过程要特别注意重叠标注。比如一段文字同时有一条高亮和一条下划线它们范围可能有交集切分时必须把两个标注的边界都考虑进去生成三段只有高亮、高亮下划线、只有下划线。具体规则我总结成三步收集所有标注的 startOffset 和 endOffset取出去重后的边界点集合。把纯文本按这些边界点切成若干不重叠的区间。对每个区间遍历全部标注找出覆盖该区间的所有标注合并它们的样式。这样做出来的 Span 列表是精确到字符的不会出现一个 Span 跨越两个标注边界的情况。实际测试中这种算法对 1000 个字、50 条标注的文本生成 Span 的耗时在毫秒级。3.3 样式映射decoration、颜色、背景色在 ArkUI 里的对应 API样式映射是另一个必须磨的细节。marker 的高亮在 Flutter 里是背景色ArkUI 的 Span 也有 backgroundColor 属性可以一一对应。但下划线的线型、位置这些参数两端 API 的表达方式并不完全一致。我在做映射时的实际方案是这样的marker/Flutter 样式ArkUI Span 样式说明背景色高亮backgroundColor直接映射注意透明度需要转换格式文字颜色fontColor直接映射下划线decoration 设置为 UnderlineArkUI 的 decoration 只支持 Underline、LineThrough、Overline而 Flutter 还支持 wavy 等线型需要做降级处理删除线decoration 设置为 LineThrough直接映射链接叠加 onTap 事件与文本颜色ArkUI 的 Span 支持 Gesture 事件可以绑定点击动作这里要特别提醒ArkUI 的 decoration 线型选项比 Flutter 少如果 marker 里设置了波浪线或虚线迁移后必须有一个降级策略一般是统一改成实线并在数据层保留原始线型字段等后续 ArkUI 支持更多线型时直接无缝升级。我一开始忽略了这个细节导致部分标注在鸿蒙端显示跟设计稿不一致排查了一阵才发现是线型降级没做。4. 富文本转换链路标注数据导出 HTML 的鸿蒙化实现marker 之所以在跨端场景里受欢迎很大程度是因为它自带了一套成熟的标注数据与 HTML 互转机制。鸿蒙化重写后这一块同样需要保留因为很多业务对端同步依赖的就是 HTML。4.1 从标注对象到 HTML 的序列化规则marker 的默认规则大致是高亮对应标签删除线对应标签下划线对应标签链接对应 标签备注则是嵌套一段自定义属性。我在鸿蒙重写时完全沿用这套规则保证导出结果兼容原有服务端解析。核心逻辑不复杂就是遍历文本按标注边界切分为每个片段生成对应的 HTML 片段再拼接。但有两个问题需要处理样式内联marker 导出的 HTML 会把背景色、文字颜色内联到 style 属性里这样 Web 端不用额外加载 CSS 也能正确渲染。我保留了这种做法比如高亮片段会生成。多标注嵌套如果同一段文字既有高亮又有下划线导出时需要嵌套还是并列按标准 HTML 语义并列即可因为两个标签的视觉样式互不覆盖。代码里的做法是先把多个样式合并成一个 style 字符串使用一个 包裹而不是生成多层嵌套标签。这样解析更稳定。4.2 HTML 逆向解析成标注模型有导出就要有导入。Web 端或者服务端返回一段带标注的 HTML 时鸿蒙端要能重新解析成语义化的标注对象。这一步天然比导出复杂因为 HTML 本身是嵌套结构而标注模型是扁平的列表。我采用的方案是递归解析 DOM 树维护一个“当前生效样式栈”function parseHtmlToAnnotations(nodes: ArrayHTMLElement, baseText: string): MarkerAnnotation[] { const annotations: MarkerAnnotation[] []; const stack: ArrayPartialAnnotationStyle []; const walk (children: ArrayHTMLElement, parentStart: number) { for (const child of children) { const tag child.tagName.toLowerCase(); if (tag mark || tag u || tag del || tag a) { stack.push(createStyleFromTag(tag, child)); walk(child.childNodes, parentStart); stack.pop(); } else if (child.nodeType Node.TEXT_NODE) { const text child.textContent || ; const start baseText.indexOf(text, parentStart); if (start 0) { annotations.push({ startOffset: start, endOffset: start text.length, style: Object.assign({}, ...stack) }); } } else { walk(child.childNodes, parentStart); } } }; walk(nodes, 0); return annotations; }这段代码里有一个关键假设baseText 是纯文本用来反查 offset。实际业务中这个纯文本可以来自服务端返回的 plainText 字段也可以从 HTML 里剥离标签后得到。推荐优先使用前者因为从 HTML 里提取文本再反查位置遇到特殊字符转义容易出错。4.3 边界情况嵌套标注、重叠标注、未闭合标签解析端最容易翻车的是三种情况嵌套标注比如文字上面的递归栈方案能正确处理因为遇到 text 节点时 stack 里同时有 mark 和 u 两个样式。不完全重叠例如abcbc这种解析后会产生两条独立标注视觉上 bc 同时拥有两种样式。但在模型层这是两条标注数据而不是一条整合后的标注后续如果要编辑需要先做合并算法。未闭合标签这是从不可靠来源拿 HTML 时的常见问题。我建议在解析前先用容错解析器处理一遍把不闭合的标签自动补全否则递归栈会错乱导致后续所有标注的 offset 全部偏移。还有一个经验解析时要给 offset 校验加一个安全兜底如果某个标注的起始位置小于前一条标注的结束位置说明解析逻辑有 bug宁可丢弃这条标注也不要让它污染整个模型。这个兜底帮我挡下过好几次线上事故。5. 实测中踩过的坑从“能跑”到“能上生产”的排查记录模型和转换链路都写完项目能跑起来但离能上生产还差得远。这一节是我最想写的部分因为真正的适配难度全在细节里。5.1 坑一Span 样式覆盖导致部分标注“隐身”第一次联调时发现当同一段文本同时存在“高亮”和“删除线”两条标注时删除线渲染正常但高亮的背景色时有时无。排查链路是这样的先怀疑样式合并逻辑但单测用例全部通过合并结果里两个样式都在。再看 Span 生成发现高亮和删除线被切进了同一个 Span样式合并时 backgroundColor 和 decoration 确实都在。最后打印 Span 的渲染树才发现是 ArkUI 的 Span 在同时设置多个样式时存在样式优先级问题某些属性在同一个 Span 内互相覆盖。解决方案有两个方向一是把高亮和删除线分成两个 Span利用 Span 的覆盖布局天然叠加二是在合并样式时就决定只保留一个“主样式”其余用附加 Span 叠加。我实测后选了后者因为前者的切分逻辑在复杂重叠场景里会指数级增加 Span 数量性能扛不住。5.2 坑二中文文本的 offset 与光标位置偏差这个坑很隐蔽。用户长按选择一段中文文本拿到的是系统返回的选择范围基于的是字符边界。但当我用这个范围去切分 Span 时发现最后一个字符位置总是不对导致标注少渲染一到两个字符。排查过程花了很久最后定位到是字体度量问题某些字体下中文字符的 advance width 计算和系统选择范围使用的逻辑不一致会出现半个字符的取舍。这不是模型层的问题而是渲染层的“光标落点”和“字符索引”不是一一映射的。解决方式是在选择范围转换成 offset 时做一次“边界校正”如果某个 offset 落在字符中间位置向上取整到下一个字符边界。这个逻辑我封成了一个工具函数所有入口都走它后续再没出过问题。5.3 坑三导出 HTML 在 WebView 里字体回退导致排版错乱服务端同学反馈鸿蒙端导出的 HTML 在 Web 端渲染时字体、行高全乱了看起来像是一段完全没有样式的纯文本。检查后发现是我导出时漏了字体族声明而 marker 原版在导出时会带上 font-family 属性。Web 端没有对应字体就回退到了默认字体整个排版质感全没了。修复很简单导出时在 style 里补上字体族和行高。但这个问题让我意识到跨端同步场景里HTML 不仅仅是“数据格式”它同时承担了“视觉规范”的职责。导出时不能只考虑语义正确还要把关键的排版参数一并输出。我把这条经验加进了团队的编码规范之后 Web 端的还原度基本持平。5.4 性能问题大文本标注时的渲染卡顿最后一个是性能问题。某测试环境里富文本有 3000 字标注 80 条全部渲染时页面滚动掉帧严重。排查后发现瓶颈不在渲染而在 Span 生成阶段每生成一个 Span就要遍历全部标注判断是否覆盖当前区间复杂度是 O(标注数 * Span数)在大文本场景下飙升。优化方案是给标注列表按 startOffset 排序切分时用一个滑动窗口维护“当前仍有效的标注”子集只有窗口内的标注才参与样式合并。这样复杂度降到了 O(标注数 Span数)3000 字 80 条标注的生成耗时从 400ms 降到了 20ms滚动手感立刻恢复。6. 这套方案还能怎么扩展从批注到编辑器再到协同适配完成之后我又把这套标注模型往上叠了几个能力这里做个简单的扩展记录有类似业务需求的可以参考。标注模型天然可以扩展成可编辑的批注系统只要再维护一个“插入/删除”的操作栈每次文本变更时同步调整所有标注的 offset。我在项目里做了一个简版实现用户在标注区域里插入新字符该字符自动归属到当前命中的所有标注范围内删除时如果删到标注边界自动收缩标注区间。这样用户就能在鸿蒙端直接编辑带标注的文本而不需要先移除标注。如果要走向协同编辑那核心就不是渲染而是标注数据的并发冲突处理。marker 的模型层在这一点上有个先天优势它的标注对象都是自包含的包含 id 和区间天然适合用 OT操作转换或 CRDT 风格来处理。鸿蒙端只需要把标注对象的增删改操作同步到服务端由服务端做冲突合并再推送回来。我自己的体会是marker 的鸿蒙化适配本质上不是某一项技术难题的攻克而是“模型设计”和“渲染实现”的分离艺术。只要把标注数据模型做得足够干净、可序列化、可计算无论底层是 Flutter 还是 ArkUI都能比较容易地落出可靠的功能。现在这套代码已经反哺到了另一个模拟项目中每次遇到新的富文本需求我第一反应都是先看能不能复用这套标注模型而不是再找一个新的库从头适配一遍。