首页 / 资讯中心 / 文章详情

鸿蒙ArkTS LazyForEach实战:键值生成与缓存复用机制详解

鸿蒙ArkTS LazyForEach实战:键值生成与缓存复用机制详解 ★ FEATURED ARTICLE
前阵子有个开发群里的朋友问我说他在项目里把ForEach换成LazyForEach之后列表倒是流畅了不少但是偶尔会出现数据对不上的情况改了一条数据界面上有时候刷新有时候不刷新搞得一头雾水。这个问题其实问到了点子上LazyForEach在鸿蒙的 ArkTS 开发里算是个老朋友了但真正能把它用明白、用透的开发者还真不算多。绝大多数人只是知道它能懒加载提升性能但对它的键值生成规则、缓存复用机制这些核心原理往往是一知半解。这篇文章我就以Arkts语法LazyForEach为主题把自己在项目里使用这个组件的经验、踩过的坑还有总结出来的实操方法一次性整理出来希望能帮到正在折腾鸿蒙列表开发的你。1. LazyForEach 到底是什么为什么大家都在用1.1 从一个最头疼的场景说起做鸿蒙应用开发不管是做商城、资讯App还是聊天工具都绕不开一个场景长列表。数据动不动就是几百上千条甚至上万条。如果你一开始用的是ForEach也就是一次性把所有列表项全部渲染出来那性能问题真的非常明显。我实测过一个大概有两千条数据的简单文本列表用ForEach渲染首帧耗时基本都在一秒以上滑动的时候帧率波动也很厉害掉到三四十帧是常事体感就是卡顿、掉帧、白屏时间长。为什么ForEach会这么吃力因为它的逻辑很简单粗暴你给我多少数据我一次性全部创建对应的组件节点全塞进组件树里。数据量小的时候还好一旦上千光是创建这些节点、计算布局、渲染上屏就已经把主线程的活儿干满了。而且这些节点会一直挂在树里即使你没滑到屏幕外的地方组件依然存在内存也会一路飙升。LazyForEach解决的就是这个痛点。翻译成大白话它叫“懒加载循环渲染”核心思想是我只创建当前视口内以及视口附近缓存区需要的组件滑出去的我回收滑进来的我再创建。实现的效果就是不管底层数据是一千条还是一万条界面上同时存在的组件节点数量基本是稳定的首帧耗时也能控制在几百毫秒甚至更低。1.2 和 ForEach 的对比差别真的很大很多人刚接触LazyForEach的时候有个误区觉得它就是把ForEach换个名字参数长得差不多于是直接替换了事。其实两者在底层机制上的差距非常大我整理了一个对比表方便你一眼看明白对比项ForEachLazyForEach渲染策略一次性渲染全部数据视口内按需渲染组件数量等于数据总条数视口 缓存区数量内存占用随数据量线性增长基本稳定滑动性能数据量大时明显卡顿流畅掉帧少数据变更处理全量重新渲染或按diff规则按键值精准定位刷新键值生成可选但默认使用index建议显式提供且规则很严那个群友遇到的问题本质上就是替换之后没有处理好键值生成规则导致LazyForEach在数据更新时无法精确匹配到对应的组件从而出现各种“灵异事件”。所以在你决定用LazyForEach之前我建议你先停下来想清楚你的列表数据量大不大列表项是否需要频繁刷新如果你的数据不到二十条而且基本不变化那么ForEach完全够用不必为了用而用。如果你的数据超过几十条或者列表项的复杂度比较高图片、多行文本、交互按钮那么LazyForEach就是你的首选。2. 上手第一步LazyForEach 基础用法与数据源规范2.1 从一个最小可用的例子开始先不扯太多理论我直接给你一个最基础的LazyForEach用法示例。在 ArkUI 里LazyForEach的完整参数是三个LazyForEach( dataSource: IDataSource, // 数据源需要实现特定接口 itemGenerator: (item: any, index?: number) void, // 生成列表项组件 keyGenerator?: (item: any, index?: number) string // 键值生成函数 )一个最简单、能跑起来的例子长这样// 1. 定义一个简单的数据类型 class SimpleItem { id: number; name: string; constructor(id: number, name: string) { this.id id; this.name name; } } // 2. 在自定义组件里使用 LazyForEach Component export struct SimpleList { private dataSource: MyDataSource new MyDataSource(); build() { List() { LazyForEach(this.dataSource, (item: SimpleItem) { ListItem() { Text(item.name) .fontSize(16) .padding(12) } }, (item: SimpleItem) item.id.toString()) } .width(100%) .height(100%) } }但注意MyDataSource是不能随便搞一个普通类糊弄过去的。LazyForEach对数据源有硬性要求必须实现IDataSource接口否则组件根本不知道你有多少条数据、数据在哪里、该怎么通知它更新。换句话说LazyForEach不接收数组它接收的是一个符合规范的数据源对象。2.2 IDataSource 接口你到底要重写哪些方法IDataSource接口总共包含四个必须实现的方法每个方法都有它的用途方法作用必须做的事totalCount(): number告诉 LazyForEach 总共有多少条数据返回数据数组的长度getData(index: number): any根据索引返回对应条目的对象返回data[index]registerDataChangeListener(listener: DataChangeListener): void注册数据变化监听器保存监听器后续数据更新时调用其方法unregisterDataChangeListener(listener: DataChangeListener): void注销监听器移除保存的监听器一个教科书式的数据源实现是这样写的class MyDataSource implements IDataSource { private dataList: SimpleItem[] []; private listeners: DataChangeListener[] []; constructor(dataList: SimpleItem[]) { this.dataList dataList; } totalCount(): number { return this.dataList.length; } getData(index: number): any { return this.dataList[index]; } registerDataChangeListener(listener: DataChangeListener): void { if (this.listeners.indexOf(listener) 0) { this.listeners.push(listener); } } unregisterDataChangeListener(listener: DataChangeListener): void { const index this.listeners.indexOf(listener); if (index 0) { this.listeners.splice(index, 1); } } // 这个方法是我自己加的方便外部添加数据时通知界面刷新 addData(item: SimpleItem): void { this.dataList.push(item); this.listeners.forEach(listener { listener.onDataAdded(this.dataList.length - 1); }); } // 同理更新某一条数据时调用 updateData(index: number, newItem: SimpleItem): void { this.dataList[index] newItem; this.listeners.forEach(listener { listener.onDataChanged(index); }); } }你可能会觉得一个简单的数组传进去不就行了吗为什么要搞得这么麻烦实际上这个接口设计背后是有道理的它把“数据存储”和“数据变更通知”剥离开让LazyForEach可以精准地知道哪一条数据发生了变化从而只更新对应的那一个组件而不是像ForEach那样动不动就全量重建。这也是LazyForEach性能优势的一个重要来源。3. 键值生成规则LazyForEach 的灵魂3.1 键值到底是什么为什么它这么关键如果你只用过ForEach并且经常用默认的键值规则也就是用数据项的索引 index 做 key那你可能没意识到键值在LazyForEach里的分量有多重。说它是灵魂一点都不夸张。LazyForEach在内部维护了一个“键值-组件”的映射关系。每个列表项组件都有一个唯一key框架通过这个key来判断当前需要渲染的数据项之前有没有对应的组件如果有直接复用如果没有新建一个如果某个key对应的组件已经滑出屏幕且超出缓存范围就销毁。这套机制的顺畅程度完全取决于key的稳定性。如果一个对象对应的key发生了变化LazyForEach会认为这是一个全新的数据项会销毁旧组件、创建新组件频繁出现这种情况性能会大打折扣甚至出现闪烁或者错乱。举一个生活化的类比假设你是快递分拣员每个包裹上贴着一张标签key你只需要看一眼标签就知道这个包裹属于哪一片区域。但如果标签经常贴错或者模糊你就得拆开包裹确认效率自然就低了。3.2 三种键值生成方式用不对就等着踩坑实际开发中键值生成函数有三种常见写法第一种用数据项的唯一ID(item: SimpleItem) item.id.toString()这是我最推荐的写法前提是你的数据项里确实有唯一ID自增主键、UUID、组合ID都行。ID稳定不变键值就稳定组件复用效率最高。第二种用多个字段拼接(item: CommentItem) ${item.userId}_${item.timestamp}这种适合没有显式ID但可以通过多个字段组合出唯一标识的场景。比如评论列表里同一个用户可能发多条评论单纯用userId会冲突加上时间戳就唯一了。第三种用索引 index(item: SimpleItem, index: number) index.toString()这种是我见过踩坑最多的。用index做key在ForEach里可能还勉强能跑但在LazyForEach里隐患非常大。因为index是位置属性不是对象属性。当你在列表头部插入一条新数据时所有后续元素的index都会变它们的key也就全变了。LazyForEach会认为整条列表都“更新”了最终表现就是你看到的一大片重渲染甚至组件复用时传入的还是旧数据界面和数据对不上。3.3 key 生成时容易忽略的三个细节除了不用index当key还有几个细节容易被忽略一是键值必须是字符串类型。如果你传了数字进去虽然有些版本框架会自动转成字符串但最好还是显式调用toString()避免不同版本之间的行为差异。二是键值必须稳定且唯一。同一个数据项在每次调用键值生成函数时返回值要一致不同数据项的键值不能重复。键值一旦冲突LazyForEach可能直接报错或者表现异常——明明有十条数据界面上只出现了九条另一条不知道跑哪去了。三是键值函数不要做复杂计算。键值生成函数在数据更新、滑动过程中会被高频调用如果在里面写耗时的逻辑比如拼接大字符串、做正则匹配性能影响会直接反映在滑动流畅度上。保持简单最好就是返回一个字符串属性或者几个字段的简单拼接。4. 缓存复用机制把它搞懂才算真正会用4.1 组件复用到底是怎么发生的LazyForEach能带来性能提升除了“按需创建”还有一个关键机制是“缓存复用”。当某个列表项滑出视口时框架不会立刻销毁它而是把它放进一个缓存池。当滚动方向反向或者附近有新的数据项需要上屏时如果缓存池里有相同key的组件就直接拿出来用省掉重新创建组件的时间和开销。这个机制听起来很美好但有个地方需要特别注意缓存复用不只是组件层面的复用更是状态层面的保留。如果你在列表项里使用了State装饰的变量或者表单项里有用户输入的内容那么组件被缓存再复用时这些状态是保留的。这带来一个很经典的坑列表项复用时老状态没清干净导致显示错乱。我举一个实际例子。我做过一个任务清单页面每个列表项里有个复选框用户勾选后State isChecked变为true同时数据源里的字段也相应更新。一开始我在列表项的onAppear里根据数据初始化状态结果滑出去再滑回来复选框状态经常不对——有的该勾的没勾有的不该勾的却勾上了。原因就是组件被复用时State还是旧值而我没有在数据更新或组件复用时重新赋值。解决办法是在列表项的build()里用数据源的值去驱动UI状态而不是让UI状态自己“记忆”。简单说数据是唯一的真相源UI尽量别存需要跨复用周期的状态。4.2 cachedCount 参数怎么设置才合适LazyForEach支持通过cachedCount属性设置缓存数量List() { LazyForEach(this.dataSource, (item: ItemType) { ListItem() { // ... } }, (item: ItemType) item.id.toString()) } .cachedCount(5)注意cachedCount是加在List上的不是加在LazyForEach上的。它表示在视口之外上下各额外保留多少个列表项组件不销毁。比如你的屏幕一屏能显示10条数据cachedCount(5)就意味着上下各多缓存5条实际渲染的组件数最多约20条。这个参数不是越大越好。调大缓存好处是滑动时更不容易出现白屏和闪烁因为在滑到下一个数据项之前它已经被提前渲染好了坏处是组件数量和内存占用会上升。我一般建议根据列表项的复杂度和滚动速度来调如果列表项里有网络图片图片加载比较慢那就适当调大一点比如5到10如果列表项很轻量纯文本2到5就够了。如果滑动深度很深、速度很快比如通讯录那种快速滚动可以再往上加。4.3 静态和动态两种模式别搞混了LazyForEach还有一个容易忽略的差异如果数据源在创建之后完全不会变化那么可以不调用registerDataChangeListener里的通知方法框架会把列表当成静态数据来处理如果数据源会增删改那就必须在每次操作后调用对应的DataChangeListener方法。很多人写数据源的时候图省事数据变了只调一个onDataReloaded()这个方法是“全量刷新”的意思它会清空所有的缓存并按当前数据整体重建列表项。结果就是每次只改了一条数据整个列表都抖一下性能甚至比ForEach还差。正确的做法是新增用onDataAdded(index)删除用onDataDeleted(index)移动用onDataMoved(from, to)修改用onDataChanged(index)只有数据源整体被替换比如从本地缓存加载了一大批新数据时才用onDataReloaded()。5. 实操一个长列表场景的完整实现与性能调优5.1 定义一个健壮的数据源前面讲了那么多理论现在我把一个相对完整的实战项目拆给你看。假设我们要做一个资讯类的首页信息流每条资讯包含标题、封面图URL、发布时间、阅读量这几个字段。因为数据会做分页懒加载所以需要一个既能支持追加又能支持下拉刷新的数据源。class NewsItem { id: number; // 唯一标识 title: string; // 标题 coverUrl: string; // 封面图 publishTime: string; // 发布时间 readCount: number; // 阅读量 // ...构造函数略 } class NewsDataSource implements IDataSource { private newsList: NewsItem[] []; private listeners: DataChangeListener[] []; totalCount(): number { return this.newsList.length; } getData(index: number): any { return this.newsList[index]; } registerDataChangeListener(listener: DataChangeListener): void { if (this.listeners.indexOf(listener) 0) { this.listeners.push(listener); } } unregisterDataChangeListener(listener: DataChangeListener): void { const idx this.listeners.indexOf(listener); if (idx 0) { this.listeners.splice(idx, 1); } } // 首屏/下拉刷新整个数据源替换 reloadData(items: NewsItem[]): void { this.newsList items; this.listeners.forEach(listener { listener.onDataReloaded(); }); } // 分页追加在末尾新增若干条 addItems(items: NewsItem[]): void { const startIndex this.newsList.length; this.newsList this.newsList.concat(items); this.listeners.forEach(listener { for (let i 0; i items.length; i) { listener.onDataAdded(startIndex i); } }); } // 单条更新比如修改某条资讯的阅读量 updateItem(index: number, updatedItem: NewsItem): void { this.newsList[index] updatedItem; this.listeners.forEach(listener { listener.onDataChanged(index); }); } }这里有一个我踩过坑后总结出来的细节追加数据时onDataAdded要逐条调用传入的是每一条新增数据对应的真实索引。有些开发者图方便追加数据后只调用一次onDataAdded(0)这会让LazyForEach以为是在索引0的位置插入了一条数据结果后面的数据全部乱套UI和数据错位得让人崩溃。5.2 列表页面的搭建与性能参数设置有了数据源接下来就是搭建列表页。信息流页面一般包含大图卡片、视频卡片、纯文本卡片不同类型这里我先用最简单的大图卡片举例。你可以根据实际项目需求扩展卡片类型注意给不同的卡片类型分配不同的组件结构就好了。Entry Component struct NewsFeedPage { State dataSource: NewsDataSource new NewsDataSource(); private pageIndex: number 1; aboutToAppear(): void { // 模拟首屏加载数据 this.loadMoreData(); } loadMoreData(): void { // 模拟网络请求返回新数据 const newItems: NewsItem[] []; for (let i 0; i 20; i) { newItems.push(new NewsItem( this.pageIndex * 100 i, 资讯标题 ${this.pageIndex}-${i}, https://example.com/cover.png, 2024-01-01 10:00, 1000 i )); } this.pageIndex; this.dataSource.addItems(newItems); } build() { Column() { List() { LazyForEach(this.dataSource, (item: NewsItem, index: number) { ListItem() { this.itemCard(item) } .onAppear(() { // 触底加载当最后一个列表项即将进入视口时加载下一页 if (index this.dataSource.totalCount() - 1) { this.loadMoreData(); } }) }, (item: NewsItem) item.id.toString()) } .width(100%) .height(100%) .cachedCount(5) .scrollBar(BarState.Off) .edgeEffect(EdgeEffect.Spring) } } Builder itemCard(item: NewsItem) { Row() { Image(item.coverUrl) .width(80) .height(80) .borderRadius(8) .objectFit(ImageFit.Cover) Column() { Text(item.title) .fontSize(16) .fontWeight(FontWeight.Medium) .maxLines(2) .textOverflow({ overflow: TextOverflow.Ellipsis }) Text(${item.readCount} 阅读) .fontSize(12) .fontColor(#999999) .margin({ top: 8 }) } .alignItems(HorizontalAlign.Start) .layoutWeight(1) .margin({ left: 12 }) } .padding(12) .width(100%) .backgroundColor(#FFFFFF) .borderRadius(12) .margin({ bottom: 8 }) } }这段代码里有几个细节值得展开讲。第一个是cachedCount(5)的选择。资讯卡片里有网络图片图片解码需要时间缓存太小的话快速滑动时会出现图片区域空白闪烁5是我在多个机型上实测下来比较稳妥的值。如果你是做重度图片场景比如瀑布流我会建议cachedCount提高到8到10。当然所有参数都建议用真机调试后再定我给的只是起点。第二个是触底加载的写法。我用了onAppear判断当前索引是否等于总条数减一这样当最后一条进入视口时就开始加载下一页。这种写法简单但有一个缺点如果手势停在最后一条上反复上下滑动可能触发多次加载。更稳的方案是用onReachEnd它是专门用来触发触底加载的List() { // ... } .onReachEnd(() { // 触底加载下一页 this.loadMoreData(); })第三种是下拉刷新。List组件本身支持onRefresh以及Refresh容器组件可以配合onRefresh回调做下拉刷新动作。刷新的核心是调用reloadData但要注意先清空旧列表再更新否则onDataReloaded会结合现有数据源重新铺列表。5.3 性能调优我实际测试后的对比数据在整个项目开发完以后我做了一组简单的对比测试。测试手机是某主流中端机型数据量是1000条纯文本资讯标题。分别使用ForEach和LazyForEach跑同样场景测试项ForEachLazyForEach首帧耗时约1200ms约350ms峰值内存约450MB约280MB滑动帧率40~50帧波动大55~60帧稳定滚动到第500条的跳转时间约900ms约300ms当然这是极端测试场景下的对比数据不同机型、不同列表项复制度会有偏差但趋势是明确的数据量越大列表项越复杂LazyForEach的优势就越明显。如果你的列表只有十几条数据还非要上LazyForEach那这种优势体现不出来反而因为键值管理多了一层开销甚至可能不如直接用ForEach来得轻快。所以选型的时候一定要结合实际场景。6. 常见问题与排查技巧实录6.1 数据改了列表却不刷新这是LazyForEach使用中最常见的问题没有之一。表现是你在数据源里修改了某个对象调用了onDataChanged(index)但界面上对应列项完全没有变化或者过一会儿才动一下。我的排查思路首先是确认 index 是否对应正确的数据项。如果 index 传错了比如把旧列表的位置当成新列表的位置刷新自然就不对。其次是确认修改的是不是同一个对象引用。LazyForEach的组件复用依赖键值如果你修改了对象的 ID 字段键值变了框架会把它当作新数据旧位置就不会刷新。这种情况下你应该保留 ID 不变只改其他内容字段。另外还有一个隐藏的坑如果你在getData方法里每次都返回新对象而不是返回缓存的同一个对象引用那么即使调用了onDataChanged组件拿到了一个全新对象状态又有可能和上一次对不上。数据源的getData应该保持稳定的引用返回而不是每次新建。6.2 滑动变卡帧率上不去滑动卡顿的原因比较多。首先排查列表项组件是不是太重比如每个ListItem里嵌了多层Column/Row、大量阴影效果、模糊效果这些都会在复用和布局时产生额外开销。可以用Builder抽离公共布局减少嵌套层级去掉非必要的视觉效果。其次如果列表项里有图片一定要设置固定的宽高千万别让图片在加载前后撑大或缩小列表项否则会引起布局抖动和滚动性能恶化。图片库也建议开启内存缓存和复用否则网络图片频繁加载同样会卡。最后如果以上都优化了还是卡可以尝试把List换成WaterFlow或者Grid因为不同容器的布局引擎对错落布局的渲染优化程度不一样。有些场景下换一个容器滑动性能反而会有惊喜。6.3 键值冲突导致的数据错乱还有一种问题发生在数据源内部两个数据项的键值一样了。比如用户昵称加时间戳生成的key两个用户同一秒发了评论key就冲突了。表现是列表偶尔会少显示一条数据或者两条数据共用同一个组件显示内容一会儿是A的一会儿是B的。排查方式是在键值生成函数里临时加日志把所有生成的key打出来看看有没有重复。解决方式很简单key的生成带上数据源的唯一字段或者干脆用一个自己维护的自增序列作为key。记住key的职责只有一个唯一标识一个数据项不需要有其他含义。6.4 常见问题速查表问题现象可能原因解决建议数据修改后界面不刷新index 传错 / 对象ID被改了核对索引保持ID不变只改内容字段列表少显示一条数据键值冲突检查键值生成函数确保唯一滑动闪烁 / 白屏cachedCount太小适当调大缓存数量向下滑再往上滑内容对不上用index做key改为用唯一ID做key下拉刷新后所有列表项抖动onDataReloaded使用频繁精确定位变更类型改用onDataAdded/onDataChanged等快速滚动时图片区域空白图片加载慢且无复用缓存设置图片固定尺寸开启图片缓存7. 一点个人体会以及扩展思路项目里用了这么久的LazyForEach我最大的体会是它确实强大但强大的前提是你必须理解它的两个底层机制——键值匹配和缓存复用。很多人觉得它是“性能神器”只要用了就能解决一切卡顿问题这很明显是个误区。你如果只是替换组件名而不去设计好数据源、键值生成和缓存策略那效果不但没有提升反而可能出现一系列莫名其妙的问题。另外我也想给读者一个扩展思路LazyForEach不只可以用在List里在Grid和WaterFlow等容器里同样适用处理瀑布流图片墙、宫格商品列表的时候思路是完全一样的。你可以把这次总结出来的数据源设计方法和键值规则迁移过去学习成本几乎为零。如果后续有机会我会再单独写一篇关于瀑布流场景下LazyForEach配合图片懒加载踩坑经验的内容那个场景里的问题会更刁钻一点。最后再分享一个小技巧调试LazyForEach相关的问题时建议在数据源的所有数据变更通知方法里都加上日志输出。一旦UI表现异常你一眼就能看出当前到底触发的是onDataChanged还是onDataReloadeddataIndex又是从哪传出来的。这个习惯帮我节省了非常多排查时间真心推荐你也试试。
阅读完成 · 觉得有帮助?
咨询建站