HarmonyOS Repeat 状态串行怎么办:中式美食长列表 key、复用和行内状态怎么拆

问题先说清楚

列表页最怕一种问题:筛选前第二行是展开的,筛选后展开状态跑到了别的行;购物清单里只勾选了“鸡蛋”,结果“番茄”也像被勾上了;排序后卡片上的局部输入框还保留着上一条数据的草稿。

这类问题看起来像 ArkUI 列表复用不稳定,实际通常是三件事混在了一起:

  • 列表项身份没有稳定下来,key 用了 index;
  • 多行数据共享了同一个对象引用;
  • 行内临时 UI 状态和数据对象状态没有分层。

Repeat 能帮助列表复用,但它不能替你判断哪一行到底是谁。身份错了,复用越积极,问题越明显。

先区分三个状态

状态类型 放在哪里更合适 典型例子
数据对象状态 数据模型里 菜谱 id、名称、价格、是否收藏
行内临时 UI 状态 以稳定 id 建表保存 展开、输入框草稿、临时高亮
页面筛选状态 页面或父组件里 关键词、分类、排序、页码

我一般不会把这些都塞进一个 item 里。item 代表数据本身,展开状态这种东西更多是“这一行在当前页面怎么显示”。如果把它和数据模型混在一起,筛选、排序、缓存、复用之后就很难排查。

案例一:用 index 当 key,筛选后状态跑到别的行

先看问题写法:

Repeat(this.recipes)
  .each((item: RecipeItem, index: number) => {
    RecipeRow({ item: item })
  })
  .key((item: RecipeItem, index: number) => index.toString())

这段代码在静态列表里看不出问题。问题发生在列表会过滤、删除、排序的时候。假设原来有三行:

index id name expanded
0 r-001 宫保鸡丁 false
1 r-002 番茄牛腩 true
2 r-003 鱼香肉丝 false

这时删掉第一行,r-002 从 index 1 变成 index 0。框架看到 key 也变了,就很难知道“原来展开的是 r-002,而不是现在的第 1 个位置”。结果可能变成:

index id name expanded
0 r-002 番茄牛腩 false
1 r-003 鱼香肉丝 true

展开状态跑到了 r-003 上,这就是典型的状态串行。

更稳的写法是用稳定业务 id:

Repeat(this.recipes)
  .each((item: RecipeItem) => {
    RecipeRow({
      item: item,
      expanded: this.expandedMap.get(item.id) ?? false,
      onToggle: () => this.toggleExpanded(item.id)
    })
  })
  .key((item: RecipeItem) => item.id)

行内临时状态不要依赖 index,而是用 id -> state 保存:

@State expandedMap: Map<string, boolean> = new Map()

toggleExpanded(id: string) {
  const next = new Map(this.expandedMap)
  next.set(id, !(next.get(id) ?? false))
  this.expandedMap = next
}

这里有一个细节:我会新建一个 Map 再赋值,而不是直接改旧 Map。这样更容易让页面状态变化被识别,也避免后面排查时搞不清楚引用有没有变。

案例二:多行共享同一个对象引用,勾选状态一起变

第二个问题更隐蔽。比如构造购物清单数据时,为了省事写了一个默认对象:

const defaultMeta: IngredientMeta = {
  checked: false,
  note: ''
}

const rows: IngredientRow[] = ingredients.map((item) => ({
  id: item.id,
  name: item.name,
  meta: defaultMeta
}))

这段代码的问题是每一行拿到的是同一个 defaultMeta 引用。你改第一行:

rows[0].meta.checked = true

第二行也会受影响,因为它们指向的是同一份对象。列表复用一参与进来,表现就更像“组件把状态串了”。

正确做法是每一行生成独立对象:

const rows: IngredientRow[] = ingredients.map((item) => ({
  id: item.id,
  name: item.name,
  meta: {
    checked: false,
    note: ''
  }
}))

如果后面要让对象字段被观察,再按状态管理 V2 的方式声明对象字段,不要靠共享引用偷懒:

@ObservedV2
class IngredientMeta {
  @Trace checked: boolean = false
  @Trace note: string = ''
}

const rows = ingredients.map((item) => ({
  id: item.id,
  name: item.name,
  meta: new IngredientMeta()
}))

本地怎么验证

我用脚本复现了两个问题。

第一组验证 index key:

const shifted = caseIndexKeyStateShift()
const stable = caseStableKeyKeepsIdentity()

console.log(shifted)
console.log(stable)

验证结果很直观:用 index 当 key 时,筛选后展开状态跑到了另一行;用稳定 id 当 key 时,展开状态还跟着原来的 r-002

第二组验证对象引用:

const shared = caseSharedObjectReference()
const separated = caseSeparateObjectReference()

console.log(shared)
console.log(separated)

共享对象时,两行都会变成 checked=true;独立对象时,只有第一行变化。

我会怎么改列表代码

我的处理顺序是:

  1. 先检查 key,所有会筛选、排序、删除、插入的列表,都不要用 index 当 key。
  2. 再检查对象引用,默认对象、缓存对象、复制对象都要确认是不是同一份引用。
  3. 把行内临时 UI 状态从数据对象里拆出来,用稳定 id 管。
  4. 数据对象字段确实需要被观察时,再用 @ObservedV2@Trace
  5. 最后再看列表性能,比如 Repeat、组件复用、图片加载、分页加载。

顺序不要反。先谈性能,容易把身份问题掩盖掉;先把身份和状态边界拆清楚,再谈复用,排查会简单很多。

和组件冻结、同步刷新有什么区别

这篇讲的是“这一行到底是谁”。组件冻结解决的是“不可见组件要不要参与刷新”;同步刷新解决的是“这一批状态什么时候刷新给后续 UI 动作”。它们能配合,但不能互相替代。

如果 key 错了,组件冻结救不了状态串行;如果对象引用共享了,applySync 也只是更快地把错误刷新出来。列表问题先查身份,再查刷新,再查性能,这个顺序更稳。

验证记录

本地验证脚本:article105-repeat-key-reuse-demo.mjs

验证通过点:

  • index key 场景能复现筛选后的展开状态错位;
  • 稳定 id key 能保持行身份;
  • 共享对象引用能复现多行勾选一起变化;
  • 每行独立对象能隔离勾选状态。

后续放到完整 ArkUI 页面里,还可以补一轮真机截图,把筛选前后每一行的展开状态和勾选状态截出来。这样读者不仅能看到代码,也能看到问题发生和修复后的界面变化。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐