
做鸿蒙应用开发这几年我有个越来越深的体会长列表页面的性能基本决定了一个 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. 先别急着优化 itemkeyGenerator 和数据源通知才是性能地基2.1 keyGenerator 写错整个 Diff 机制直接失效keyGenerator 是 LazyForEach 的第二个核心参数。它的作用是为每个 item 生成一个唯一标识让框架可以判断哪些 item 是原地更新、哪些是新插入的。这个参数写得好不好直接决定复用池能不能正常工作。最常见的低级错误是用 index 做 keyLazyForEach( this.dataSource, (item: MessageItem, index: number) { MessageListItem({ item: item }) }, (item: MessageItem, index: number) index.toString() )这个写法在列表不做插入、删除、排序时看起来一切正常。但只要在头部插入一条新消息原来的第 0 条变成第 1 条key 从 0 变成 1Diff 算法就会判定原来的所有 item 全部失效这是全新的列表于是整个可视区全部重建。表现就是滚动位置错乱、焦点丢失、动画重复播放、输入框状态被清空。这些问题在 IM 会话页里尤其致命。比 index 更坑的是用随机数或时间戳做 key。每次渲染都生成新 key等于主动告诉框架我不需要任何复用LazyForEach 就退化成全量重建性能比普通 ForEach 还差。正确的做法是用业务稳定 IDLazyForEach( 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.1s1.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 不是所有列表都适合 LazyForEachLazyForEach 虽然强但不是银弹。数据量不到一屏甚至只有十几条时惰性加载省下的创建开销微乎其微反而多了缓存池、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 秒连续滚动 快速回拉记录首屏时间、平均帧率、掉帧次数、内存峰值四个数字。这个习惯之所以重要是因为性能优化最怕的只有一句话做完感觉顺了结果下个版本又卡回去。有了基线表每次版本迭代列表性能是否劣化表格会直接告诉你。后来我把这套基线表写成了团队内部的性能回归清单从此长列表再没出过不可控的性能回退。优化技巧会过时但量化驱动的习惯不会。