往期推文全新看点(文中附带全新鸿蒙5.0全栈学习笔录)
✏️ 鸿蒙应用开发与鸿蒙系统开发哪个更有前景?
✏️ 嵌入式开发适不适合做鸿蒙南向开发?看完这篇你就了解了~
✏️ 对于大前端开发来说,转鸿蒙开发究竟是福还是祸?
✏️ 鸿蒙岗位需求突增!移动端、PC端、IoT到底该怎么选?
✏️ 记录一场鸿蒙开发岗位面试经历~
✏️ 持续更新中……
丢帧问题原理
在了解如何定位应用丢帧问题之前,开发者需要简单了解HarmonyOS中图形渲染的流程,便于在遇到卡顿时可以分析卡顿可能出现的阶段和原因。
在HarmonyOS中,图形系统采用了统一渲染的模式,遵循着一个典型的流水线模式,以90Hz刷新率为例,每个Vsync周期是11.1ms,整个过程如下图所示。如果是60Hz,每个Vsync的周期是16.7ms;如果是120Hz,则每个Vsync的周期是8.3ms。
图190Hz刷新率渲染流程
在整个渲染流程中,首先是由应用侧响应消费者的屏幕点击等输入事件,由应用侧处理完成后再提交给Render Service,由Render Service协调GPU等资源处理后,再将最终的图像统一送到屏幕上进行显示。
- 应用侧(App)处理用户的屏幕点击等输入事件,生成当前界面描述的数据结构。其中,界面描述数据包括UI元素的位置,大小,资源,UI元素的绘制指令,动效属性等。
- Render Service(渲染服务部件)是图形栈中负责界面内容绘制的模块,其主要职责就是对接ArkUI框架,支撑ArkUI应用的界面显示,包括控件、动效等UI元素。Render Service的RenderThread线程在Vsync下触发UI绘制,绘制过程包含3个阶段:Animation动效,Draw描画和Flush提交。
- Display是显示屏幕的抽象概念,可以是实际的物理屏也可以是虚拟屏。
其中应用侧的渲染流程如下图所示,了解ArkUI的渲染流程有助于我们定位应用侧的卡顿问题出现在哪个环节:
图2ArkUI渲染管线结构与Frame Insight性能打点
- Animation:动画阶段,在动画过程中会修改相应的FrameNode节点触发脏区标记,在特定场景下会执行用户侧ets代码实现自定义动画;
- Events:事件处理阶段,比如手势事件处理。在手势处理过程中也会修改FrameNode节点触发脏区标记,在特定场景下会执行用户侧ets代码实现自定义事件;
- UpdateUI:自定义组件(@Component)在首次创建挂载或者状态变量变更时会标记为需要rebuild状态,在下一次Vsync过来时会执行rebuild流程,rebuild流程会执行程序UI代码,通过调用View的方法生成相应的组件树结构和属性样式修改任务。
- Measure:布局包装器执行相关的大小测算任务。
- Layout:布局包装器执行相关的布局任务。
- Render:绘制任务包装器执行相关的绘制任务,执行完成后会标记请求刷新RSNode绘制
- SendMessage:请求刷新界面绘制。
在整个处理流程中,应用侧和Render Service侧都有可能出现卡顿导致最终用户观测到丢帧的可能,我们分别将这两种情况命名为AppDeadlineMissed和RenderDeadlineMissed。一般而言,前者可能是应用逻辑处理代码不够高效导致的,后者可能是界面结构过于复杂或者GPU负载过大等原因导致的。这两个故障模型通过Frame模板都可以直观地看到。相应的故障模型如下面两幅图所示。
图3应用卡顿导致丢帧的故障模型
**图4 **Render Service卡顿导致丢帧的故障模型
丢帧问题思路分析
补充了图像渲染流程的基本知识和丢帧的故障模型后,接下来我们介绍丢帧问题的分析思路,下图展示了解决丢帧问题的简要流程:
图5丢帧问题处理流程
从上图可以看到处理丢帧问题一般需要以下四个步骤:
- 识别卡顿:首先使用 AppAnalyzer 检测应用是否存在性能问题,如果检测存在丢帧问题,然后使用 Frame Profiler 、SmartPerf Host等工具录制Trace,查看应用平均帧率、丢帧率等,同时查看丢帧发生的位置。
- 分析丢帧原因:首先查看CPU调用判断系统是否存在异常,如果判断系统异常开发可以通过 在线提单 的方式进行反馈;如果系统没有异常,可以继续分析Trace查看卡顿帧的详细信息。最后查看函数调用栈,查看是否存在耗时函数。
- 选择优化方案:根据步骤2分析的丢帧原因,选择适合的优化方案。
- 验证优化效果:优化完成后需要重新测试验证丢帧问题是否得到解决,这里可以再次通过步骤1来确认优化效果
接下来本文将以“HMOS世界”应用的首页列表为例,介绍如何通过Frame分析、定位、解决卡顿问题的全过程。为了便于演示这个长列表的调优过程,这个列表初始加载了1000条数据。
我们在滑动列表的过程中,随着时间的推移,我们可以感觉到越来越卡顿,接下来我们将介绍如何分析并解决这个卡顿问题。
图6”HMOS世界”首页长列表示意图
第1步:识别丢帧
使用AppAnalyzer检测性能问题
首先使用AppAnalyzer工具进行性能问题检测,AppAnalyzer是DevEco Studio中提供的检测评分工具,用于测试并评价HarmonyOS应用或元服务的质量,能快速提供评估结果和改进建议,当前支持的测试类型包括兼容性、性能、UX测试和最佳实践等。因为本文主要是介绍丢帧问题的分析,所以下面重点介绍了使用AppAnalyzer对列表滑动响应和滑动过程中的流畅性能检测
- 启动DevEco Studio,连接设备,打开应用。
- 单击菜单栏Tools > AppAnalyzer。
- 在AppAnalyzer页面Module选择框选择应用/服务工程模块。
- 根据应用的类别选择Category。
- 选择Rules,这里选择Benchmark(性能套餐),勾选”Fast Response to In-app Swipes”(应用内滑动操作响应快)、“Smooth In-app Swiping“(应用内滑动过程流畅)和“Smooth In-app Transitions“(应用内转场操作流畅)。
- 点击Start启动检测,检测过程中,手机需要保持解锁亮屏状态。
- 工具会先对应用功能进行自动检测,开发者不需要进行操作,在自动检测结束后需要根据提示手动遍历应用功能。
2. 自动检测和手动遍历完成后点击Stop停止测试任务。
- 获得检测结果,下面列举了检测通过和未通过的示例。
- 检测通过示例,如下图所示,无异常信息。
- 检测未通过示例,例如下图结果,有多项检测未通过。
点击左侧的菜单栏对应的选项,可以查看异常的具体信息,这里以”Fast Detection Of Smoothness During Sliding”选项为例,应用滑动时的卡顿率应该小于5ms/s,但是示例中有多帧超时达到8.49ms,存在进一步优化的空间,关于应用滑动流畅的体验标准可以参考应用/服务体检规则。
录制Frame模板
发现卡顿丢帧问题后创建Frame模板录制,在录制期间复现卡顿丢帧场景,具体操作步骤请参见 性能问题定位:深度录制。
录制完成后,在时间轴上拖动鼠标选定要查看的时间段,这里选择了一个2.5s的时间区段。选中Frame主泳道,查看下面的Statistics栏,可以发现应用在这个时间段内丢了16帧,丢帧率达到了7%。
认识卡顿帧
下面是使用Frame Profiler录制的一段Trace,在时间轴上拖动鼠标选定要查看的时间段,这里我们选择了一个2.5s的时间区段。选中Frame主泳道,查看下面的Statistics栏,可以发现应用在这个时间段内丢了16帧,丢帧率达到了7%。
丢帧问题可能出现在Render Service侧,也有可能出现在App侧。上图中的丢帧主要出现在应用帧,针对这种丢帧现象我们继续分析,放大右侧的图表,选中超时的帧查看详细数据,期望时间为8.3ms(当前设备为120Hz),而实际处理时间为8.9ms。
说明
在“RS Frame”和“App Frame”标签的泳道中,正常完成渲染的帧显示为绿色,出现卡顿的帧显示为红色。其中期望结束时间点之前的部分为浅红色(两条白色竖线区间),超出期望结束时间的部分为深红色,异常帧显示为黄色。
发现问题后,接下来我们来分析这个丢帧问题。导致应用丢帧的原因非常多,可能是应用本身原因,可能是系统原因,也有可能是硬件层原因。不同卡顿原因在Trace中有不同表现,识别需要大量经验积累。
第2步:分析丢帧原因
丢帧问题分析过程,主要是结合App主进程和Render Service渲染进程Trace数据,先排查系统是否异常,再分析应用本身原因,开发者可以通过以下步骤来定位丢帧问题。
2.1 看线程状态和运行核,看是否被其他进程抢占资源,排除系统侧运行异常。
看线程状态
从下图可以看到,应用线程大部分时间处于Running状态,无特殊异常。运行在CPU10和CPU11上
图7丢帧处应用主线程状态
看运行频率
查看关键任务是否跑在了小核,以低频运行。从CPU Slice和Frequency泳道,如图8所示,可以看到丢帧处应用线程和前面正常帧类似,都主要运行在大核上(该设备03号CPU是小核,411号CPU为大核)。鼠标悬浮在Frequency泳道上,可以看到CPU运行频率。
图8丢帧处应用主线程运行核
通过上面的分析,可以看到应用线程正常运行在CPU大核上,且运行频率正常。到这里,这个示例可以排除系统异常。
如果应用线程运行出现以下问题,开发者可以进行在线提单反馈异常。
- 执行频率较低
- 线程在小核上工作
- 线程频繁在Running和Runnable之间切换
- 线程频繁在Running和Sleep之间切换
- 不重要的线程占用了大核
说明
出于兼顾高性能、低功耗的需求,多核工程机常采用异构架构设计,根据CPU频率,区分大中小核等。
2.2找到Trace中每一帧耗时的部分,大致定位是App侧问题还是RS侧问题,并结合Trace标签,初步定位原因。
通过Frame泳道,我们可以快速发现丢帧的位置,并完成初步的定界:
- App侧有红色出现,需要审视UI线程的处理逻辑是否过于复杂或低效,以及是否被其它任务抢占资源。
- 如果是Render Service帧处理有红色出现,需要审视是否是界面布局过于复杂。可以借助DevEco Studio内的ArkUI Inspector、HiDumper等工具进一步分析,可以参考布局嵌套过深示例。
前面示例中的丢帧主要出现在应用侧,针对这种丢帧现象我们继续分析,放大右侧的图表,选中超时的帧(220#帧)查看详细数据,期望时间为8.3ms(当前设备为120Hz),而实际处理时间为8.9ms。
接下来通过Trace再看看每一帧的具体耗时情况。这里有一个小技巧,我们可以点击泳道信息区的收藏按钮,将应用帧处理的泳道收藏置顶,可以有效防止上下文信息丢失。点击图标跳转到卡顿帧应用侧Trace详情,如下图所示:
可以看到这这几帧的卡顿可能都是BuildLazyItem方法耗时较长导致,可以大致推测,是列表懒加载时,Item绘制时间较长导致的。
同时在ArkUI Component泳道上,可以直观的看到,自定义组件ArticleView的绘制频率比较高且比较耗时,对于太过频繁的绘制组件,可能也是影响应用丢帧的原因。
需要注意的是在Frame模板中,要想查看ArkUI Component泳道需要在泳道录制前进行手动勾选,如下图所示:
2.3查看ArkTS函数调用栈信息,排查应用代码。
可以结合Frame Profiler工具,选择ArkTS Callstack泳道查看热点函数,方便地跳回源码,定位具体是哪一个自定义组件绘制时间较长。如下图所示,可以看到自定义组件ArticleCardView的绘制频繁。下面以220#帧为例子,通过热点函数可以看到其中initialRenderView 和__lazyForEachItemGenFunction这两个方法比较耗时,占比分别达到52.7%和22.9%,其中绿色的”ArkTS”表示双击该行可以跳转到应用源码。
我们以initialRenderView函数的耗时为例进行分析,展开函数,可以看到主要是列表项ListItem的子组件ArticleCardView创建比较耗时。
展开其中一个组件函数调用链进行详细分析,通过查看函数调用,可以猜测是由于使用了@Prop变量,@Prop装饰的变量会对父组件传入状态值进行深拷贝,如果@Prop装饰器装饰的变量为复杂Object、class或其类型数组时,会增加状态创建时间以及占用大量内存。双击跳转到源码,可以看到自定义组件ActionButtonView中确实使用了@Prop装饰器变量。
其它函数耗时的详细调用这里不一一列举。
第3步:选择优化方案
选择优化方案需要一些经验的积累,开发者可以参考一些性能优化 的最佳实践,来选择相应的优化方法。
下面我们对丢帧问题进行优化,针对前面的一些分析结果,我们可以从两方面来入手解决卡顿问题:
- 使用组件复用能力@Reusable来减少组件的频繁创建。可复用组件从组件树上移除时,会进入到一个回收缓存区。后续创建新组件节点时,会复用缓存区中的节点,节约组件重新创建的时间。
- 简化组件创建的逻辑,使用更高效的@Builder来构建列表项Item的子组件,替代原有@Component自定义组件的方式。此外使用@Builder以后,就不需要使用@Prop了,从而减少了数据的深拷贝耗时。
优化后示例代码如下:
@Component
struct DiscoverView {
// ...
build() {
// 列表
List() {
LazyForEach(this.dataSource, (item: LearningResource) => {
ListItem() {
ArticleCardView() // 省略参数
.reuseId('article')
}
}, (item: LearningResource) => item.id)
}
}
}
// 列表Item
@Reusable
@Component
export struct ArticleCardView {
// ...
aboutToReuse(params: Record<string, Object>): void {
// ...
}
Row() {
ActionButtonBuilder() // 省略参数
ActionButtonBuilder()
ActionButtonBuilder()
}
build() {
// ...
}
}
// 使用@Builder构建子组件
@Builder
function ActionButtonBuilder() { // 省略参数
// ...
}
第4步:验证优化效果
最后,我们可以使用步骤一的方式,来验证优化后的结果。下面用Frame模板录制后发现,丢帧情况得到明显改善,列表快速滑动15.9s,丢帧率为0%,丢帧问题得到解决。如下图:
如果此时问题仍未解决,可以再重新分析Trace定位问题,然后选择优化方式。
常见丢帧问题
下面列举了一些常见的丢帧问题以及对应的Trace,以及给出了一些优化方案,便于开发者遇到类似的问题,访问识别和定位。
自定义动画丢帧问题
在播放动画或者生成动画时,画面产生停滞而导致帧率过低的现象,称为动画丢帧。
播放动画时,系统需要在一个刷新周期内完成动画变化曲线的计算,完成组件布局绘制等操作。建议使用系统提供的动画接口,只需设置曲线类型、终点位置、时长等信息,就能够满足常用的动画功能,减少UI主线程的负载。
下面使用了自定义动画,动画曲线计算过程很容易引起UI线程高负载,易导致丢帧。
@Entry
@Component
struct AnimationDemo1 {
@State widthSize: number = 200;
@State heightSize: number = 100;
@State flag: boolean = true;
computeSize() {
let duration = 2000;
let period = 16;
let widthSizeEnd = 0;
let heightSizeEnd = 0;
if (this.flag) {
widthSizeEnd = 100;
heightSizeEnd = 50;
} else {
widthSizeEnd = 200;
heightSizeEnd = 100;
}
let doTimes = duration / period;
let deltaHeight = (heightSizeEnd - this.heightSize) / doTimes;
let deltaWeight = (widthSizeEnd - this.widthSize) / doTimes;
for (let i = 1; i <= doTimes; i++) {
let t = period * (i);
setTimeout(() => {
this.heightSize = this.heightSize + deltaHeight;
this.widthSize = this.widthSize + deltaWeight;
}, t);
}
this.flag = !this.flag;
}
build() {
Column() {
Button('click me')
.onClick(() => {
let delay = 500;
setTimeout(() => {
this.computeSize();
}, delay);
})
.width(this.widthSize)
.height(this.heightSize)
.backgroundColor(0x317aff)
}.width('100%')
.margin({ top: 5 })
}
}
使用Frame Profiler录制Trace,可以看到动画帧率只有63fps左右,而当前设备是支持的设备刷新率是120Hz。
建议开发者通过系统提供的属性动效API实现上述动效功能,使用属性动画或者显式动画,下面以属性动画实现上面的效果为例:
@Entry
@Component
struct AnimationDemo2 {
@State widthSize: number = 200;
@State heightSize: number = 100;
@State flag: boolean = true;
build() {
Column() {
Button('click me')
.onClick(() => {
if (this.flag) {
this.widthSize = 100;
this.heightSize = 50;
} else {
this.widthSize = 200;
this.heightSize = 100;
}
this.flag = !this.flag;
})
.width(this.widthSize)
.height(this.heightSize)
.backgroundColor(0x317aff)
.animation({
duration: 2000, // 动画时长
curve: Curve.Linear, // 动画曲线
delay: 500, // 动画延迟
iterations: 1, // 播放次数
playMode: PlayMode.Normal // 动画模式
}) // 对Button组件的宽高属性进行动画配置
}
.width('100%')
.margin({ top: 5 })
}
}
使用Frame Profiler录制优化后的Trace,可以看到动画帧率有了较大的提升,达到了116.9fps。
布局嵌套过深
视图的嵌套层次会影响应用的性能。在屏幕刷新率为120Hz的设备上,每8.3ms刷新一帧,如果视图的嵌套层次多,可能会导致没法在8.3ms内完成一次屏幕刷新,就会造成丢帧卡顿,影响用户体验。因此推荐开发者移除多余的嵌套层次,使用相对布局 (RelativeContainer),缩短组件刷新耗时。
例如下面这个示例在列表中加载了2000条数据,同时子组件ChildComponent里面的布局嵌套了20层Stack组件。
class MyDataSource implements IDataSource {
private dataArray: string[] = [];
public pushData(data: string): void {
this.dataArray.push(data);
}
public totalCount(): number {
return this.dataArray.length;
}
public getData(index: number): string {
return this.dataArray[index];
}
registerDataChangeListener(listener: DataChangeListener): void {
}
unregisterDataChangeListener(listener: DataChangeListener): void {
}
}
@Entry
@Component
struct StackDemo1 {
// ... 此处省略LazyForEach数据初始化过程
private data: MyDataSource = new MyDataSource();
build() {
List() {
LazyForEach(this.data, (item: string) => {
ListItem() {
ChildComponent({ item: item })
}
.reuseId('child')
}, (item: string) => item)
}.cachedCount(5)
}
}
@Reusable
@Component
struct ChildComponent {
@State item: string = '';
aboutToReuse(params: Record<string, Object>): void {
this.item = params.item as string;
}
build() {
Stack() {
Stack() {
// ... 此处省略Stack嵌套
Text(this.item)
.fontSize(50)
.margin({ left: 10, right: 10 })
}
// ...
}
}
}
使用Frame Profiler进行录制,这里我们直接看应用侧的Trace数据,具体分析步骤可以看前面的丢帧问题分析思路章节
结合卡顿帧对应时间段的Trace数据,可以定位到FlushLayoutTask耗时过长,它的作用是重新测量和布局所有的Item,其中Measure方法耗时比较久,因此卡顿原因可能是布局处理逻辑过于复杂或低效。
开发者可以使用ArkUI Inspector,在DevEco Studio上查看应用在真机上的UI显示效果。利用ArkUI Inspector工具,开发者可以快速定位布局问题或其他UI相关问题,效果图如下:
可以直观的看到Item的嵌套比较深,接下来我们可以减少不必要的嵌套来尝试解决丢帧问题,示例代码如下:
@Reusable
@Component
struct ChildComponent {
@State item: string = '';
aboutToReuse(params: Record<string, Object>): void {
this.item = params.item as string;
}
build() {
Stack() {
Text(this.item)
.fontSize(50)
.margin({ left: 10, right: 10 })
}
}
}
再次使用Frame Profiler进行录制,可以看到丢帧问题已得到解决。
主线程中执行冗余和耗时操作
应避免在主线程中执行冗余与易耗时操作,否则可能会阻塞UI渲染,引发界面卡顿或掉帧现象,特别是在高频回调中执行耗时操作。
丢帧问题优化建议
前面我们简单介绍了图形渲染的流程,了解到了图像渲染的两个关键步骤:首先由应用侧响应消费者的屏幕点击等输入事件并且生成当前的界面描述数据结构,然后交给Render Service进行绘制。在这两个步骤中分别会出现AppDeadlineMissed和RenderDeadlineMissed卡顿。前者可能是应用逻辑处理代码不够高效导致的,可以结合Trace数据和热点函数进行分析;后者可能是界面结构过于复杂或者GPU负载过大等原因导致的,可以使用布局检查器ArkUI Inspector工具和HiDumper命令行工具辅助分析定位。
针对一些常见的丢帧问题,下面列举了一些优化建议:
- 尽量减少布局的嵌套层数,合理使用布局,使用相对布局 (RelativeContainer)来减少层级。
- 使用组件复用减少组件的重复创建与渲染。
- 合理管理状态变量,精准控制组件的更新范围,避免冗余刷新。
- 使用LazyForEach加载长列表,长列表的优化可以参考优化长列表加载慢丢帧问题。
- 使用系统提供的动画接口,减少动画丢帧。
- 优化主线程中冗余和耗时操作。