ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

HarmonyOS自定义文本组件RcText:从V1到V4的演进与优化实践

HarmonyOS自定义文本组件RcText:从V1到V4的演进与优化实践 在HarmonyOS6这个版本周期里我投入精力最多的一件事就是把一个名为RcText的文本组件从“能用”打磨到“好用”。说实话最初拿到需求时我没太当回事无非是文本展示系统自带Text组件又不是不能用。但真正在业务里铺开后才意识到问题远没那么简单同一块区域要根据接口返回的不同结构拼出五六种样式状态一变整段文案要跟着换风格一个列表页有几十处文本需要动态调整性能还得扛得住。折腾下来这个RcText前后改了四轮光结构性重构就做了两次手工测试跑了几十遍。今天这篇就把我在这个项目里踩过的坑和最终沉淀下来的方案完整梳理一遍。这个组件最终服务了详情页、信息流、日志面板、运营活动页四个业务模块线上运行了大半年没出过严重崩溃。如果你也在做鸿蒙应用正在被复杂的文本展示逻辑折磨或者想看看一个自定义组件从V1到V4是怎么演进的这篇文章应该能给你一些参考。1. 项目背景与整体设计思路1.1 为什么需要专门做一个RcText组件鸿蒙的ArkUI框架里文本展示的基本手段是Text组件配合TextSpan可以做样式嵌套。单看这个能力应付一般的静态文案绰绰有余。但业务场景复杂起来之后我用原生方案遇到了一堆很现实的问题动态拼接麻烦。接口返回的数据不是现成的HTML或者富文本而是三段不同颜色、两段需要加粗、还有一段要变成可点击链接的状态码。用原生Text做这种拼接代码会变成一团乱麻每次需求变更都要改动调用方逻辑。样式不统一。同一套设计规范里的标题、正文、辅助文字、警示文字在不同页面写出来经常是同样的字号和颜色各写一遍后续改设计规范要全局搜索替换风险极高。状态联动缺失。一个文本组件往往需要感知页面主题变化、白天夜间模式切换、字号缩放等系统状态。原生写法下每个页面都要自己监听漏一处就会出现深色模式下的白字白底事故。复用度太低。我看了下当时的代码库文本相关的业务组件散落在二十多个文件里有的复制粘贴了三四遍每次修bug都要改好几个位置。RcText的立项初衷就一条把这个领域内所有重复劳动收拢到一个统一的组件里对外提供简洁的接口对内处理所有和文本渲染相关的脏活累活。标题里的“组合应用”四个字指的不只是组件内部不同文本片段的组合也包括它外层和List、Tabs、Scroll等容器组件的组合。1.2 半年演进路线从RcText V1到V4这个组件不是一拍脑袋写出来的过程相当曲折。整个演进路径我大致划分为四个阶段V1阶段约1个月针对单个业务页面的特殊文案需求写了一个带样式配置项的封装组件。当时的API很粗糙只能传字符串数组每个字符串带一个样式对象。V2阶段约2个月接入第二个业务模块后发现V1的设计无法支持点击事件和局部刷新于是做了第一次重构引入了文本片段的数据模型和状态管理机制。V3阶段约1.5个月这个阶段主要补齐了性能短板。当时在信息流场景里滑动出现了明显的掉帧排查到最后是组件内部状态管理粒度过粗触发了大范围的State变量刷新。V4阶段约1.5个月最后一次重构重点解决的是扩展性和维护性问题。我把样式注册机制改成全局声明式管理业务方可以通过配置文件注册主题样式组件内部不再硬编码任何颜色值。每次回头改自己写的代码都有一种想穿越回去给自己两拳的冲动。但正是这些反复逼着我逐步想清楚了文本组件在设计上到底哪些是核心矛盾、哪些是边缘需求。1.3 设计目标如何定义“好用”在动手写V4之前我拉上组里的同事一起列了一个清单明确“好用”的具体含义。这个清单在项目后期帮了大忙每次纠结要不要加新功能时就拿它做决策依据业务方新增一种文本样式时不需要改动RcText内部代码。这个优先级排第一。支持任意层级的文本组合嵌套但要保证状态更新的精准性。改一个词的颜色不能把整段文本全部重绘。组件对页面其余部分的性能影响可以忽略不计。接口API直观新接手这个组件的同事能在半小时内上手使用。到项目结束时再看这四条基本都满足了。但过程中也放弃了几个看起来很美、实际性价比很低的设计比如像前端那样做完整的Virtual DOM式文本节点树复杂度太高收益并不明显。2. 核心架构与关键机制解析2.1 文本片段的数据模型设计RcText的核心数据结构我把它叫做TextSegment。它代表一段不可再分的最小样式化文本单元。这个设计思路借鉴了字符串协议里Span的概念但在鸿蒙环境下做了一些适配。// 文本片段模型 interface TextSegment { // 实际要展示的文本内容 text: string; // 样式标识指向样式注册表中已注册的样式Key styleKey?: string; // 优先级高于styleKey的临时样式用于特殊场景覆盖 customStyle?: TextStyle; // 点击事件标识 actionId?: string; // 标记是否允许在换行时断开 breakable?: boolean; }这里最关键的设计决策是样式优先用styleKey引用而不是直接嵌入完整的样式对象。业务方在构造文本片段时只需要写{ text: 查看全部, styleKey: link }不用关心这个link在深浅色主题下分别是什么颜色。组件内部在渲染时会对styleKey做一次解析将全局样式和主题样式合并生成最终样式。2.2 样式注册表与主题联动样式注册表是整个组件的中枢。设计上参考了Web前端CSS变量的思路但比CSS变量更简化// 样式注册表 class StyleRegistry { private static defaultStyles: Recordstring, TextStyle {}; private static themeOverrides: Recordstring, TextStyle {}; static registerStyle(key: string, style: TextStyle) { this.defaultStyles[key] style; } static resolveStyle(key: string): TextStyle { const base this.defaultStyles[key] ?? {}; const override this.themeOverrides[key] ?? {}; return { ...base, ...override }; } }有了这个注册表之后主题切换变优雅了。RcText内部通过监听全局主题变化事件拿到最新的主题标识重新执行一次resolveStyle这样就避免为每一段文本单独处理主题逻辑。V1版本里那种在页面代码里直接写this.textColor isDark ? #FFFFFF : #1A1A1A的写法被彻底清出了项目。顺带一提这套机制对设计规范变更特别友好。团队决定把所有的辅助文字从12号字改成13号字时只需要在注册表里改一个值。以往这种改动要动十几个页面现在只动一处上线后基本无感。2.3 组合渲染的方式TextSpan与组件混合策略RcText的渲染层基于原生Text和TextSpan构建但为了支持一些特殊能力比如表情图标、小徽标、内嵌数字角标我会把部分内容抽出来交给额外的子组件渲染。整体渲染策略如下纯文本片段直接构造TextSpan对象由Text统一渲染。带图标的片段在TextSpan的child节点中放置一个ImageSpan这种原生方案对图文混排的效果很稳定。需要自定义绘制的片段这类情况不多早期V2版本专门做过一个CustomPaint嵌入但实测性能不好后来大多数场景用图片图标替代了。组合渲染的难点不在“能组合”而在“如何组合得高效”。最朴素的实现方式是把所有片段顺序拼成一个TextSpan数组但这里有个性能陷阱如果页面有几百个文本片段每个片段都生成独立的TextSpan和样式对象首帧渲染耗时就会明显上升。我的优化策略是把连续相同样式的文本片段在渲染前合并成一个大片段减少TextSpan的数量。比如输入是[{text:H, color:red}, {text:armony, color:red}, {text:OS, color:blue}]在进入渲染层之前会先压缩成[{text:Harmony, color:red}, {text:OS, color:blue}]。这个合并逻辑放在组件的数据预处理阶段不占主线程太多时间但对渲染性能的提升非常显著。3. 组合应用实操四个典型场景拆解3.1 场景一信息流列表中的动态摘要文本信息流列表是RcText最早上线的场景也是问题暴露最多的地方。列表里每一条内容都包含标题、摘要描述、标签组和操作链接而且摘要部分会根据展开状态变化折叠状态最多展示两行末尾显示“...展开”展开状态展示全部文本末尾显示“收起”这个交互如果用纯Text实现需要同时管理两套字符串内容还有一个隐藏的点击判断逻辑。用RcText组合后我把整个摘要区拆成若干个文本片段const collapsedSegments: TextSegment[] [ { text: content.summary.slice(0, maxLength), styleKey: content }, { text: ..., styleKey: ellipsis }, ];展开和折叠的切换本质上是替换RcText的数据源而不是修改组件内部状态。RcText的Prop数据绑定会让它在数据变化后自动刷新UI避免了我手动操作原生Text节点。实际开发中我遇到的一个细节是点击“展开”时不仅摘要文本要变其下方的操作按钮组也需要从“展开”切换成“收起”。这种情况下我把操作链路的文本也纳入了RcText的数据源但这部分文本片段设置了actionId点击后回调给页面处理。这样整个区域的状态切换真正做到了一条数据驱动。实操时建议在信息流列表场景中给RcText做一层轻量缓存。因为用户在列表中快速滑动时很多项的文本内容没变没必要重复生成TextSpan。我在这一层加了基于内容哈希的简单内存缓存命中率的提升对滑动的流畅度帮助很大。3.2 场景二详情页图文混排的富文本结构详情页是RcText V3版本开始支持的场景也是“组合应用”最复杂的一环。页面结构是顶部大图、中间若干段落文字、中间穿插小图或视频标签、底部操作栏。多年Web开发的人第一反应可能是直接上WebView或者原生富文本组件但在鸿蒙应用里综合考虑包体积和性能轻量级自研方案更合适。RcText在这里承载的是正文部分。正文的输入不是纯文本而是从服务端拉取的结构化片段数组每个片段有类型判断interface ArticleBlock { type: paragraph | heading | quote | image | video; content: string; imageSrc?: string; videoUrl?: string; }我把这些结构化类型转换成RcText或配套组件的组合。具体操作上正文里每一段文字生成一个RcText实例图片块和视频块则通过外层容器的when逻辑条件渲染。这样排版灵活也方便做懒加载。值得说的是标题处理。详情页正文有可能由多个标题片段组成不同层级的标题有不同的字号和行距。我的做法是在样式注册表中预注册headline1、headline2两个样式Key然后根据服务端下发的层级信息映射到对应的Key。削掉了V2版本里那种直接在业务代码中写fontSize: 28的做法。3.3 场景三运营活动页的实时数据看板运营活动页是一个相对特殊的场景页面上的数据比如累计参与人数、当前进度值会通过推送或轮询频繁更新更新的同时还要有平滑的数字过渡效果。这个需求涉及的“组合”含义是把静态文案和动态数据组合在一起。RcText的设计中没有直接内置动画能力但提供了onContentChange生命周期回调。我在运营页的实践中利用这个回调驱动了一个独立的数字滚动动画而不是把动画逻辑塞进RcText内部。具体步骤在页面中创建一个RcText数据源包含静态文案当前参与人数和动态数字片段136842。RcText内部在数据更新后触发回调页面拿到旧的数字和新的数字。页面调用一个数字动画工具类将新旧数字做差值递增每帧更新RcText动态片段的数值。动画结束后将最终数字一次性写入并停止刷新。这种做法的好处是动画逻辑可以按业务场景自由替换组件本身保持纯粹的文本展示职责。有的页面可能要缩放动画有的页面可能要闪烁效果如果全部塞进组件RcText会变成一个臃肿的“万金油”。3.4 场景四调试日志面板的多级文本过滤日志面板是我自己日常调试工具的一部分后来被组里同事拿去复用了。它的核心需求是同一屏内展示多行不同级别的日志每行的颜色、图标、背景都不一样同时要支持按级别过滤显示。在这个场景里RcText的文本片段组合能力派上了大用场。每条日志生成一个RcText数据源包含三段第一段时间戳灰色小号字样式Key为logTime。第二段级别标签比如INFO和ERROR颜色按级别区分样式Key分别为logInfo和logError。第三段日志正文支持关键词高亮命中关键词的部分以高亮样式Key独立成段。基于这个结构搜索日志时不需要重新创建组件只需要把日志正文再按关键词切分一次追加高亮片段。实测下来上千行日志的页面在搜索场景下依然能保持流畅滚动。4. 状态管理与性能优化实录4.1 细粒度的状态刷新拆分RcText早期版本最大的性能问题是每次文本数据变化都会导致整个组件树的刷新。这个问题在普通页面不算严重但在信息流列表中一次数据更新可能会连锁触发十几条列表项的State变化最终表现为滑动丢帧。我做的第一个优化是区分“数据模型层”和“渲染描述层”。数据模型层负责保存业务方传入的文本片段数组更新时比较新旧片段的差异。渲染描述层负责生成TextSpan只有差异部分的内容才会触发重新生成。实现上我参考了列表diff的思路但省略了复杂的节点移动识别。识别规则简化成三种情况片段数量变了全量重新生成TextSpan数组。片段数量没变但某个片段的文本或样式变了只替换对应位置的TextSpan。完全没有变化直接返回缓存好的TextSpan数组。这套机制上线后信息流场景的滑动帧率从44帧左右提升到了59帧左右效果很明显。4.2 避免不必要的父子组件通信在用ArkTS的State和Prop连接父子组件时有一个容易忽视的陷阱父组件状态变化会触发子组件重建即便子组件的输入数据完全没变。RcText在V2阶段就踩过这个坑当时详细信息页切换Tab时页面整体状态刷新导致了正文组件不必要的重建。解决方案是把RcText的对外数据接口从Prop改为ObjectLink配合一个封装的状态类。这样父子组件之间的数据流就是引用型的父组件只改字段值子组件能感知变化但不会因为父组件整体刷新而重建。如果你在鸿蒙上做自定义组件建议从一开始就注意状态的绑定粒度。不要图省事把一个对象塞进State那会造成极大的性能浪费页面复杂后排查起来也会很痛苦。4.3 内存优化文本对象池与复用RcText同时在线数量最多的时候是某些详情页里嵌套了多个独立文本区域。因频繁创建和销毁对象GC压力升高明显表现为偶发卡顿。我做了一个简单的对象池预先分配一批TextSpan对象和TextStyle对象。组件销毁时把对象回收到池中而不是直接丢掉。新建文本片段时优先从池中取出可复用对象。这个对象池的命中率其实达不到100%因为不同文本的长度和样式差异很大但即便只有三分之一的对象能复用对GC次数的降低也是可感知的。实测在信息流的压力测试中应用整体的GC暂停次数下降了约27%效果符合我的预期。注意对象池适合频繁创建短生命周期对象的场景。如果文本内容非常稳定对象池的收益不明显反而会增加代码复杂度。要按实际场景决定是否引入不要为了优化而优化。5. 常见问题排查与调试实录5.1 文本不收起省略号失效某个信息流版本上线后测试反馈一个列表项的摘要文字始终不能正确显示省略号展开和收起切换后位置还错乱。排查过程比较有代表性先怀疑Text的maxLines属性设置不对检查后发现maxLines已经设置为2但配合RcText的渲染层后Text实例收到的是一个已经组装好的SpanString对象组件内部对Text的高度没有正确约束。真正原因是列表项在外层使用了Flex布局Text被拉伸后没有收到紧凑的高度。修复方案是给RcText的根节点加一个constraintSize约束显式限制文本展示区域的最大高度并在收起模式下将溢出部分隐藏为省略号。这个教训是自定义文本组件在设计上必须考虑父容器的布局约束不能默认所有场景都能拿到合理的剩余空间。5.2 主题切换后部分文本颜色不更新深色模式切换测试时发现有一部分文本还是深色模式前的颜色其他文本都正常。这个问题困扰了好几天后来定位到是样式注册表里存了带缓存性质的TextStyle对象主题覆盖更新时某些被缓存的样式片段的引用没有被更新。原因在代码层面是我在主题更新逻辑中先更新了themeOverrides但组件内部的样式缓存没有标记失效。修复方法是给注册表增加一个版本号字段每次主题更新时版本号自增组件在渲染前校验版本号不一致就强制刷新样式缓存。这个问题让我认真想了很久最后在RcText的API设计上追加了一个invalidateCache方法专门给这类缓存失效的场景使用也顺手暴露给有特殊需求的调用方。5.3 Tabs页面切换后文本闪一下接入Tabs容器后有用户反馈切换页面时文本会“闪一下”先是短暂空白再显示内容。这个现象高概率和页面懒加载相关。RcText在组件出现在可视区域时才执行首次布局而此时界面已经进入过渡动画状态空白帧就被动画放大了。解决思路不是改RcText本体而是调整Tabs页面的内容加载策略。我在页面中把文本数据在切换前预先加载好并给RcText所在区域设置一个最小高度占位符这样切换时不会出现高度为0导致的闪烁。这里我学到一个经验组件使用方有时比组件本身更需要仔细设计。一个再高效的文本组件如果频繁被无脑创建销毁也会出现各种观感问题。5.4 点击区域穿透和误触发给详情页的某个文本片段加了点击事件后测试发现用户点击链接周围的普通文字时也会误触发跳转。这个问题的本质是Text组件区域内的点击判定包含了整行的矩形热区而不是按照实际文字字形来判断。鸿蒙原生Text本身提供了onClick回调但没有提供类似“点击命中指定Span”的内置能力。我的应对方案是在RcText内部分层处理。可点击片段使用独立的Gesture绑定在onTap事件中检查点击位置是否落在这个片段的实际显示区域范围内。具体用的是Text的getTextRect能力把该片段中点坐标换算到组件坐标系中做包含判断。这种做法的兼容性还过得去但如果你在页面里有大量的短行文本建议优先用原生方案或者让点击区域放大一些否则用户点击体验会差。5.5 ArkTS编译时报类型错误ArkTS的类型检查比Java和TypeScript都要严格在写RcText时经常遇到因为Style对象的结构不匹配而编译失败的问题。特别是样式注册表中存储了可选字段有的字段在部分样式下是undefined另一个样式下又是数字类型联合类型推断很容易出错。解决这类问题的最好办法是尽早定义完整的接口类型把所有可选字段显式声明出来不要依赖推导。我给TextStyle接口补充了完整的字段定义每个字段都标注了可选性这样后续加字段时有编译器兜底不会等运行到某个极端场景时才暴雷。6. 项目复盘与给后来者的小建议6.1 组件重构的价值从“能用”到“可扩展”RcText做到V4再回头看最有价值的不是代码量减少或者性能参数提升而是组件有了明确的“能力边界”。V1之前我把文本相关的逻辑散落在页面里出问题时要同时开很多文件定位。重构的核心收益是让这个组件成为团队内部一个可以信任的基建。信息流团队不需要知道RcText内部如何解析样式注册表不需要关心它缓存了哪些对象。他们的心智模型就是“这段文本我要什么样式就配置什么Key。”这让我体会到文档和接口传递的不仅仅是用法还是一种解决方案的抽象。6.2 如果重新做一次我会在哪里改变决策即使现在RcText已经稳定运行了大半年我依然能找出几个当初的决策不够完美多语言支持做晚了。V3版本里没有预留资源文件加载的逻辑后来国际化需求进来时补了不少桥接代码。如果早一点考虑到这一点文本片段的text字段直接用资源Key会清爽很多。样式注册表的Key规范可以更早统一。项目早期没有明确的命名约定出现了语义相似的Key比如contentText和bodyText同时存在。后来做了一次对齐但已经污染了一些历史调用方。展示框和高亮逻辑可以下沉。当前的高亮搜索是在调用方做的每次搜索都要自己写一遍关键字切分。如果把这部分逻辑做进RcText并提供配置开关会更利于复用。这些经验很大程度上来自业务侧各种奇奇怪怪的需求我们永远没有办法在项目启动时就预测到所有场景。只能说尽量保持核心抽象足够稳定同时留好扩展位。6.3 组件库的沉淀与文档规范在RcText稳定后我花了不少时间整理组件的API文档和示例代码。这个投入看起来不像写业务代码那样直接产出功能但对团队协作的提升非常显著。文档中我重点写了三块每个API的适用场景和边界比如realTimeUpdate接口在大列表里不建议使用在详情页中其实够用。兼容性说明包括组件在哪些容器下有问题、哪些功能依赖系统版本能力。回归用例典型场景的快照测试确保后续每次改动都不会打破已有行为。这套文档后续帮其他同事接手这个组件省了大量时间。他们可以直接按用例写功能而不是像侦探一样在各种复杂的交互逻辑中猜测组件的行为。6.4 我的一点经验之谈最后分享一点不太技术、但很重要的东西。做组件和做业务页面最大的区别是思维模式。业务页面是“一个需求一把梭”组件则是“一个抽象服务所有类似需求”。这意味着你需要在做任何一件事之前多想一层、多抽象一层。我自己在设计RcText时很多灵感来自前端的富文本库和React的渲染机制但直接把别人现成的实现抄过来又是行不通的。鸿蒙ArkUI有自己的线程模型、状态管理框架和布局体系你必须理解这些底层的约束才能做出适合这个生态的组件。如果你现在也正被某个自研组件折磨我的建议是先不要急着写代码拿半天时间想清楚你要解决的核心矛盾是什么边界在哪里什么必须做什么宁可不做。这个思考过程比我写了十几年代码中的任何一个优化技巧都更有价值。
返回列表