ArkTS 列表滚动时为什么会闪现旧数据?我扒了 LazyForEach 的复用逻辑

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 的相关实现。虽然拿不到完整源码,但从编译产物和官方文档的蛛丝马迹里能拼出大致流程:

  1. LazyForEach 维护一个复用池(RecyclePool),池子里是已经 aboutToDisappear 但还没被销毁的组件节点。
  2. 当新的数据项需要渲染时,框架先从池子里找一个"匹配"的节点。
  3. 匹配的依据就是 keyGenerator 返回的 key。如果 key 相同,直接复用;如果 key 不同,也会尝试复用同类型的节点,然后调用 aboutToReuse 让开发者做状态重置。
  4. 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 之前会先检查三件事:

  1. 数据模型有没有业务唯一 ID?没有就加一个,哪怕只是为了 key。
  2. 子组件内部有没有 @StateImage 异步状态?有就一定要写 aboutToReuse
  3. cachedCount 是不是设得太大?大部分场景 1 到 3 就够了。

另外,LazyForEach 目前不支持嵌套在 if/else 里动态切换数据源,也不支持直接对 this.arraypush 后自动刷新。我们项目里配合了 BasicDataSource 自己封装了一层通知机制,否则数据更新时列表不会重绘。这个坑不在今天的主题里,但你要是也遇到列表不刷新,可以往这个方向查。


顺带一提,雷达鸭鸿蒙版的瀑布流就是靠这套方案才把帧率稳在 60fps。要不是这次闪现问题,我估计到现在都没认真看过 LazyForEach 的复用逻辑。

如果你也在鸿蒙上做长列表,不妨检查一下你的 keyGenerator 是不是还在用 index。这个问题藏得挺深,但修起来成本不高。


关于作者:我是老三,10 年以上软件开发经验,软件设计师、人工智能应用工程师。目前专注鸿蒙应用开发(ArkTS)北向开发与 Web 前端,也在探索 AI 自动化方向。不定期在 CSDN 分享鸿蒙和 AI 相关的技术文章。

本文遵循 MIT 协议,转载请注明出处。

相关推荐
MonkeyKing2 小时前
鸿蒙ArkTS深度剖析:ArkTS与TS/JS核心差异、静态强类型实战优势
typescript·harmonyos
TrisighT2 小时前
Electron鸿蒙PC上写日志文件,我被权限和路径坑了两次
electron·harmonyos
TrisighT1 天前
一个下午搞定 ArkTS 折叠面板?结果我从两点写到晚上九点
harmonyos·arkts·arkui
花椒技术4 天前
HJPusher / HJPlayer SDK 实践:我们为什么把直播推播链路拆成一套可复用能力
设计模式·harmonyos·直播
一维Ace4 天前
HarmonyOS ArkTS 按钮组件全解:Button、Toggle 状态交互实战
harmonyos
anyup5 天前
来简单聊聊鸿蒙开发,万元奖金的事~
前端·华为·harmonyos
Georgewu5 天前
【无测试机别害怕】华为云鸿蒙云手机南:从零到联调全流程详解
harmonyos
Georgewu6 天前
【HarmonyOS 7】DevEco Code安装与使用
harmonyos
Georgewu6 天前
【HarmonyOS 7】鸿蒙应用开发如何屏蔽剪切板
harmonyos