最近我重做了一个资讯瀑布流。页面初始有 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

它没有复杂算法,只是把数据变化和组件身份重新对齐。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐