1. 同一套列表代码,为什么在OpenHarmony上比Android更容易露怯
去年年中我接到一个挺头疼的活儿:把一套已经跑在 Android 上的 Flutter 应用迁到 OpenHarmony 设备上。App 里最核心的页面是一屏聊天消息记录,用的是最常规的ListView.builder,每条消息有头像、昵称、时间、文本内容,偶尔带一张小图。这页面在 Android 中端机上滑起来能到 58~60 帧,基本感知不到卡顿。结果换到 OpenHarmony 的开发板上,同样的代码,我往下一滑,帧率直接掉到 30 帧上下,伴随着明显的顿挫感,甚至松手后还有一小段“惯性之后突然停住”的迟滞。
那段时间我翻了不少资料,发现很多人都在问“Flutter for OpenHarmony 上长列表为什么不流畅”“ListView.builder 是不是在鸿蒙上失效了”。其实不是失效,而是ListView.builder 只在“减少 Widget 构建数量”这个层面做了优化,它解决不了布局、绘制、图片解码、内存回收等一系列下游开销。而这些下游开销在 OpenHarmony 的 Flutter 适配版本上,表现得比 Android 上更明显。
这篇文章就是从那段时间的踩坑里整理出来的。我不打算堆概念,只讲三件事:搞清楚长列表为什么会卡、哪些优化手段真正有效、以及如何在 OpenHarmony 设备上量化和验证效果。适合正在做 Flutter 跨端适配、或者刚开始在 OpenHarmony 上做 Flutter 应用的开发者,哪怕你对 OpenHarmony 还不熟,按着步骤也能把列表优化到能用的程度。
先说个反直觉的结论:在 OpenHarmony 上,ListView.builder本身带来的构建开销通常不是最大瓶颈,真正的耗时大头往往出在你每个 ListItem 内部那些不起眼的属性上——边框圆角、多层文本、图片解码、甚至一个忘记加 const 的构造函数。这些单看都不严重,但叠加到每秒滚动几十个 item 的场景里,就会被放大。
1.1 Flutter for OpenHarmony 的适配现状决定了我们不能照搬经验
Flutter 官方一直没把 OpenHarmony 作为一等平台来支持,目前能在 OpenHarmony 上跑起来的 Flutter,基本来自开放原子开源基金会下面的 flutter_flutter、flutter_engine 等适配仓库。这意味着几个现实约束:
第一,引擎版本通常会落后 Flutter 官方主干一个身位。你在官方博客里看到的新特性、新渲染优化,不一定立刻能在 OpenHarmony 适配版里用上。我遇到过的例子是某个版本对 Impeller 的支持就不完整,切到 Impeller 后部分列表出现异常色块,最后还是回退到了 Skia 后端。
第二,平台通道和插件生态要区别对待。很多在 Android/iOS 上直接用的插件,在 OpenHarmony 上需要找对应的 OpenHarmony 实现,甚至自己写 Platform Channel。这个问题在长列表里会间接影响性能——比如图片加载插件如果用不了,你退回用Image.network或自研加载,压力全落在 Dart 侧和 CPU 侧,滑动性能自然变差。
第三,设备硬件差异非常大。我手上那台测试板的 CPU 性能大概只有主流手机的一半不到,GPU 更是薄弱。同样的列表,在手机上“恰好压在 16ms 线附近”,到开发板上就可能翻车。所以本文的优化思路多少是“吴下阿蒙”式的土办法,但恰恰是这些土办法,在弱设备上最管用。
1.2 一次原始列表的帧耗时统计:先别猜,先量化
拿到卡顿反馈后,我第一件事不是找代码猜原因,而是打开 Flutter 自带的性能调试,统计了三组数据:帧间耗时(Frame time)、构建耗时(Build time)、绘制渲染耗时(Rasterizer time)。
在跑满 500 条消息的列表页,滑动手势稳定滚动的过程中:
| 指标 | 数值 | 说明 |
|---|---|---|
| 平均帧耗时 | 32~40ms | 明显超出 16ms/帧的预算 |
| 最坏帧耗时 | 78ms | 出现在图片开始加载的瞬间 |
| Build 平均耗时 | 6~8ms | 不算极端,但也不低 |
| Rasterizer 平均耗时 | 20~26ms | 这是真正的大头 |
| 内存占用 | 持续增长至 500MB+ | 图片缓存和 Widget 树膨胀 |
一看这个分布就明白了,卡顿的主要来源不在 Dart 的 build 阶段,而在于光栅化(Rasterizer)耗时过高。换句话说,屏幕上大量的圆角裁剪、阴影、半透明层叠,逼着渲染引擎反复做复杂的像素合成。OpenHarmony 适配版引擎的合成性能偏弱,这个问题被放大了。
1.3 先分清你是哪种“卡”:三种典型症状对应完全不同的优化方向
长列表的“卡”从来不是一种病。我会先让团队把问题归类,再决定用什么药:
- 症状A:滑动时明显掉帧,但静止后页面清晰稳定。这类问题大概率出在绘制与合成阶段,比如 RepaintBoundary 缺失、大量 saveLayer、图片解码峰值。优化重点放在减少绘制复杂度上。
- 症状B:滚动速度不均匀,划快了会“冲过头”,停住时偶现白屏。这类多数是 lazy 加载的时机和 cacheExtent 配合不当,或者数据解析阻塞了 UI isolate。重点在 compute/isolate 和滚动节流上。
- 症状C:长时间滑动后内存持续上涨,最后触发 GC 卡顿。这类往往是 Widget 和图片缓存没有释放,或者列表项内部持有了大对象。重点在缓存管理和 item 复用管控。
三个症状经常同时出现,但总有一个主导因素。我的建议是先在现有代码上做一轮最小化改造,分别验证,而不是一次性上一堆优化手段,否则你根本不知道哪个起了作用、哪个反而拖慢了性能。
2. ListView.builder 的真实开销分布:不仅仅是 build() 的锅
很多人一提到ListView.builder就说“它只会构建可视区内的 item,所以性能没问题”。这句话方向上没错,但它掩盖了三个更深层的开销来源。我当时也是对团队成员原样转述这句话,结果被现实打脸——builder 确实只构建可视区附近的 item,但每一个 item 从 widget 到像素,中间要经历 build、layout、paint 三条流水线,builder 只砍掉了“非可视区 widget 的构建”,却没法帮你砍掉可视区内 item 的 layout 和 paint 耗时。
2.1 视图复用机制和它解决不了的“下游问题”
ListView.builder的核心是懒加载,它维护一个可视窗口加上前后各一段cacheExtent(默认是 250 逻辑像素)作为预加载区。滑动时,滚出预加载区的 item 会被销毁,滚入预加载区的 item 会由 builder 重新创建 widget。所以严格说,它是一个**“有限重建”机制,够不上传统的 ViewHolder 复用**。
Viewport + cacheExtent 这个组合,决定了屏幕外 item 不会无缘无故占用 Dart 内存,但也决定了每一个新滚入的 item,都必须完整走一遍“构建控件树 → 计算布局 → 生成绘制指令”。如果你的 item 内部有大量圆角裁剪、阴影、渐变,或者文本结构很深,光这一遍流程就能吃掉几毫秒到十几毫秒不等。
在 OpenHarmony 适配版上,这个通路还比 Android 侧更“吃”硬件性能,因为底层渲染后端把绘制指令转成实际像素的合成阶段,对 CPU/GPU 的占用更高。所以只靠ListView.builder的懒加载是远远不够的,item 本身的“造价”必须尽量做得便宜。
2.2 一个小实验:把 item 内所有“视觉装饰”都拆掉,性能立刻回升
我做过一个非常粗暴的实验:把 item 里的头像圆形裁剪、消息气泡圆角阴影、时间文本的样式层叠全部简化成纯色Container,其他逻辑不动。结果显示:
| 配置 | Rasterizer 帧耗时 | 掉帧情况 |
|---|---|---|
| 原始 item(圆角+阴影+头像裁剪) | 20~26ms | 明显卡顿 |
| 简化 item(纯色+无阴影) | 8~11ms | 极少掉帧 |
| 进一步简化(连文本样式都扁平化) | 6~8ms | 基本满帧 |
这组数据说明:真正压垮列表的是绘制复杂度,而不是 Flutter 框架的 Widget 数量。当然生产环境不可能阉割 UI 来保性能,但至少告诉我们优化方向——尽量用低成本的绘制方案实现同样的视觉效果,才是长列表优化的本质。
2.3 最容易忽略的三个隐性开销:图片解码、文本测量与 saveLayer 链
除了 build/layout/paint 三件套,还有三个隐性开销特别容易在长列表里爆发:
第一个是图片解码峰值。Image.network默认会先从网络拿压缩格式,再在 UI isolate 上解码成位图。如果图片尺寸虚大(比如列表头像地址直接用了 1000x1000 的原图),解码耗时可能达到几十到上百毫秒,滑动时就会在图片即将进入可视区的那一刻产生一次瞬间掉帧。前面我测得 78ms 的最坏帧就是这里来的。
第二个是文本测量。Flutter 里的 Text 在 layout 阶段要做文本编排,文本越长、样式越丰富、最大行数约束越复杂,测量耗时越高。消息列表这种场景,每条消息长度不同、可能带链接、可能折行,几乎每一条都在消耗排版算力。数据量大时,这会均匀地推高平均帧耗时。
第三个是saveLayer 链。Flutter 里只要出现“圆角裁剪 + 阴影 + 透明度叠加 + 复杂子层合成”的组合,引擎就可能触发 saveLayer——即把一块内容先离屏渲染到临时缓冲,再合并到父图层。这个操作代价非常高,相当于让 GPU 额外开辟一块离屏缓冲并做完整合成。在 OpenHarmony 的弱 GPU 设备上,一个列表页里十几个 item 同时触发 saveLayer,会直接把 GPU 拖垮。
3. 七个按收益排序的优化动作,照着改就行
这一章是全文的核心干货。我把踩过坑之后留存下来的优化动作,按“性价比从高到低”排列,你不需要全部做,优先做前三项就能解决大部分问题。
3.1 给列表一个固定高度特征:itemExtent 的威力
如果你的列表 item 高度固定(或在一段范围内固定),务必设置itemExtent。这是我在 OpenHarmony 上做长列表优化时,性价比最高的一个改动,没有之一。
itemExtent的作用是直接告诉列表框架“每一项的滚动范围是固定值”,于是框架在 layout 阶段就不需要对 item 做精确测量,也不需要反向推算滚动偏移量对应的索引。它省掉的是每一个 item 的布局测量和滚动位置计算,这块在消息列表这类规则结构里能省下非常可观的 CPU 开销。
ListView.builder( itemExtent: 72.0, // 每条消息固定高度 itemBuilder: (context, index) { return MessageCell(message: messages[index]); }, )需要注意,itemExtent不是万能的。如果你的消息高度不固定、文本可能从一行膨胀到十行,硬设固定高度反而会导致内容溢出/截断。折中方案是给 item 一个“估算高度+动态修正”,比如先按两行文本预置高度,在文本构建完成后再通过一个上报机制修正,但那个复杂度就高了。我的建议是:UI 允许的情况下,优先把列表做成固定高度,这里省下的性能远比做动态高度后花一堆技巧去优化更划算。
顺带一提,如果 item 高度统一,还可以把itemExtent和prototypeItem同时考虑。OpenHarmony 的 Flutter 适配版本对prototypeItem的支持也正常,它比itemExtent更适合“高度相同但无法给死数字”的场景,二选一即可。
3.2 让每个 item 的 Widget 尽可能“免重建”:const 与不变子树
Flutter 的 Widget 是不可变的配置对象,理论上同一个位置如果前后两次 build 传入的 Widget 对象是同一个实例,就可以跳过那部分 diff 和重建。这就是const关键字的价值。
我最初看到消息 item 代码时,发现大量TextStyle、EdgeInsets、BoxDecoration都是每次 build 时新建的复合对象,完全没有常量化。改造成常量后,不仅 build 耗时降低了,内存里也少了一堆临时小对象,GC 压力随之下降。
class MessageCell extends StatelessWidget { const MessageCell({super.key, required this.message}); @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: const BoxDecoration( color: Colors.white, borderRadius: BorderRadius.all(Radius.circular(12)), ), child: Text( message.content, maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 16, color: Colors.black87), ), ); } }但我得提醒一下:const不是银弹。它只对“完全由编译期常量构成”的 Widget 树生效。一旦某个 item 需要展示动态数据(比如消息内容),那这个 Text 的 child 就必然每次不同,const就只能用在样式、装饰、间距这些与数据无关的部分。改造时不要为了追求全 const 而扭曲代码结构,重点是把不变的部分提取成常量,减少重复创建。
更实际的做法是:把 item 内部的“静态外壳”和“动态内容”分层。外壳组件用const,内容组件通过参数传入。这样每次 item rebuild 时,外壳的 diff 成本远低于动态内容的构建成本。
3.3 RepaintBoundary:把“重绘隔离”用在对的地方,而不是越加越好
RepaintBoundary的工作原理是在 widget 树里插入一个独立的图层边界,引擎会优先把这个边界内的内容离屏渲染成纹理,之后页面其他区域发生变化时,这个边界内不会被迫重新绘制,而是直接复用已有纹理。
在长列表里,给每个 item 套一层RepaintBoundary的典型收益是:当某个 item 内部状态变化(比如网络图片加载完成、点赞动画)时,它不会导致整个可视区内其他 item 一起重绘。
ListView.builder( itemBuilder: (context, index) { return RepaintBoundary( child: MessageCell(message: messages[index]), ); }, )但这里有一个很容易踩的坑:每个 item 都加RepaintBoundary并不总是好事。它等于把每个 item 都做成一张独立纹理,合成时 GPU 要处理更多的纹理层叠。在 OpenHarmony 的弱 GPU 设备上,图层数量过多会显著增加合成压力,反而让滑动帧率下降。
我的实际经验是:只在内容重绘成本高、且会频繁变化的 item 上使用RepaintBoundary。比如图片消息、带状态的控件,使用它隔离重绘;纯文本的静态消息,完全没必要套。另外一个技巧是只在 item 树中“成本最高的那一两支”上套,而不是在 item 根部套一层管全部。比如把Text包一层,防止气泡其它区域重绘时连带排文本。
3.4 图片的像素级控制:decodeWidth/decodeHeight 与缩略图策略
图片是长列表里最容易被低估的性能杀手。一张列表页假如同时存在 6~8 张网络图,如果不做任何处理,开启滑动的一瞬间会同时发起解码任务,那一帧的光栅化耗时能飙到 80ms 以上。
优化方向有三个,层层递进:
第一,服务端做缩略图。如果服务端能直接下发 100x100 的带头像缩略图和 480x360 的聊天图片缩略图,压根不要让客户端去拉原图,这是最彻底的方案。
第二,客户端强制指定解码尺寸。Image.network支持cacheWidth和cacheHeight参数,让底层在解码阶段就只解出目标尺寸的位图,而不是解出全尺寸再缩小。我在消息头像上设置了 96x96,聊天图片设置了 4:3 且最大宽度 480 的缩略尺寸之后,Rasterizer 耗时直接下降一大截。
Image.network( message.imageUrl, cacheWidth: message.isAvatar ? 96 : 480, cacheHeight: message.isAvatar ? 96 : null, fit: BoxFit.cover, )第三,给图片加载加一个“可见再加载”的开关。图片只有在滚动停止、且 item 真正进入可视区时才开始解码。这个结合第 3.6 小节的滚动节流使用,效果更佳。
这里额外提醒一个 OpenHarmony 相关的点:如果项目里用到了原生图片加载插件(比如 image_picker、cached_network_image 在 OpenHarmony 上的适配版本),务必确认它们是否真的生效。我有一次发现 cached_network_image 在 OpenHarmony 设备上访问网络缓存目录时路径异常,导致每次重新解码,最后只能自己写了个简单的磁盘缓存管理器。
3.5 把非 UI 计算请出 UI isolate:compute 与 isolate 的正确用法
Flutter 的 UI 线程既是 build/layout 的执行者,也是平台通道和用户交互的主线程。任何超过 1ms 的同步计算,比如千条消息里按关键词过滤、把 JSON 字符串解析成对象、给消息做时间分组、计算撤回消息的状态等,都会直接卡掉 UI。
正确的做法是把这些计算放进后台 isolate。最简单的是用compute函数:
List<Message> parseMessages(String jsonStr) { // 这里做 JSON 解析和消息模型映射 return MessageListParser().parse(jsonStr); } // 在 UI isolate 里这样调用 final messages = await compute(parseMessages, jsonStr);但注意,compute传参是按值拷贝的。如果你把一整个巨大的 List 传过去,拷贝成本可能超过计算本身。我的经验是:传最精简的数据结构,让后台 isolate 只做纯计算,再返回精简结果。比如只传 List<Map> 的原始字段,而不是把整个 Message 对象图传过去。
另外,长列表的最佳实践不只是把“单次解析”放后台,而是把整个数据管道的管理都考虑成异步流。比如列表初次加载:先让 UI 用空态/骨架屏展示,再拉后台解析数据,数据就绪后一次性更新列表。千万别在每一帧里同步去过滤某个列表。
3.6 滑动过程中的加载调度:让敏感操作学会“等一等”
列表滑动时,用户的眼睛对掉帧极其敏感,但对“延迟 200ms 出现一张图”却感知很弱。所以一个非常有效的策略是:滑动过程中暂停一切重量级操作,滑动停止后再批量处理。
我用了一个极简的监听器实现这个效果:
class SlidingLoadManager { Timer? _debounce; void init(ScrollController controller) { controller.addListener(() { _debounce?.cancel(); _debounce = Timer(const Duration(milliseconds: 120), () { // 滑动停止:开始加载可视区图片、触发分页预取 _onIdle(); }); }); } void _onIdle() { // 通知列表项开始加载图片/展开完整内容 } }同时,在滑动期间可以主动降低图片解码优先级,或者干脆不发起新请求。有一个细节要注意:停止计时器不能太短(小于 80ms 会导致还没真正停下就触发了),也不能太长(长于 300ms 会让人感觉内容加载迟缓)。我实测 120~150ms 是个比较合适的区间。
除此之外,cacheExtent也可以适当调小。默认 250 逻辑像素在弱设备上意味着提前加载了很多看不见的 item。我在开发板环境中把cacheExtent调成 80~100,明显减少了无谓的 item 构建:
ListView.builder( cacheExtent: 80, itemExtent: 72.0, itemBuilder: (context, index) => MessageCell(message: messages[index]), )3.7 数据层配合:分页、增量更新与本地缓存
列表性能从来不只是前端的事。如果数据层一次性下发 10000 条消息,再强的渲染也扛不住——就算 UI 不卡,内存里躺着上万个模型对象也会让 GC 频繁触发。所以工程上必须做分页:
- 每次只加载 20~50 条,滑动到接近底部时自动触发下一页。
- 服务端尽量支持增量更新,避免全量替换列表。
- 对已经渲染过的消息模型做轻量化缓存,比如只保留渲染所需字段,不需要完整保留所有元数据。
更进阶一点,可以在数据层维护一个“列表条目是否已渲染”的标志。当 item 滑出很远再回来时,如果数据没有变化,直接复用之前构建好的 RenderObject 缓存。这听起来复杂,但 Flutter 的PageStorage和AutomaticKeepAliveClientMixin能在一定程度上帮你做这件事,只是后者用不好会造成内存泄漏,还是得配合 item 的销毁机制一起调。
4. OpenHarmony 设备上的专项适配:引擎、多线程与资源路径
前面的手段在 Android、iOS 上同样适用。但如果你要交付的是 OpenHarmony 应用,真的有几点和别的平台不太一样,需要单独调配。
4.1 先搞清楚设备上的 Flutter 引擎是哪条分支
Flutter for OpenHarmony 的适配版引擎,与官方 Flutter 引擎相比,在渲染流水线、平台通道实现上都有差异。我在项目初期吃过一个亏:把 Android 上的 Flutter 版本和 OpenHarmony 适配版混用了,结果部分平台通道直接静默失败,列表数据加载不出来,还以为是列表卡顿。
所以第一件事:锁定你的 OpenHarmony SDK 版本和对应 flutter_flutter/flutter_engine 适配仓库版本,保证引擎分支匹配,最好在 CI 配置里固定版本号,不要用latest。在性能和稳定性出现矛盾时,优先选择“官方/适配仓库明确支持的稳定版”,不要盲目追 Flutter 新版本。
4.2 使用 OpenHarmony 的异步能力:taskpool 与多线程协作
OpenHarmony 应用侧提供了 taskpool、worker 等多线程能力,可以处理比 Dart isolate 更底层的计算和 IO。在实际项目中,我发现 OpenHarmony 文件 IO 和网络请求的一些回调,如果直接在 Flutter 的 UI isolate 里等待,会有额外时延。更好的做法是:网络请求走 OpenHarmony 侧原生能力(通过自定义 Platform Channel),在原生侧完成解析、缓存,甚至缩略图裁剪,最后把精简后的 JSON 结构交给 Dart。
不过这里要小心,Platform Channel 的通信是异步且有拷贝开销的。数据量大时,不要整包传,设计好接口粒度。比如一次只传一页 20 条消息的轻量字段,而不是把整份数据库查询结果往 Dart 侧灌。
4.3 资源加载路径的差异:Asset 和文件缓存不能想当然
OpenHarmony 的资源管理方式和 Android 不一样,路径获取方式、应用沙箱目录、Asset 打包规则都各自独立。如果你在列表里用了大量本地资源,强烈建议单独写一个资源加载抽象层,屏蔽底层差异。我在项目中碰到的坑是:OpenHarmony 的 Asset 资源加载速度比 Android 慢不少,列表里直接引用大量小图标会出现闪白。解决方案是把列表 icon 从单个 Asset 文件改为内存常驻的图标字体或统一绘制到一张雪碧图上,减少 IO 次数。
另外,图片缓存目录的位置在 OpenHarmony 上也有自己的规范,使用path_provider的 OpenHarmony 适配版本时,一定要测试缓存目录的可写性和空间配额。否则会出现“明明磁盘有空间,写入却失败”的诡异情况。
4.4 档位适配:低端设备弱 GPU 场景下的绘制降级
OpenHarmony 设备覆盖范围极广,既有高性能平板,也有 RK 系列开发板、低端电视盒子。对于长列表,我建议做一个简单的设备档位分级:
| 档位 | 判定标准 | 列表策略 |
|---|---|---|
| 高性能 | GPU 支持高刷且内存充足 | 完整 UI,图片原图,正常动画 |
| 中端 | 能跑 60 帧但余量有限 | 缩略图,适当削减阴影,减少 saveLayer |
| 低端 | 帧率长期低于 40 | 禁用模糊/阴影,图片强制极低清晰度,必要时关闭 item 动画 |
这个分级不需要很精确,运行时查一次设备型号或根据历史帧耗时动态判断即可。目的是在低端设备上宁可使用朴素一点的 UI,也要保证基本滑动流畅度。
5. 用数据验证优化结果:帧耗时、内存与工具链
优化做完了,怎么判断到底有没有效果?不能靠感觉,必须靠数据。我把项目中用过的一套验证方法列出来,照做即可。
5.1 Flutter 自带的性能工具:DevTools 里的 Timeline 与 Rasterizer 统计
在跑 OpenHarmony 应用时,可以通过 Flutter DevTools 连接调试中的 App。进入 Performance 面板后,录制一段稳定滑动的过程,重点看两个数据:
- UI Thread 耗时:对应 build/layout 以及 Dart 逻辑。
- Raster Thread 耗时:对应 engine 的光栅化、纹理合成。
如果 Raster 显著高于 UI,说明问题集中在绘制侧,回到第 3.3、3.4 节的方案优化;如果 UI 侧高,说明问题集中在 widget 构建和数据计算,回到 3.2、3.5、3.6 节。
另外,不要只盯着平均帧耗时。长列表性能优化更应关注 P95 或最坏帧耗时。平均 16ms 但每隔几百毫秒跳一次 120ms 的体验,依然很难受。
5.2 鸿蒙侧工具:hdc 命令与性能数据采集
OpenHarmony 设备可以通过 hdc 命令连接。想从系统层面看性能,可以这样:
# 连接设备 hdc list targets # 进入 shell 查看 CPU 占用(需根据实际工具调整) hdc shell配合top、dumpsys等命令观察进程 CPU、内存占用。尤其要注意 GC 引起的周期性卡顿:如果在 logcat/hilog 里看到频繁的 Dart GC 日志,说明内存里的临时对象过多,需要回头检查是不是有大量重复创建的 Widget 或图片位图没有释放。
5.3 三组关键指标与验收标准
我通常给团队定三组验收标准,达到后才会认为列表优化完成:
| 指标 | 低端设备目标 | 中高端设备目标 |
|---|---|---|
| 滑动平均帧耗时 | ≤ 20ms | ≤ 12ms |
| 最坏帧耗时(剔除首次加载) | ≤ 60ms | ≤ 30ms |
| 内存增长(持续滑动 5 分钟) | ≤ 100MB | ≤ 150MB |
| GC 触发频率(持续滑动 5 分钟) | ≤ 5 次 | ≤ 3 次 |
注意,最坏帧耗时很难彻底消灭,我们允许首次图片加载、首次页面进入时有一次明显的掉帧,但正常滑动过程中尽量避免。把“偶发卡顿”控制在一定范围内,比盲目追求“绝对 16ms”更现实。
6. 一个聊天列表的优化全记录:从 40ms 掉帧到稳定 60 帧
最后分享一个真实 case,就是开头说的那个聊天消息列表。我会给出一组完整的“优化前 → 逐步优化 → 验证结果”的记录,方便你对照自己的项目。
6.1 第一现场:平均帧耗时 38ms,松手后画面还需 200ms 才稳定
当时的页面结构是:一个ListView.builder,每条消息包含头像(圆角裁剪)、昵称、时间、文本气泡(圆角+阴影)、偶尔的图片缩略图。数据一次性加载 500 条,没有分页。第一次测试数据如下:
| 指标 | 优化前 |
|---|---|
| 平均帧耗时 | 38ms |
| UI 线程平均耗时 | 7ms |
| Raster 线程平均耗时 | 24ms |
| 最坏帧耗时 | 96ms |
| 内存占用 | 410MB 左右 |
看到数据后,我判断重点在 Raster 和内存,于是按下面的顺序逐项优化。
6.2 分步优化与每一步的数据变化
第一步:给列表加 itemExtent + 给静态样式加 const。消息高度当时设计为固定 72px,直接设置 itemExtent。这一步改动很小,但效果明显:
| 指标 | 优化后(第一步) |
|---|---|
| 平均帧耗时 | 23ms |
| UI 线程平均耗时 | 4ms |
| Raster 线程平均耗时 | 18ms |
| 最坏帧耗时 | 62ms |
第二步:给图片消息设置解码尺寸,并给头像和气泡分别包上 RepaintBoundary。这一步把图片解码峰值压了下来,最坏帧从 62ms 降到 35ms:
| 指标 | 优化后(第二步) |
|---|---|
| 平均帧耗时 | 14ms |
| Raster 线程平均耗时 | 12ms |
| 最坏帧耗时 | 35ms |
| 内存占用 | 330MB |
第三步:消息 JSON 解析与时间分组计算移入 compute。这一步主要降低了 UI 线程的峰值占用,偶尔的“卡一下”几乎消失:
| 指标 | 优化后(第三步) |
|---|---|
| 平均帧耗时 | 10ms |
| UI 线程平均耗时 | 2ms |
| 最坏帧耗时 | 28ms |
| 内存占用 | 280MB |
第四步:滚动节流 + cacheExtent 调小,搭配分页加载。最后把一次 500 条改成每页 40 条、触底加载下一页。这下内存不再持续膨胀,长时间滑动后的 GC 卡顿也基本消失:
| 指标 | 优化后(第四步) |
|---|---|
| 平均帧耗时 | 8ms |
| 最坏帧耗时 | 20ms |
| 内存占用 | 160MB(稳定后) |
| 滑动 5 分钟 GC 频率 | 2 次 |
这个案例给我的启发是:优化动作应该有节奏地一个个上,并且每上一个就测一次数据。看似不起眼的itemExtent和const往往比花哨的缓存组件更管用;而图片这种重型资源,必须在前端限制解码尺寸,否则再强缓存也无法挽救峰值卡顿。
6.3 优化完成后,还要做的“护城河”工作
性能优化完成不算完,项目还在迭代,谁也不能保证后续加需求不会把列表重新拖垮。我会在代码里埋两个小工具:
一个是在列表滑动时周期性计算帧耗时,如果连续几帧超过阈值,就自动上报一条性能日志,方便线上发现回归。另一个是给列表的“复杂装饰能力”做一个开关,一旦设备档位判定较低,可以手动或自动关闭头像圆角裁剪、阴影等视觉效果。这两个工具不复杂,但能让你在下一次性能问题出现时,不必从零开始排查。
最后再分享一个小技巧:优化长列表时,永远先做“减法”再做“加法”。先把 item 里所有不必要的装饰、动态效果、对象创建减到最少,跑通了再逐步把视觉效果加回来,并在每一档上加完立刻测一次性能。这样做既能保住视觉保真,又能卡住性能红线。我在多个 OpenHarmony 设备上试过,这个流程比一次性套用所有优化方案要靠谱得多。