ForEach 一把梭,800 条把鸿蒙平板 OOM 了——换 LazyForEach 内存砍 76%,但刷新坑真反直觉

发布时间:2026/7/27 23:44:29

ForEach 一把梭,800 条把鸿蒙平板 OOM 了——换 LazyForEach 内存砍 76%,但刷新坑真反直觉 我们团队在鸿蒙北向开发里踩过的坑ForEach 能排进前三。官方 Quick Start 里它无处不在文档示例清一色 ForEach 配 State 数组看着人畜无害。等真拿它渲染几百条业务数据崩得连妈都不认识。说起来我做的 App 雷达鸭华为应用市场能搜到鸿蒙版那个一人公司案例的瀑布流列表第一版就是 ForEach 一把梭。800 条数据从接口拉回来直接塞进 State测试机当场 OOM页面白屏转圈我们盯着性能面板愣了半分钟。当时项目赶工期我们对 ArkTS 还生看见文档里 ForEach State 的示例这么顺眼想都没想就抄了。说白了那会儿我们的心态就是官方都这么写能出啥事这种「文档崇拜」在项目里害了我们不止一次这次是最贵的一回。// ArkTS — 问题版长列表用 ForEach 一把梭Componentstruct CaseListPage{Statecases:CaseItem[][]// 800 条案例数据首屏全量构建aboutToAppear():void{// 从接口拉全量数据直接塞进 Statethis.casesloadAllCases()}build(){List(){ForEach(this.cases,(item:CaseItem){ListItem(){CaseCard({item:item})// 每张卡片含封面图 标题 简介}},(item:CaseItem)item.id)}.width(100%).height(100%)}}定位过程也挺蠢。一开始我们以为是图片没压缩把 CaseCard 里的图全换成占位图内存纹丝不动又怀疑是 State 数组太大触发 GC 频繁结果 Profiler 一抓根因一目了然ForEach 在 aboutToAppear 之后把 800 个 ListItem 节点全量塞进组件树光实例引用就占掉一大块加上每个卡片的图片解码直接撞内存墙。截图甩到群里没人再替 ForEach 说话。后来翻了下 ArkUI 的渲染机制才彻底明白ForEach 不是「用到才建」它是声明式地把整个数组映射成组件子树编译期就决定了要挂多少节点。八百条就是八百个 ListItem 组件对象常驻内存GC 想回收都没机会——除非你切走整个页面。这设计对短列表友好对长列表就是温柔的刀。我们那台 MatePad 实测800 条案例ForEach 一把梭时内存峰值 412MB首屏 2.1 秒滚起来只有 22fps手指一滑就掉帧。说白了就是懒——当时想着反正是官方示例照抄准没错现实抽了一巴掌。后来换成 LazyForEach 自定义 IDataSource同样的 800 条内存掉了 76%峰值 98MB首屏 0.6 秒滚动稳在 58fps。差别在哪ForEach 是「全量构建」组件树一上来就把 800 个 ListItem 全建出来LazyForEach 是「按需构建」视口里能看到几个就建几个划走了节点回收复用。方案内存峰值首屏耗时滚动帧率ForEach800 条412 MB2.1 s22 fpsLazyForEach IDataSource98 MB0.6 s58 fps这里得说清楚 LazyForEach 为什么省。它底层是虚拟化列表只维护视口附近的节点划出视口的节点进复用池下次划回来复用同一个组件实例、只换数据。代价是你得自己实现 IDataSource 告诉它「有多少条、第几条是啥」。顺带一句LazyForEach 的第三个参数 keyGenerator 也得给稳定唯一 id跟 ForEach 一个道理——我们早期偷懒用下标当 key列表一重排就串数据那个坑够另开一篇。// ArkTS — 修复版LazyForEach 自定义 IDataSourceclassCaseDataSourceimplementsIDataSource{privatecases:CaseItem[][]privatelisteners:DataChangeListener[][]// 反直觉①LazyForEach 每次渲染前都来问这个必须返回【当前】真实长度totalCount():number{returnthis.cases.length// ✅ 实时返回别缓存}getData(index:number):CaseItem{returnthis.cases[index]}registerDataChangeListener(listener:DataChangeListener):void{if(!this.listeners.includes(listener)){this.listeners.push(listener)}}unregisterDataChangeListener(listener:DataChangeListener):void{constidxthis.listeners.indexOf(listener)if(idx0){this.listeners.splice(idx,1)}}// 业务侧新增数据时必须走这里内部触发 listener 回调UI 才动pushData(item:CaseItem):void{this.cases.push(item)this.listeners.forEach((l)l.onDataChange(this.cases.length-1))}// 批量灌初始数据调 onDataReloaded 让 LazyForEach 整列表重读pushDataBatch(items:CaseItem[]):void{this.cases.push(...items)this.listeners.forEach((l)l.onDataReloaded())}}Componentstruct CaseListPage{privatedataSource:CaseDataSourcenewCaseDataSource()aboutToAppear():void{this.dataSource.pushDataBatch(loadAllCases())}build(){List(){LazyForEach(this.dataSource,(item:CaseItem){ListItem(){CaseCard({item:item})}},(item:CaseItem)item.id)}.width(100%).height(100%)}}但 LazyForEach 有个反直觉的坑我们在这卡了快一天。你以为改了底层数组UI 会自动刷新想得美。LazyForEach 根本不读 State它只读你传给它的那个 IDataSource 实例的 totalCount() 和 getData()。你往数组里 push 一条只要没调 listener 的回调列表一个像素都不动老位置还显示着旧数据。我们当时看到的实际现象特别迷惑在后台新增一条案例前端列表条数纹丝不动可你使劲往下滑那条数据其实在只是卡在错误位置、还跟另一条长得一样。查了半天以为是接口没推到头来才发现是 LazyForEach 压根不知道数组变了。// ArkTS — 反直觉点改数组不 notifyUI 纹丝不动// ❌ 直觉写法直接往数组塞数据this.dataSource.cases.push(newItem)// 假设 cases 能访问UI 不刷新// ✅ 正确写法通过 dataSource 暴露的方法让它内部 notifythis.dataSource.pushData(newItem)// 内部调了 onDataChangeUI 立刻刷新// 更阴的totalCount 用缓存长度新增数据时 UI 永远停在旧条数totalCount():number{returnthis.cachedLength// ❌ stale 值LazyForEach 以为没新增}// 必须实时返回totalCount():number{returnthis.cases.length// ✅}我们当时还犯过一个蠢错觉得给 State 重新赋值整个 dataSource 不就刷新了折腾半天发现 LazyForEach 绑定的是实例引用、靠 listener 通知重赋值压根不触发。如果让我重来第一版就老老实实把 pushData / pushDataBatch 里调 notify 写好省得后面返工。踩完这一茬我们内部有个共识ArkUI 的「状态驱动刷新」只对 State/Link 这类装饰器生效LazyForEach 走的是另一套 listener 机制别拿 State 的心思去套它。我个人特别讨厌这种「看起来像响应式、其实全靠手动 notify」的设计但架不住它真省内存认了。顺带说一句我们测首屏耗时的土办法没那么花哨但够用// ArkTS — 简单粗暴的耗时打点aboutToAppear():void{constt0:numberDate.now()this.dataSource.pushDataBatch(loadAllCases())hilog.info(0x00,perf,首屏数据就绪: %{public}d ms,Date.now()-t0)}// 内存峰值我们直接看 DevEco 的 Profiler不用在代码里抠话又说回来别走向另一个极端。列表要是就二三十条ForEach 完全够用代码还清爽为了 LazyForEach 那一坨 IDataSource 样板牺牲可读性纯属脱裤子放屁。我们后来在代码评审里定了条规矩列表可能过 100 条一律 LazyForEach以下随便造。这套标准上线后新页面再没出过列表相关的 OOM算是花一次学费买来的纪律顺手还把它写进了项目的 PR 模板谁敢在长列表里写 ForEach 直接被打回。反正我们项目现在默认禁用长列表 ForEach宁可多写几十行 IDataSource。你那边要是也踩过这个欢迎来聊聊是怎么填的。我是老三10 年以上软件开发经验软件设计师、人工智能应用工程师。平时专注鸿蒙应用开发ArkTS 北向和 Web 前端也在摸索 AI 自动化不定期在 CSDN 写点鸿蒙 / AI 方向的实战笔记。本文遵循 MIT 协议转载请注明出处。

相关新闻