做鸿蒙应用开发这几年,我有个越来越深的体会:长列表页面的性能,基本决定了一个 App 在用户心里的"丝滑感"。HarmonyOS 6 的 ArkUI 提供了 LazyForEach 作为官方推荐的惰性加载方案,很多新入坑的开发者把它当成万能钥匙——数据量大?上 LazyForEach;列表卡?再套一层 LazyForEach。但我在多个项目里实测下来,LazyForEach 解决的是"组件创建"这个维度的性能问题,如果你的 keyGenerator 乱写、数据源刷新粒度太大、item 组件内部塞了太多私货,惰性加载不但救不了你,反而会因为复用和 Diff 机制带来更隐蔽的卡顿。这篇文章是我把一张 IM 会话列表从"卡得没法用"调到稳定 60 帧全过程的复盘,也会把 ArkUI 惰性加载里最容易踩的坑一次性讲清楚。适合正在用 ArkUI 写长列表,或者刚接触 HarmonyOS 性能优化的人。
1. 惰性加载的"惰"与"不惰":LazyForEach 到底替你省了什么
1.1 LazyForEach 实际干活的三个环节
LazyForEach 不是一个独立组件,它依赖 IDataSource 接口来驱动。真正决定性能的,是数据源、滚动容器和组件复用池三者之间的配合。
滑入可视区的 item 会创建组件节点;滑出可视区的 item 不会立刻销毁,而是丢回缓存池;当新的 item 滑入时,如果类型匹配,就直接拿旧节点的壳,重新绑定新数据。ArkUI 内部再通过 key 做 Diff,判断哪些 item 是原位更新、哪些是需要重新创建的。这个机制拆开看,惰性主要体现在三个层面:
- 节点创建懒:屏幕外还有几百条 item,不会提前创建对应的组件树。
- 布局计算懒:滚动容器只在可渲染区域附近做 layout,离屏 item 不参与布局约束计算。
- 数据读取懒:getData() 是真正要做业务取值的地方,很多格式化、裁剪逻辑可以按需执行。
打个比方:LazyForEach 像咖啡馆固定的那批杯子,不同客人在座位上轮流转,而不是每次来客人都熔掉旧杯子重新吹一个新杯子。这个"杯子复用"的设计,就是它比 ForEach 强的基础。
1.2 惰性加载不背锅:它没解决的三类开销
但这里有个特别容易误导人的点:惰性加载只解决了"创建开销",它不能让你 item 内部的渲染变轻。
如果一条消息气泡内部有一个阴影动画、一张需要即时解码的大图、好几层嵌套的透明度动画,那么一个 item 从数据到出画面可能就要花 70ms。每滑入一个新 item,主线程就要忙 70ms,帧率照样往下掉。这时候你去埋怨 LazyForEach,其实是框架替你背了锅——框架已经尽量少干活了,干活的依然是你 item 里那一大坨东西。
另一个代价是复用本身。因为 item 是"租来的",租客变了,里面的陈设必须全部换掉。如果你在 item 里用了非 @ObjectLink 管理的全局状态,或者依赖了外部可变变量,滑出再滑入后很容易出现"上一页的数据串到下一页"的诡异 bug。我见过不少新手排查半天读写冲突,最后发现是 item 复用后某个静态变量没重置。
所以做惰性加载优化的第一原则是:先明白 LazyForEach 帮你省了什么,剩下的性能缺口全部要靠 item 瘦身、缓存策略和数据源改造来解决。
2. 先别急着优化 item:keyGenerator 和数据源通知才是性能地基
2.1 keyGenerator 写错,整个 Diff 机制直接失效
keyGenerator 是 LazyForEach 的第二个核心参数。它的作用是为每个 item 生成一个唯一标识,让框架可以判断哪些 item 是原地更新、哪些是新插入的。这个参数写得好不好,直接决定复用池能不能正常工作。
最常见的低级错误是用 index 做 key:
LazyForEach( this.dataSource, (item: MessageItem, index: number) => { MessageListItem({ item: item }) }, (item: MessageItem, index: number) => index.toString() )这个写法在列表不做插入、删除、排序时看起来一切正常。但只要在头部插入一条新消息,原来的第 0 条变成第 1 条,key 从 "0" 变成 "1",Diff 算法就会判定"原来的所有 item 全部失效,这是全新的列表",于是整个可视区全部重建。表现就是:滚动位置错乱、焦点丢失、动画重复播放、输入框状态被清空。这些问题在 IM 会话页里尤其致命。
比 index 更坑的是用随机数或时间戳做 key。每次渲染都生成新 key,等于主动告诉框架"我不需要任何复用",LazyForEach 就退化成全量重建,性能比普通 ForEach 还差。
正确的做法是用业务稳定 ID:
LazyForEach( this.dataSource, (item: MessageItem) => { MessageListItem({ item: item }) }, (item: MessageItem) => item.id )如果列表里有多种不同类型的 item——比如会话页里既有时间戳行,又有消息行,还有红包卡片——推荐在 key 里加类型前缀,避免不同类型因为 key 冲突而互相错误复用:
keyGenerator: (item: CellModel) => `${item.type}_${item.id}`这里给一个忠告:keyGenerator 的结果不是越长越好。它会影响 Diff 的字符串比较开销,过长的 key(比如一整个 JSON 字符串)在高频刷新时会放大成本。稳定、全局唯一、尽量短,才是合格的 key。
2.2 IDataSource 的刷新粒度:别什么事都全量 onDataReloaded
很多项目接了 LazyForEach 之后,数据源更新一律这么写:改完数组,调一下 onDataReloaded()。从功能上讲,列表确实刷新了;从性能上讲,这是最贵的刷新方式。
onDataReloaded() 会通知框架重新评估所有 item 的 key,并触发全量 Diff。哪怕你只是在列表尾部追加了一条消息,它也会把可视区里的所有 item 都过一遍。数据量小的时候无感,几千条消息的 IM 会话页,每收到一条新消息就全量 Diff 一次,滑动必然发闷。
正确做法是精确到"哪变了就报哪":
export class MessageDataSource implements IDataSource { private items: MessageItem[] = []; private listeners: DataChangeListener[] = []; totalCount(): number { return this.items.length; } getData(index: number): MessageItem { return this.items[index]; } registerDataChangeListener(listener: DataChangeListener): void { if (this.listeners.indexOf(listener) < 0) { this.listeners.push(listener); } } unregisterDataChangeListener(listener: DataChangeListener): void { const idx = this.listeners.indexOf(listener); if (idx >= 0) { this.listeners.splice(idx, 1); } } // 新增一条消息:精确通知新增位置 addMessage(item: MessageItem): void { this.items.push(item); this.listeners.forEach(listener => { listener.onDataAdd(this.items.length - 1); }); } // 更新某条消息:只刷新该 index updateMessage(index: number, item: MessageItem): void { this.items[index] = item; this.listeners.forEach(listener => { listener.onDataChange(index); }); } // 删除某条消息 deleteMessage(index: number): void { this.items.splice(index, 1); this.listeners.forEach(listener => { listener.onDataDelete(index); }); } }接口里对应的方法分别是 onDataAdd、onDataMove、onDataDelete、onDataChange、onDataReloaded。新增用新增的通知,删除用删除的通知,更新用更新的通知。只有当你整个列表的数据源被整体替换时,才调用 onDataReloaded()。
这里有一个我踩过的坑:精确通知必须保证 index 在"通知那一刻"是正确的。如果你在异步回调里先改了数据,再发通知,中间数据可能已经被其他地方改动,导致通知位置错位。稳妥做法是数据源更新和通知尽量放在同一个线程队列里执行,不要在子线程直接操作 UI 绑定的数据源后再抛通知。
2.3 用一张表记住不同 key 策略的实测表现
| 表现 | 根因 | 优化方向 |
|---|---|---|
| 头部插入后滚动错乱、焦点丢失 | key 用了 index | 换成业务稳定 ID |
| 动画重复播放、列表闪烁 | key 不稳定/随机 | 换成全局唯一 ID,避免随机数 |
| 消息更新后整页发闷 | 数据源全量 onDataReloaded | 精确到 onDataChange / onDataAdd |
| 不同类型的 item 互相串数据 | key 无类型前缀 | key 里加${item.type}_${item.id} |
这张表可以说是我做列表性能优化的第一道检查清单。先把单一变量都确定对了,再谈 item 内部优化。
3. cachedCount、item 组件与图片异步加载:滚动卡顿的三大隐形元凶
3.1 cachedCount 不是越大越好,也不是越小越省
LazyForEach 的第四个参数 cachedCount 控制的是缓存的 item 数量,默认值是 1。也就是说,可视区之外框架默认只预留 1 个 item 的缓冲。对单屏能容纳 5~8 条消息的列表来说,这个默认值明显不够:快速滑动时,新 item 刚进入预构建区,框架来不及创建,边缘就会出现白屏或明显的"先空后填"。
很多人遇到白屏后的第一反应是把 cachedCount 调到 50。这确实解决了白屏,但代价是另外两个性能问题:一是内存暴涨,每个缓存 item 都是真实的组件实例;二是首屏构建时间变长,因为 LazyForEach 会把可视区外加缓存区内的所有 item 一次性预构建出来。
我现在的做法是估算式配置:
@State cachedCount: number = 6; aboutToAppear(): void { // 用屏幕可用高度和 item 估算高度去反推缓存数量,再加一个余量 const screenHeight = this.getScreenHeight(); // 从 UIContext 取实际高度 const estimatedItemHeight = 96; this.cachedCount = Math.ceil(screenHeight / estimatedItemHeight) + 2; }然后在 LazyForEach 上把动态计算值传进去:
LazyForEach( this.dataSource, (item: MessageItem) => { MessageListItem({ item: item }) }, (item: MessageItem) => item.id, this.cachedCount )这样配置的好处是:在设备尺寸差异较大的场景下,缓存数量能跟随屏幕容量自适应,而不是拍脑袋写死一个数。
要注意:这个方法对"item 高度比较均匀"的列表非常有效。但如果列表里既有 20px 的日期分隔行,又有 1000px 的图文卡片,固定一个缓存数量会顾此失彼。此时我通常会给 List 设置一个估算高度提示,或者按区块动态调整缓存的 item 范围,而不是继续死磕单个 cachedCount 数值。
3.2 item 组件里藏着的隐形开销
前阵子有个项目找我排查列表卡顿,代码拉到本地一看,ListItem 内部的根节点是一个五层嵌套的 Column,里面套着两个带 Shadow 属性的 Text、一个动态变化的背景色、还有一个点击时触发的透明度动画。
Shadow 属性在 ArkUI 里的开销比很多人想象中大。如果某个 item 的阴影值随着手势或动画连续变化,那么每一帧都会触发阴影重绘,这个绘制成本会落在一条路径上:UI 线程做属性动画,渲染线程做阴影计算,两个线程在滚动过程中互相抢资源,帧率自然保不住。
我最后给的建议是:item 内部尽量保持轻量、静态。一个 item 只做展示,把真正的交互逻辑放在子组件或事件回调里。如果确实需要动画,也要把它限制在很小的区域内,并且尽量使用系统提供的、有硬件加速支持的属性,避免用阴影、模糊、透明叠加这类渲染成本较高的组合。
另外一个容易被忽略的开销是圆角裁剪。很多人给图片直接加 borderRadius 实现圆角头像,这在数据量小的时候没问题;但长列表里每条消息都有头像,每帧都在做圆角裁剪计算,累计成本非常可观。优化思路是把圆角在图片解码阶段就处理好,生成一张带圆角的缓存图,展示时直接复用,而不是在渲染阶段每次做裁剪。
3.3 快速滑动场景下的图片异步加载与"串台"
图片异步加载是长列表另一类顽疾。快速滑动时,旧 item 的图片请求回调还没回来,组件已经被复用给新 item,于是回调结果被设置到新位置的 Image 上,表现为图片串台、闪图、短暂显示错误内容。
Image 组件本身有缓存机制,但网络加载和解码是异步的。item 被复用后,组件实例并没有销毁,旧回调仍然可能触发。这里我建议两个配合使用的策略。
第一个是 onVisibleAreaChange 控制可见性。item 真正进入可视区时才触发原图加载,离开可视区就取消后续解码任务:
ListItem() { Column() { Image(this.item.thumbUrl) .objectFit(ImageFit.Cover) .onError(() => { // 失败处理:显示占位图或重试按钮 }) // 其余内容 } } .onVisibleAreaChange((isVisible: boolean) => { if (isVisible) { // 进入可视区,通知图片加载库加载原图 } else { // 离开可视区,取消高优先级解码任务 } })第二个策略是"小图先行,原图后到"。列表先展示缩略图,保证滚动流畅,等 item 稳定停留在可视区内再加载大图。这跟主流社交 App 的做法一致,用户感知到的就是"图片渐进清晰",而不是"滑动时卡一下"。
4. 一个 IM 会话页的调优复盘:从掉帧到稳定 60 帧
4.1 复现与数据采集:用 Profiler 抓住掉帧现场
这个案例是一个针对开发阶段的 IM 会话页,消息量不大,前后端联调时只有几百条数据,但在 HarmonyOS 6 真机上滑动时却卡得明显。用户反馈"手指划一下,画面一抖一抖的"。
第一步先把问题量化。打开 DevEco Studio 的 Profiler,录制 30 秒的连续滚动操作。记录三组数据:平均帧率、掉帧次数(超过 16.6ms 的帧)、CPU 热点调用栈。
这里有个重要提醒:不要在调试模式下录帧。调试模式会关闭一部分编译优化,帧率数据严重失真。我通常用 release 包或者非调试包做性能采集,否则你可能会优化一个根本不存在的瓶颈。
实测数据是这样的:正常滚动时平均帧率只有 38 帧左右,滚动速度较快时掉帧严重,每秒掉帧达到 15 次。首屏加载大约用了 2.1 秒,因为页面一次性把 500 条消息全部塞进了数据源,LazyForEach 还没开始'"惰",光数据拷贝和初始化就花了不少时间。
4.2 定位过程:二分法切除嫌疑组件
拿到 Profiler 数据后,定位思路是"二分法切除"。
第一步,我把 item 内部所有子组件注释掉,只保留一行 Text 显示消息内容。再录一次数,平稳 60 帧。这说明列表框架本身没问题,瓶颈在 item 内部。
第二步,逐个恢复组件。先恢复普通文本,帧率没有明显变化;恢复图片组件后,出现了轻微掉帧;恢复未读气泡的阴影动画后,掉帧开始明显;再把图片的 borderRadius 圆角裁剪打开,又多出一个掉帧点。
问题锁定得很清晰:三宗罪——未读气泡的阴影在反复重绘;图片圆角在渲染阶段做裁剪;数据源新增消息时用了 onDataReloaded 全量刷新。
4.3 改动方案与优化前后数据对比
改动方案分四步:
- keyGenerator 从 index 改成消息 id。
- 数据源新增消息改为 onDataAdd 精确通知。
- 阴影动画从"逐帧动态变化"改成"静态显示 + 条件渲染",只有真正需要提醒时才展示阴影效果。
- 图片圆角处理挪到解码阶段生成缓存图,不在渲染层做 borderRadius 裁剪。
优化前后数据对比如下:
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 平均帧率 | 38 帧 | 59 帧 |
| 每秒掉帧次数 | 15 次 | 1 次左右 |
| 首屏加载时间 | 2.1s | 1.2s |
| 新消息接收后列表响应 | 全量 Diff,明显发闷 | 局部插入,基本无感 |
这次调优让我特别确定一件事:很多列表卡顿的根因不在 LazyForEach,也不在 ArkUI 框架,而在调用方自己写的 item 结构和对数据源通知的不当使用。框架提供的惰性加载只是一个基础能力,真正的性能差异全在于你怎么用它。
5. 深度优化方案:一份可以直接抄作业的实现清单
5.1 稳定 key + 精确数据源刷新:标准写法
这里给出一个可以直接复用的组合模板。先定义被观察的数据类:
@Observed export class MessageItem { id: string = ''; sender: string = ''; content: string = ''; timestamp: number = 0; readState: number = 0; liked: boolean = false; }再实现数据源。上面已经贴过完整代码,核心思想是:新增用 onDataAdd,更新用 onDataChange,删除用 onDataDelete,只有整体替换才用 onDataReloaded。
页面里这样用:
@State private dataSource: MessageDataSource = new MessageDataSource(); build() { LazyForEach( this.dataSource, (item: MessageItem) => { MessageListItem({ item: item }) }, (item: MessageItem) => item.id, this.cachedCount ) }这段代码的基础是:id 必须稳定且全局唯一。如果后端一时没给消息 id,前端可以用 "senderId + timestamp + 序号" 组合出相对稳定的 key,但强烈建议后续让后端补上真正唯一 ID。
5.2 cachedCount 动态计算:封装成公共方法
我习惯把动态缓存数量封装成一个独立函数,方便多个页面复用:
function calcLazyCacheCount(screenHeight: number, estimatedItemHeight: number): number { if (estimatedItemHeight <= 0) { return 6; } return Math.ceil(screenHeight / estimatedItemHeight) + 2; }这里的 "+2" 是给滑动方向上的预构建留出的余量。如果你在低端设备上测试仍有白屏,可以再把余量提高到 +3,但要留意内存占用。实测下来,缓存数量在"单屏可容纳 item 数 + 2"附近是最经济的选择。
5.3 图片加载与解码复用:避免重复踩坑
图片部分的实践方案可以整理成三条规则:
- 列表页永远用小图或缩略图做首次展示,原图在 item 稳定进入可视区后再加载。
- 占位图用本地资源或纯色背景,不要拿网络图片当占位,否则还没有正式内容就先制造一次网络请求。
- 图片圆角尽量在解码阶段处理,不要在渲染阶段用 borderRadius 高频裁剪。
配合 onVisibleAreaChange 做可视区优先级管理,滚动过程中的图片解码压力会大幅下降。尤其在大量图片的 feed 流场景里,这个策略能直接决定滑动跟不跟手。
5.4 @Observed / @ObjectLink:让 item 按字段更新
在列表性能优化里,状态管理粒度决定了刷新范围。如果你的页面用一个大的 @State 管理整个列表,那么任何一条消息的已读状态变化,都可能触发页面级 re-render,牵连所有 item。这显然是浪费。
使用 @Observed 修饰数据类,配合 @ObjectLink 在子组件中观察,可以让刷新范围精确到 item 内部:
@Component export struct MessageListItem { @ObjectLink item: MessageItem; build() { Column() { Text(this.item.sender) .fontSize(14) Text(this.item.content) .fontSize(16) .opacity(this.item.readState === 1 ? 0.6 : 1.0) Row() { Text(this.item.liked ? '已赞' : '点赞') .onClick(() => { this.item.liked = !this.item.liked; }) } } } }@ObjectLink 观察的是"这个对象内部属性"的变化,而不是整个页面的脏检查。当消息的 liked 字段变化时,只有当前 item 的对应区域重新渲染,其他 item 完全不受影响。这是大型列表性能飞跃的关键,也是从"能用"到"好用"的分水岭。
5.5 item 骨架抽取:静态部分和动态部分分开
我的最后一个建议是:不要在 ListItem 里写一个巨型根组件,把所有内容堆在一起。
把 item 内部拆成几个小组件,静态部分(头像、时间、背景)放一层,动态部分(点赞、已读、动画)单独抽一个子组件。这样当动态字段变化时,只有那一个小组件 re-render,静态部分完全跳过。代码维护上也会更清晰,调试时哪里掉了帧,一眼就能定位到对应组件。
6. 这些边界条件最容易翻车,以及我现在养成的性能基线习惯
6.1 不是所有列表都适合 LazyForEach
LazyForEach 虽然强,但不是银弹。数据量不到一屏甚至只有十几条时,惰性加载省下的创建开销微乎其微,反而多了缓存池、key 维护和 Diff 成本,用普通 ForEach 或者 Column 直接渲染反而更简单。还有一些特殊场景,比如需要 item 常驻离屏来配合跨 item 的连续动画、视差滚动,惰性加载会把离屏 item 回收,动画状态就丢了。这时候你要么调大 cachedCount 兜底,要么干脆放弃惰性方案。
6.2 item 高度差异极大的场景
微博式 feed 流里,有的内容只有一行文字,有的是一张大图,item 高度可能差几十倍。cachedCount 怎么调都会别扭:按小 item 估,大 item 滑入时白屏;按大 item 估,内存白白多出一大截。我的做法是给 ListItem 设置合理的"估算高度"属性,减少布局引擎的猜测成本,同时按内容区块预估大致的 item 高度分布。这块没有一劳永逸的参数,只能针对业务压测几次,找到一个让白屏率和内存占用都相对平衡的值。
6.3 和 Tabs、侧滑容器联动时的内存问题
长列表如果嵌套在 Tabs 或侧滑容器里,页面切走后 list 并不会立即销毁,而是保留在容器中等待回切。这时候务必在 onVisibleAreaChange 返回 false 时暂停视频、取消图片解码、停掉高频动画,否则多页签场景下内存会悄悄涨上去。这种问题通常不在首页暴露,往往是用户多切换几次页面后才出现卡顿或 OOM,排查起来相当头疼。
6.4 我目前的个人习惯:性能基线表
做了这么多列表优化后,我养成的一个习惯是:每次改动前先录一组"性能基线",改动后再录一组,只改一个变量,对比一次。基线的固定动作用"30 秒连续滚动 + 快速回拉",记录首屏时间、平均帧率、掉帧次数、内存峰值四个数字。
这个习惯之所以重要,是因为性能优化最怕的只有一句话:"做完感觉顺了",结果下个版本又卡回去。有了基线表,每次版本迭代列表性能是否劣化,表格会直接告诉你。后来我把这套基线表写成了团队内部的性能回归清单,从此长列表再没出过不可控的性能回退。优化技巧会过时,但量化驱动的习惯不会。