健康菜谱助手的食材百科页,最早一版是拿 ForEach 硬渲染的:一千二百多种食材,一进页面全部实例化。开发时用二十条数据测试毫无察觉,真机装上正式数据后,进页面的瞬间掉帧、内存直接涨了四十兆。这篇记录这一页的三次迭代:从 ForEach 到 LazyForEach 的第一次改造、数据源接口设计踩的坑、以及最后自己实现的缓存窗口——三次改动都不大,但每一次都让我对"列表到底该怎么渲染"的理解深一层。

三次迭代演进

一、先量化问题,再动手改造

动手之前先测了基线。开发工具的性能面板给出三个数字:首帧渲染 640 毫秒、进页面后内存峰值 92 兆、快速滑动时偶发丢帧。数字背后是同一个原因——ForEach 把一千二百个列表项全部创建了组件实例,哪怕屏幕上只显示得下十二个。这三条基线后来成了每次迭代的对照组:改完一项,三个数字各变化多少,记录进版本说明。没有基线的优化,等于闭着眼睛开药。

指标ForEach 版目标
首帧渲染640ms≤150ms
内存峰值92MB≤40MB
快速滑动偶发丢帧无感知

二、第一次改造:换成 LazyForEach 只花了半小时

LazyForEach 的用法文档写得很清楚,但有一个前置条件卡了我一会儿:它不接受普通数组,必须传入实现 IDataSource 接口的对象。第一次改造的代码量确实只有半小时——把数组包一层,实现四个必需方法:

class FoodDataSource implements IDataSource {
  private list: FoodItem[] = []
  private listeners: DataChangeListener[] = []

  totalCount(): number { return this.list.length }
  getData(index: number): FoodItem { return this.list[index] }
  registerDataChangeListener(l: DataChangeListener) { this.listeners.push(l) }
  unregisterDataChangeListener(l: DataChangeListener) {
    const i = this.listeners.indexOf(l)
    if (i >= 0) this.listeners.splice(i, 1)
  }
  // 通知方法:增删改时调用,驱动 LazyForEach 局部刷新
  notifyReload() { this.listeners.forEach(l => l.onDataReloaded()) }
  notifyAdd(index: number) { this.listeners.forEach(l => l.onDataAdd(index)) }
}

改完的实测:首帧 640 毫秒降到 110 毫秒,内存峰值 92 降到 34 兆——一次性创建一千二百个组件变成按需创建十几个。半小时的改动换来这个收益,是我这一年性价比最高的半小时。

三、踩坑:DataSource 的通知方法不是可选项

第二次迭代是被一个 bug 逼出来的。百科页顶部有分类筛选,切换"蔬菜""肉类""水产"时数据源要整体替换。我最初偷懒,直接替换内部 list 数组然后调用 notifyReload——结果界面偶尔显示旧数据:列表已经滚到第 300 项,替换后 totalCount 变小了,但 LazyForEach 的缓存索引没有同步,出现空白行。正确做法是数据变动必须走对应的通知方法:整体替换用 onDataReloaded,单项插入用 onDataAdd,删除用 onDataDelete,且必须先改数据再通知,顺序反了通知读到的是旧状态。把这条规矩写进团队注释后,这类问题再没出现过。

replaceCategory(items: FoodItem[]) {
  this.list = items          // 1. 先改数据
  this.notifyReload()        // 2. 再通知,顺序不能反
}
removeFood(index: number) {
  this.list.splice(index, 1)
  this.listeners.forEach(l => l.onDataDelete(index))
}

四、第三次迭代:缓存窗口解决回弹闪烁

LazyForEach 默认的缓存策略在快速滑动时有个细微问题:往回滑时,刚被回收的组件要重新创建,列表项里的图片会闪一下。系统提供 cachedCount 参数设置预加载数量,但预加载创建的是组件,图片加载仍受网络与解码影响。我的解法是在 DataSource 里再加一层轻量缓存:每个列表项渲染所需的派生数据(显示名、分类色、首字)只算一次,存进 Map,组件重建时直接取——组件可以回收,计算结果不回收。图片闪烁另用图片组件自身的缓存参数解决,两者分开治理。

private derived = new Map<number, DerivedInfo>()
getDerived(index: number): DerivedInfo {
  if (!this.derived.has(index)) {
    const f = this.list[index]
    this.derived.set(index, { display: f.name.slice(0, 6), color: categoryColor(f.category), initial: f.name[0] })
  }
  return this.derived.get(index)
}

缓存窗口示意图

五、三次迭代的最终数据与适用边界

三个版本的对照数据最后放在一张表里。LazyForEach 不是万能的:数据量在一百条以内时,ForEach 和它的差异用户根本感知不到,引入 IDataSource 反而增加代码复杂度。我的判断标准是预估数据量超过两百条,或者列表项里有图片等重组开销大的资源,才上 LazyForEach。食材百科一千二百条,属于必须改造的范围。

指标ForEachLazyForEach+缓存窗口
首帧渲染640ms110ms108ms
内存峰值92MB34MB31MB
回滑闪烁—偶发消除

列表性能三层心智模型

六、总结:列表性能的三层心智模型

这次改造让我把列表性能拆成三层来想:组件创建层(LazyForEach 解决"不该创建的别创建")、数据通知层(IDataSource 的方法语义解决"变了的东西要正确地变")、资源复用层(派生数据缓存解决"重建要重建得快")。三层各管一段,排查问题时先判断卡顿属于哪一层,再对症下药。食材百科页此后又加过搜索高亮、长按删除等功能,数据源接口稳定没再动过——接口设计对了,后面的功能都是往里加,不是推倒重来。

参考资料:

  • 华为开发者:LazyForEach 惰性加载
  • 华为开发者:IDataSource 接口说明
  • 华为开发者:列表性能优化指南

#鸿蒙 #HarmonyOS 7(API 26)

七、搜索高亮的集成:派生缓存的一次扩展

百科页后来加了搜索框,输入关键词时列表实时过滤并高亮命中片段。这个功能接进缓存窗口时遇到一个设计问题:高亮位置依赖关键词,而派生数据缓存的 Map 键是索引——关键词变了,缓存全部作废。最初的方案是关键词每次变化就清空 Map,结果连续输入时每敲一个字就全量重算一千二百条,输入框明显卡顿。最终改成两级键:Map 的外层键是关键词快照,内层才存派生结果,关键词没变时缓存照常命中,变了也只清一次。这个模式后来用在所有依赖动态参数的派生计算上,输入防抖加两级键,搜索框再也没有超过一帧的卡顿。

private derivedByKeyword = new Map<string, Map<number, DerivedInfo>>()
getDerived(kw: string, index: number): DerivedInfo {
  let inner = this.derivedByKeyword.get(kw)
  if (!inner) { inner = new Map(); this.derivedByKeyword.set(kw, inner) }
  if (!inner.has(index)) inner.set(index, computeDerived(this.list[index], kw))
  return inner.get(index)
}

八、长按删除的删除动画:通知方法的正确打开方式

长按删除单个食材项,期望的体验是被删行平滑收起,下面的行顺势上移。实现时又回到数据通知的老话题:onDataDelete 触发的是整段刷新动画,直接调用会让列表闪一下。翻文档后发现 LazyForEach 对删除动画的支持需要在列表项外层包 transition,组件销毁时的退出动画由 transition 承担,DataSource 只负责正确通知。两者配合之后,删除的视觉反馈终于顺滑:先播放退出动画,动画结束数据源再真正删除并通知。这里的时间差要控制好——动画 250 毫秒,删除动作延迟 260 毫秒发出,早了会看到行闪没,晚了会出现双击删除同一行。把延迟数写成常量并在注释里说明为什么是 260,后来接手的同学改动画时长时才知道要同步改这个数。

九、滚动定位:分类跳转的三种实现对比

页面左侧有 A-Z 字母索引条,点击要滚到对应首字母区块。实现方式试了三种:第一种用 scrollToIndex,代码最短,但滚动动画无法控制幅度,长距离跳转时内容一闪而过;第二种用 scrollEdge 加估算偏移,估算不准,定位经常差半行;第三种(最终采用)在 DataSource 里维护每个首字母的起始索引,配合 scrollToIndex 的对齐参数,精确且稳定。三种方式的取舍核心是要准还是要顺,字母索引场景要准,长列表顺滑浏览场景才需要动画。功能选型没有高下,只有场景匹配与否。

这一系列的最后一个提醒:给 DataSource 写单元测试非常值得。totalCount、getData、通知方法的组合行为用几行断言就能覆盖,而它们恰恰是最容易在迭代中被改坏的部位。三次迭代每次都先跑一遍数据源测试再动 UI,这个习惯让我在改缓存窗口时没有引入任何回归。

文末补充一个被问得最多的细节:LazyForEach 和 Tabs 组合使用时的坑。百科页被放进了 Tabs 的一个页签后,预加载策略又失效了——Tabs 默认会预加载相邻页签,相邻页签里的 LazyForEach 也随之提前创建,内存回升。解决方法是给 Tabs 传自定义的预加载页数参数,把非当前页签的预加载关掉,等用户真正切过去再渲染。组合组件时,每一层组件都有自己的预加载语义,叠加起来会互相放大,最终以真机内存曲线为准逐层收紧。这条经验让我在后来的侧滑返回、嵌套滚动场景里少走了很多弯路:遇到组合组件的性能问题,先画出组件树,把每层的自动行为列出来,再逐层决定保留还是关掉。

系列至此收尾。三次迭代、一条主线:让渲染的量与可见的量对齐,让变化的传播路径正确,让重建的成本足够低。清单不长,但每一条都值得在动手前先想一遍。

附录里补一份这次改造留下的问题排查清单,遇到列表类性能问题时按顺序过一遍,多数卡顿都能在三步内定位到层级。第一步,确认列表组件类型:ForEach 渲染的条目数与数据量是否一致,一致就先换 LazyForEach;第二步,确认数据变更是否走了对应的通知方法,替换用 Reloaded、单项用 Add 或 Delete,顺序必须先改数据后通知;第三步,确认列表项内部有没有重组开销大的计算——排序、过滤、字符串拼接都该挪到数据源或派生缓存里,组件内只做纯展示。三步之外的问题再考虑自定义缓存窗口,多数项目走不到这一步。

这份清单的价值在交接时最明显。食材百科页后来由另一位同学接手维护,他把这份清单贴在了页面目录的 README 里,半年后跟我说,新加的分类页复用同一套 DataSource 时没有踩任何坑——文档的生命力不在于写得多详细,而在于它是从真实坑里长出来的。

最后交代一下这套方案在团队外的验证情况。有两位读者照着文章复现了改造,一位在八百条数据的场景里拿到了和我接近的数字,另一位在六百条数据上发现 LazyForEach 与 ForEach 差距不如预期——这正是第六节说的适用边界在起作用:六百条已经处于临界区,改造收益取决于列表项的复杂度。复现的结果反过来验证了文中的判断标准不是拍脑袋,不同数据规模、不同条目复杂度的组合确实需要分别评估。欢迎更多同学在实际项目里套用这三层模型,把数据贴回来,判断标准可以一起打磨得更准。

Logo

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

更多推荐