鸿蒙ArkUI自定义文本组件RcText的架构设计与实践

📅 发布时间:2026/10/9 6:56:40
鸿蒙ArkUI自定义文本组件RcText的架构设计与实践
1. 为什么系统Text组件撑不住——RcText立项的真实原因做鸿蒙原生应用开发的朋友应该都有同感ArkUI的Text组件在简单场景下确实够用但一旦业务复杂起来它就成了整个页面里最让人头疼的一块。我在某阅读类应用项目里负责内容详情页的迭代从第一版用系统Text做纯文本展示到后来逐步加入关键词高亮、章节跳转、用户提醒、话题点击再到长按选词、复制指定片段——每一个需求砸下来都是在Text组件身上打补丁。打到第四五个补丁的时候整个实现已经绕得没法看了。我印象最深的一次改动是产品要求在正文段落里对特定关键词显示不同颜色同时其中部分关键词可以点击跳转到百科页而另外一些关键词需要长按弹出操作菜单。用当时系统Text的能力我只能通过TextSpan一段一段拼点击事件挂在onClick回调里长按事件挂不上颜色样式靠手工计算String索引切割。结果就是文案一变索引全乱服务端返回的内容结构稍微调整前端切割逻辑就要重写。真正让我决定推倒重来的是另一个特别典型的场景——猫头鹰式布局。文章首字要放大占位后面跟着正常字号排版段落中间还要混排小图标、出版社标记、价格气泡。这些需求用Text的Span接口能勉强拼出来但代码量爆炸可维护性趋近于零。我统计了一下当时那个详情页的Text相关代码有将近2800行里面全是索引计算、颜色分段、事件绑定的硬编码逻辑。于是在项目的第四个迭代窗口我给自己定了一个目标做一个专门的组件用半年时间把它打磨到能在多个业务页复用。这个组件就叫RcText——Rc在项目里最早的注释是Rich Composite后来团队内部更习惯叫它Reusable Component Text。无论是哪种解释它的核心诉求是一致的让文本不再是一行行拼出来的死字符串而是一种可组合、可驱动、可响应事件的文本节点树。立项的时候我先把痛点列了个清单用来约束设计方向文本节点必须支持嵌套组合而不是平铺的Span数组样式必须支持统一注册和局部覆盖不能每个页面各写一套事件必须覆盖点击、长按、触摸反馈且能和页面手势共存内容来源必须支持本地硬编码与服务端结构化数据两种模式组件自身不感知业务只做文本渲染与交互分发这个清单看起来不复杂但真正落地之后我才意识到每一条背后都对应着一整套架构取舍。接下来我会把这几个关键部分逐步拆开讲包括最终的实现思路、代码骨架、我在半年里踩过的比较深的坑以及一些写在文档之外的经验。2. 地基怎么打RcText的渲染架构与Span体系设计2.1 从平铺Span到树形Children的结构转变系统Text组件的Span体系是平铺的一个Text下挂一堆TextSpan每个TextSpan有自己的样式和事件回调。这种模型对付一两段富文本问题不大但要表示段落里嵌套一个气泡气泡里又有一段加粗文字这种层级关系平铺结构就会显得非常别扭。RcText的第一个设计决策是把平铺Span数组改成树形Node树。每个节点都实现同一个接口拥有自己的渲染逻辑和事件处理逻辑。这样段落内嵌图标再内嵌文字就变成了RcParagraph - RcInlineIcon - RcTextNode的树状结构渲染顺序就是树的先序遍历顺序。接口在设计时参考了态渲染的思维方式没有做成传统的继承体系而是把节点拆成两部分描述节点Descriptor和渲染节点RenderNode。描述节点只存数据、样式名、事件绑定不持有任何渲染状态渲染节点由描述节点生成负责实际的测量、布局和绘制。这样做的直接好处是业务层只需要构建描述树渲染树可以复用和缓存服务端下发的新数据来了直接重新构建描述树不需要管理一堆废弃的渲染实例。以下是RcText核心接口的精简版我保留了实际项目中真正跑过的结构删掉了跟具体业务相关的字段// RcTextNode.ts export interface RcTextNode { type: text | icon | paragraph | custom; styleKey?: string; content?: string; children?: RcTextNode[]; onEvent?: (event: RcTextEvent) void; } export interface RcTextEvent { type: click | longPress | touchStart | touchEnd; nodeId: string; pageX: number; pageY: number; }可能有人会问Text组件本身就有TextSpan实现嵌套为什么还要自己造一套接口我的回答很简单TextSpan的事件处理不够细长按只有onLongPress触摸的精细过程拿不到而且它跟Text组件的生命周期绑定太紧一旦要做性能优化后面会讲到你想在渲染层插一脚、做一个自己的缓存池会发现根本无从下手。RcText把数据层和渲染层分离本质上是给自己留了往后优化的操作空间。2.2 样式注册中心让样式从硬编码变成声明式在做RcText之前项目里到处是这样的代码TextSpan(重要内容) .fontSize(16) .fontColor(#FF8800) .fontWeight(FontWeight.Bold)单个看没什么问题但整个页面二三十处不同样式的文本每处都要重新写一遍。后来产品说统一把正文颜色加深一点我花了整整一个下午全局搜索替换。这种体验我不想再来第二次。RcText的解决方案是引入样式注册中心。每个样式以StyleKey为标识注册的时候可以继承基础样式也可以覆盖部分属性。实际渲染的时候节点只存styleKey不存具体样式值。层叠关系通过mergeStyle实现节点自身的样式优先级高于注册样式。// RcTextStyleRegistry.ts export class RcTextStyleRegistry { private static styles: Recordstring, RcTextStyle {}; static register(key: string, style: RcTextStyle) { const parent style.extend ?? ; const merged parent ? { ...RcTextStyleRegistry.styles[parent], ...style } : { ...style }; RcTextStyleRegistry.styles[key] merged; } static get(key: string): RcTextStyle { return RcTextStyleRegistry.styles[key] ?? RcTextStyleRegistry.styles[default]; } }这个机制往小里说解决了样式复用往大里说其实是在为动态换肤和主题切换铺路。后来做夜间模式的时候我只需要在切换主题时重新注册一套样式值页面所有RcText会自动跟着变不需要改业务代码。2.3 首版组件封装的代码骨架组件入口沿用了ArkUI自定义组件的标准封装方式对外暴露的接口尽量简单。业务方使用RcText大概长这样RcText({ nodes: this.articleNodes, styleSheet: this.styleSheet, onEvent: (event) this.handleTextEvent(event), selectable: false, maxLines: 0 })内部实现的渲染核心是一个buildContent方法负责把描述节点树转换成实际渲染用的布局组件。因为ArkUI目前的Text组件还不能完全脱离Span体系所以RcText内部仍然会用Span但对外屏蔽了这些细节业务方感知不到。Builder private buildContent() { Text() { // 递归将RcTextNode描述树转换成Span树 this.buildNodeRecursive(this.nodes); } .textAlign(this.alignment) .textOverflow({ overflow: this.maxLines 0 ? TextOverflow.Ellipsis : TextOverflow.None }) .maxLines(this.maxLines) }这里我要多说一句架构上的领悟对外暴露树形描述结构、对内转成平铺Span其实是一个双向的适配层。描述树是为了让业务方构建时更自然Span树是为了让底层渲染不失控。未来如果ArkUI推出了更丰富的原生文本渲染接口我只改适配层就能平滑升级业务代码完全不受影响。这类接口友好实现封闭的思路在组件设计的早期就应该定下来否则后期换渲染引擎的时候会被动很多。3. 组合应用专场RcText在复杂业务场景中的落地姿势3.1 段落内混排文字、图标、气泡及事件联动RcText的第一个实战场景是详情页里那段曾经让我头皮发麻的猫头鹰式排版。需求大概是这样首字放大占位、正文正常字号、段落中间嵌一个小图标表示正版授权、句末加一个出版社气泡标签。所有这些元素合起来要能在一行内自动折行、断行规则正确并且点击图标和气泡要有不同的响应。用RcText做这个需求描述树长这样const nodes: RcTextNode[] [ { type: paragraph, children: [ { type: text, styleKey: firstLetter, content: 一 }, { type: text, styleKey: body, content: 个夏日的午后 }, { type: icon, styleKey: licenseIcon, content: icon_license }, { type: text, styleKey: body, content: 我在旧书店里 }, { type: text, styleKey: bubble, content: 签名版, onEvent: (event) { if (event.type click) { // 弹出签名版说明浮层 } } } ] } ]这里有个细节值得展开图标不是用图片文件实现的而是直接用字体图标字符。type: icon节点本质上还是文本节点只是走的是iconfont字体。这样做的好处是排版基线对齐容易处理而且图标可以参与文字选中、复制等系统行为。如果用Image作为行内元素基线对齐和折行逻辑会非常难搞这是我一开始没想明白、后面踩了坑才转过来的。混排事件的命中测试也是一个容易翻车的地方。RcText里每个文本节点都有独立的onEvent底层实现是把节点对应的Span加上onClick然后通过nodeId回调到业务层。这里要特别注意点击事件和长按事件不要直接绑定在同一个Span上因为ArkUI对手势的判定顺序在某些场景下会有冲突。我在RcText内部做了一个事件仲裁层把各节点的事件统一收集起来由组件自己决定分发策略业务方拿到的永远是一个干净的事件对象。3.2 服务端结构化内容驱动从JSON到文本节点的映射另一个高频场景是服务端下发富文本内容。做内容类App的都知道服务端返回的正文不可能只是纯字符串通常是带标记的JSON结构。RcText在项目里定义了一个轻量的JSON渲染协议服务端只需要按约定输出节点结构前端直接用RcTextParser解析并渲染。协议大概长这样{ type: paragraph, children: [ { type: text, styleKey: body, content: 这本书讲述了 }, { type: text, styleKey: highlight, content: 硅谷往事, action: openDetail }, { type: text, styleKey: body, content: 背后的技术变迁。 } ] }前端解析器做的事情很单纯把JSON转成RcTextNode数组。关键是协议要约定action字段的语义这部分是业务规则RcText组件本身不处理只负责把事件原样抛给页面层。我在项目里维护了一份协议文档每新增一类节点结构都要先在文档里补充约束再动代码。这个习惯在后期帮了大忙因为一个富文本协议一旦被多个页面依赖字段语义不清就会出大乱子。这里我想特别强调一个实践经验不要试图在RcText里实现所见即所得的编辑器。渲染器只需要做到能展示、能响应事件就够了。编辑器涉及光标、选区、输入法那是另一个复杂度数量级的问题混在一起会让组件失控。如果业务需要编辑功能建议思路是编辑器和渲染器共用同一套描述结构但实现上完全分离。3.3 长按选区与复制RcText如何弥补系统文本的短板系统Text在selectable上的能力在较长一段时间里都比较有限尤其是部分文本可选、部分不可选、部分可复制但不可选这种精细化控制原生支持很难满足。RcText在这方面做了一个分层策略每个节点在描述阶段就可以声明selectable属性渲染时会把这个标记透传到选区计算逻辑中。具体实现上RcText没有自己从零写选区算法而是加了一个透明覆盖层组件根据文本布局结果计算出每个节点的边界矩形然后把这些矩形交给一个自定义的手势层去处理长按选词。这样做的成本比想象中低但效果却非常实用。我举个具体的项目例子在合同类文档页面甲方名称、乙方名称、金额数字这些关键信息需要支持长按复制但中间的条款正文不允许复制防止误操作。这个需求如果用系统组件做几乎无解。RcText只要在对应节点上设置selectable: false渲染层就会把那段文字排除出可选中区域长按时选区只能落在允许的节点上。3.4 与滚动容器和Navigation的协同工作组件如果在页面上单独渲染问题通常不大。真正的麻烦在于把RcText放进Scroll、List或Navigation体系里这时候手势冲突和布局测量问题就会浮现出来。我遇到过一个比较隐蔽的问题当RcText放在List的ListItem里时列表滑动时偶尔会触发文本节点的高亮反馈手指稍微一抖就会误判成点击。定位后发现这是触摸事件穿透导致的。RcText在touchStart的时候记录了坐标touchEnd的时候计算位移量只有位移小于5vp才判定为点击超过则让列表滑动继续。这个判定逻辑类似于可滑动区域内按钮的点击容错是所有可滚动容器内交互组件的必修课。另外在Navigation页面切换时RcText如果持有一些异步加载的字体文件引用需要在页面onPageHide时做释放处理。这个坑我后面还会细说这里先记住一个原则组件不要持有页面上级对象的强引用所有依赖都应该通过参数传入否则页面销毁时容易出现内存回收不及时的问题。4. 半年磨一剑我踩过的坑和性能调优全过程4.1 白屏与幽灵文本一次字体加载引发的渲染事故RcText支持注册自定义字体详情页里有一类特殊风格引文用的是合字字体。我在接入初期直接把字体文件放在rawfile目录通过FontRegister注册。测试发现冷启动后进入详情页有大约5%的概率出现首屏白字或幽灵文本——文字占位是对的但字形显示不出来过几秒才恢复。排查链路如下先把崩溃和卡顿排除确认纯渲染问题无任何报错日志用分屏对比方法把字体注册时机从页面aboutToAppear提前到EntryAbility启动阶段问题依旧复现进一步做单字体验证把引文字体换成系统字体问题不再出现锁定是自定义字体加载导致最终定位是字体注册的异步回调与页面渲染流程存在竞态字体还没注册完成渲染层已经拿到了要使用该字体的文本节点解决方案是在RcText内部增加一个字体就绪状态管理字体注册完成后通过状态变量通知所有正在等待的RcText实例重新渲染在未就绪期间用一个低沉的本体字体先占位避免白字。同时把常用字体文件的注册提前到应用启动阶段尽量压缩竞态窗口。// RcTextFontManager.ts export class RcTextFontManager { private readyMap: Recordstring, boolean {}; async ensureFont(key: string, path: string): Promisevoid { if (this.readyMap[key]) return; await FontRegister.registerFont({ familyName: key, familySrc: path }); this.readyMap[key] true; } }4.2 列表滚动时的帧率骤降显示对象池与Span缓存策略RcText在LazyForEach列表里做长列表渲染时一开始的帧率表现非常差。文章列表每屏大概能见到四五条内容每条内容里有上百个文本节点滑动时能明显感觉到掉帧帧率测试掉到40帧左右。性能排查我用了两步走。第一步先去掉所有事件绑定跑一遍帧率有所回升但依旧不理想基本排除事件回调开销。第二步用布局耗时打点工具逐个环节测时间发现主要耗时集中在Text重新构建和Span树递归生成上。说明每滑动一个新条目进入视野RcText都在重新构建整棵渲染树白白做了大量重复工作。优化思路是给RcText加了一个显示对象池。Node描述树保持不变的情况下渲染树和Span实例可以被缓存复用。具体做法是把节点描述树计算出一个稳定的hashKey以hashKey为维度缓存渲染结果只有当描述树真正变化时才重新生成渲染树。对于列表场景由于服务端数据基本稳定hashKey不变渲染树复用的命中率高达90%以上。private getRenderCacheKey(nodes: RcTextNode[]): string { // 递归生成节点的稳定哈希忽略事件引用只关注结构与样式 return JSON.stringify(nodes.map(n ({ type: n.type, styleKey: n.styleKey, content: n.content }))); }配合List的cachedCount属性适当扩大预加载范围优化后列表滚动帧率恢复到稳定的60帧。这里我给一个实操建议做列表性能优化时不要一上来就怀疑是RcText的问题先看看List的复用配置是否合理ListItem的布局是否过于复杂。很多掉帧问题其实在列表容器层面就能解决组件的优化是最后一公里。4.3 内存泄漏一个被页面路由引用卡住的RcText实例用DevEco的内存检测工具做一轮全量检查时发现详情页退出后RcText实例没有被及时回收。定位链路的起点是某业务代码在onEvent回调里捕获了页面对象而RcText又被业务层的一个单例管理器持有形成了一个页面 - RcText - 回调 - 页面的引用环。这类问题在事件驱动组件里非常常见。解决的通用思路是组件对外暴露的事件回调只允许接收事件数据不接收页面对象业务层如果确实需要页面上下文在事件回调里临时获取不要长期持有强引用。另外一个更隐蔽的问题是自定义字体。字体注册成功后部分系统版本会把字体资源在框架层缓存如果RcText在onPageHide时没有释放对字体的引用可能出现页面销毁但字体资源迟迟不归还的情况。我的处理是在组件提供releaseResources()方法页面级在onPageHide里显式调用将字体引用和缓存节点一并置空。4.4 版本适配差异同样的代码不同平台表现不同RcText在适配过程中遇到的版本差异主要集中在三个方面第一Span的onClick事件在部分版本上无法响应子级Span的独立点击只能响应整个Text区域。这直接影响了事件仲裁策略的设计——我在早期版本依赖Span级事件后来统一改成RcText内部自行计算点击位置命中节点彻底绕开了系统行为差异。第二textOverflow配合Span时省略号的显隐行为在各个版本不完全一致。有些版本末尾是图标或气泡时省略号不出现有些则直接吃掉末尾内容。这个问题的兜底方案是在RcText构造描述树时主动预估末尾节点宽度超出最大行宽则截断描述树末尾节点而不是依赖系统省略号。第三字体基线对齐在各版本间存在微小偏移导致行内图标偶尔上下跳动几像素。RcText给图标节点提供了baselineOffset属性业务侧可以按版本差异做微调。版本适配这件事我的态度是不迷信系统能力的一致性。凡是涉及文本排版、事件命中的关键逻辑RcText自身一定要有兜底实现系统能力只能作为增强选项不能作为唯一依赖。5. 组件发布前夜API收敛、测试与团队协作5.1 API设计三原则少暴露、能扩展、可降级RcText做了半年最大的收获不只是渲染和性能上的实现而是在API设计上逐渐摸索出了三条原则。少暴露组件对外暴露的属性只有nodes、styleSheet、selectable、maxLines、onEvent这几个。任何新需求先进来先问自己能不能用现有属性组合出来而不是急着加新属性。比如部分文字倾斜完全可以通过注册一个italic样式实现不需要加italicNodes属性。能扩展节点的type支持custom这意味着业务方可以注册自定义节点渲染器。这个设计让RcText不必预知所有业务形态比如后来出现的倒计时文本滚动跑马灯文本都是通过custom节点类型接入的没有改动组件核心代码。可降级RcText内部使用了若干较新的框架接口我在渲染核心外面包了一层能力检测。检测到当前环境不支持某些能力时自动降级为文本拼接渲染保证功能可用只是失去部分交互细节。不能因为一个组件的不兼容导致整个页面白屏。5.2 单元测试与快照回归组件重构的护身符RcText这类组件最怕重构。因为文本渲染的边界条件太多——空内容、全角符号、超长文本、Emoji、混合换行——任何一个改动都可能波及线上场景。项目里给RcText搭了一套基于渲染结果的快照回归测试构造一组覆盖各种边界情况的描述树渲染后把布局结果和关键坐标点导出成JSON快照。每次改动组件代码先跑一遍快照对比差异过大的地方一眼就能看出影响范围。这套机制在后期迭代中帮了大忙好几次看似无害的样式调整都靠快照测试提前发现了布局回归。另外一个值得推荐的做法是把RcText的示例工程单独提出来不跟业务App混在一起。示例工程里维护了几十种文本场景的展示页每次开发联调都直接在示例工程里验证不依赖业务数据。这样既加快了调试速度也避免了业务环境对组件问题的干扰。5.3 文档先行组件库落地的隐形基础设施技术文档这件事很多开发者在组件初期会忽略觉得等稳定了再写。我的经验是恰恰相反——RcText从立项第一周就开始维护一份设计文档每做一个关键决策就记一笔。半年下来这份文档成了团队接手和维护组件最重要的资产。文档里除了API说明还记录了很多为什么这样做的决策背景。比如记录为什么用树形Node而不是平铺Span后续同事遇到类似问题直接翻文档就能理解不需要再找我反复解释。组件从一个人维护变成多个人维护文档先行是成本最低的过渡方式。RcText最终沉淀为一个独立的内部组件模块详情页、话题页、运营配置页、会员中心都有使用。算下来覆盖的线上业务场景有十多个总调用次数到目前已经过亿。但这个数字不是重点重点是它证明了以可组合的文本节点树为核心的设计思路在鸿蒙ArkUI体系里是走得通的。6. 给后来者的一些实在建议半年的时间跨度放到整个项目周期里不算短。做RcText的过程里我反复在要不要用系统原生能力和要不要自己造轮子之间摇摆最后沉淀出几个比较实在的判断标准供参考。第一如果系统组件只是少几个事件回调优先考虑包一层适配而不是重造。RcText之所以值得做是因为当时的需求已经触及了系统Text组件的结构性边界而不是少了某个API。结构性不适配才有重建的价值单纯缺API包一层就好。第二做组件之前先把数据结构和协议定清楚。RcText后来的很多便利都源于早期花了大量时间定义Node描述结构和服务端渲染协议。数据结构稳了渲染、事件、样式体系都会跟着稳数据结构乱了后期九头牛都拉不回来。第三组件性能优化一定等真实场景数据出来再动手。我在早期做了很多预防性优化比如给所有节点加缓存、预渲染整页后来测试发现很多优化在真实场景下根本没有收益反而增加了复杂度。性能优化应该以线上帧率和内存检测数据为准用数据说话不要靠预感。最后如果你的项目里也有一类反复让你绕路的组件需求我的建议是不要急着打补丁花点时间把需求往回收一收抽象成一个更通用的模型哪怕前期多花几周也是值得的。RcText的经历让我体会最深的一点是组件化的价值不在于看起来高级而在于它帮你把复杂度封装在一个可控的边界内让业务代码回归简单。这个边界划在哪里、怎么划才是组件设计的真正功力所在。