ArkUI 长列表掉帧治理:LazyForEach 键值、@Reusable 复用与预加载的五个坑
最近在给一个 HarmonyOS 单词打卡应用做性能收尾:首页是今日词表,往下滑能翻到上千条历史记录。功能早就跑通了,但快速滑动时帧率会明显掉一截,偶尔还出现"这一行的展开状态串到了下一行"。查了一圈,问题既不在数据库也不在网络,全在 List 的写法上。这篇按排查顺序记五个坑,附带可复用的分层思路。
先说结论:掉帧往往是"优化被抵消"
ArkUI 的 List + LazyForEach + cachedCount 本来就是为长列表准备的:按需创建组件、复用出屏节点、预加载临近项。但这三件事各自有前置条件——键值必须稳定、复用时必须重置局部状态、预加载必须去重。任何一条不满足,框架不但帮不上忙,还会额外做一轮无用功。所以排查顺序建议固定为:先数组件创建次数 → 再看键值稳定性 → 再查复用残留 → 最后看预加载请求量。
一、坑 1:用 index 当 key,插入一条数据全表重建
最容易写也最致命的一版是这样的:
// 反例:key 与位置绑定
LazyForEach(this.dataSource, (item: WordItem, index: number) => {
ListItem() { WordRow({ item: item }) }
}, (item: WordItem, index: number) => index.toString())
LazyForEach 靠 keyGenerator 的返回值判断"这个组件还能不能复用"。用 index 做 key,意味着第 5 个位置永远是 key=5。当用户在列表顶部插入一条新词,原本 key=5 的位置换成了另一条数据,框架发现 key 没变、数据却变了,只能走重建;而删除中间一项时,后面所有位置的 key 全部错位,等于触发一轮级联重建。表现就是:滑动时偶尔一顿,插入/删除后必卡。
正确做法是用与位置无关、生命周期内稳定的业务 ID:
// 正例:稳定业务主键
LazyForEach(this.dataSource, (item: WordItem) => {
ListItem() { WordRow({ item: item }) }
}, (item: WordItem) => item.id)
这里有个容易混淆的点:key 稳定之后,同一条数据的字段变化不会再触发组件重建(框架认为还是同一个节点)。所以列表项的数据模型要用 @Observed 装饰,子组件用 @ObjectLink 接收,让字段级变化走状态刷新,而不是靠改 key 强行重建。把"身份"和"内容"分开,是后面所有优化的前提。
二、坑 2:keyGenerator 里藏了隐式开销
知道要用稳定 ID 之后,另一个极端是"为了保险多拼点东西":
// 反例:每次都做一次序列化 (item: WordItem) => item.id + '_' + JSON.stringify(item.tagList) + '_' + item.updatedAt
keyGenerator 会在每一轮可见区域计算里被反复调用,滚动一屏可能执行几十上百次。里面放 JSON.stringify、字符串模板拼接、数组排序这类操作,等于把开销压到主线程的关键路径上,掉帧反而更明显。而且把 updatedAt 拼进 key 会重新引入坑 1:字段一变 key 就变,组件又被重建。
原则很简单:keyGenerator 只做一次 O(1) 的字段读取,最好直接返回一个已有的 string 或 number,不拼接、不计算、不访问嵌套对象。需要按条件区分渲染分支时,把分支判断放进组件内部用 @State/@Prop 处理,别塞进 key。
三、坑 3:@Reusable 复用后,局部状态串了行
加上 @Reusable 之后创建次数确实降下来了,但很快出现开头说的怪现象:某一行点开的释义卡片,滑出去再滑回来,展开状态跑到了另一行;图片的加载中标记也偶尔错位。原因是复用组件不会重新走 aboutToAppear,所有非数据源驱动的局部状态都留在实例上。
ArkUI 给的钩子是 aboutToReuse(params),它会在组件被重新绑定到新数据时调用。所有局部状态都必须在这里显式重置或按新数据重算:
@Reusable
@Component
struct WordRow {
@ObjectLink item: WordItem;
@State expanded: boolean = false;
@State imgLoading: boolean = true;
aboutToReuse(params: Record<string, Object>) {
this.expanded = false; // 展开态必须归零
this.imgLoading = true; // 加载标记必须归零
this.item = params.item as WordItem;
}
build() {
Column() {
Text(this.item.word).fontSize(18)
if (this.expanded) { Text(this.item.meaning) }
}.onClick(() => { this.expanded = !this.expanded })
}
}
按这个思路过一遍:expanded、selected、动画进度、计时器句柄、图片加载标记、输入框草稿,凡是"只属于这一次展示"的状态,都要在 aboutToReuse 里处理。反过来,属于数据本身的状态(是否已掌握、打卡进度)就该留在 @Observed 模型里,不要放进组件局部状态,否则复用时也会被误清掉。
还有个配套细节:如果列表项内部有大量 if/else 分支或按类型走不同的 @Builder,复用池会按结构分开缓存,命中率反而下降。差异大的行建议拆成不同的 ListItem 类型,用 LazyForEach 的 onDataReloaded 之外的方式做类型区分,或者干脆在数据源里预置 viewType 并让组件内部结构尽量一致。
四、坑 4:cachedCount 不是越大越好
cachedCount 决定视口外预创建多少项。设太小(比如 1),快速滑动时来不及创建,会出现短暂白块;设太大(比如 30),首屏就要多建三十个组件,内存和首帧耗时一起上去,而且这些常驻节点会拉长每一轮 diff 的耗时。
我的经验值是按一屏可见条数取 1~1.5 倍,再从实测调:先固定一个值,用打点统计"每帧新建组件数"和"滑动时白块出现次数",哪个先到瓶颈就往哪个方向微调。列表项结构重(含图片、多层嵌套)就取偏小值,结构轻(纯文本两行)可以取偏大值。
List({ space: 8 }) {
LazyForEach(this.dataSource, (item: WordItem) => {
ListItem() { WordRow({ item: item }) }
}, (item: WordItem) => item.id)
}
.cachedCount(8)
.edgeEffect(EdgeEffect.None)
另外 List 的 space、divider、edgeEffect 也会影响布局成本,逐个关掉测一轮,比凭感觉调参快得多。
五、坑 5:onScrollIndex 预加载打出请求风暴
为让图片和释义"滑到即显示",常见写法是在 onScrollIndex 里判断接近底部就拉下一页。问题在于快速滑动时这个回调触发极密,一次甩动能打出十几个分页请求,回来后主线程被 JSON 解析和数组 splice 堵住,正好在最需要流畅的时候卡住。
三件事要一起做:
- 去重:用
loading标志 + 已请求的 page 集合,保证同一页只发一次,请求没回来前不再触发。 - 节流与提前量:只在
endIndex >= total - threshold时触发,且加一个最小间隔(例如 300ms),避免回调级别的抖动。 - 批量更新数据源:数据到达后一次性 push 并通知
LazyForEachreload,不要逐条 insert——逐条会触发多轮 key diff。
private loading: boolean = false;
private lastTrigger: number = 0;
private requestedPages: Set<number> = new Set();
onReachEnd(endIndex: number, total: number) {
const now = Date.now();
if (this.loading || now - this.lastTrigger < 300) return;
if (endIndex < total - 10) return;
const page = Math.floor(total / PAGE_SIZE) + 1;
if (this.requestedPages.has(page)) return;
this.requestedPages.add(page);
this.loading = true;
this.lastTrigger = now;
fetchWords(page).then(list => {
this.dataSource.append(list); // 一次批量 append
this.loading = false;
}).catch(() => {
this.requestedPages.delete(page); // 失败要允许重试
this.loading = false;
});
}
补两个容易漏的边界:请求失败时要把 page 从集合里摘掉,否则这一页永远加载不出来;列表被筛选或重置时,requestedPages 必须清空,不然新列表的分页会全被误判成"已请求过"。另外,取消请求时别忘了把 loading 归位,界面会一直卡在"加载中"。
六、怎么确认真的改好了
不要靠"感觉顺了"收工,用可量化的三个指标:
- 组件创建次数:在
aboutToAppear与aboutToReuse各打一条 hilog 计数,滑动 3 屏后统计比值。理想状态是创建次数接近一屏条数、复用次数远大于创建次数;如果创建次数随滑动线性增长,说明 key 仍在变。 - 帧率与丢帧:用 DevEco Studio 的 Profiler 录一段快速滑动,看 Frame 轨道的超时帧分布,配合
hdc shell抓取图形相关统计定位是布局还是绘制耗时。 - 网络请求数:同样一次甩动,统计分页接口实际发出的次数。改完去重后应该稳定在 1~2 次,而不是十几次。
这三个数改前各记一次、改后各记一次,比任何主观描述都更能说明问题,也方便后面回归。
七、收拢成四层,下次不用重查
把这次的结论固化成分层,后面接别的项目只换第一层:
- 数据层:每条记录带稳定业务 ID,模型用
@Observed,字段变化走状态刷新而非重建。 - 键值层:keyGenerator 只返回 O(1) 的稳定 ID,不拼接、不序列化、不含时间戳。
- 复用层:
@Reusable+aboutToReuse显式重置所有局部状态,组件内部结构尽量统一。 - 调度层:
cachedCount按屏容量取值,预加载做去重、节流、批量 append 与失败可重试。
分完之后,"滑起来串数据"只需查第三层,"越滑越卡"查第二层,"滑到底卡一下"查第四层,定位路径是固定的。
小结
这五个坑没有一个是"数据量太大",全都是把框架已有的优化条件破坏掉了:key 不稳定让按需创建退化成反复重建,keyGenerator 里的额外计算把开销搬到了滚动关键路径,复用时不重置状态让节点带着上一条数据的痕迹,预加载不去重则在最需要帧率的时候抢占主线程。
所以长列表性能治理的顺序建议固定为:先用打点确认组件创建次数 → 修 key → 修复用 → 再调 cachedCount 与预加载。按这个顺序走,通常一轮就能把帧率拉回来;反过来从"是不是该换个更快的数据库"开始猜,往往一天就没了,而列表其实根本没在等数据。
- 点赞
- 收藏
- 关注作者
评论(0)