滚回来全串了,LazyForEach 配 @Reusable 时谁在替你保管状态
一个列表页,两百多条卡片,每张卡片有图、有标题、有收藏按钮。第一屏渲染完美,往下滑也流畅。然后你把列表往回滚,发现第三张卡片的收藏按钮亮着,点开详情却是另一条数据;再滑下去再回来,某张卡片的标题变成了刚才某条已经滑过去的记录。你把 key 换成了唯一 ID,把 cachedCount 从 5 调到 3,问题依旧偶现。
这里有两个容易混淆的东西在同时工作。LazyForEach 负责“按需创建”,@Reusable 负责“滑出去的组件实例不销毁、放进缓存池等下一个同类型条目来用”。问题出在后者:框架从缓存池取出旧实例挂回组件树时,不会帮你重置组件内部的状态。上一个条目留下的选中态、输入内容、图片加载状态,全都原样留在那个实例里。key 管的是“数据项和组件实例的对应关系”,它不负责清理实例内部的本地状态。
aboutToReuse 漏一个 @State,脏数据就跟着回来
@Reusable 组件被复用时,框架会触发 aboutToReuse,把新的构造参数传进来。文档的措辞很克制:“需要在 aboutToReuse 中手动刷新组件内部的状态变量”。“手动刷新”这四个字是全部问题的核心——你不刷,它就留着。
一个卡片组件里通常不止一个状态。title、imageUrl 这些从参数派生的展示字段,你大概率会更新。但收藏按钮的 isLiked、展开折叠的 isExpanded、输入框里的草稿、图片的 loadState,这些同样是 @State,复用时如果没在 aboutToReuse 里按新条目的数据重置,滚回来就是上一个条目的值。
typescript
@Reusable
@Component
struct FeedCard {
@State title: string = '';
@State imageUrl: string = '';
@State isLiked: boolean = false;
@State isExpanded: boolean = false;
aboutToReuse(params: Record<string, Object>): void {
const item = params.item as FeedItem;
this.title = item.title;
this.imageUrl = item.imageUrl;
this.isLiked = item.isLiked; // 漏掉这行,收藏态串位
this.isExpanded = false; // 漏掉这行,展开态串位
}
build() {
Column() {
Image(this.imageUrl).width('100%').objectFit(ImageFit.Cover);
Text(this.title).fontSize(14);
if (this.isExpanded) {
Text('详情内容...').fontSize(12);
}
}
}
}
aboutToReuse 里的每一行赋值都对应一个可能残留的旧值。最稳妥的做法是把所有驱动 UI 的 @State 列成一张清单,逐项在 aboutToReuse 里按新数据重置;漏掉任何一项,那一项就会以“上一个条目的值”的形式出现在当前条目上。如果某个状态是“每次复用都应该回到初始值”的,直接赋初始值;如果是“从新条目数据派生”的,从 params 里取新值赋进去。不要依赖 aboutToAppear 来做这件事——快速滚动复用时,aboutToAppear 不一定执行。
没加装饰器的成员变量,复用时连更新机会都没有
比漏刷 @State 更隐蔽的一种情况:驱动 UI 的变量忘了加 @State,只写成了普通成员变量。普通成员变量在 aboutToReuse 里重新赋值,不会触发 UI 刷新,因为状态更新机制根本不监视它。你看到的现象是:日志打印出来值已经变了,屏幕上还是旧的。这跟“复用没生效”长得一模一样,但根因是状态装饰器漏了。
判断方法很简单:组件里所有会影响 build 输出的变量,都必须带状态装饰器。用 @State 存的本地展示状态,用 @Prop 或 @Link 接收外部传入的值,用 @ObjectLink 绑定嵌套对象的属性。一个变量如果只是用来在方法之间传中间结果、不影响渲染,那它可以不加;只要它出现在 build 里,就必须加。
key 不稳定,组件被当成“换了人”重建,状态照样丢
@Reusable 的复用前提是“同类型组件在同一个复用组里”。如果你在 LazyForEach 的 keyGenerator 里用了不稳定的值——标题、状态字段、index、甚至 Date.now()——框架会认为当前数据项已经不是原来那个了,触发的是销毁和重建,而不是复用。这时候 @Reusable 的缓存池根本没机会介入,你看到的“状态丢失”其实是组件被重建了。
typescript
// 不稳定:标题可能重复,状态字段会变,index 在增删后错位 (item: FeedItem) => item.title (item: FeedItem) => item.status (item: FeedItem, index: number) => index.toString() // 稳定:业务唯一 ID,不随 UI 状态变化 (item: FeedItem) => item.id
key 只应该由业务上唯一且不随展示状态变化的字段生成。用 index 作 key,在列表头部插入一条新数据后,原来所有条目的 key 都往后挪了一位,框架会认为整个列表都变了,大量组件重建,复用完全失效。用标题作 key,遇到重名数据直接冲突,两个条目共享同一个组件实例,更新时只渲染第一个匹配项。
数据源用了 @Local,notify 发了但 UI 不动
LazyForEach 的数据源本身有一个容易被忽略的约束:不要用 @Local 等状态变量去持有 IDataSource 实现。官方问答里写得很直接:floorDataSource 使用状态变量时,状态变量改变不会触发 LazyForEach 的 UI 刷新。
LazyForEach 的刷新走的是 DataChangeListener 的 onDataAdd、onDataChange、onDataReload 这条通道,不是 ArkUI 常规的状态变量观察机制。你把数据源塞进一个 @Local 变量,然后 this.dataSource = newData,状态变量确实变了,但 LazyForEach 不认这个信号,列表不会刷新。正确的做法是把数据源声明为普通私有成员,通过 notifyDataAdd 等方法显式通知 LazyForEach。
typescript
// 不要这样:状态变量持有数据源,变更不触发 LazyForEach 刷新
@Local dataSource: BasicDataSource<FeedItem> = new BasicDataSource();
// 正确:普通成员,通过 DataChangeListener 通知
private dataSource: BasicDataSource<FeedItem> = new BasicDataSource();
addItem(item: FeedItem): void {
this.dataSource.pushData(item); // 内部调用 notifyDataAdd
}
BasicDataSource 的 pushData 必须在内部调用 listeners 的 onDataAdd,否则即使数据源里有了新数据,LazyForEach 也不知道多了一条。只改数组长度、不发通知,列表停在原来的条目数上。
结构不一致的卡片,用 reuseId 把它们拆成不同的复用组
如果同一个 @Reusable 卡片里存在 if 条件渲染,比如“有图”和“无图”两种布局,复用时组件树结构可能和缓存中的实例对不上。框架会销毁不匹配的子组件再创建新的,复用带来的收益被抵消,行为还可能异常。
解法不是把 if 删掉,而是用 .reuseId() 把结构不同的卡片分到不同的复用组。有图的卡片一个 reuseId,无图的卡片另一个,框架只会从各自匹配的缓存池里取实例,结构对齐了,aboutToReuse 的行为才可预期。
typescript
FeedCard({ item: item })
.reuseId(item.imageUrl ? 'feed_card_with_image' : 'feed_card_no_image')
reuseId 区分的是“复用组”,不是“数据项”。同一组内的卡片结构一致,aboutToReuse 的重置逻辑可以统一;不同组之间不互相复用,避免结构不匹配带来的重建。
cachedCount 调大不等于复用更好,它只是提前创建更多实例
cachedCount 控制的是可视区域外预创建的条目数量。把它调大,确实能减少快速滑动时的白块,但代价是提前创建更多组件实例。在带网络图片的卡片列表里,cachedCount: 20 可能意味着二十张图片的组件被提前创建、提前触发加载和解码,内存和主线程压力一起上来。
一个更务实的起点是 2 到 6,然后根据滑动白块的程度和内存曲线微调。cachedCount 解决的是“预创建”的节奏,@Reusable 解决的是“创建过的实例不销毁、拿来复用”的成本。两者配合的前提是 aboutToReuse 把状态重置干净、key 稳定、结构对齐。这三件事没做,cachedCount 调多大都只是在更大的范围内制造更多带着脏状态的实例。
V2 的 @ReusableV2 帮你重置,但 aboutToReuse 不再有入参
如果项目用的是 V2 状态管理,@ReusableV2 的行为和 V1 的 @Reusable 有一处关键差异:复用时框架会自动重置组件内状态变量的值,重新计算 @Computed,并触发相关的 @Monitor。这减少了“漏刷状态”的负担,但 aboutToReuse 没有入参,你不能再从 params 里直接取新数据来赋值。
在 @ReusableV2 里,新数据需要通过 @Param 从父组件传入,aboutToReuse 适合做那些“状态重置之外”的收尾工作,而不是主要的数据绑定通道。从 V1 迁到 V2 时,aboutToReuse(params) 的写法需要重构成 @Param 接收加 aboutToReuse() 无参的形式,不要把 V1 的 params.item 直接搬过来。
排查顺序:先确认“是不是真的没在复用”
下次列表出现“滚回来串了”的时候,先别急着改 key 或调 cachedCount。在组件的 aboutToAppear、aboutToReuse、aboutToRecycle 里各打一行带条目 ID 的日志,滑出去再滑回来,看日志到底走了哪条路径。如果 aboutToReuse 根本没触发,问题在 key 或 cachedCount 的配置上,组件被重建了而不是被复用;如果 aboutToReuse 触发了但数据还是旧的,回去检查两件事:aboutToReuse 里是不是漏了某个 @State,以及驱动 UI 的变量有没有忘记加状态装饰器。一个临时对照手段是把 LazyForEach 换成普通 ForEach 跑一遍,如果串位消失了,问题锁定在复用链路上,而不是数据源本身。
综上所述,LazyForEach 和 @Reusable 组合的复用失效,很少是某一个 API 调错了,而是一组前提条件没有同时满足:key 稳定且唯一,保证框架走的是复用而不是重建;aboutToReuse 把组件内每一个 @State 都按新条目重置,漏一个就串一个;驱动 UI 的变量都有状态装饰器,普通成员变量改了界面不会动;数据源用普通成员加显式通知,不要塞进 @Local;结构不一致的卡片用 reuseId 分组;cachedCount 从 2 到 6 起步,别把它当成“预加载越多越流畅”的开关。复用池只负责保管实例,不负责保管状态。状态的归属和重置,从你写下第一个 @State 的时候就已经是你的责任了。
更多推荐


所有评论(0)