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

列表页最怕一种问题:筛选前第二行是展开的,筛选后展开状态跑到了别的行;购物清单里只勾选了“鸡蛋”,结果“番茄”也像被勾上了;排序后卡片上的局部输入框还保留着上一条数据的草稿。
这类问题看起来像 ArkUI 列表复用不稳定,实际通常是三件事混在了一起:
- 列表项身份没有稳定下来,key 用了 index;
- 多行数据共享了同一个对象引用;
- 行内临时 UI 状态和数据对象状态没有分层。
Repeat 能帮助列表复用,但它不能替你判断哪一行到底是谁。身份错了,复用越积极,问题越明显。
| 状态类型 | 放在哪里更合适 | 典型例子 |
|---|---|---|
| 数据对象状态 | 数据模型里 | 菜谱 id、名称、价格、是否收藏 |
| 行内临时 UI 状态 | 以稳定 id 建表保存 | 展开、输入框草稿、临时高亮 |
| 页面筛选状态 | 页面或父组件里 | 关键词、分类、排序、页码 |
我一般不会把这些都塞进一个 item 里。item 代表数据本身,展开状态这种东西更多是“这一行在当前页面怎么显示”。如果把它和数据模型混在一起,筛选、排序、缓存、复用之后就很难排查。
先看问题写法:
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;独立对象时,只有第一行变化。
我的处理顺序是:
- 先检查
key,所有会筛选、排序、删除、插入的列表,都不要用 index 当 key。 - 再检查对象引用,默认对象、缓存对象、复制对象都要确认是不是同一份引用。
- 把行内临时 UI 状态从数据对象里拆出来,用稳定 id 管。
- 数据对象字段确实需要被观察时,再用
@ObservedV2和@Trace。 - 最后再看列表性能,比如
Repeat、组件复用、图片加载、分页加载。
顺序不要反。先谈性能,容易把身份问题掩盖掉;先把身份和状态边界拆清楚,再谈复用,排查会简单很多。
这篇讲的是“这一行到底是谁”。组件冻结解决的是“不可见组件要不要参与刷新”;同步刷新解决的是“这一批状态什么时候刷新给后续 UI 动作”。它们能配合,但不能互相替代。
如果 key 错了,组件冻结救不了状态串行;如果对象引用共享了,applySync 也只是更快地把错误刷新出来。列表问题先查身份,再查刷新,再查性能,这个顺序更稳。
本地验证脚本:article105-repeat-key-reuse-demo.mjs
验证通过点:
- index key 场景能复现筛选后的展开状态错位;
- 稳定 id key 能保持行身份;
- 共享对象引用能复现多行勾选一起变化;
- 每行独立对象能隔离勾选状态。
后续放到完整 ArkUI 页面里,还可以补一轮真机截图,把筛选前后每一行的展开状态和勾选状态截出来。这样读者不仅能看到代码,也能看到问题发生和修复后的界面变化。
更多推荐

所有评论(0)