14:30 ------ 现象:一闪而过的旧封面
当时我在调试"案例列表"页。每条案例有封面、标题、标签、收益数字,数据量不大,两百来条。切到 LazyForEach 之后,滑动帧率是稳了,但快速上下滑几趟,偶尔会看到某张封面闪一下,变成另一条案例的图,紧接着又切回来。整个过程不到 100 毫秒,截图都抓不住。
我第一反应是图片加载框架的问题。毕竟我们用的是 Image 组件异步加载网络图,会不会是占位图没设置好?我把 placeholder 换成统一底色,加了 objectFit,甚至把缓存策略从 CacheOptions.On 改成 None。闪屏还在。
第二反应是数据问题。我打印了 itemGenerator 里拿到的数据,确认当前索引对应的 caseId 是对的。也就是说,数据层没乱,但视觉上乱了。这时候我才把目光移到组件复用本身。
15:10 ------ 打开复用日志,看到 RecyclePool
DevEco Studio 的 ArkUI Inspector 里有个不太起眼的开关:Debug -> View -> ArkUI Component Reuse。打开之后重新跑真机,Log 里出现大量类似这样的行:
text
[LazyForEach] reuse node from pool, old key=case_182, new key=case_203
[LazyForEach] create new node, key=case_204
[LazyForEach] dump pool, size=12
我盯着这些日志看了几分钟,大概猜到了机制:LazyForEach 会把滑出可视区的组件节点缓存起来,等新的数据项进入可视区时,直接拿旧节点改一改用。这跟移动端 RecyclerView 的思路差不多。但问题来了------如果旧节点上的图片还在异步加载,或者状态没清干净,新数据套上去的那一瞬间,用户看到的就是旧数据。
我顺手翻了翻 ArkTS 框架里 LazyForEach 的相关实现。虽然拿不到完整源码,但从编译产物和官方文档的蛛丝马迹里能拼出大致流程:
LazyForEach维护一个复用池(RecyclePool),池子里是已经aboutToDisappear但还没被销毁的组件节点。- 当新的数据项需要渲染时,框架先从池子里找一个"匹配"的节点。
- 匹配的依据就是
keyGenerator返回的 key。如果 key 相同,直接复用;如果 key 不同,也会尝试复用同类型的节点,然后调用aboutToReuse让开发者做状态重置。 itemGenerator只是第一次创建组件时走的逻辑,复用时不会重新执行整个itemGenerator。
这就解释了为什么打印 itemGenerator 里的数据是对的------因为复用阶段根本不进 itemGenerator,它走的是 aboutToReuse。
16:00 ------ 源码级行为:为什么 index 当 key 会加剧问题
官方文档对 keyGenerator 的说明很短:"用于生成键值,框架通过键值识别可复用组件"。很多人,包括我最初,都这么写:
typescript
// 天真的写法
LazyForEach(this.dataSource, (item: CaseItem, index: number) => {
CaseCard({ caseItem: item })
}, (item: CaseItem, index: number) => index.toString())
这写法在数据不变、只滚动的情况下勉强能跑。但一旦数据有增删,或者排序变化,index 对应的 item 就变了。框架拿着 key=5 的旧节点,复用来展示新的 index=5 的数据,可这个节点之前可能是另一个 item 的。图片缓存、动画状态、点赞状态这些没清掉,闪现就来了。
更隐蔽的是,即使数据不变,快速滚动时框架也可能把 key=5 的节点复用给 key=18 的节点------如果它认为两者"类型兼容"。这时候 aboutToReuse 就成了唯一的兜底。如果你没实现它,旧状态直接带到新数据上。
我把 key 改成业务唯一 ID 之后,日志明显干净了很多:
text
[LazyForEach] reuse node from pool, old key=case_182, new key=case_182
注意,old key 和 new key 相同,说明框架找到了真正属于同一条数据的节点。这种情况下组件状态天然一致,闪现概率大幅下降。但 key 对了就够了吗?不够。因为同一条数据的封面 URL 也可能更新,节点上的旧图片仍然可能闪一下。
16:45 ------ 修复:在 aboutToReuse 里手动清状态
我先贴出我们定稿的组件代码,然后再解释每个部分为什么这样写:
typescript
// CaseItem.ets
export class CaseItem {
caseId: string = ''
title: string = ''
coverUrl: string = ''
tags: string[] = []
revenue: string = ''
}
// CaseCard.ets
@Component
export struct CaseCard {
@ObjectLink caseItem: CaseItem
@State coverLoaded: boolean = false
@State placeholderColor: ResourceColor = '#E5E7EB'
aboutToReuse(params: Record<string, Object>): void {
// 关键:复用前先把视觉状态复位
this.coverLoaded = false
this.placeholderColor = '#E5E7EB'
// 从 params 里拿到新的数据引用
const newItem = params['caseItem'] as CaseItem
if (newItem) {
this.caseItem = newItem
}
}
aboutToRecycle(): void {
// 进入复用池前,取消可能还在进行的异步操作
this.coverLoaded = false
}
build() {
Column() {
Stack({ alignContent: Alignment.Center }) {
Image(this.caseItem.coverUrl)
.width('100%')
.aspectRatio(1.5)
.objectFit(ImageFit.Cover)
.onComplete(() => {
this.coverLoaded = true
})
if (!this.coverLoaded) {
Column() {
Text('加载中...')
.fontSize(12)
.fontColor('#9CA3AF')
}
.width('100%')
.aspectRatio(1.5)
.backgroundColor(this.placeholderColor)
}
}
Text(this.caseItem.title)
.fontSize(14)
.fontWeight(FontWeight.Medium)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ top: 8 })
Text(this.caseItem.revenue)
.fontSize(12)
.fontColor('#F59E0B')
.margin({ top: 4 })
}
.padding(12)
.backgroundColor('#FFFFFF')
.borderRadius(8)
}
}
然后列表页这样写:
typescript
// CaseListPage.ets
import { CaseItem, CaseCard } from './CaseCard'
@Entry
@Component
struct CaseListPage {
@State caseList: CaseItem[] = []
aboutToAppear() {
// 模拟拉取数据
this.caseList = Array.from({ length: 200 }, (_, i) => ({
caseId: `case_${i}`,
title: `案例标题 ${i}`,
coverUrl: `https://example.com/covers/${i}.jpg`,
tags: ['副业', 'SaaS'],
revenue: `¥${(i + 1) * 1000}`
}))
}
build() {
List() {
LazyForEach(this.caseList, (item: CaseItem) => {
ListItem() {
CaseCard({ caseItem: item })
}
}, (item: CaseItem) => item.caseId)
}
.cachedCount(2)
.edgeEffect(EdgeEffect.Spring)
.padding(12)
}
}
这里有几个我踩过才记住的点:
aboutToReuse的入参不是新数据本身,而是一个Record<string, Object>,键名跟你在itemGenerator里传给组件的属性名一致。我们传的是CaseCard({ caseItem: item }),所以取params['caseItem']。- 必须先复位视觉状态,再赋值数据。如果先赋值,
Image组件可能已经用新 URL 去加载了,但旧图片还在占位图下面,顺序不对仍会闪。 aboutToRecycle不是必须实现,但如果你在组件里开了定时器、动画或者订阅,这里要清。我们只是把coverLoaded复位,保守一点。cachedCount不要设太大。我们设成 2,可视区上下各预渲染两行,平衡流畅度和内存。设成 5 以上,复用池变大,旧状态管理的复杂度也跟着涨。
17:30 ------ 验证:日志 + 真机滑动
改完之后我跑了三轮测试。第一轮只改 key 不改 aboutToReuse,闪现频率从大概每滑 30 条出现 1 次降到每滑 80 条 1 次。第二轮加上 aboutToReuse 复位,连滑 5 分钟没复现。第三轮我把 cachedCount 从 2 提到 5,闪现又出现了,但概率很低。这说明缓存越大,风险越高,但核心还是状态复位有没有做好。
我还顺手写了个辅助函数,专门用来在 aboutToReuse 里批量复位 @State:
typescript
// ReuseHelper.ets
export function resetReuseState(
instance: Object,
keys: string[],
defaultValues: Record<string, Object>
): void {
keys.forEach((key) => {
if (key in (instance as Record<string, Object>)) {
;(instance as Record<string, Object>)[key] = defaultValues[key]
}
})
}
// 用法
aboutToReuse(params: Record<string, Object>): void {
resetReuseState(this, ['coverLoaded', 'placeholderColor'], {
coverLoaded: false,
placeholderColor: '#E5E7EB'
})
const newItem = params['caseItem'] as CaseItem
if (newItem) {
this.caseItem = newItem
}
}
这个 helper 在雷达鸭里用了三四个页面。说实话,我不确定这是不是最优雅的写法,但它确实让我少写了很多重复代码。
18:00 ------ 几个我现在的习惯
现在我写 LazyForEach 之前会先检查三件事:
- 数据模型有没有业务唯一 ID?没有就加一个,哪怕只是为了 key。
- 子组件内部有没有
@State或Image异步状态?有就一定要写aboutToReuse。 cachedCount是不是设得太大?大部分场景 1 到 3 就够了。
另外,LazyForEach 目前不支持嵌套在 if/else 里动态切换数据源,也不支持直接对 this.array 做 push 后自动刷新。我们项目里配合了 BasicDataSource 自己封装了一层通知机制,否则数据更新时列表不会重绘。这个坑不在今天的主题里,但你要是也遇到列表不刷新,可以往这个方向查。
顺带一提,雷达鸭鸿蒙版的瀑布流就是靠这套方案才把帧率稳在 60fps。要不是这次闪现问题,我估计到现在都没认真看过 LazyForEach 的复用逻辑。
如果你也在鸿蒙上做长列表,不妨检查一下你的 keyGenerator 是不是还在用 index。这个问题藏得挺深,但修起来成本不高。
关于作者:我是老三,10 年以上软件开发经验,软件设计师、人工智能应用工程师。目前专注鸿蒙应用开发(ArkTS)北向开发与 Web 前端,也在探索 AI 自动化方向。不定期在 CSDN 分享鸿蒙和 AI 相关的技术文章。
本文遵循 MIT 协议,转载请注明出处。