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: Record<string, 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: Record<string, boolean> = {}; async ensureFont(key: string, path: string): Promise<void> { 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的经历让我体会最深的一点是:组件化的价值不在于"看起来高级",而在于它帮你把复杂度封装在一个可控的边界内,让业务代码回归简单。这个边界划在哪里、怎么划,才是组件设计的真正功力所在。