从项目标题到落地实现这篇东西我想了很久才动笔。React Native做跨端不是新鲜事但“OpenHarmony RN”组合起来做Text富文本渲染网上的资料确实少得可怜。如果你也是被这个需求砸中的人心里大概有数RN的Text组件在iOS和Android上开箱即用嵌套、加粗、变色、点击事件全都有可一搬到OpenHarmony上底层渲染机制变了文档也不成体系原本五分钟能搞定的事硬是能折腾好几天。这篇文章就把我在实际项目中踩过的坑、梳理出的实现思路、还有最终能跑通的方案一次性讲清楚。先说项目背景。我们要在一个OpenHarmony设备上跑React Native业务页面里大量出现富文本——不是简单的“一段文字换个颜色”而是类似“价格数字变红加粗单位小字点击跳转”这种混合样式甚至还要在文本里内嵌文章片段。RN官方文档里Text组件的嵌套写法非常优雅JSX天然表达了富文本树形结构但关键问题是OpenHarmony的原生侧不能直接“翻译”这棵JSX树。普通Text能显示可一旦嵌套层级深了、样式多了、事件加上了各种诡异问题就来了。这篇文章适合谁看正在做OpenHarmony适配的RN开发者、从零搭建RN到鸿蒙技术方案的架构师以及被富文本渲染性能问题折磨到头秃的移动端同学。1. 先把问题摆清楚OpenHarmony上的RN可不是iOS/Android的复制粘贴1.1 为什么RN上Text的“富文本”这么特殊我先给你拆一下RN的Text组件设计思路。在RN里写嵌套Text比如Text style{styles.base} {商品价格 } Text style{styles.price}¥299/Text { } Text style{styles.buy} onPress{handleBuy}立即购买/Text /Text这段代码在你的老朋友iOS/Android上RN会把它构建成一棵ShadowTree影子树TextShadowNode知道自己是“文本容器”它会把子Text全部扁平化成一个原生Text控件内部的属性子节点不是独立原生控件而是变成NSAttributedStringiOS或者SpannableStringAndroid的一部分。这样做的好处非常明显——一个TextView就能承载所有样式和点击区域文本排版自然、性能高、点击热区准确。这套机制看起来天衣无缝但它极度依赖底层平台提供的“富文本字符串”能力。iOS的NSAttributedString和Android的SpannableString都是系统级支持RN只管做映射。可是OpenHarmony这边ArkUI的文本体系是另一套玩法Text组件里可以内嵌Span子组件实现富文本虽然概念上接近但API、属性命名、事件处理模型跟RN的JSX语义有不少差异尤其是“嵌套Text的样式继承”和“点击事件命中区域”这两个细节直接照搬映射逻辑必然出问题。1.2 跨到OpenHarmony之后的变化OpenHarmony的ArkUI底层是自绘渲染引擎跟Android的View体系、iOS的CoreAnimation都不是一回事。RN社区的OpenHarmony适配层React Native for OpenHarmony圈内一般叫RNOH虽然已经能跑基础组件但Text这种带复杂层级和事件交互的组件在映射层要处理的东西比普通View多得多RN JS侧描述的是“树”而ArkUI的Span体系是“数组”需要做树到数组的序列化顺序一错文本就乱。RN的TextStyle是CSS风格的驼峰属性ArkUI的Span TextStyle是另一种结构需要做递归转换。RN的Text支持onPressArkUI里Span的点击事件需要绑定在具体Span上映射时要给每个可点击Span单独挂事件不能整个Text一把梭。字体、行高、对齐方式在ArkUI里以“父Text属性”为主子Span的个别样式可能不生效这在iOS/Android上不存在非常容易踩坑。一句话总结OpenHarmony上做RN Text富文本渲染核心工作是“在适配层实现一套JSX富文本树到ArkUI Span数组的翻译器”同时还得保留RN的扁平化优势不能简单粗暴地每个嵌套Text都生成一个原生View。2. 技术选型用系统能力还是搞映射层2.1 方案对比三条技术路线的取舍我在动手之前把现有可行方案盘了一遍主要就三条路方案核心思路优点缺点A. 纯RN自带组件直接用RN的ViewText嵌套写死代码改动最小富文本层级一深就崩样式不继承性能差B. 用ArkUI原生封装一个富文本组件在OpenHarmony侧写一个自定义组件接收RN传过来的富文本JSON用ArkUI的Span渲染渲染性能和原生一致能把控细节需要自行设计数据协议事件回调要桥接工作量大C. 基于RNOH的组件映射层扩展在RNOH的UIKit映射层里为Text增加Span序列化和递归解析逻辑让RN写法和体验尽量对齐iOS/Android对业务侧透明JSX不变复用RN生态需要深入了解RNOH源码适配层调试困难我最终选了方案C但不是因为C最完美而是因为业务侧诉求很明确RN代码必须同时跑iOS/Android/OpenHarmony三端业务团队不想为鸿蒙单独维护一套富文本JSX写法。方案A肯定不行因为现有页面已经出现样式丢失方案B太重而且要业务侧改写法压根没考虑。选C还有个现实原因RNOH本来就有Text组件的映射实现只是它目前支持的能力只覆盖了基础文本和简单嵌套我要做的不是从零写而是在已有工程上补“富文本翻译”这一层。这样既能借力社区适配层的基础能力又不需要业务侧改动属于性价比最高的路径。2.2 为什么说“扁平化”是绕不开的设计目标提到RN Text很多不熟悉底层的人以为原生侧也是一个一个子控件排下去其实不管是iOS、Android还是鸿蒙的适配层追求的都是“一个TextView渲染整段富文本”。原因很简单原生View太重。假设一段文案里嵌套了10层Text如果每层都变成独立View光布局测量就要多出好几倍开销滚动列表里直接卡成PPT。文本排版需要行级联动。富文本里的字体大小不同基线要对齐如果拆成多个控件行高、基线、换行都很难精确控制用一个原生控件渲染才能保证整段文字像一整段印出来一样。点击热区需要连续。富文本经常是“前半段可点、后半段不可点”多个原生控件拼接很难做到恰好无缝放大镜下能看到1像素缝隙。所以我在设计映射层时核心思路就是一个函数输入是RN的TextShadowNode树输出是一份扁平的Span数组描述把树打平但保留每个Span的样式和事件数据。这个思路后面会展开讲。3. 核心实现让RN Text在OpenHarmony上渲染富文本3.1 整体架构在RNOH的Text组件上做“翻译器”RNOH框架里RN侧的Text组件最终会映射到原生侧一个名叫“Text”的定制组件上这个组件负责接收JS侧通过ShadowTree传递过来的属性、样式和子节点信息。富文本场景下JS侧传过来的不是一个扁平的字符串而是一棵嵌套的ShadowNode树每个节点都可能有自己的style、eventHandler、字符串内容。我的改造思路是加一个“Parser层”位置就在原生侧拿到ShadowNode数据之后、真正创建ArkUI组件之前。流程大致是拿到Text组件的首层子节点数组遍历每一个子节点判断它是普通文本节点、嵌套Text节点、还是其他组件比如图片对嵌套Text节点递归执行同样的解析过程解析的结果统一转成Span描述对象包含字符串内容、TextStyle样式、点击事件标识最后把Span数组一次性交给ArkUI的Text容器容器用Span子组件逐个渲染。这个设计的最大好处是JSX层面的写法一个字不用改业务侧嵌套Text还是那个嵌套Text原生侧却能正确渲染。3.2 核心代码从ShadowNode到Span数组的转换下面这段是我在映射层里写的关键解析函数用TS写了伪代码风格的实现RNOH原生区域用ets/ts混编但核心逻辑可以通用描述// 输入: RN JS侧传来的 TextShadowNode 节点树 // 输出: 扁平化后的 Span 描述数组 function flattenTextTree(node): SpanDescriptor[] { const spans []; const children node.children || []; for (const child of children) { if (child.type TEXT) { // 纯文本节点直接产出 span spans.push({ content: child.content, textStyle: convertStyle(node.style), }); } else if (child.type TEXT_NODE) { // 嵌套 Text 节点递归解析并把父级样式作为继承底色 const inheritedStyle mergeStyle(node.style, child.style); const nestedSpans flattenTextTree(child); for (const span of nestedSpans) { span.textStyle mergeStyle(inheritedStyle, span.textStyle); spans.push(span); } // 如果该嵌套节点绑定了 onPress需要给整个递归结果标记事件 if (child.onPress) { spans.forEach((span) { span.clickable true; span.onPressKey child.eventKey; }); } } else { // 其他内联组件比如图片先跳过处理 } } return spans; }这段代码看起来简单但有两个非常关键的细节样式继承顺序和事件作用域。RN里嵌套Text的样式规则是“子样式覆盖父样式”但“覆盖”不是清空父样式而是合并文字颜色、字体大小这种属性子级可以改但如果有未设置的属性必须继承父级的值。我在刚开始就漏了这一步导致父级设置的字号在子级没设置时变成了默认值文字大小错乱。关于事件作用域RN里嵌套Text的onPress是绑在整段子Text上的点击子Text范围内的任意字符都要触发包括空格和标点。而ArkUI的Span事件是Span粒度的如果我把嵌套Text直接拆成多个Span就得保证这些Span都绑上同一个事件。3.3 样式转换表RN TextStyle到ArkUI Span样式的映射RN的TextStyle有二十多个常用属性我给项目整理了一份转换对照表这是排查问题时的“字典”至少有代码可查RN属性ArkUI侧Span属性注意事项colorfontColorCSS属性值是字符串色值直接透传fontSizefontSizeRN传入的是numberArkUI也是number无须转换fontWeightfontWeightRN是string或numberArkUI用的是枚举/string需要归一化处理fontStylefontStyleRN是italic/normalArkUI是Italic/Normal注意大小写lineHeightlineHeightRN的lineHeight是numberArkUI也支持number但要注意基线差异letterSpacingletterSpacing部分版本ArkUI的Span不支持需要降级处理textDecorationLinetextDecorationRN是underline/line-throughArkUI是枚举对象需要映射textAlign父Text属性Span不支持单独对齐必须上提给父容器fontFamilyfontFamily如果用的是自定义字体要确保字体已经注册到系统层不止一次出现过这种情况业务代码里写了个textDecorationLine: underlineiOS上是下划线鸿蒙上直接光秃秃没反应。后面查下来是ArkUI的span下划线属性名跟RN完全不一样转换表里没覆盖到。所以我的建议是凡是接到这个需求的团队优先把这张表建立起来并且把“不支持”的清单也列出来否则每一个小属性都是一个隐藏的雷。3.4 递归解析的边界情况嵌套层级多深会炸RN文档里说Text可以任意嵌套但从工程角度说递归处理不是无限深的。我实测过几种场景嵌套5层正常性能无明显下降。嵌套10层渲染时间明显增加列表滚动出现轻微掉帧。嵌套15层以上内存占用开始上涨极端情况下会出现栈溢出虽然RN业务很难写出这种代码但富文本是后端下发的保不齐。为什么嵌套深了性能会崩因为我的转换逻辑是递归实现每一层都要做样式合并而样式合并是对象深拷贝操作嵌套越深重复拷贝的次数越多。这个问题的优化手段是“样式继承拍平”子级的样式如果跟父级完全一致就不重复记录只在最终生成Span时递归查一次而不是每层都拷贝。如果你们接到的富文本数据可能嵌套很深建议加一个保护性逻辑嵌套层级超过某个阈值比如8层时自动把后面的层级拍平以最内层样式为准输出一个Span虽然信息略有损失但至少不会卡死页面。3.5 点击事件与动态样式的落地细节富文本里最常见的交互就是“点这一段触发跳转”。RN的Text用onPress绑在子Text上但OpenHarmony的ArkUI里Span有一个很实用的能力是onClick事件同时可以设置hitTestBehavior来控制命中区域。实际操作中我踩过一个坑如果Span的文本内容太短比如只有一个字“看”加上文字周围没有padding点击热区可能非常小用户手指点上去经常没反应。我的解决方法是给可点击Span单独设置一个padding虽然Span的padding在部分版本不生效更通用的方案是给这个Span绑定事件时额外设置一个命中区域扩展或者把父Text设置为可点击然后通过事件坐标来判断是否命中该区域。另外动态更新富文本的场景也值得说一句。比如页面里有个倒计时秒数变化需要局部变红如果整个Text重新渲染会把所有字符串重建一遍性能肯定不行。我的做法是提前把Span拆成“静态部分”和“动态部分”动态区域单独抽成一个函数每次数据变化只更新该Span的content其他Span原样保留。实测下来对列表滚动的性能影响可以忽略。4. 实测踩坑与排查速查表4.1 最诡异的问题文本内容明明在却一个字都不显示这可能是最多人遇到的第一个坑。RN侧Text写得好好的iOS/Android都能显示到OpenHarmony上变成空白既不报错也不闪退。我排查了一整天最后发现是Style里设置了一个不支持的属性组合fontFamily指定了一个没有注册到系统的字体名。ArkUI在遇到不存在的字体时不会回退到默认字体而是直接放弃渲染。所以富文本渲染的第一步一定要核对自定义字体是否已经通过原生侧注册过。如果没有注册最好的办法是在样式转换时做“字体白名单校验”转不了的字体名直接置空让系统用默认字体。4.2 emoji与中文混排为什么换行位置不对富文本里经常有“领券立减50元 立即抢购”这样的文案。在iOS/Android上emoji显示得挺自然但OpenHarmony上我发现两个问题一是emoji显示为方框二是包含emoji的文本在换行时会出现截断。方框问题是因为系统字体里没有对应的emoji字体这个只能通过设备厂商的字体配置解决应用层能做的是确保文本编码是UTF-8且没有乱码。换行问题则是因为Span切分时刚好把emoji的多字节序列从中间截断了修复方式是确保Span切分不会发生在Unicode代理对中间简单说就是遍历字符串时用Array.from或Intl.Segmenter按完整字符切不要用split()。4.3 性能问题一个列表全是富文本滑起来掉帧富文本渲染有个天然矛盾渲染效果越好计算量越大。我在一个信息流列表里实测过每条数据都是20行以上的富文本最开始用Span数组全部重建的方式渲染滑动帧率只有40多帧。优化之后稳定在58帧左右。优化手段有三板斧。第一板斧是“缓存”按内容哈希缓存解析后的Span描述数组内容没变就直接复用第二板斧是“分层”静态内容和动态内容分开渲染动态部分单独刷新第三板斧是“拍平”把嵌套深度优化成最多两层结构避免递归解析在每帧刷新时都执行。4.4 富文本溢出显示不下就截断但用户想看到全部内容还有一类场景是卡片文案长度不确定给Text框设了固定行数超出的部分显示省略号。ArkUI的Text支持maxLines和textOverflow但RN侧的numberOfLines属性在适配层不一定能正确映射。我的经验是在富文本场景下不要把行数控制交给JSX属性而是直接在ArkUI侧设置maxLines同时把textOverflow设置成Ellipsis这样即使后端下发的文案不可控界面也不会被撑爆。如果业务要求“收起的文案点击展开”那就要监听Span的点击事件来动态修改maxLines值并且给Text加一个动画过渡否则展开瞬间很生硬。4.5 点击事件丢失同一个富文本里多个点击区域互相干扰富文本里经常有“同意《用户协议》和《隐私政策》”这种两个可点击区域连在一起的场景。RN上没问题但ArkUI的Span如果相邻第二个Span的点击区域可能被第一个Span覆盖或者两个Span的点击区域重叠导致用户永远只能点到第一个。解决思路是给每个可点击Span分配独立的id和命中区域同时在ArkUI侧的点击事件处理里加一个“按坐标命中判定”。具体代码逻辑是先判断点击坐标落在哪个Span的bounds内再触发展开对应的onPress回调而不是简单地给所有Span都挂同一套事件。4.6 富文本渲染问题排查速查表现象首要排查点次要排查点文本完全不显示字体是否注册样式里是否有不兼容属性是否开启了系统省电模式文字显示但样式丢失转换表里是否有未覆盖属性嵌套层级是否过深继承逻辑是否出错文字重叠、换行错乱Span切分是否完整是否含emoji多字节字符父Text是否设置了不匹配的行高点击无反应事件是否绑定在可点击Span上命中区域是否过小是否存在Span间事件覆盖列表滑动卡顿是否每次刷新全量重建Span数组是否缺少缓存机制嵌套是否过深文字被截断且无省略号是否设置了maxLines和textOverflow是否被父容器裁剪这个表格是我的排查第一现场每一条都对应真实出现过的问题。建议你在开发调试阶段把这个表打印出来贴在工位上绝对能省一半查Bug时间。5. 往深一步富文本组件的后续扩展方向做完Text基础富文本渲染之后有几个方向是可以后续接着啃的。第一个是图片内嵌。富文本里经常有“文字小图标”的场景比如“官方认证V”这种。RN的Text可以嵌套ImageiOS/Android能内联显示ArkUI这边需要把图片映射成ImageSpan组件同时处理好图片大小跟行高的对齐关系。这个功能我做了基础版但图片加载时没有占位逻辑在弱网环境下会出现文字先出来、图片后弹出来的突兀感后续要加上图片加载完成的占位态。第二个是Markdown文本渲染。单纯把富文本JSON渲染出来只是第一步我更希望RN侧能直接用Markdown写文案在映射层解析成Span数组。这样后端只需要下发markdown字符串不需要维护一套富文本JSON协议数据包体积也能小不少。这个方向跟我这次做的“树转Span数组”天然契合相当于把解析源从RN ShadowNode换成markdown AST渲染层完全复用。第三个是TextInput混排。富文本渲染做完了富文本编辑的复杂度直接翻了几倍。RN的TextInput在OpenHarmony上本身就存在光标/焦点适配问题如果要支持“回复某条评论时评论区自动带上对方用户名且不可删除且可点击跳转”那必须在TextInput的底层设置AttributedString这个相比只读Text的Span渲染要麻烦得多建议放到二期再评估。6. 最后说几句实在话这套映射层的核心其实只是一顿“翻译”的功夫把RN的Text树翻译成ArkUI的Span数组真正耗时间的不是代码量而是那些边界情况——样式继承、事件热区、emoji切分、字体回退每一个单独拎出来都像个小蚂蚁咬一口不致命但咬多了真会让人崩溃。我在实际项目里最大的体会是两件事。第一不要试图在JS侧模拟ArkUI的行为你永远模拟不准直接让原生侧拿到最原始的RN树信息用ArkUI自己的Span去渲染才能保证视觉效果跟iOS/Android一致。第二富文本的调试一定要造足够的测试用例颜色、粗体、下划线、换行、emoji、点击事件、嵌套每一样都准备一组固定数据每次改动后全量回归否则今天修好这个、明天弄坏那个就是瞎忙。最后再分享一个小技巧给每一个可点击Span加上一个稳定的“业务标识”比如actionId点击回调统一回传这个ID。这样后续不管是埋点统计、路由跳转、还是切换弹窗都只需要在原生侧写一个分发函数业务侧不用为每一种富文本点击单独绑事件。这个习惯一旦养成后续扩展新交互会轻松很多。
