基于List的滑动丢帧性能问题分析思路&案例
1. 场景导入
基于ArkUI的List组件实现的滚动列表视图,在手指抛滑场景下,通过分析掉帧情况来判断List滑动是否流畅,保障用户极致流畅体验。
2. 性能指标
最大连续丢帧数:指从页面开始有响应变化到页面结束刷新的过程中,由于显示器画面刷新频率低于预设的画面帧率而未能正常呈现的最大连续帧数。一般而言,当连续值超过3时,用户可以明显感知到卡顿掉帧,数值越大卡顿时间越长。最大连续丢帧数越接近于0,用户流畅性体验越好。
2.2 性能衡量起止点介绍
以大于300mm/s的速度,连续3次抛滑,每次半屏。抓取滑动过程Trace,查看Frame泳道中应用进程和RenderService的最大连续丢帧数。
List组件的抛滑过程,可以通过应用进程下的H:APP_LIST_FLING泳道标识。性能衡量的起点为第一次抛滑开始点,衡量的结束点为第三次抛滑的结束点。
泳道 | 描述 |
H:APP_LIST_FLING | 从手指按下开始拖动到抬手后的惯性滚动及最后尾动效的抛滑全过程。 |
H:touchEventDispatch | 拖滑阶段,从手指开始拖动到抬起。 |
H:TRAILING_ANIMATION | 抛滑尾动效阶段 |
Tip:尾动效阶段系统会进行降帧处理,所以如果要统计FPS情况,通常只会统计从抛滑开始到尾动效起点的这一阶段
3. 问题定位流程
3.1.1 查看操作录屏辅助定位
处理三方应用问题时,可以优先查看操作录屏,查看操作场景,看能否发现一些有助于定位的信息,比如卡顿的页面布局情况、卡顿的现象等等。
3.1.2 Trace抓取
滑动帧率Trace抓取请参考【附录1: 滑动帧率Trace抓取方法】。
3.2 问题定位思路
滑动丢帧类问题的通用定位思路为先确认抛滑的起止点,然后看抛滑过程中最大连续丢帧数,如果大于0帧,则根据Trace信息进一步确认问题点,确认责任领域并对齐处理,处理流程如下图:
3.2.1确认起止点
参考【2.2 性能衡量起止点介绍】
3.2.2 找问题点
3.2.2.1 判断丢帧进程
首先通过Frame泳道判断丢帧进程,其中绿色代表没有丢帧,其他颜色均为丢帧。其中“粉红色”代表该帧的期望时间,“红色”标识超时部分。
应用进程问题
如下图,应用进程连续丢了5帧,但可以看到只有261帧、263帧、264帧耗时较长,因此只分析这3帧即可。另外发现最后一帧序号也是264,这是因为前一帧耗时较长,导致该帧和前一帧都提交到了RS的264帧上,应用帧上的序号是被提交到的RS帧序号相对应的。
RS进程问题
RS进程丢帧可能是应用进程导致的,如上图RS侧丢了1帧,但可以看到RS侧264帧丢帧原因是由于应用进程的264帧耗时较长,提交较晚导致。所以这种情况只分析应用侧丢帧原因即可。如果应用进程中没有丢帧且每帧耗时比较均匀,但是RS侧发生丢帧,则说明不是应用侧导致丢帧,此时只分析RS进程丢帧原因即可。
3.2.2.2 找丢帧Trace
选中Frame泳道,点击Statistics下面的应用进程右侧图标进入Frame List
过滤Jank Type为AppDeadlineMissed类型的帧,点击跳转应用进程。
详细分析丢帧Trace。
3.2.3 根因分析方法
应用侧的渲染流程如下图所示,了解ArkUI的渲染流程有助于我们定位应用侧的卡顿问题出现在哪个环节。
阶段 | 描述 |
Animation | 动画阶段,在动画过程中会对相应的组件标记脏区 |
Events | 事件处理阶段,比如手势事件处理。在手势处理过程中也会对组件标记脏区 |
UpdateUI | 组件在首次创建或状态变量变更时会标记为需要rebuild状态,在下一次Vsync过来时会通过View的方法生成相应的组件树结构和属性样式修改任务。 |
Measure | 执行组件的大小测算任务。 |
Layout | 执行组件的布局任务。 |
Render | 执行绘制任务,执行完成后会标记请求刷新RSNode绘制 |
SendMessage | 将绘制数据提交到RS侧,请求刷新界面绘制 |
应用进程丢帧分析
跟据Trace图,初步分析耗时较长阶段。261帧由于懒加载组件预创建耗时较长导致丢帧;263帧由于组件复用耗时长丢帧;264帧由于组件结构复杂嵌套层级多导致丢帧。
序号 | 所属泳道 | Trace点 | 描述 |
1 | 应用进程 | H:LazyForEach predict | LazyForEach预处理 |
2 | 应用进程 | H:CustomNode:BuildRecycle 自定义组件名 | 自定义组件的复用,包含执行aboutToReuse方法的耗时 |
3 | 应用进程 | H:CreateTaskMeasure[组件名][self:组件id][parent:父组件id] && H:Measure[组件名][self:组件id][parent:父组件id] | 执行组件的布局测量任务 |
通过ArkTS CallStack泳道,可以看到应用侧具体调用栈,进一步分析定位问题原因。如下图通过调用栈可以分析出:组件复用时会组件树进行递归,这个过程耗时较长,可以看下组件数是否组件嵌套层级过深;updateDirtyElement耗时长,应用侧可以分析下是否存在冗余节点被触发更新;aboutToReuse耗时长可以看下应用侧该回调中是否存在耗时逻辑。
应用UI组件树的嵌套情况,可以通过ArkUI Inspector查看。
经验总结: 应用进程丢帧通常是组件结构嵌套层级深、耗时应用业务逻辑阻塞UI线程等问题导致。如果是UI结构复杂问题可以让应用通过减少嵌套层级、使用组件复用等方式优化。如果是有耗时业务逻辑,则可以通过将耗时逻辑放到Taskpool或Worker中优化。
RS进程丢帧分析
RS进程丢帧一般是由于界面结构过于复杂或者GPU负载过大等原因导致的。如果应用侧没有丢帧且每帧耗时比较平均,则可以初步判断应用侧没有问题,同时也可以通过应用侧Trace中H:SendCommands下的H:MarshRSTransactionData cmdCount查看应用提交的绘制指令树是否过多。如下图RS侧丢帧原因是由于RS侧的H:RSUniRender:FlushFrame阶段耗时较长,此时可以找图形子系统进一步确认耗时根因。
经验总结: RenderService侧丢帧通常是应用侧UI线程阻塞提交绘制指令较慢导致,此时应当初步定位应用侧耗时长原因。如果应用侧无丢帧情况,绘制指令正常提交,则可以找图形子系统协助进一步分析丢帧原因。
4. 典型问题
4.1 耗时任务阻塞UI主线程
Stage模型下的线程主要有三类:主线程、TaskPool、Worker。主线程主要用于执行UI绘制、处理应用代码逻辑,TaskPool和Worker的作用是为应用程序提供一个多线程的运行环境,用于处理耗时的计算任务或其他CPU密集型任务。当主线程存在耗时的计算任务时,会使主线程阻塞,导致应用丢帧。
4.1.1 问题根因分析
应用进程中间有一段大段“空白”,UI线程未提交任何绘制指令,同时CPU10大核却处于Running状态,表示此时应用侧正在执行ArkTS的业务代码。按每帧8.3ms算,这里阻塞了75.9ms,丢了9帧左右。
通过ArkTS Callstack泳道,可以看到耗时点主要在三个文件:FlowApi.ets、BaseFeedFlowListVM.ets、Mapi.ets。
与伙伴确认业务逻辑主要为:在列表滑动过程中,List将要到达底部时,会通过网络请求会获取到一个博文的列表数据,数据量较大。然后在FlowApi.ets、Mapi.ets中会对数据进行转换处理,处理后的数据通过BaseFeedFlowListVM.ets文件再转换成
4.1.2 优化方案
在List滑动过程中对数据进行处理耗时较长,占用大量CPU资源,导致主线程被阻塞,这部分数据处理的相关业务逻辑与UI绘制无关,但却长时间占用CPU资源,导致UI线程被阻塞丢帧。可以将该数据处理逻辑放到TaskPool中利用多核并行化处理优化。
除应用侧的耗时逻辑外,某些与UI绘制无关的耗时系统接口调用也可以放到TaskPool中优化。
4.2 @Prop传参深拷贝耗时长
4.2.1 问题根因分析
观察应用Trace发现Measure阶段的H:CustomNode:BuildItem [MediaCard]耗时较长4ms 661μs,通过观察ArkUI Component泳道,得出自定义组件MediaCard构建耗时较长。
通过ArkTS CallStack观察应用ArkTS调用栈。其中observeComponentCreation2为@Observed传参时相关逻辑调用栈。resetLocalValue、copyObject、deepCopyObject、deepCopyObjectInternal、getDeepCopyOfObjectRecursive为@Prop接收参数时拷贝数据的相关调用栈。
由此分析得出:应用侧MediaCardComponent.ets文件中的自定义组件MediaCard通过@Observed+@State方式声明了某个对象类型的数据,并将该变量传给了子组件MediaImage,但在MediaImage中并未使用@ObjectLink接收该变量,而是使用@Prop接收,导致该状态变量发生了深拷贝,深拷贝过程耗时较长3ms 339μs。
4.2.2 优化方案
@Prop装饰器存在性能问题,@Prop装饰的变量会对父组件传入状态值进行深拷贝,如果@Prop装饰器装饰的变量为复杂Object、class或其类型数组时,会增加状态创建时间以及占用大量内存。如果需要观察嵌套类对象的深层属性变化,推荐选择@State+@Observed+@ObjectLink组合方案。
4.3 UI复杂导致单帧超长
4.3.1 问题根因分析
List滑动场景中,应用侧单帧耗时较长91.8ms,导致应用丢帧。通过Trace可以看到在这一帧中创建了大量组件。
4.3.1.1 组件复用失效
首先,观察Trace发现,在ListItem的Measure阶段,出现了大量H:CustomNode:BuildItem,说明此时发生了大量自定义组件重新创建,而没有被复用。
经与应用讨论分析,应用的单个ListItem包含4个部分(头像、文本、九宫格、底部按钮)。ListItem的结构非固定的,其子组件可能会出现多种情况,如图文、视频、纯文本等等,动态性较高。同时又因为其复用是以整个ListItem为单位,所以在进入复用池时ListItem会存在多种可能性。导致在新ListItem期望复用创建时,在复用池中可能未找到对应的可被复用组件,自定义组件被重新创建,组件复用失效。
4.3.1.2 嵌套层级深
通过ArkUI Inspector观察UI组件树结构,可以发其中存在大量自定义组件的__Common__节点(如下图红框),且存在容器之间组件冗余嵌套的情况(如下图绿框)。冗余的嵌套会带来不必要的组件节点,加深组件树的层级,在创建和布局阶段会产生较大的性能开销。
4.3.1.3 冗余的状态变量
框选该帧并筛选Trace点:H:ViewPU.viewPropertyHasChanged,该Trace点表示状态变量发生了更新,其中后三个参数分别为自定义组件名、自定义的状态变量名、该状态变量更新后影响的组件数量。可以发现这里有大量为“0”的Trace点,表示该状态变量更新时未触发任何组件刷新,即该状态变量未绑定UI组件,因此可以将其改为普通变量。
4.3.2 优化方案
4.3.2.1 组件复用失效优化
细化组件复用的颗粒度,将原来对ListItem的复用改为对ListItem中各部分子组件复用,提高组件复用成功率。
相关修改代码参考:
4.3.2.2 嵌套层级深优化
方案一: 当自定义组件设置通用属性后,UI组件树就会产生__Common__节点,可以通过属性内移解决。将自定义组件上设置的属性内移到自定义组件中的第一层系统组件上。
方案二: 避免冗余的嵌套,对于这类冗余的容器,应该尽量优化,减少嵌套深度。建议采用相对布局RelativeContainer进行扁平化布局,有效减少容器的嵌套层级,减少组件的创建时间。
方案三: 使用@Builder代替@Component自定义组件。通过@Component声明的组件在创建时会产生额外耗时,建议尽量使用@Builder声明组件。
4.3.2.3 冗余的状态变量优化
将没有跟UI组件绑定的状态变量改为普通变量。@State、@Prop等装饰器修饰的状态变量在创建、Get、Set时都会产生耗时,因此应该尽量减少冗余的状态变量,避免性能损耗。
附录1:滑动帧率Trace抓取方法
Step1: 电脑连接上设备,在DevEco Studio上打开Profiler。
Step2: 设备上运行需要测试的应用,在设备列表选择设备,选择要测试的应用,和主进程。
Step3: 创建Frame模板,并点击录制,待所有泳道都进入到recording状态后。
Step4: 执行相关滑动操作。
Step5: 操作完成,点击结束录制,待分析完成后,可以在泳道上看到trace数据。
Step6: trace的路径点击Help -> Show Log in Explorer。
返回到上一层,找到.insight文件下。
附录2:List滑动场景通用Trace点说明
基础List滑动
以一个最基础的List Demo为例,通过脚本抛滑并使用IDE Profiler工具抓取Trace。
拖动阶段
选取拖动阶段某一Trace如下:
序号 | Trace | 描述 | 参数说明 |
1 | H:client dispatch touchId:32903 | 系统派发touch事件 | 事件id |
2 | H:OnVsyncEvent now: [时间戳] | 收到Vsync信号,渲染流程开始 | 时间戳--纳秒级 |
3 | H:FlushVsync | 处理用户输入、刷新视图同步事件、计算帧信息、提交绘制渲染等 | |
4 | H:DispatchTouchEvent id:0, pointX=[x坐标] pointY=[y坐标] type=2 | 处理拖拽手势事件 | 触摸点的xy坐标信息,type=2表示手势事件类型为移动 |
5 | H:HandleDragUpdate | 执行拖拽更新任务 | |
6 | H:AddDirtyLayoutNode[List][self:7][parent:6] | 标记List组件为脏区 | 组件名、组件ID、父组件ID |
7 | H:UITaskScheduler::FlushTask | 刷新UI界面,包括布局计算、渲染和提交等 | |
8 | H:FlushLayoutTask | 执行布局任务 | |
9 | H:CreateTaskMeasure[List][self:7][parent:6] && H:Measure[List][self:7][parent:6] | 执行List组件的布局测量任务 | 组件名、组件ID、父组件ID |
10 | H:ListLayoutAlgorithm::MeasureListItem:27 && H:Measure[ListItem][self:10][parent:7][key:] | 计算ListItem列表项的布局尺寸 | 列表项索引、组件名、组件ID、父组件ID |
11 | H:SkipMeasure | 组件大小布局未发生变化,跳过measure过程 | |
12 | H:CreateTaskLayout[List][self:7][parent:6] && H:Layout[List][self:7][parent:6] | 执行List组件布局任务 | 组件名、组件ID、父组件ID |
13 | H:Layout[ListItem][self:10][parent:7][key:] | 执行ListItem组件布局任务 | 组件名、组件ID、父组件ID |
14 | H:SyncGeometryNode[List][self:7][parent:6][key:] | 同步几何节点 | 组件名、组件ID、父组件ID |
15 | H:FlushRenderTask 1 && H:FrameNode[List][id:7]::RenderTask | 执行渲染绘制任务 | 当前页面需要绘制的节点数量、需绘制的组件名和ID |
16 | H:FlushMessages && H:SendCommands | 通知图形侧进行渲染 | |
17 | H:MarshRSTransactionData cmdCount:14 transactionFlag:[24390,75] | 向图形侧发送绘制指令 | 绘制指令数量、应用进程号、指令序列号 |
18 | H:OnIdle, targettime:222549467458334 | Vsync中的空闲,一般会用来做预加载之类的操作,当H:OnVsyncEvent时间小于某值时触发该事件 | 时间戳 |
惯性滑动阶段
惯性滑动阶段Trace点与拖动阶段基本一致,唯一区别点在于拖动阶段是通过手势事件标脏,而惯性滑动阶段是通过动画触发的组件标脏。
序号 | Trace | 描述 | 参数说明 |
1 | H:RunningCustomAnimation num:[1] | 自定义动画,RSModifierManager管理的在UI线程运行的动画 | num表示动画的数量,如果大于0,则表示有动画在运行 |
2 | H:AddDirtyLayoutNode[List][self:7][parent:6] | 标记List组件为脏区 | 组件名、组件ID、父组件ID |
3 | H:FlushDirtyNodeUpdate | 更新被标脏的节点 |
尾动效阶段
尾动效阶段相较其他两阶段的区别点,在于尾动效阶段如果移动距离小于1像素则不会向RS提交H:SendCommands。
选取一个应用进程未提交帧,放大后可以看到H:SendCommands下方并无H:MarshRSTransactionData的Trace点。无该Trace点时说明ArkUI中没有需要绘制的内容,因此没有提交绘制指令到RenderService侧。
懒加载场景滑动
基于上文List Demo改造,实现懒加载效果。List懒加载场景下滑动的Trace点与上文基础List基本一致,其区别点主要在于懒加载的滑动场景下会出现组件创建和销毁过程,因此本章节主要介绍创建和销毁组件的关键Trace点。
创建组件
当List组件的cachedCount属性设为0时,ListItem的创建会发生在H:FlushLayoutTask阶段。如果不为0,则会在H:OnIdle阶段预创建组件。
序号 | Trace | 描述 | 参数说明 |
1 | H:Builder:BuildLazyItem [4] | 创建一个LazyItem项目 | 创建的项目索引 |
2 | H:Create[Child][self:28] | 创建一个自定义组件 | 自定义组件名、组件ID |
3 | H:CustomNode:OnAppear && H:aboutToAppear | 执行自定义组件的aboutToAppear 方法 | |
4 | H:CustomNode:BuildItem [Child][self:28][parent:27] | 执行自定义组件的build方法 | 自定义组件名、组件ID、父组件ID |
5 | H:Create[Text][self:31] | 创建一个Text组件 | 组件名、组件ID |
6 | H:Measure[Text][self:31][parent:27][key:] | 计算Text组件布局 | 组件名、组件ID、父组件ID |
7 | H:LazyForEach predict | LazyForEach预处理 | |
8 | H:List predict | List组件预处理 |
销毁组件
组件销毁只会发生在H:OnIdle空闲阶段。
序号 | Trace | 描述 | 参数说明 |
1 | H:LazyForEach predict | LazyForEach预处理 | |
2 | H:aboutToDisappear | 执行自定义组件的aboutToDisappear方法 | |
3 | H:aboutToBeDeleted | 删除组件 |
组件复用场景滑动
基于懒加载场景Demo改造,实现组件复用效果。与懒加载场景相比,在滑动过程中不会发生组件的销毁和创建,而是会在组件将要销毁时,将其放入缓存池中,在需要创建时再从缓存池中取出,并重新赋值。因此本章节主要介绍Reuse阶段和Recycle阶段关键Trace点。
Reuse阶段
Recycle阶段
序号 | Trace | 描述 |
1 | H:LazyForEach predict | LazyForEach预处理 |
2 | H:aboutToRecycleInternal | 标识组件进入复用池并调用组件的aboutToRecycle方法 |
3 | H:ViewFunctions::ExecuteRecycle | 执行组件回收 |