ArkUI 渲染性能优化实战:从列表掉帧到状态分割、LazyForEach 和 Profiler 回归
ArkUI 渲染性能优化实战:从列表掉帧到状态分割、LazyForEach 和 Profiler 回归
ArkUI时,最容易被低估的性能问题不是“页面打不开”,而是“页面能用,但越滑越卡”。商品列表、页面路线列表、动态流、消息列表都有类似症状:第一屏还流畅,滑到第二屏开始掉帧;筛选条件一变,整页闪一下;图片刚出现时手感变沉;删除一条数据后列表项错位;开发机上还行,真机一录Profiler可以看到做帧工作差不多。
这篇文章不讲抽象的性能口号,只处理一个真实问题:ArkUI长列表滚动掉帧,怎么用状态分割、稳定key、LazyForEach、图片异步加载和DevEco Profiler做一次可验证的优化闭环。
读完以后,你应该能带走四件事:
1.知道列表卡顿该先看哪里,而不是上来乱拆组件。
2. 可以把大对象状态拆成更可控的页面状态、列表数据源和局部选中状态。
3. 能用LazyForEach和稳定键减少无意义的列表项重建。
4. 能用Profiler记录伺服结果,判断优化到底有没有用。
一、此类卡顿在真实项目里长什么样
先把问题说具体。下面这些现象如果只靠肉眼判断,很容易被判判为“手机性能不行”或者“图片精致”:
| 现象 | 可能原因 | 先看哪里 |
|---|---|---|
| 列表滑动到第二屏开始掉帧 | 可见区域外组件创建过多,或者项目重建过绝缘 | ForEach/LazyForEach使用方式 |
| 修改筛选条件后整页提示 | 页面状态粒度过粗,一个字段变化牵动整棵UI | @State是否包了大对象 |
| 删除一条数据后列表错位 | key 使用数据库索引,数据位置变化后恢复关系乱掉 | key key 生成函数 |
| 图片出现瞬间卡一下 | 图片请求、解码、占位策略压力到渲染路径上 | 图片加载时机和服务器策略 |
| Profiler 里帧运行有尖峰 | 主线程有密集构建、布局或同步任务 | Trace中ArkUI与主线程泳道 |
我建议不要直接修改代码。先写一条能稳定恢复现的路径,例如:
1.打开商品列表页。
2.等首屏数据加载完成。
3.连续滑动3屏。
4. 点击筛选条件。
5.重新滑动2屏。
6.删除或收藏其中一个项目。
后面的路径会作为优化的对比脚本。没有稳定的路径,优化就容易变成“感觉快了”。
二、数据与版本边界:文档解决的是 ArkUI 列表渲染,不是全仓库性能
本文参考针对 HarmonyOS NEXT / ArkTS / ArkUI 声明式 UI 工程,重点放在页面层和列表渲染层。网络请求、数据库查询、图片 CDN、渲染接口运行不在图纸主线里,只在影响 UI 渲染时提出处理边界。
| 项目 | 此处边界 |
|---|---|
| UI框架 | ArkUI 声明式开发方式 |
| 开发语言 | 方舟 |
| 性能工具 | DevEco Studio Profiler、ArkUI 相关 Trace |
| 组件列表 | List、ListItem、LazyForEach |
| 数据来源 | 实现IDataSource,手动通知列表数据变化 |
| 验证目标 | 滚动平滑度、帧运行尖峰、列表项重建范围 |
官方文档里有几个点很关键:考虑ForEach在大量子组件场景下可能会带来卡顿,应LazyForEach;LazyForEach需要开发者实现IDataSource,并通过关键识别数据项;DevEco Profiler可以记录Trace,帮助定位定位点。论文的代码就是围绕这些边界组织的。
三、先写一个“会卡”的版本,才能知道改在哪里
很多列表页面一开始都会写成这样:页面把筛选条件、选中状态、加载、列表数据全部塞进一个@State对象里,然后用ForEach直接渲染。
interfaceProductListPageState{关键字:字符串; 排序方式:'默认'|'价格'|'销售额'; selectedId:字符串; 加载中:布尔值; 产品:ProductCardModel[];}@入口@成分struct ProductListPage{@StatepageState:ProductListPageState={关键词:'', 排序方式:'默认'selectedId:'',加载中:否, 产品:[]};建造(){柱子(){产品筛选栏({关键词:this.pageState.keyword, 排序:this.pageState.sort})列表(){ForEach(this.pageState.products,(item:ProductCardModel,index:number)=>{ListItem(){产品卡({模型:项目, 已选中:this.pageState.selectedId===item.id})}})}}}}代码的问题不是语法,而是职责混在一起:
1.keyword改变时,列表数据和选中状态也被包在同一个大对象里。
2.ForEach直接完整面对备份,数据量上来后首屏外的构建压力更加明显。
3.index出现在渲染回调里,后续很容易顺手拿索引做 key 或业务标识。
4. 图片、收藏、选中等局部变化没有被隔离,容易扩大UI更新范围。
这就是“看起来代码少,实际维护成本高”的典型写法。优化的第一步不是拆掉组件,而是拆掉变化源。
四、把大对象状态拆开:让每次变化只影响该影响的地方
列表页通常至少有三类状态:
| 状态类型 | 例子 | 更新频率 | 推荐位置 |
|---|---|---|---|
| 页面筛选状态 | 关键词、排序、分类 | 地中海 | 页面@State |
| 列表数据 | 商品、路线、消息储备 | 较低但数据量较大 | 独立数据源 |
| 局部交互状态 | 已选中、收藏、曝光 | 高度 | item 或轻量映射 |
改造后的页面不要把所有现场塞进一个对象:
typeProductSort='default'|'price'|'sales';@入口@成分struct ProductListPage{@State关键字:字符串='';@Statesort:ProductSort='default';@StateselectedId:string='';@Stateloading:boolean=false;privatedataSource:ProductListDataSource=newProductListDataSource();私有仓库:ProductRepository=newProductRepository();aboutToAppear():void{this.reloadProducts();}privateasyncreloadProducts():Promise<void>{this.loading=true;constresult=awaitthis.repository.query({关键词:this.keyword, 排序:this.sort});this.dataSource.replaceAll(result);this.loading=false;}}大概代码的边界很清楚:
- 页面
@State只保存会直接影响页面控制区的轻量字段。 - 列表数据定位
ProductListDataSource,后续由它通知列表刷新。 reloadProducts()的输入只有筛选条件,不把UI组件对象文档层。loading只控制加载提示,不涉及列表项的键和复用关系。
拆除状态的收益不是“代码更优雅”,而是后续排查时能判断:如果选中状态变化,不会导致整批列表数据重新加载;如果只是筛选条件变化,项目复用应该由关键决定,而不是被队列索引牵着走。
五、模型先稳定:不要让UI直接依赖接口原始字段
item列表的字段应该先整理成UI模型。接口返回什么字段是一回事,页面渲染需要什么字段是另一回事。
导出接口 ProductCardModel{id:字符串; 标题:字符串; coverUrl:字符串;priceText:字符串; 标签:字符串[]; 更新时间:编号; 收藏:布尔值;}exportfunctiontoProductCardModel(raw:ProductResponseItem):ProductCardModel{返回{id:raw.productId, 标题:原始名称??'未命名商品',封面网址:raw.cover, 价格文本:`¥${raw.price}`,tags:raw.labels??[],更新时间:raw.updateTime, favorite:raw.favorite===true};}这里的重点是“稳定”:
id来自必须业务唯一标识,不能用库存位置代替。title、tags此类字段在进入 UI 前就处理默认值,避免 item 内各处写空判断。updatedAt可以参与密钥生成,用于表达“同一个id的内容确实改变了”。favorite是局部交互字段,后续可以单独更新某一个。
如果页面直接使用接口原始字段,后续接口改名、空值、排序都会增量到UI层。对性能优化来说,这种增量让你很难判断到底是渲染问题还是数据问题。
六、稳定关键:列表项能不能复用,先看这里
LazyForEach的关键不要用数据库索引。索引的问题在删除、插入、排序后最明显:第二个项目删除后,后面的索引全部变化,框架很难按身份业务判断谁是谁。
exportfunctionproductKey(item:ProductCardModel):string{返回`${item.id}_${item.updatedAt}`;}如果你的业务里updatedAt不可靠,可以只用id:
exportfunctionstableProductKey(item:ProductCardModel):string{返回 item.id;}怎么选?
| 关键写法 | 适合场景 | 风险 |
|---|---|---|
项目.id | 数据内容变化会通过数据源通知更新 | 内容更新后如果通知不准确,可能看不到变化 |
item.id + updateAt | 内容版本明确,替换关系清楚 | updatedAt高频变化会降低复用收益 |
index.toString() | 基本不建议 | 插入、删除、排序后容易错位 |
我的习惯是:业务稳定列表优先用id;需要明确替换项目内容时再加入版本字段。不要为了“外观唯一”把时间乱塞,否则每次加载都像全量新数据,复用价值会被协调。
七、LazyForEach 数据源:把“刷新全表”和“更新一个”分开
下面是一个可以直接迁移的IDataSource写法。它区分了全量替换和单项更新,后续排查时也更容易判断是哪种更新触发了UI变化。
exportclassProductListDataSourceimplementsIDataSource{私有监听器:DataChangeListener[]=[];私有数据:ProductCardModel[]=[];totalCount():数字{返回this.data.length;}getData(index:number):ProductCardModel{返回this.data[index];}registerDataChangeListener(listener:DataChangeListener):void{如果(!this.listeners.includes(listener)){this.listeners.push(listener);}}unregisterDataChangeListener(listener:DataChangeListener):void{this.listeners=this.listeners.filter((item:DataChangeListener)=>item!==listener);}replaceAll(next:ProductCardModel[]):void{this.data=next;this.listeners.forEach((listener:DataChangeListener)=>listener.onDataReloaded());}updateOne(index:number,item:ProductCardModel):void{如果(index<0||index>=this.data.length){返回;}this.data[index]=item;this.listeners.forEach((listener:DataChangeListener)=>listener.onDataChange(index));}findIndexById(id:string):number{returnthis.data.findIndex((item:ProductCardModel)=>item.id===id);}}解决三个问题的代码:
replaceAll()用于筛选条件变化、下拉刷新、重新查询。updateOne()用于收藏、选中、局部字段变化,不需要全表重载。
3.findIndexById()让业务层通过id查找item,不把数据库索引传入给交易页面。
如果你发现收藏了一个项目后整页抖一下,优先检查是否把局部更新写成了replaceAll()。
八、把 LazyForEach 接收页面:item 只拿自己需要的数据
页面里使用LazyForEach时,item 组件不要读取整个页面状态,只传递它真正需要的字段。
建造(){柱子(){产品筛选栏({关键词:this.keyword, 排序:this.sort,onSearch:(keyword:string,sort:ProductSort)=>{this.keyword=关键字;this.sort=sort;this.reloadProducts();}})如果(this.loading){加载中().width(32).height(32)}列表({空格:10}){LazyForEach(this.dataSource,(item:ProductCardModel)=>{ListItem(){产品卡({模型:项目, 已选中:this.selectedId===item.id,onSelect:(id:string)=>{this.selectedId=id;},onFavoriteChange:(id:string,favorite:boolean)=>{this.updateFavorite(id,favorite);}})}},(item:ProductCardModel)=>productKey(item))}.cachedCount(4).edgeEffect(EdgeEffect.Spring)}}privateupdateFavorite(id:string,favorite:boolean):void{constindex=this.dataSource.findIndexById(id);constcurrent=this.dataSource.getData(index);this.dataSource.updateOne(index,{...当前的, 最喜欢的});}代码里有两个容易被忽视的细节:
ProductCard拿到的是单个model,而不是整页products。
2.收藏变化走updateOne(),避免把一个局部动作变成全部刷新。
cachedCount(4)不是很好。它表示列表可缓存的项目数量,合适的值要结合项目复杂度、图片数量和设备性能调整。列表项很重时,缓存过多反而会增加内存压力。
##九、图片加载别堵住渲染路径:先占位,再替换
列表经常和图片有关,但不要简单理解成“图片越小越好”。更常见的问题是图片加载策略舞蹈:项目构建时同步准备图片、重复请求同一张图、失败后不断重试。
首先给图片一个明确的模型:
导出接口 CoverRequest{productId:字符串; url:字符串; widthVp:数字; heightVp:数值;}exportfunctioncoverCacheKey(request:CoverRequest):string{返回`${request.productId}_${request.widthVp}x${request.heightVp}`;}然后在关联组件里使用占位状态:
@成分struct ProductCover{@Prop请求:CoverRequest;@State已加载:boolean=false;建造(){堆(){如果(!this.loaded){柱子().width(this.request.widthVp).height(this.request.heightVp).backgroundColor('#EEF3F6').borderRadius(12)}图片(this.request.url).width(this.request.widthVp).height(this.request.heightVp).objectFit(ImageFit.Cover).borderRadius(12).onComplete(()=>{this.loaded=true;})}}}也许代码的目的不是实现完整的图片服务器,而是先把渲染路径稳定下来:
- 项目创建避免时先有固定尺寸占位,图片回来后再挤压布局。
loaded是调整内部状态,不影响列表页整体状态。coverCacheKey()给后续接入缓存层留边界,避免缓存规则散布最多里组件。
如果你的项目有统一图片库,可以保留这个请求模型,把真实下载和缓存挖掘基础库处理。
十、关联组件可以拆小的,但不要拆碎片的
有人遇到卡顿后把一个物品拆成十几个小组件,结果代码变复杂,性能不一定变好。拆组件的标准不是“越细越好”,而是看变化频率。
一个比较稳定的拆卸方法如下:
@成分struct ProductCard{@Prop模型:ProductCardModel;@Propselected:布尔值;onSelect:(id:string)=>void=()=>{};onFavoriteChange:(id:string,favorite:boolean)=>void=()=>{};建造(){行({空格:12}){产品封面({要求:{productId:this.model.id,url:this.model.coverUrl,widthVp:96,heightVp:96}})列({空格:6}){文本(this.model.title).fontSize(16).fontWeight(FontWeight.Medium).maxLines(2)Text(this.model.priceText).fontSize(15).fontColor('#E65A2E')ProductTagRow({tags:this.model.tags})排(){Text(this.selected?'已选中':'查看详情').fontSize(12).fontColor(this.selected?'#19A66A':'#64748B')空白的()按钮(this.model.favorite?'已收藏':'收藏').fontSize(12).onClick(()=>{this.onFavoriteChange(this.model.id,!this.model.favorite);})}}.layoutWeight(1)}.padding(12).borderRadius(16).backgroundColor(this.selected?'#F0FFF7':'#FFFFFF').onClick(()=>{this.onSelect(this.model.id);})}}这里我只拆了两个子组件:
ProductCover:图片加载有自己的状态,适合单独拆卸。ProductTagRow:标签显示逻辑独立,后续可做折叠或限行。
标题、价格、按钮仍然在ProductCard里,因为它们共同构成了一个列表项的主要布局。拆掉太碎使得数据传递和排查路径变长,读取代码的人反而更难判断哪个状态触发了更新。
##十一、用Profiler做前后对比:别只说“我感觉快了”
优化对称记录同一条路径。建议记录下面这些信息:
exportinterfaceRenderCheckRecord{场景:字符串; 设备:字符串; beforeFps:数值; afterFps:数字; maxFrameMs:数字; traceFile:字符串; 注意:字符串;}exportfunctionrenderOptimizationAccepted(record:RenderCheckRecord):boolean{返回 record.afterFps>=55&&record.maxFrameMs<=24;}记录示例:
constproductListCheck:RenderCheckRecord={scene:'商品列表连续滑动5屏并收藏1个商品',device:'HarmonyOS NEXT真机',beforeFps:43, afterFps:58, 最大帧数(毫秒):21, traceFile:'product_list_scroll_after.trace',note:'替换 ForEach 为 LazyForEach,分割页面状态,图片增加固定占位'};记录不要写在生产代码里,可以放在性能测试记录、团队文档或提交说明里。它的价值在于让优化能力被复盘:下次有人修改列表时,可以沿着同一条路径再记录一次。
Profiler里重点看三类信息:
| 观察点 | 正常趋势 | 如果异常 |
|---|---|---|
| FPS 圆形 | 连续滑动时移动减少 | 返回项目构建和图片加载排查 |
| 帧运行 | 尖峰减少,长帧变少 | 看主线程是否同步重任务 |
| ArkUI 追踪 | 施工、布局、前后同步下降 | 检查状态变化是否仍然过大 |
##十二、常见问题排查:按症状倒推,不要凭感觉改
| 症状 | 优先怀疑 | 检查方法 | 修复方向 |
|---|---|---|---|
| 收藏一个项目,列表整体闪动 | 局部更新写成全量刷新 | 搜索是否调用replaceAll() | 改成updateOne()并保持密钥稳定 |
| 删除项目后显示错位 | key 使用了数据库索引 | key 查看密钥生成函数 | 改用业务 id 或 id + 内容版本 |
| 滑动时图片区域跳动 | 图片没有固定占位尺寸 | 断网或慢网环境复现 | 给图片容器固定宽高和占位背景 |
| 首屏加载慢但滑动不卡 | 数据请求或首屏计算重 | 分别录主屏和滑动轨迹 | 优先查接口、解析和首屏初始化 |
| Profiler 记录结果每次差很多 | 路径测试不稳定 | 数据量、滑动距离和操作顺序固定 | 先写现成剧本,再比较结果 |
| LazyForEach 后项不刷新 | 数据源通知不正确或关键没变化 | 检查onDataChange/onDataReloaded | 局部变更通知索引,内容替换时调整关键策略 |
排查时不要一次改五处。一次只改一个变量,记录一次结果。列表性能问题最怕“全部改了,但不知道是哪一刀生效”。
##十三、发布前验收清单:让优化能被别人接手
把列表优化合入主路径之前,我会按下面这张清单过一遍:
| 检查项目 | 是否必须 | 通过标准 |
|---|---|---|
| 有稳定复现路径 | 是 | 相同设备上可重复触发优化前问题 |
| 列表键不使用索引 | 是 | 插入、删除、排序后项不错 |
| 大对象状态已拆分 | 是 | 筛选、选中、加载、数据源边界清晰 |
| LazyForEach 数据源职责声明 | 是 | 整体更新和局部更新分开 |
| 图片有固定位置 | 建议 | 慢网下布局不跳动 |
| Profiler 有防守记录 | 是 | 能看到FPS或长帧指标变化 |
| 失败回退可解释 | 是 | 出问题时知道先查数据源、关键还是图片 |
如果团队里多人维护同一个列表页面,最好把这张清单放到代码评审说明里。性能优化不是一次性的“调参”,而是后续每次需求都要守住的边界。
##十四、读者可以直接照做的改造顺序
如果你正在修改自己的项目,可以按这个顺序来,别一开始就重构整个页面:
1.固定一条复现路径,用DevEco Profiler记录一次优化前Trace。
2.检查列表中是否以仓库索引作为key,如果是,先改成业务id。
3.把页面大对象@State拆成筛选、选中、加载和独立数据源。
4.把ForEach替换为LazyForEach,实现IDataSource。
5.区分replaceAll()和updateOne(),不要把局部变化写成全量刷新。
6. 给图片增加固定尺寸占位,避免加载回来后挤压布局。
7. 重新记录一次同一路径的Trace,比较FPS、长帧和ArkUI运行。
这个顺序的好处是每一步都有明确的收益,也方便回滚。真正的工程优化不是把代码写得“看起来高级”,而是让下一个接手的人知道:这个列表为什么这么写,哪些地方不能随便改。
参考资料
- 华为开发者文档:UI 性能开发
https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/ui-performance-overview - 华为开发者文档:LazyForEach 数据懒加载
https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-rendering-control-lazyforeach - 华为开发者文档:懒加载优化性能
https://developer.huawei.com/consumer/cn/doc/best-practices/bpta-lazyforeach-optimization - 华为开发者文档:主线程运行操作优化
https://developer.huawei.com/consumer/cn/doc/best-practices/bpta-time-optimization-of-the-main-thread
总结
ArkUI 列表卡顿不要只从“换组件”入手。更稳定的路径是:先复现,再录制;稳定模型和密钥,再先拆状态;先把全量刷新和局部更新分开,再用LazyForEach控制可见区域渲染;最后用 Profiler 回归验证。这样写出来的优化不是一次临时重建,而是能被团队继续维护的工程边界。
