1. 项目概述1.1 核心背景为什么需要扩展RcTextHarmonyOS 6推出之后ArkUI的文本渲染能力其实已经有了明显提升基础的Text组件在性能上比早期版本流畅不少。但真正做业务落地的时候大家会发现一个尴尬的现实原生Text组件在复杂排版场景下顶多算“够用”距离“好用”还有不小的差距。我接手这个项目是在半年前当时业务方给的需求其实不算复杂——要在内容详情页里承载长文展示支持混合排版、头像嵌入、局部高亮、动态事件响应。听起来像是一个普通富文本需求但真正动手之后才发现坑比想象中深。ArkUI自带的Text组件虽然支持Content字符串拼接和Span子组件但它的能力边界非常明显段落级别的样式定制能力薄弱行内元素的生命周期管理粗糙事件系统对于文本片段的点击响应也不够精细。说白了原生Text组件适合“显示”不适合“承载业务逻辑”。如果你需要在文本里嵌入自定义组件、做复杂的交互反馈、或者渲染非规则布局硬用原生组件堆需求代码会迅速腐化成一团乱麻。1.2 组件扩展能力的设计目标既然原生组件不满足那就自己动手扩展。项目代号就叫RcText取的是“Rich Custom Text”的意思。半年下来这套组件已经从最初的文本展示工具演进成一个支持多段式排版、动态图文混排、精细交互控制的富文本渲染框架。这套扩展能力核心解决三个问题第一让文本的每一个段落都具备独立的样式和布局能力而不是整篇文本套用一个全局样式第二让文本内部可以嵌入真正的自定义组件不局限于图片还包括头像、按钮、进度条等任何ArkUI可渲染的元素第三让文本的点击、长按、滑动等交互可以精确到字符级别同时不影响外层列表的滚动性能。适合谁看如果你正在用HarmonyOS做内容类应用或者正被ArkUI文本组件的排版能力卡住这篇文章会对你有帮助。我会把整个扩展思路、核心实现、踩坑记录都摊开讲包括一些细节参数的推导过程尽量做到可以直接参考。1.3 半年时间都花在了哪里很多人好奇一个文本组件为什么要做半年。说实话如果只是做一版能用的封装两周就够了。这半年的时间主要花在了三个方向上性能调优、状态同步、边界处理。性能上的挑战来自ArkUI的渲染机制。文本组件如果承载大量行内元素每次状态刷新都可能导致整段文本重新布局。我在前期原型阶段就发现包含三十个以上动态组件的长文滑动帧率会掉到明显可感知的卡顿。后面花了大量时间做局部刷新和渲染优先级优化。状态同步则是另一个深坑。文本组件里有交互元素时组件自身的状态和文本容器的状态需要保持同步。比如一个行内的可点击按钮点击之后要改变按钮自身的显示状态同时还要通知文本容器刷新布局。两者之间的通讯机制设计不合理就会出现状态不同步或者多余的布局计算。边界情况更是磨人。emoji的宽度计算、不同字体大小下的行高一致性、极端窄屏下的换行逻辑这些细节不考虑到位组件一上线就会被用户教做人。这半年的过程让我深刻体会到文本组件的复杂度不在“显示文字”本身而在“文字之外的一切”。2. 整体架构设计与技术选型2.1 为什么不用WebView方案在立项之初团队内部其实有过一轮方案讨论。当时的主流选项有三个直接用原生Text堆需求、套一个WebView加载HTML、自研文本渲染方案。WebView方案第一时间被我否了。原因很直接内容应用对启动速度和内存占用极其敏感WebView在HarmonyOS上的初始化成本偏高一个WebView实例的内存占用轻松吃掉几十MB。而且WebView渲染的文本和原生控件的交互有天然隔阂想在文本里嵌一个原生按钮需要桥接层来回通信性能和开发效率都是灾难。原生Text堆需求的方案我们也尝试过原型验证。用Span子组件可以勉强实现行内元素的插入但Span的能力非常有限它只能承载文本和图片不能承载ArkUI的自定义组件。而且当段落数量超过一定规模Span嵌套层级深了之后代码的可维护性基本归零。所以最后我们选择了自研方案基于ArkUI的Canvas或者Flex布局自绘文本排版层配合原生组件做交互层。这个方案的灵活性最高后续的所有扩展能力都可以在这个架构上生长出来。2.2 渲染层选型为什么选择自绘方案这里需要展开讲讲渲染层选型的细节。HarmonyOS的ArkUI提供了两种自绘路径Canvas自绘和Flex容器内文本布局。Canvas自绘的优势是渲染效率高所有文本绘制在画布上布局计算完全由自己控制。但它的问题也很明显——文本里的动态组件没法直接画进去。你可以在Canvas上画出一段文字但是这段文字对应的坐标位置要嵌入一个按钮组件就需要按钮组件以悬浮层的方式覆盖在Canvas上方坐标对齐的精度和滚动跟随的流畅度都是难题。Flex容器方案则是另一种思路声明一个Flex容器容器内的每一个文本片段和可交互元素都作为一个子组件。这样做的好处是ArkUI的布局系统帮我们处理了对齐和换行组件可以真正嵌入到文本流中。但代价是当文本片段数量多时Flex的递归布局计算成本会急剧上升。最终我们采用了混合方案段落级别的排版用Canvas自绘来保证性能段落内部的行内元素用Flex布局来保证灵活性。大方向上是多段落的长文用Canvas做整体排版和滚动时的性能兜底当某个段落内部需要嵌入交互组件时该段落单独切到Flex渲染模式。这种架构下两种模式的切换由组件内部自动管理上层开发者只需要配置段落类型。注意这个混合架构的切换逻辑是整个项目中最容易出Bug的部分。Canvas段落和Flex段落之间的高度衔接、滚动位置同步、焦点管理每一处都需要精细处理。我建议在架构初期就把段落类型接口设计好不要等开发到一半再回头加。2.3 数据模型段落驱动的内容结构RcText的核心数据模型不是字符串而是段落对象数组。每个段落对象包含若干关键字段内容文本、样式配置、行内元素列表、交互事件配置、渲染模式。interface RcParagraph { // 段落唯一标识 id: string; // 当前段落的渲染模式canvas 或 flex renderMode: canvas | flex; // 文本内容 text: string; // 段落级样式 style: RcParagraphStyle; // 行内元素列表 inlineNodes: RcInlineNode[]; // 段落级事件 event?: RcParagraphEvent; } interface RcInlineNode { // 元素类型image、custom、text type: image | custom | text; // 在该段落文本中的起始位置 offsetStart: number; // 在文本中的结束位置 offsetEnd: number; // 自定义组件的构建工厂 componentBuilder?: () void; }这个数据模型的设计参考了前端富文本编辑器里常见的“行内元素嵌入”思路——一个文本段落不再是一串平铺的字符而是一个有序的节点集合。字符和组件共存于同一个流式布局坐标系里。这样做的好处是上层业务传入内容时只需要描述“这一段文本里哪个位置有什么东西”剩下的换行、对齐、溢出处理全由RcText统一处理。我见过不少团队在类似场景下选择用正则表达式去解析文本里的占位符然后替换成组件。这种方案在前期的确省事但文本一长、占位符一多偏移量的计算就很容易出错。尤其是当用户动态更新文本内容时正则解析的结果和实际渲染位置会产生偏差。用结构化数据模型从根源上规避了这类问题。3. 核心能力详解段落体系与行内元素扩展3.1 段落级样式系统RcText把样式从“全局”下沉到了“段落”这是它和原生Text组件最本质的区别。在原生Text里fontSize、fontColor、lineHeight是所有文本统一生效的在RcText里每一个段落都可以有独立的字体、字号、行高、字间距、段间距。interface RcParagraphStyle { fontSize: number; fontWeight: number; fontColor: string; // 行高倍数默认为 1.5 lineHeightMultiple: number; // 段间距默认为 0 paragraphSpacing: number; // 缩进 firstLineIndent: number; // 对齐方式 textAlign: start | center | end | justify; }段落级样式最大的应用场景是资讯类内容的排版。正文段落、标题段落、引用段落、图片说明段落通常都有各自不同的字体大小和间距设置。用原生Text组件时我们往往要拆成多个Text组件分别控制样式然后手动对齐间距。而用段落级样式系统只需要一个RcText组件传入一组样式各异的段落即可。关于行高这里有一个值得注意的细节。HarmonyOS上直接设置lineHeight为绝对值在不同字体下会出现文字被裁剪的问题。我们内部经过多轮测试最终采用的是“行高倍数”模型——行高根据当前字号动态计算乘以配置的倍数。这个方案在字体切换和系统字体缩放场景下更稳。段间距和行高之间的配合也需要仔细调参。按我的经验正文段落的段间距设置为行高的0.5倍左右视觉上比较舒服。如果段间距设得太大文本看起来就像被切成了一块一块的独立碎片阅读连续性会被破坏。这类参数我没有直接用固定值而是在数据模型里暴露给上层由业务方根据内容风格自行微调。3.2 行内元素扩展不只是图片讲参数讲架构可能还是有点抽象。回到实际场景RcText的行内元素扩展最让我满意的能力是段落内部可以嵌自定义组件组件会像字符一样参与文本流的排版。举个具体的例子。在内容和社区类应用里经常会遇到“文本中提到一个用户点击头像可以跳转个人主页”的需求。原生做法是把头像和文本拆开布局通过计算坐标让头顶在文本的某个位置。这样做的痛点非常明显屏幕宽度变化、字体大小变化时头像的位置就会对不齐需要重新计算。RcText的做法是在文本流里声明一个用户头像组件节点它和文字一起参与换行和布局。头像的垂直对齐方式可以通过配置指定比如基线对齐、居中、或顶部对齐。这样做的好处是无论用户的系统字体怎么调整头像和文字的相对位置关系都不会被破坏。const paragraph: RcParagraph { id: user-info, text: 张三 在评论区里说今天天气不错。, inlineNodes: [ { type: custom, offsetStart: 1, offsetEnd: 2, // 用户头像组件可点击跳转 componentBuilder: () buildUserAvatar(zhangsan) } ] }头像只是一个典型场景实际上行内元素还可以是任何类型的自定义组件。我在这半年里陆续在RcText上接入过文本内的进度条、投票按钮、状态标签、小图表、甚至是可折叠的摘要卡片。只要ArkUI能渲染的组件理论上都能通过componentBuilder嵌入到文本流中。需要说明的是行内元素的布局参与者类型还包括一种“占位型”节点。这类节点不渲染任何内容只占据一定宽高用于排版时将后续的文本推到下一行。占位型节点在做首字下沉、文本旗帜标识时很有用。3.3 行内元素的尺寸协商与布局缓存行内元素的尺寸协商是本组件最关键的内部机制之一。一个自定义组件在嵌入文本流之前组件本身的尺寸是未知的需要先测量再根据测量结果参与换行计算。我采用的测量策略是行内元素的宽度和高度由元素自身声明文本布局引擎在排版前先收集所有行内元素的尺寸信息。对于尺寸不固定的元素比如需要异步加载的图片布局引擎在计算该行时会先预留一个占位尺寸等真实尺寸加载完成后通过局部刷新机制修正该行的换行结果。这一块踩过一个比较深的坑当文本段内容纳多个尺寸不同的异步图片时如果每张图片加载完成都触发一次段落重排用户会看到文本在频繁跳动严重影响阅读体验。后来我设计了一套“布局缓存”机制——每个段落根据宽度缓存最终布局结果只有宽度变化、内容变化、或显式调用刷新接口时才会触发重新布局。图片加载完成后先判断当前缓存布局中该行的剩余空间是否能容纳真实尺寸如果能容纳就不触发整段重排。这个优化看起来不起眼但对滚动流畅度的提升非常显著。纯文本长文场景下布局缓存的命中率基本在95%以上意味着绝大多数滚动帧都不会触发布局计算。4. 事件系统设计与交互处理4.1 字符级点击与区域命中RcText的交互能力走的是“区域命中”的路线每一个可交互元素定义了自己的可点击区域组件把点击事件分发给命中的元素。这里需要放弃一个惯性思维——不要把事件绑定到具体的Text组件或自定义组件上而是在文本布局结果之后再统一处理。布局引擎计算出每个元素的坐标矩形后事件系统维护一张“命中区域表”。用户点击时系统把触摸点坐标映射到文本坐标系遍历命中区域表找到命中的元素触发对应回调。这个设计的优势有两个。第一事件命中不依赖组件自身的触摸事件避免了ArkUI原生组件事件冒泡导致的性能损耗第二文字片段级别的局部高亮可以轻松实现因为高亮状态本质上是命中该区域时同步刷新一个样式标记位。interface RcParagraphEvent { // 段落内某段文本的点击事件 onTextClick?: (range: TextRange) void; // 行内元素点击事件 onInlineNodeClick?: (node: RcInlineNode) void; }4.2 手势冲突处理文本滚动与组件事件的共存在滚动容器内使用含有可交互元素的文本组件时手势冲突是一个绕不开的问题。简单的场景——文本里的按钮需要响应点击同时外层列表需要响应滑动——就足以让新手团队头疼。RcText的策略是给可交互元素定义一个“长按触发滑动、短按触发点击”的判定窗口。按下时系统先不拦截手势给滚动容器一个响应机会如果在约80ms内没有产生明显的滑动位移判定为点击目标元素如果位移超过阈值交给滚动容器处理。这个逻辑说起来简单但调参过程比较磨人。判定阈值太小用户正常滑动时容易误触发行内组件阈值太大用户点击行内元素时感觉“迟钝”。经过反复测试我最终将位移阈值设定为6dp时间阈值为80ms在误触率和点击响应速度之间取得了不错的平衡。提示这个阈值会受屏幕尺寸和用户操作习惯的影响我给出的参数适用于通用场景。如果你的目标用户是平板用户或者大屏用户建议将位移阈值适当放大因为大屏上的滑动距离通常更大。4.3 异步事件场景下的状态同步行内组件的交互往往伴随着异步操作。用户点击文本里的“关注”按钮按钮进入加载状态请求结束后变为“已关注”。这个过程涉及到组件自身的状态更新也涉及到文本布局是否需要重排。我在RcText里约定了一个原则行内组件的异步状态变化如果可以保持原尺寸只通知组件自身刷新UI不触发文本重排如果尺寸发生变化必须先走布局引擎的测量流程再触发局部刷新。举一个实际会发生尺寸变化的场景文本里嵌入了“展开全文”按钮点击后按钮文本变成“收起”文本内容增加。按钮本身尺寸变化不大但后续段落的内容量发生了变化这时就必须触发重排。为了避免异步状态回调和组件销毁产生竞态条件行内组件在销毁时统一执行一个dispose钩子清理未完成的异步任务。这个钩子的实现不在本文展开但建议你务必重视否则组件频繁创建销毁的应用里闪退不离十是异步回调访问了已释放的组件实例。5. 实操过程从零搭建RcText核心模块5.1 基础文本排版的实现步骤第一步定义段落数据模型和行内节点接口确保业务层的富文本内容能够映射为结构化数据。这是整个扩展的地基。第二步搭建文本布局引擎。布局引擎接收段落数组和容器宽度输出每个文本行的渲染坐标和尺寸。对于纯文本段落这一步可以参考ArkUI的Text组件内部测量逻辑对于包含行内元素的段落需要实现一个流式布局把行内元素按顺序放入当前行行宽不够则折行。第三步实现段落渲染器。纯文本段落渲染到Canvas上借助CanvasRenderingContext2D的fillText接口绘制文本携带行内元素的段落用Flex容器渲染内部混排文本片段和组件。第四步实现视觉映射和命中测试。布局引擎在计算每行文本坐标时同步保存每个文本片段和行内元素的坐标矩形形成命中检测表。第五步接入事件系统。点击坐标映射到文本控件本地坐标遍历命中检测表触发目标事件。// 一个最小可行的段落渲染伪代码 function renderParagraph(parag: RcParagraph, width: number): void { if (parag.renderMode canvas) { drawTextOnCanvas(parag.text, parag.style); return; } // flex模式遍历文本和行内元素构建子组件树 const children []; let cursor 0; for (const node of parag.inlineNodes) { // 标签位置之前的文本片段 if (node.offsetStart cursor) { children.push(buildTextSpan(parag.text.substring(cursor, node.offsetStart))); } // 嵌入的行内元素 children.push(node.componentBuilder()); cursor node.offsetEnd; } // 收尾文本 if (cursor parag.text.length) { children.push(buildTextSpan(parag.text.substring(cursor))); } renderFlexContainer(children); }5.2 性能调优渲染优先级与局部刷新模块跑通之后性能调优是重点。直接说结论文本组件的性能瓶颈不在于渲染时的绘制耗时而在于布局计算的触发频率。布局计算一旦被频繁触发即使是性能不错的设备也会出现明显的卡顿。我采用的第一个策略是渲染优先级。长文加载时优先渲染前两屏的内容屏幕外的段落延迟到滚动进入视口附近再渲染。这个策略能显著降低首帧渲染耗时。具体实现上监听滚动位置和段落缓存区的上下边界对比动态地创建和销毁段落渲染器。第二个策略是局部刷新。段落的布局结果被缓存后当某个行内元素尺寸变化只重排该段落不影响其他段落。如果尺寸变化导致的换行影响到后续段落的起始位置再级联触发后续段落的布局更新。这里需要注意级联更新要限制深度避免屏外段落被无谓重排——我们内部约定最多级联更新一个屏高范围的段落。第三个策略是文本绘制缓存。对于纯文本段落特别是渲染内容完全没变化的段落把绘制结果缓存成位图每次滚动时直接快速绘制缓存位图。实测下来这个优化让长文列表的滚动帧率从约40帧提升到稳定的60帧。5.3 兼容性与异常降级策略自绘方案的副作用是和系统文本渲染能力存在一定差异。在极端情况下比如系统字体特殊、无障碍模式开启自绘渲染可能表现异常。RcText为此设计了一套降级策略检测到系统进入无障碍模式或字体缩放比例异常时自动切换回原生Text渲染段落的简单模式。无障碍模式方面自绘文本最大的问题是屏幕阅读器无法直接读出文字内容。对于内容类应用无障碍是不可妥协的硬性要求。RcText在无障碍模式下每个段落会在外部挂一个隐藏的原生Text节点用于无障碍焦点读取同时关闭Canvas的绘制改为显示原生文本。这套降级策略耗费了不少开发时间但从结果看非常值得。它保证了组件在系统边界情况下的可用性也方便我们在问题排查时确认渲染异常究竟是组件缺陷还是系统特殊情况。6. 常见问题与排查技巧实录6.1 文本默认高度异常与长文本截断问题Q1行内嵌入了自定义组件后该行的文本垂直位置偏上或者偏下和相邻行对不齐。A这是行内元素垂直对齐参数缺失导致的。RcText对行内元素提供三种对齐模式基线对齐、中线对齐、顶部对齐。默认使用基线对齐但自定义组件的基线需要显式声明或者自动测量。建议在行内元素节点上增加一个verticalAlign字段开发时显式指定。Q2长文本加载时首屏白屏时间较长用户等待体验差。A排查首屏的布局计算耗时一般问题出在首屏渲染时全量计算了所有段落。正确的做法是只计算视口范围内的段落布局其他段落延迟到可见前一屏距离时再计算。RcText内部实现了一个预加载距离参数默认设定为一个屏幕的高度按需调整即可。Q3滚动时文本偶尔出现“跳动”或者“闪烁”。A优先检查布局缓存是否在滚动期间被意外清除。一个比较隐蔽的触发点是滚动监听里调用了refresh()接口很多开发者为刷新某个状态而直接调用了整段刷新。建议对刷新行为做具体定位——只刷新需要变化的那一段落。6.2 内存增长排查与异步回调崩溃关于内存文本组件里最经典的内存泄漏场景是行内组件未正确释放。RcText的组件工厂每次布局时会创建新的组件实例如果布局被频繁触发而又没有销毁旧的组件实例内存就会出现持续增长。我们内部制定了严格的实例管理规范行内组件的创建必须经过componentManager统一管理布局重算时旧实例统一销毁再由工厂创建新实例。如果你在自行实现时绕过了这个管理器一定要在段落析构时手动释放行内组件。异步回调导致崩溃的问题本质上和事件注册/反注册时序有关。一个行内组件被销毁后如果它发起的网络请求在销毁之后才返回回调就会尝试访问一个已经释放的对象。解决方法是给所有异步回调增加一个当前组件的存活校验最简单的方式是用一个生命周期令牌token每次创建时递增回调携带创建时的令牌握手失败则丢弃回调。6.3 行内元素尺寸测量偏差如果你在实现时发现嵌入的组件尺寸和预期不符排查顺序建议是先检查组件的测量是否在布局阶段正确执行再检查容器是否设置了正确的尺寸约束。RcText要求行内组件实现一个幂等的measure接口在布局计算前后各调用一次确保测量结果的确定性。尺寸偏差还有一种常见来源组件内部的边距padding和边框border被遗漏在测量范围之外。在ArkUI的布局体系里外层容器计算子组件尺寸时使用的是内容区尺寸如果组件的padding没有被算入总宽高就会导致文本换行结果和视觉效果不一致。6.4 详细排查思路参考表现象可能原因排查重点文本首屏加载慢布局计算全量执行检查段落预加载机制滚动掉帧滚动过程中触发重排检查布局缓存命中率行内组件位置偏移垂直对齐参数缺失检查inlineNodes参数点击区域不匹配命中检测表未更新检查是否在刷新后重建index全局字体变化后排版错乱行高模型未适配检查相对行高参数动态组件销毁后崩溃异步回调未清理检查生命周期令牌7. 经验总结与后续展望7.1 这半年踩坑后沉淀的方法论回看整个项目周期如果让我给正在做类似组件的团队提几个建议首当其冲的是数据模型一定要在设计阶段反复权衡它是整个组件的地基。我前期在数据模型上返工过三次每一次返工都意味着渲染层、事件层全部推倒重来。第二性能优化不要赶在功能开发之前做。先保证功能逻辑正确、数据流清晰再上性能工具定位瓶颈。过早优化会让你在架构不稳定的阶段浪费大量时间而且优化的结果在需求调整后往往要进行大规模返工。第三事件的命中机制越早设计越好。交互能力是富文本和高性能文本的分水岭。很多团队在开发文本组件时先做完布局和渲染最后才考虑事件导致事件系统只能通过遍历组件树的方式蒙混过日。这种做法在复杂交互场景下会迅速碰壁。7.2 后续可能的演进方向RcText目前的架构已经能支撑绝大多数富文本需求。后续我想尝试的方向有两个一个是文本排版和AI能力结合——根据语义内容自动调整段落样式和排版规则另一个是组件级别的动态主题切换——通过更新样式配置让整个文本渲染风格实时切换而不需要重建文本内容。距离终极形态还有不少距离但有了这套地基后续的生长会顺畅很多。这半年最大的收获倒不是组件本身的能力有多强而是让我重新理解了文本渲染这件事——它拼的不是“画字”的效率而是“如何让文字承载更复杂的世界”。
阅读完成 · 觉得有帮助?