
ArkUI 渲染性能优化实战从列表掉帧到状态分割、LazyForEach 和 Profiler 回归ArkUI时最容易被低估的性能问题不是“页面打不开”而是“页面能用但越滑越卡”。商品列表、页面路线列表、动态流、消息列表都有类似症状第一屏还流畅滑到第二屏开始掉帧筛选条件一变整页闪一下图片刚出现时手感变沉删除一条数据后列表项错位开发机上还行真机一录Profiler可以看到做帧工作差不多。这篇文章不讲抽象的性能口号只处理一个真实问题ArkUI长列表滚动掉帧怎么用状态分割、稳定key、LazyForEach、图片异步加载和DevEco Profiler做一次可验证的优化闭环。读完以后你应该能带走四件事1.知道列表卡顿该先看哪里而不是上来乱拆组件。2. 可以把大对象状态拆成更可控的页面状态、列表数据源和局部选中状态。3. 能用LazyForEach和稳定键减少无意义的列表项重建。4. 能用Profiler记录伺服结果判断优化到底有没有用。一、此类卡顿在真实项目里长什么样先把问题说具体。下面这些现象如果只靠肉眼判断很容易被判判为“手机性能不行”或者“图片精致”现象可能原因先看哪里列表滑动到第二屏开始掉帧可见区域外组件创建过多或者项目重建过绝缘ForEach/LazyForEach使用方式修改筛选条件后整页提示页面状态粒度过粗一个字段变化牵动整棵UIState是否包了大对象删除一条数据后列表错位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在大量子组件场景下可能会带来卡顿应LazyForEachLazyForEach需要开发者实现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.selectedIditem.id})}})}}}}代码的问题不是语法而是职责混在一起1.keyword改变时列表数据和选中状态也被包在同一个大对象里。2.ForEach直接完整面对备份数据量上来后首屏外的构建压力更加明显。3.index出现在渲染回调里后续很容易顺手拿索引做 key 或业务标识。4. 图片、收藏、选中等局部变化没有被隔离容易扩大UI更新范围。这就是“看起来代码少实际维护成本高”的典型写法。优化的第一步不是拆掉组件而是拆掉变化源。四、把大对象状态拆开让每次变化只影响该影响的地方列表页通常至少有三类状态状态类型例子更新频率推荐位置页面筛选状态关键词、排序、分类地中海页面State列表数据商品、路线、消息储备较低但数据量较大独立数据源局部交互状态已选中、收藏、曝光高度item 或轻量映射改造后的页面不要把所有现场塞进一个对象typeProductSortdefault|price|sales;入口成分struct ProductListPage{State关键字:字符串;Statesort:ProductSortdefault;StateselectedId:string;Stateloading:booleanfalse;privatedataSource:ProductListDataSourcenewProductListDataSource();私有仓库ProductRepositorynewProductRepository();aboutToAppear():void{this.reloadProducts();}privateasyncreloadProducts():Promisevoid{this.loadingtrue;constresultawaitthis.repository.query({关键词this.keyword 排序this.sort});this.dataSource.replaceAll(result);this.loadingfalse;}}大概代码的边界很清楚页面State只保存会直接影响页面控制区的轻量字段。列表数据定位ProductListDataSource后续由它通知列表刷新。reloadProducts()的输入只有筛选条件不把UI组件对象文档层。loading只控制加载提示不涉及列表项的键和复用关系。拆除状态的收益不是“代码更优雅”而是后续排查时能判断如果选中状态变化不会导致整批列表数据重新加载如果只是筛选条件变化项目复用应该由关键决定而不是被队列索引牵着走。五、模型先稳定不要让UI直接依赖接口原始字段item列表的字段应该先整理成UI模型。接口返回什么字段是一回事页面渲染需要什么字段是另一回事。导出接口 ProductCardModel{id字符串 标题字符串 coverUrl:字符串;priceText字符串 标签字符串[] 更新时间编号 收藏布尔值}exportfunctiontoProductCardModel(raw:ProductResponseItem):ProductCardModel{返回{idraw.productId 标题原始名称未命名商品,封面网址raw.cover 价格文本:¥${raw.price},tags:raw.labels??[],更新时间raw.updateTime favorite:raw.favoritetrue};}这里的重点是“稳定”id来自必须业务唯一标识不能用库存位置代替。title、tags此类字段在进入 UI 前就处理默认值避免 item 内各处写空判断。updatedAt可以参与密钥生成用于表达“同一个id的内容确实改变了”。favorite是局部交互字段后续可以单独更新某一个。如果页面直接使用接口原始字段后续接口改名、空值、排序都会增量到UI层。对性能优化来说这种增量让你很难判断到底是渲染问题还是数据问题。六、稳定关键列表项能不能复用先看这里LazyForEach的关键不要用数据库索引。索引的问题在删除、插入、排序后最明显第二个项目删除后后面的索引全部变化框架很难按身份业务判断谁是谁。exportfunctionproductKey(item:ProductCardModel):string{返回${item.id}_${item.updatedAt}}如果你的业务里updatedAt不可靠可以只用idexportfunctionstableProductKey(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.listenersthis.listeners.filter((item:DataChangeListener)item!listener);}replaceAll(next:ProductCardModel[]):void{this.datanext;this.listeners.forEach((listener:DataChangeListener)listener.onDataReloaded());}updateOne(index:number,item:ProductCardModel):void{如果(index0||indexthis.data.length){返回;}this.data[index]item;this.listeners.forEach((listener:DataChangeListener)listener.onDataChange(index));}findIndexById(id:string):number{returnthis.data.findIndex((item:ProductCardModel)item.idid);}}解决三个问题的代码replaceAll()用于筛选条件变化、下拉刷新、重新查询。updateOne()用于收藏、选中、局部字段变化不需要全表重载。3.findIndexById()让业务层通过id查找item不把数据库索引传入给交易页面。如果你发现收藏了一个项目后整页抖一下优先检查是否把局部更新写成了replaceAll()。八、把 LazyForEach 接收页面item 只拿自己需要的数据页面里使用LazyForEach时item 组件不要读取整个页面状态只传递它真正需要的字段。建造{柱子{产品筛选栏({关键词this.keyword 排序this.sortonSearch:(keyword:string,sort:ProductSort){this.keyword关键字;this.sortsort;this.reloadProducts();}})如果(this.loading){加载中().width(32).height(32)}列表({空格:10}){LazyForEach(this.dataSource,(item:ProductCardModel){ListItem(){产品卡({模型项目 已选中this.selectedIditem.idonSelect:(id:string){this.selectedIdid;},onFavoriteChange:(id:string,favorite:boolean){this.updateFavorite(id,favorite);}})}},(item:ProductCardModel)productKey(item))}.cachedCount(4).edgeEffect(EdgeEffect.Spring)}}privateupdateFavorite(id:string,favorite:boolean):void{constindexthis.dataSource.findIndexById(id);constcurrentthis.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已加载booleanfalse;建造{堆{如果(!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.loadedtrue;})}}}也许代码的目的不是实现完整的图片服务器而是先把渲染路径稳定下来项目创建避免时先有固定尺寸占位图片回来后再挤压布局。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,heightVp96}})列({空格: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.afterFps55record.maxFrameMs24;}记录示例constproductListCheck:RenderCheckRecord{scene:商品列表连续滑动5屏并收藏1个商品,device:HarmonyOS NEXT真机,beforeFps43 afterFps58 最大帧数毫秒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 回归验证。这样写出来的优化不是一次临时重建而是能被团队继续维护的工程边界。