1. 项目缘起:为什么在鸿蒙上做 Flutter 富文本这么费劲
1.1 一次线上反馈引发的追查
事情从一个再普通不过的线上反馈开始。我们的内容社区应用在部分华为设备上被用户投诉"滑动页面明显掉帧",而且不是偶发,是稳定复现。排查下来,问题聚焦在几个详情页:这些页面里有大量图文混排的长文章,每一段都包含不同颜色、不同字号、行内引用块、图片占位、代码片段等富文本结构。
用 Flutter 的 Text.rich 加 WidgetSpan 实现时,文字一多、内嵌组件一多,性能就肉眼可见地崩。CPU Profile 里 TextPainter.layout 和 Paragraph.build 相关的耗时居高不下,滚动过程中每帧都有重复的文本布局计算。这个场景在标准 Android 和 iOS 上虽然也有损耗,但尚可接受,换到鸿蒙设备上却直接击穿了底线。
为什么?因为 Flutter 跑在鸿蒙上不是原生级渲染,它的文本能力依赖底层平台的文本服务对接。鸿蒙的文本排版引擎、字体度量、Emoji 渲染规则与 Android 存在大量细节差异。而 Flutter 官方直到今天也没有把 HarmonyOS 列为一级支持平台,大量依赖 dart:ui 文本接口的逻辑,在鸿蒙引擎适配层会被放大成额外的性能开销和渲染偏差。
1.2 为什么偏偏选 attributed_text 来破局
查了社区里各种方案后,我们把目光锁定了 attributed_text 这个三方库。它在 Flutter 社区里不算特别热门,但在富文本渲染这个细分领域里,它做了一件非常关键的事:彻底绕开了 WidgetSpan 混排的模式,把富文本建模为可独立计算、可独立绘制的结构化数据。
我先把结论放在前面:这个库的核心价值不是"多了一种富文本写法",而是它改变了渲染模型——从"文本 + 组件树混合"变成了"纯文本绘制 + 占位符定位"。 正是这个模型层面的差异,让它成为鸿蒙端侧适配的最佳候选。
当然,直接拿过来用是不可能的。鸿蒙的 Flutter 引擎适配层还在持续迭代,attributed_text 底层依赖的 dart:ui 文本接口在 ohos 引擎上存在差异,需要做一系列端侧改造。这篇文章就把我们整个适配过程中的技术判断、方案设计、实测数据和踩坑记录完整复盘一遍,给后面要趟这条路的人省点时间。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先把原理吃透:attributed_text 的渲染模型与性能痛点
2.1 TextSpan 的世界里,混排到底混在哪
要理解 attributed_text 的价值,得先把 Flutter 原生富文本的渲染链路看清楚。
常规写法是 Text.rich(TextSpan(...))。我们可以在一个 TextSpan 里嵌套子 span,设置各种样式,也可以在 TextSpan 里塞 WidgetSpan,让文本流中间嵌入任意组件。这套 API 用起来非常顺手,但性能隐患从渲染模型层面就注定了。
TextSpan 本质上是一个"描述树"。Flutter 引擎拿到这棵树后,遍历所有节点,把纯文本节点交给文本排版引擎(在鸿蒙端是 ohos 的文本服务),把 WidgetSpan 节点剥离出来,转成组件树中的真实节点,然后通过布局系统计算它们在文本流中的位置。
问题出在"真实节点"这四个字上。一个 WidgetSpan 被插入组件树后,它不是一个轻量的排版占位符,而是一整套完整的 Widget-Element-RenderObject 链路。假如一段富文本里有 40 个行内标签、8 个图片占位、几个自定义徽标,组件树里就要多出几十上百个节点。而且文本排版引擎在计算换行时,无法为这些组件做出精确的行内布局决策,只能先给它们分配占位宽度,再让组件树布局器去做二次计算。两次布局之间的协调成本,在超长文本场景下会被放大到不可接受。
2.2 attributed_text 换个活法:把富文本变成"可计算的绘制数据"
attributed_text 的思路完全不同。它定义了一套与平台无关的富文本模型:属性区间、内联对象、段落样式等全部归一化到一个 AttributedText 对象里。文本流中的所有非文本元素(图片、图标、自定义组件)不再作为 Widget 存在,而是被建模为内联对象,记录在文本流的某个偏移位置上。
渲染阶段,库内部用 TextPainter 完成基础文本测量和布局,然后根据内联对象在文本中的偏移量,计算出它们的精确矩形位置,最后在绘制阶段统一画出来。整个过程中,组件树里只有一个 custom render object,所有富文本内容都在绘制阶段一次性完成。
这个模型带来一个直接收益:不需要为每个内联元素创建 Widget、Element、RenderObject,组件树规模急剧缩小。举个我们实测过的例子,一段包含 120 个内联标签的富文本,原方案组件树节点数是 500 多,换成 attributed_text 后直接降到 30 以内。对 Flutter 框架来说,组件树节点数量直接影响 build/layout 的遍历深度,这个降幅在低端鸿蒙设备上的帧率改善非常明显。
2.3 原生瓶颈在鸿蒙端被放大的底层原因
如果把问题归因到"WidgetSpan 太费",只说对了一半。另一半在于鸿蒙 Flutter 引擎的文本服务实现。
Flutter 在 Android 上走的是 Skia/SkiaParagraph 配合系统字体服务,在鸿蒙上则需要对接 HarmonyOS 的文本排版能力。鸿蒙的文本服务具备自己的排版策略、字体回退表和绘制管线。早期的 Flutter ohos 引擎适配版本中,文本相关的平台通道调用链更长,部分接口需要经过多次跨层转发。TextPainter.layout 每一次触发,都伴随着比 Android 更高的固定开销。
这导致一个现象:同样一段复杂度较低的富文本,Android 和鸿蒙的渲染耗时差距可能只有个位数毫秒,肉眼无感;但一旦富文本复杂度上去,内联组件变多、文本行数变多,鸿蒙端的耗时曲线会以远超 Android 的斜率攀升。用户感知到的就是:轻量页面还行,复杂页面直接卡。
所以我们的核心目标很明确:把富文本场景中高频、高成本的"组件混排"从渲染链路里剔除,让鸿蒙端只需要做"纯文本排版 + 轻量自绘",把跨层调用带来的固定成本压到最低。 这就是 attributed_text 成为主角的原因。
3. 鸿蒙端侧适配的整体技术路径
3.1 先弄清现状:Flutter on HarmonyOS 到底处于什么阶段
动手前,必须先摸清楚适配环境。2024 年底到 2025 年,Flutter 在鸿蒙上的可用方案大致有几个分支:OpenAtom 基金会主导的 flutter_flutter 鸿蒙化分支、华为开发者联盟提供的 ohos 引擎适配包、以及社区维护的独立构建产物。它们共同的底层思路是:把 Flutter 引擎的 platform 层抽出来,对接鸿蒙的图形渲染、字体管理和事件分发。
对我们做上层库适配的人来说,不需要重新编译引擎,但必须搞清楚三个关键接口在 ohos 环境下的行为:
ParagraphBuilder和Paragraph的文本布局行为是否和标准 Flutter 一致;- 字体管理接口(
FontCollection、loadFontFromList等)是否正常工作; - 平台视图中文本输入、系统剪贴板等交互是否可用。
实测下来,基础文本渲染在这些分支上已经能跑通,但细节差异很多,比如 TextHeightBehavior、自定义字体回退顺序、标点挤压规则等。这些细节直接决定了富文本在鸿蒙上是否"高保真"。
3.2 三条适配路线:桥接原生、纯 Dart 自绘、混合方案
在正式动代码之前,我们做了详细的技术选型对比,核心是下面三条路线。
路线 A:PlatformView 桥接鸿蒙原生富文本。 即每个富文本区域用 PlatformView 承载鸿蒙原生 Text 组件。优缺点是同样明显的:原生富文本能力最强,但 PlatformView 在滚动容器里的性能开销很大,而且每个富文本块都是独立原生视图,跨层通信频繁,与"消灭组件混合"的目标背道而驰。直接否决。
路线 B:基于 attributed_text 的纯 Dart/RenderObject 自绘方案。 富文本统一走文本测量 + 自定义绘制,不创建平台视图,不引入额外原生依赖。优点是渲染链路最短、可控性最强;缺点是原生能力(如系统词典、文本选择器)需要额外桥接。在我们的业务场景里,富文本主要是展示型内容,交互不多,所以这个方案最合适。
路线 C:混合方案。 展示型富文本走自绘,编辑/交互型富文本走原生,通过能力开关切换。兼顾了体验和性能,但工程量更大,维护成本高。
最终我们选择了路线 B,并在局部保留路线 C 的扩展接口。这里说一句实话:不要在架构设计阶段过度追求"全都要",先解决 80% 场景的确定性,比画一个完美的大饼重要得多。
3.3 工程结构怎么组织才不翻车
工程结构上,我们没有直接在业务工程里改 attributed_text,而是把它 fork 出来,维护了一个独立仓库 attributed_text_ohos。这样做的好处是:业务工程能通过 pub 依赖正常管理版本,后续 upstream 有更新时可以定期 rebase,所有鸿蒙适配逻辑都隔离在单独 package 里,不影响主工程。
内部结构分为三层:
- 适配层:处理
dart:ui与 ohos 引擎的差异,主要对Paragraph、TextPainter的不兼容行为做 shim; - 映射层:把业务方的富文本数据模型(后端下发的 JSON 结构、本地 Markdown 解析结果、运营配置样式表)映射为 attributed_text 的
AttributedText模型,这一层的核心是"样式语义归一化",后续第四章节会展开; - 渲染层:自定义
RenderObject和绘制逻辑,包含布局缓存、分块渲染、命中测试等能力。
这样的分层保证了各个层面的改动是正交的:以后鸿蒙引擎适配升级了,我们不需要重写映射层;业务侧新增一种富文本形态,也只需要在映射层加一个新 adapter。
4. 高保真富文本映射的实现细节
4.1 样式归一化:后端下发的 JSON 如何变成排版可用的模型
"高保真"三个字,字面意思是"渲染结果和设计稿一致",但做起来第一个难倒人的问题往往是:来自不同数据源的样式,字段名和取值单位都不统一。
我们后端下发的富文本 JSON 里,颜色可能是 "#FF6677",也可能是 "rgba(255,102,119,0.8)" 或 "red";字号可能是 "fontSize": 16,也可能是 "font_size": "16px";行高可能是一个倍率,也可能是一个固定像素值。运营配置的样式表里又有另一套规则:加了 {{bold}} 这类模板变量,或者带 CSS 类名的语义化写法。
映射层要做的就是把这些全部归一化。我把它拆成三步:
- 类型归一化:所有颜色转成
ui.Color,所有尺寸转成逻辑像素double,所有枚举值转成统一的TextStyle字段; - 语义归一化:把
bold、strong、fontWeight700这类语义标签统一到同一个FontWeight值; - 继承归一化:把后端下发的嵌套结构递归拍平成"绝对样式",即每个文本区间上的样式完成继承合并,渲染层不需要再处理级联逻辑。
在属性区间的模型设计上,采用了一个有序区间数组,按起始偏移排序。这样在文本长度变化或局部样式更新时,只需要做一次区间二分查找,不需要遍历整棵样式树。
4.2 文本测量与基线对齐的鸿蒙差异处理
高保真最容易露馅的地方,不在颜色和字号,而在基线对齐和行高表现。
鸿蒙的默认字体是 HarmonyOS Sans,它的 hhea 表(字体水平头部信息)中 ascent/descent 数值与 Roboto 不同。相同行高设置下,文字垂直居中的视觉效果在鸿蒙和 Android 上存在明显差异。项目初期,我们对接过若干版本的 Flutter ohos 引擎,面板显示部分行的底部会多出几个像素的空白,或者行内文字偏下,视觉上非常难受。
适配方法分两个层面:
- 引擎层的行高语义差异,通过在样式映射时对 lineHeight 做补偿计算。补偿系数不是拍脑袋定的,而是用固定测试文本在不同引擎上逐行对比
LineMetrics数据,拟合出的修正函数。 - 内联图片、内联标签的垂直对齐,采用 CSS inline-box 的 baseline 规则,把内联对象按基线进行偏移定位,而不是简单地按行顶部对齐。
关于基线对齐,我们写了一个回归测试页面:二十种典型富文本结构(纯文本、行内加粗、混合字号、嵌套图片、上下标等),每种结构都截图对比 Android 真机和鸿蒙真机。前期每改一版适配代码,就跑一轮这个测试,直到逐像素一致率达到业务可接受的范围。
4.3 内联组件映射为绘制指令而不是组件树
这是"消灭组件混合"的关键实现段。业务里的内联元素大致分三类:
- 信息型:比如话题标签、@用户、投票标签,本质是"带背景色的文本片段 + 对齐属性";
- 资源型:行内图片、表情图片、加载图标;
- 自定义型:代码行号块、折叠块、进度条。
attributed_text 允许为文本流中的任意偏移位置附加内联对象。我们的映射层为每种内联元素生成一个统一的 InlineObjectSpec 描述,包含元素类型、尺寸、数据源、绘制回调,并把它注册到文本模型中。
绘制阶段,RenderObject.paint 拿到每个内联元素的矩形位置后,调用对应的绘制回调。比如话题标签,绘制回调里画一个圆角矩形背景,再在背景上绘制文本;行内图片则根据图片类型走 ImageProvider 或本地资源加载。关键点在于:这些元素全部在画布上直接绘制,不参与 Widget/Element/RenderObject 管道,也无需像 PlatformView 一样去建立原生视图。
4.4 Emoji、特殊符号与字体回退的保真策略
富文本里最容易被忽视的坑是 Emoji。HarmonyOS 系统 Emoji 的默认字体(通常是 HarmonyOS Sans Symbol)和 Android 的 Noto Color Emoji 在视觉风格、基线占位、颜色渲染方式上都有差异。同一个 "😀" 字符,Android 上是彩色位图字形,鸿蒙上某些版本却可能渲染成黑白描边字形,或者出现整体上移。
处理策略分三步:
- 统一 Emoji 资源包:在应用内打包一套跨平台一致的 Emoji 字体,通过
FontLoader加载,避免完全依赖系统字体; - 强制回退顺序:配置字体回退表时,明确把我们的 Emoji 字体放在高优先级,保证鸿蒙设备上优先使用应用内字体而非系统字体;
- 兜底检测:检测字体中是否存在码位对应的字形,若不存在则降级使用系统默认字体,并针对性地调整垂直偏移。
字体回退顺序的配置在这方面是一个精细活。鸿蒙的自定义字体加载路径与标准 Flutter 存在一定差异,loadFontFromList 后如果不做字体家族名的显式绑定,某些引擎版本会静默失败。踩过坑之后,我们统一在适配层做了一层字体管理封装,确保每个加载的字体都会校验 fontFamily 返回值。
5. 复杂场景性能优化与实测结果
5.1 布局缓存:一个毫秒级性能热点的消除
完成高保真映射后,第一版实测性能已经比 Text.rich + WidgetSpan 方案好了不少,但在极端长文本(超过 8000 字、包含 200+ 内联对象)上,仍然出现偶发掉帧。定位后发现问题不在绘制,而在重复布局。
Flutter 的 TextPainter 在文本内容不变、约束不变的情况下,理论上可以缓存布局结果。但业务场景中,滚动时父容器可能给文本区域传入不同的约束(比如从 BoxConstraints 的宽松模式变为紧约束模式),导致 TextPainter 反复执行 expensive 的 layout。
我们的优化方案是设计了一个两级缓存:
- 第一级基于"文本内容 + 约束宽度"做精确匹配,命中直接返回
LayoutInfo; - 第二级基于"文本内容 + 约束宽度区间"做近似匹配,约束宽度变化在一定阈值内时复用上一份布局,等滚动停止后再做精确布局。
这两级缓存把滚动场景下的布局调用量降到了原来的十分之一。实测数据见 5.3 节表格。
5.2 分块渲染与增量绘制:让滚动时的每一帧都稳定
另一个显著的优化是分块渲染。
对于超长富文本,我们不再绘制整个段落区域,而是根据视口 clipRect 只绘制可见区域内的文本行和内联对象。attributed_text 本身支持访问每一行的 LineMetrics,我们可以精确知道哪些行落在视口内、哪些行需要绘制。这样,即便一个文本块总高度有 5000 像素,实际滑动中每一帧需要绘制的行数也只在 20~30 行左右。
此外,为了减少绘制开销,我们对不变的文本内容使用了 PictureRecorder 缓存:将静态部分的绘制指令录制到 ui.Picture,滚动时直接 canvas.drawPicture,只有动态变化的部分(比如进度条、加载占位图)才走实时绘制。
这里有一个容易被忽略的注意点:ui.Picture 缓存切不可无限做大,否则 GPU 显存压力会很大。 我们的策略是只对单块高度超过 800 像素、且内容完全静态的文本块使用 Picture 缓存,并且在上层统一管理缓存容量,超过阈值时主动丢弃最久未使用的缓存。
5.3 实测数据:不同方案下的帧率与耗时对比
说再多原理,不如放一组实测数据。测试机型为华为 HarmonyOS 4.2 真机,测试样本是一段模拟业务场景的复杂富文本:3000 字正文,内嵌 80 个话题标签、12 张行内图片、8 段代码样式文本块。每项数据在相同环境下取 10 次平均值。
| 方案 | 组件树节点数 | 单次首帧布局耗时 (ms) | 滚动平均帧耗时 (ms) | 滑动流畅度 (掉帧数/千帧) |
|---|---|---|---|---|
| Text.rich + WidgetSpan | 643 | 38.6 | 22.4 | 47 |
| attributed_text 直接引入 | 27 | 21.3 | 14.9 | 21 |
| attributed_text + 鸿蒙适配 | 27 | 18.7 | 11.2 | 8 |
| attributed_text + 鸿蒙适配 + 缓存/分块 | 27 | 5.8 | 7.6 | 2 |
可以明显看到,最终的方案在首帧布局耗时上从 38.6 ms 降到了 5.8 ms,掉帧数从每千帧 47 次降到了 2 次。在高端机型上可能感知不出巨大差异,但在中低端鸿蒙设备上,这个差距就是"能不能用"和"好用"之间的区别。
当然,这组数据针对的是我们真实业务样本,不同业务要看自己的富文本复杂度。但一个大方向是明确的:鸿蒙端富文本性能问题的核心不在"画得快不快",而在"布局算得慢不慢"。 任何能减少布局次数和组件树节点的优化,投放产出比都远超在绘制层面的各种微调。
6. 踩坑记录与完整排查链路
6.1 坑一:自定义字体加载"看似成功,实际失效"
现象:在鸿蒙设备上,应用自定义字体在部分富文本块中不生效,中文字符回退到了系统字体,而英文、数字却正常显示自定义字体。
排查链路:首先在应用层全局搜索字体加载逻辑,确认 FontLoader.loadFontFromList 执行成功。接着怀疑是字体文件本身缺少中文字形,但同样的字体文件在 Android 上是正常的,排除这个可能。继续用 FontCollection.getFallbackFonts 检查字体回退顺序,发现鸿蒙引擎对 FontFamily.resolve 的解析逻辑与标准 Flutter 不同:自定义字体如果没有显式声明覆盖范围,中文字符会被优先导向系统默认字体。
修复:在字体清单中显式声明 IllFormedFontFamilyOverride 映射,或者更直接的做法是在 TextStyle 里通过 fontFamilyFallback 列表把自定义字体置于最前。此外,绕开字体加载时机问题也值得一提:尽量在 main() 中同步加载并 await 确认完成后再启动业务页面,避免异步加载完成后富文本已布局的情况。
6.2 坑二:Emoji 与行高塌陷
现象:包含 Emoji 的富文本行,行高明显高于正常文本行,且 Emoji 字符底部被截断。
排查链路:打开调试工具逐行检查 LineMetrics,发现包含 Emoji 的 LineMetrics.ascent 数值异常偏大。进一步排查,确认是系统 Emoji 字体的 metrics 与正文字体不匹配,导致排版引擎在计算行盒时出现叠加。
修复:使用应用内统一 Emoji 字体资源替代系统 Emoji 字体,同时把 Emoji 的垂直位置通过 FontFeature 或 TextStyle.height 做微调。这个坑在不同版本的 HarmonyOS 上表现不一致,所以代码里做了一个运行时的版本判断:仅在特定系统版本上应用补偿值。
6.3 坑三:列表页快速滑动时的白闪
现象:在 ListView 中快速滑动包含大量富文本块的列表项,部分文本块在滑动过程中出现短暂白屏闪烁。
排查链路:一开始以为是绘制优化过猛,RepaintBoundary 用错了位置。后来借助 Profile 的"Frame Analysis"才发现,白闪出现在文本块重新入视口、缓存被清空、需要重新布局的瞬间。进一步检查发现,我们的缓存回收策略太激进:在快速滑动场景下,相邻列表项的布局缓存持续被清空和重建,导致频繁的同步布局。
修复:给缓存增加"生命周期"概念,用 AccessTime 做 LRU 淘汰,而不是按照"离开视口立即清空"的策略;同时把布局操作改成在 SchedulerPhase.persistentCallbacks 外的 idleCallback 中预执行,避免在帧的布局阶段抢占主线程资源。整套优化做完后,白闪现象彻底消除。
6.4 踩坑后的反思:适配工作应该以"验证体系"为底座
回看整个项目,真正消耗时间的不是写适配代码本身,而是要持续回答"改完这版,效果是否变好"这个问题。为此,我们搭建了一个富文本截图回归系统:多套测试文本、多个测试字号和多个显示宽度,自动渲染成截图,与基准版本做像素级 diff。任何改动引入的视觉回归,都能在几分钟内发现,从而避免"按下葫芦浮起瓢"。
给后面做类似工作的团队一个建议:从项目第一天就搭建视觉回归和性能回归的基线,而不是等踩了坑再补。 富文本适配涉及的细节太多,靠人工肉眼检查永远覆盖不全。
7. 最后的实战经验与后续扩展
项目收尾后,有几个体会特别深。
第一,适配第三方库到新平台时,不要一上来就看"底层接口是否一致",而要先想清楚"我们要从渲染链路里拿走什么"。我们这次最成功的设计决策,就是一开始就锁定了"组件混排是主要矛盾",所有技术选型都围绕减少组件树节点展开。
第二,鸿蒙侧的文本服务还在快速演进,我们 fork 的适配包已经经历了三轮引擎适配更新。保持适配层与核心逻辑的隔离非常必要,每次引擎升级只需要修改 shim 层,业务和渲染层代码完全不动,这个架构收益在维护阶段被反复放大。
第三,如果后续业务会出现用户可选择的富文本内容(比如用户手动排版),我们预留的"混合方案"接口就派上用场了:纯展示走自绘,交互编辑走原生,通过能力开关切换。
最后分享一个排查性能问题的小技巧:在鸿蒙设备上开启的 Flutter Profile 模式看网格帧耗时,不要只看平均帧率,要看 P90、P95 甚至 P99 掉帧分布。富文本掉帧往往是间歇性的,平均帧率漂亮但 P99 极度难看的情况非常常见。把优化目标定在"消灭 P99 毛刺"上,用户的体感就对了。
