你做的 App 要是也有长列表,肯定见过这个建议:“cachedCount设大点,滑动更流畅”。我一开始也信,直接拉满。我做的雷达鸭鸿蒙版首页就是个瀑布流,列表项高度不固定、图片多,结果把cachedCount从默认 1 改到 20 之后,用户反馈说"越滑越卡"。我愣了:这参数不是优化性能的吗?
直接上结论:在我的这个页面里,cachedCount=4比cachedCount=20平均帧率高出 16fps,首屏时间还少了 340ms。下面我把测试过程和数据摊开讲。
测试页面长这样
一个List里塞 200 条 item,每个 item 包含一张网络图片、两行文字、一个关注按钮。图片高度不固定,模拟真实内容流。测试设备是 Mate 60 Pro,HarmonyOS NEXT 5.0,DevEco Studio 5.0 Release。
这个页面在雷达鸭鸿蒙版上对应的场景是"案例列表",一行一个卡片,滚动时图片懒加载。加载的是远端 CDN 图片,本地 mock 数据,避免网络波动影响测试结果。
核心代码:
// ListPerformancePage.etsimport{http}from'@kit.NetworkKit';interfaceCaseItem{id:string;title:string;cover:string;author:string;liked:boolean;}@Entry@Componentstruct ListPerformancePage{@StatecaseList:CaseItem[]=[];@StatecachedCount:number=1;// 测试时会改成 1/4/8/12/20aboutToAppear(){this.loadCases();}loadCases(){// 实际测试用本地 mock 数据,避免网络波动constmock:CaseItem[]=[];for(leti=0;i<200;i++){mock.push({id:`case_${i}`,title:`案例标题${i}`,cover:`https://example.com/cover/${i}.jpg`,author:`作者${i%30}`,liked:false});}this.caseList=mock;}build(){Column(){List({space:12}){LazyForEach(this.caseList,(item:CaseItem,index:number)=>{ListItem(){CaseCard({item:item})}.height(LengthMetrics.vp(this.estimateHeight(item)))},(item:CaseItem)=>item.id)}.cachedCount(this.cachedCount).scrollBar(BarState.Off).edgeEffect(EdgeEffect.Spring).layoutWeight(1)}.width('100%').height('100%').padding(16)}estimateHeight(item:CaseItem):number{// 简单预估:标题两行约 56,图片按 3:4 比例约 160,作者行 24,padding 24return264;}}@Componentstruct CaseCard{@Propitem:CaseItem;build(){Column(){Image(this.item.cover).width('100%').height(160).objectFit(ImageFit.Cover).borderRadius(8)Text(this.item.title).fontSize(16).fontWeight(FontWeight.Medium).maxLines(2).textOverflow({overflow:TextOverflow.Ellipsis}).margin({top:8,bottom:4})Row(){Text(this.item.author).fontSize(12).fontColor('#666')Button(this.item.liked?'已关注':'关注').fontSize(12).height(28).backgroundColor(this.item.liked?'#e0e0e0':'#ff6600')}.width('100%').justifyContent(FlexAlign.SpaceBetween)}.width('100%').padding(12).backgroundColor('#fff').borderRadius(12).shadow({radius:6,color:'rgba(0,0,0,0.06)',offsetX:0,offsetY:2})}}我用这个脚本抓的帧率
DevEco Studio 自带的 Profiler 能看帧率,但为了重复跑对比,我写了段内部测试代码,在List滑动时每隔 100ms 记录一次主线程耗时,然后算 fps。
这个脚本的关键点在于:不要直接依赖List的onScroll事件频率,因为onScroll触发的间隔并不完全等同于渲染帧率。更稳的做法是起一个setInterval定时器,每秒统计一次这段时间内markFrame()被调了多少次,然后除以秒数得到 fps。
// FrameTracker.etsexportclassFrameTracker{privatelastTime:number=0;privateframeCount:number=0;privatefpsSamples:number[]=[];privatetimer:number|null=null;start(){this.lastTime=Date.now();this.frameCount=0;this.fpsSamples=[];this.timer=setInterval(()=>{constnow=Date.now();constelapsed=now-this.lastTime;if(elapsed>0){constfps=(this.frameCount*1000)/elapsed;this.fpsSamples.push(Math.round(fps));}this.lastTime=now;this.frameCount=0;},1000);}markFrame(){this.frameCount++;}stop():number[]{if(this.timer){clearInterval(this.timer);this.timer=null;}returnthis.fpsSamples;}averageFps():number{if(this.fpsSamples.length===0)return0;returnMath.round(this.fpsSamples.reduce((a,b)=>a+b,0)/this.fpsSamples.length);}}用法:在List的onScroll回调里每帧调一次frameTracker.markFrame(),滑动 5 秒后停止,取平均 fps。
// 在 build 的 List 上加上 onScrollList({space:12}){// ... LazyForEach}.onScroll(()=>{this.frameTracker.markFrame();})数据直接摆出来
| cachedCount | 平均帧率 | 首屏可见时间 | 峰值内存 |
|---|---|---|---|
| 1 | 51fps | 680ms | 142MB |
| 4 | 58fps | 620ms | 148MB |
| 8 | 54fps | 710ms | 161MB |
| 12 | 46fps | 820ms | 178MB |
| 20 | 42fps | 960ms | 201MB |
测试方法:每个取值跑 5 次,取中位数。滑动速度保持统一,从顶部匀速滑到第 50 条。测试前清一次应用进程,保证每次起始状态一致。
你看,cachedCount=1其实不算最差,但滑动时白块明显,因为上下都没预渲染。cachedCount=4是拐点,帧率最高、首屏最快。超过 8 之后开始往下掉,20 的时候肉眼已经能感受到卡顿。
我第一反应是不信。cachedCount不是缓存更多节点吗?多缓存不应该更流畅?结果数据打脸。我专门又跑了三轮确认,还把List的onScroll回调去掉,只测纯滑动,数据还是这个趋势。
为什么大了反而卡
ArkTS 的List用LazyForEach时,只渲染视口内的节点。cachedCount控制的是视口上下额外创建多少个节点。注意:是创建,不是只保留数据。
每个ListItem里面都有Image、Text、Button、阴影、圆角。节点一多,主线程在滑动过程中要同时做这几件事:
- 创建新的
ListItem组件实例; - 触发图片下载和异步解码;
- 计算动态高度(如果你的 item 高度不固定,还要走一次布局);
- 回收滑出屏幕的节点。
cachedCount=20意味着你还没滑到那,系统已经提前创建了 20 个完整 item。每个 item 里的图片都开始请求,解码线程一下子挤进来。这跟你预想的"流畅"完全不是一回事。
问题也出在这:ArkTS 的 UI 主线程和动画线程是分离的,但节点创建和布局依然走主线程。cachedCount太大,主线程 GC 和布局开销会先吃掉帧预算。反映在数据上就是帧率下降、首屏变长。
还有一个容易被忽略的点:图片解码是并发的,但 GPU 纹理上传和合成是在主线程打点的。预创建太多 item,会导致一次上传大量纹理,某一帧直接爆掉 16ms 的预算。你看到的"卡顿"往往不是持续掉帧,而是隔三岔五顿一下。
我的最终配置
List({space:12}){LazyForEach(this.caseList,(item:CaseItem,index:number)=>{ListItem(){CaseCard({item:item})}.height(LengthMetrics.vp(this.estimateHeight(item)))},(item:CaseItem)=>item.id)}.cachedCount(4)// 实测这个页面最优.scrollBar(BarState.Off).edgeEffect(EdgeEffect.Spring).layoutWeight(1)如果你 item 结构简单,比如只有一行文字,可以试探到 6-8。如果 item 里图片多、组件重,建议从 2-4 开始测。别上来就拉满。
还有一个细节:.height()一定要给ListItem固定或预估高度。如果你不给高度,List必须等子组件布局完才能知道每个 item 占多少空间,这会让cachedCount的预创建效果大打折扣。我当时图省事,有一版没设高度,结果cachedCount=4和cachedCount=12几乎没差别,排查了半天才发现是这个原因。
几个容易踩的误区
误区一:缓存越大,滑动越不卡。错。缓存的本质是用提前创建换取滑动流畅度,但创建本身也是成本。超过某个阈值后,创建成本会超过缓存收益。
误区二:所有页面都用同一个值。错。瀑布流和简单文字列表的最优值差很多。我每次遇到新页面都会跑一组对比数据,而不是抄一个"最佳实践"。
误区三:只看平均帧率。错。卡顿很多时候是瞬时掉帧。cachedCount=20平均帧率 42fps,看着还行,但滑动时经常会突然跌到 30fps 以下,体感就是"一顿一顿"。所以测的时候一定要看 fps 曲线的最小值和方差,不能只取平均。
顺带一提的副作用
cachedCount调大还会让内存涨。我的测试里从 1 到 20,峰值内存涨了 60MB。鸿蒙 NEXT 对后台应用内存管控本来就比 Android 严,你一个列表页面多占 60MB,系统杀后台的概率就高一截。这点在低端机型上更明显。
所以这件事说到底是取舍:你愿意用内存和首屏时间换滑动流畅度吗?数据告诉我,换过头就亏了。我现在的做法是每个列表页面先按默认 1 跑一遍,再按 4 跑一遍,挑表现好的那个上线。结构简单的页面偶尔 8 更好,但超过 8 的我基本没再设过。
你列表页面cachedCount现在设的是多少?有没有跟我一样试过拉满然后掉帧的?欢迎评论区贴数据,我也想看看别的页面是不是这个规律。
老三— 10+ 年软件开发经验,软件设计师,人工智能应用工程师。专注鸿蒙 ArkTS 北向开发与 Web 前端,同时折腾 AI 自动化。不定期在 CSDN 分享鸿蒙和 AI 方向的技术文章。
本文遵循 MIT 协议,转载请注明出处。
标签:鸿蒙、ArkTS、List、性能优化、cachedCount