HarmonyOS 7 ArkUI + LazyForEach:瀑布流增量更新中的稳定 Key、局部刷新与复用错位修复【鸿蒙心迹】
最近我重做了一个资讯瀑布流。页面初始有 110 条内容,接口会插入新卡片,也会修改已有卡片。最开始我只是把新结果拼到数组里再整体刷新,数据一多,就偶尔出现“图片没变、标题变了”“点进 feed_105,回来却像 feed_106”的错位。
接口数据没有错,真正的问题是 LazyForEach 的组件复用和业务数据身份没有对齐。
这篇只讲 FeedPatchLab:怎样用稳定 Key 控制组件身份,怎样分别通知插入和局部修改,以及什么时候应该复用、什么时候应该接受重建。

一、先判断是数据错,还是复用错
本次最终数据固定为:Session lazy_patch_20261001_12,State STABLE,Total Items 120,Inserted 6,Changed 4,Reused 110,Rebuilt 10,Key Collisions 0,Frame Cost 16.4 ms,Last Item feed_119。
状态流转是:
READY → PATCHING → NOTIFYING → STABLE
我先把接口返回值和用户实际点击的 item.id 都打进 HiLog。数据没有重复,顺序也正常。问题出在 keyGenerator:如果使用数组 index,前面插入 6 条数据以后,后面的 index 整体移动,但组件树仍可能把“原来的第 20 个节点”拿来复用。
对业务来说,第 20 个位置却已经换成另一个 feed。
所以第一条规则很简单:瀑布流里不要用数组下标充当业务身份。
二、稳定 Key 先解决“谁是谁”
页面层直接把业务 id 交给 LazyForEach。
@Entry
@Component
struct FeedPatchPage {
private dataSource: FeedDataSource = new FeedDataSource()
build() {
Scroll() {
WaterFlow() {
LazyForEach(
this.dataSource,
(item: FeedCard) => {
FlowItem() {
FeedCardView({ item })
}
},
(item: FeedCard) => item.id
)
}
}
}
}
feed_104 永远是 feed_104,即使前面插入新内容,它的 key 也不会变化。这样框架复用组件时,身份才和业务对象一致。
如果服务端返回重复 id,我会在数据层拒绝第二条,最终本轮 Key Collisions=0。
三、插入和修改,不要都退回整组 reload
一开始我直接 reload,结果虽然正确,却等于每次都让整组数据重新确认。后来把 DataSource 改成按操作类型通知。
class FeedDataSource implements IDataSource {
private items: FeedCard[] = []
private listeners: DataChangeListener[] = []
totalCount(): number {
return this.items.length
}
getData(index: number): FeedCard {
return this.items[index]
}
registerDataChangeListener(listener: DataChangeListener): void {
if (!this.listeners.includes(listener)) {
this.listeners.push(listener)
}
}
unregisterDataChangeListener(listener: DataChangeListener): void {
const index = this.listeners.indexOf(listener)
if (index >= 0) this.listeners.splice(index, 1)
}
insertAt(index: number, item: FeedCard): void {
if (this.items.some(v => v.id === item.id)) return
this.items.splice(index, 0, item)
this.listeners.forEach(v => v.onDataAdded(index))
}
changeAt(index: number, item: FeedCard): void {
this.items[index] = item
this.listeners.forEach(v => v.onDataChanged(index))
}
}
新增时通知 onDataAdded(index),已有项变化时通知 onDataChanged(index)。这样框架知道哪里真的多了节点,哪里只是现有节点内容变化。
本轮是 Inserted=6、Changed=4,最后只重建 10 个节点,其余 110 个继续复用。
四、批量 Patch 前先做业务层 diff
接口结果不会直接塞进 DataSource。我先按 id 建索引,再计算 insert 和 change。
private applyPatch(incoming: FeedCard[]): void {
this.state = 'PATCHING'
const indexById = new Map<string, number>()
for (let i = 0; i < this.dataSource.totalCount(); i++) {
indexById.set(this.dataSource.getData(i).id, i)
}
for (const item of incoming) {
const oldIndex = indexById.get(item.id)
if (oldIndex === undefined) {
this.dataSource.insertAt(
this.dataSource.totalCount(),
item
)
this.inserted++
continue
}
const oldItem = this.dataSource.getData(oldIndex)
if (oldItem.version !== item.version) {
this.dataSource.changeAt(oldIndex, item)
this.changed++
}
}
this.state = 'NOTIFYING'
}
id 只负责身份,version 只负责判断内容有没有变化,两者不要混在一起。
如果接口已经返回 insert/update/delete 语义,客户端就不用再做一遍 diff。
五、日志里要能看见“到底重建了多少”
项目结构很小:
entry/src/main/ets/
├─ pages/FeedPatchPage.ets
├─ datasource/FeedDataSource.ets
├─ model/FeedCard.ets
└─ ui/FeedCardView.ets
HiLog 固定输出:
insert batch=6
changed=4
reused=110 rebuilt=10
key collisions=0
State: PATCHING -> STABLE
现在至少能确认,本次变化只影响 10 个节点。

六、最终结果比“肉眼不卡”更有意义
最终手机页面里是:Total Items=120、Inserted=6、Changed=4、Reused=110、Rebuilt=10、Key Collisions=0、Frame Cost=16.4 ms、Last Item=feed_119。
列表中能看到 feed_104、feed_105、feed_106、feed_107,以及本轮新插入的 feed_114。
反复执行 patch 后,index key 能复现错位;改成 item.id 后多轮运行都保持一致。

七、生命周期和重建边界也要收口
registerDataChangeListener() 和 unregisterDataChangeListener() 必须成对。正式项目如果 DataSource 放进全局 Store,而页面不断创建销毁,listener 不注销就会越积越多。
复用也不是越多越好。卡片结构从普通图文切到视频、或者单图切到三图时,我会接受一次重建,而不是继续堆条件分支。
这次 Reused=110、Rebuilt=10 已经是比较健康的结果。以后再做长列表,我会先确认:业务对象有没有稳定 id,DataSource 能不能表达真实增删改,日志能不能看出局部更新影响了多少节点。
FeedPatchLab 最后稳定在:
READY → PATCHING → NOTIFYING → STABLE
它没有复杂算法,只是把数据变化和组件身份重新对齐。
更多推荐



所有评论(0)