一、卡顿原理
鸿蒙的图形系统采用了统一渲染的模式,遵循着一个典型的流水线模式,以90Hz刷新率为例,每个Vsync周期是11.1ms,如果是60Hz,每个Vsync的周期是16.7ms;如果是120Hz,则每个Vsync的周期是8.3ms。每一帧都必须在Vsync周期内完成渲染,如果上一帧无法在Vsync周期内完成渲染,下一帧就无法正常渲染,这样就发生了丢帧现象。用户看到的就是上一帧画面,页面就没有刷新,给人的感觉就是卡顿。
如上图所示,在整个渲染流程中,首先是由应用侧响应消费者的屏幕点击等输入事件,由应用侧处理完成后再提交给渲染服务,由渲染服务协调GPU等资源处理后,再将最终的图像统一送到屏幕上进行显示。
应用侧的渲染流程如下图所示,了解ArkUI的渲染流程有助于我们定位应用侧的卡顿问题出现在哪个环节:
- Animation:动画阶段,在动画过程中会修改相应的FrameNode节点触发脏区标记,在特定场景下会执行用户侧ets代码实现自定义动画;
- Events:事件处理阶段,比如手势事件处理。在手势处理过程中也会修改FrameNode节点触发脏区标记,在特定场景下会执行用户侧ets代码实现自定义事件;
- UpdateUI:自定义组件(@Component)在首次创建挂载或者状态变量变更时会标记为需要rebuild状态,在下一次Vsync过来时会执行rebuild流程,rebuild流程会执行程序UI代码,通过调用View的方法生成相应的组件树结构和属性样式修改任务。
- Measure:布局包装器执行相关的大小测算任务。
- Layout:布局包装器执行相关的布局任务。
- Render:绘制任务包装器执行相关的绘制任务,执行完成后会标记请求刷新RSNode绘制
- SendMessage:请求刷新界面绘制。
应用侧和渲染服务侧都有可能出现卡顿,鸿蒙将应用侧的卡顿命名为AppDeadlineMissed,将渲染服务侧的卡顿命名为RenderDeadlineMissed。一般而言,前者可能是应用逻辑处理代码不够高效导致的,后者可能是界面结构过于复杂或者GPU负载过大等原因导致的。
应用卡顿故障模型渲染服务卡顿故障模型
二、使用Profiler定位卡顿
profiler是DevEco Studio提供的性能调优工具,仅支持在真机上调优。将下面的代码部署到真机,滚动列表的时候就会触发卡顿。使用Profiler定位卡顿,看看Profiler能否定位到耗时代码的位置。
@Entry
@Component
struct Jank {
@State message: string = 'Hello World';
private arr: number[] = []
aboutToAppear(): void {
for (let i = 0; i <= 1000; i++) {
this.arr.push(i);
}
}
build() {
RelativeContainer() {
List({ space: 10 }) {
ForEach(this.arr, (item: number) => {
ListItem() {
TextNumber({ item: item })
}
})
}.onScrollIndex(() => {
// 滚动的时候循环打印日志,触发卡顿。使用Profiler定位卡顿,看看Profiler能否定位到这段耗时代码。
let i = 0
while (i < 10000) {
console.log("卡了卡了卡了卡了")
i++
}
})
.height('100%')
.width('100%')
}.height('100%')
.width('100%')
}
}
@Component
export struct TextNumber {
@Prop item: number = 0
build() {
Text(`${this.item}`)
.width('100%')
.height(100)
.fontSize(20)
.fontColor(Color.White)
.textAlign(TextAlign.Center)
.borderRadius(10)
.backgroundColor(0x007DFF)
}
}
使用profiler定位卡顿需要录制,复现卡顿场景。按照下面两幅图中的步骤操作。
执行上面八个步骤后,在设备上滚动列表,复现卡顿场景,录制了几秒后,点击停止按钮,profiler就会自动分析。
分析完成后的结果
查看详细信息
通过查看详细信息,发现丢帧率达到了94.3%,丢了83帧,最大连续丢帧数达到了38帧,这是很严重的卡顿。
从下图可以看到,每帧都是红色,发生了严重的卡顿。
选中一帧,查看详细信息。
可以看到这这几帧的卡顿可能都是initialRendevView方法耗时较长导致,可以大致推测,是列表懒加载时,绘制时间较长导致的。
ArkUI Component泳道上,自定义组件TextNumber的绘制频率比较高且比较耗时,对于太过频繁的绘制组件,也是影响应用丢帧的原因。
经过上面的分析,我们知道发生了卡顿,但并不知道具体是哪行代码有问题。选择ArkTS Callstack泳道查看热点函数,方便地跳回源码,定位具体是哪个函数时间较长。按照下图操作,就能精准的定位到耗时代码的位置。对于示例中的这个卡顿,将卡顿代码删除即可。
除了上面的卡顿外,ArkTS Callstack还定位到自定义组件的创建也耗时。
三、卡顿解决方案
3、1解决组件引发的卡顿
- 使用组件复用能力@Reusable来减少组件的频繁创建。可复用组件从组件树上移除时,会进入到一个回收缓存区。后续创建新组件节点时,会复用缓存区中的节点,节约组件重新创建的时间。
@Reusable
@Component
export struct TextNumber {
@Prop item: number = 0
build() {
Text(`${this.item}`)
.width('100%')
.height(100)
.fontSize(20)
.fontColor(Color.White)
.textAlign(TextAlign.Center)
.borderRadius(10)
.backgroundColor(0x007DFF)
}
}
- 简化组件创建的逻辑,使用更高效的@Builder来构建列表项Item的子组件,替代原有@Component自定义组件的方式。此外使用@Builder以后,就不需要使用@Prop了,从而减少了数据的深拷贝耗时。
@Builder
text(item: number) {
Text(`${item}`)
.width('100%')
.height(100)
.fontSize(20)
.fontColor(Color.White)
.textAlign(TextAlign.Center)
.borderRadius(10)
.backgroundColor(0x007DFF)
}
- 组件嵌套过深、冗余刷新等都会引发卡顿,解决方案可查看布局优化
3、2优化主线程的耗时操作
有效避免主线程执行冗余与易耗时操作是至关重要的策略,面对高频回调接口在短时间内密集触发的场景,需要避免接口内的耗时操作,尽量保证主线程不被长时间占用,从而防止阻塞UI渲染,引发界面卡顿或掉帧现象。
3、2、1使用多线程
禁止在主线程中做耗时操作,耗时操作放到子线程。列表在加载更多的时候需要请求数据,如果在主线程中直接处理请求数据,会直接阻塞主线程。即使使用异步还是会在主线程。使用TaskPool,将耗时任务交由子线程。但是子线程将结果返回给主线程,主线程反序列化数据的过程中依然会消耗一定时间。系统提供了@Sendable装饰器来实现内存共享,可以在返回的Item类上使用@Sendable装饰器。@Sendable装饰器可以实现数据在多线程间的传递行为是引用传递,使用方式如下:
@Sendable
export class Home {
url: string = ''
id: number = 0
}
3、2、2主线程的冗余操作,日志打印、Trace打点和空回调
如下代码,在onScrollIndex函数中打印日志。如果不停的滑动列表,就会打印非常多次日志。打印一次日志的平均耗时为84μs,打印400多次,大概会浪费35ms。日志打印和Trace追踪可以排查问题。然而在release阶段,Trace可能会占用额外的CPU资源、内存以及存储空间。即使debug日志并未实际打印,但内部的构造逻辑依旧会被执行。在release版本,应当清除冗余的Trace追踪以及debug日志。 对于回调函数体内不包含任何业务逻辑代码的冗余回调而言,只要注册了回调接口,系统底层仍会耗费资源去监测对应事件的发生,并将这些数据传递给ArkTS侧。即使这些数据最终在ArkTS层没有被有效利用,底层的计算和通信开销已然存在。为了避免不必要的资源消耗,应当移除这类无实际用途的回调函数注册。
@Entry
@Component
struct Jank {
@State message: string = 'Hello World';
private arr: number[] = []
aboutToAppear(): void {
for (let i = 0; i <= 1000; i++) {
this.arr.push(i);
}
}
build() {
RelativeContainer() {
List({ space: 10 }) {
ForEach(this.arr, (item: number) => {
ListItem() {
TextNumber({ item: item })
.onClick(() => {
// 无任何业务操作的空回调
})
}
})
}.onScrollIndex(() => {
hiTraceMeter.startTrace('ScrollSlide', 1001);
// 冗余日志
console.debug('Debug', ('内容:' + '日志'));
// 冗余打点
hiTraceMeter.finishTrace('ScrollSlide', 1001);
})
.height('100%')
.width('100%')
}.height('100%')
.width('100%')
}
}
3、2、3避免在高频事件回调中执行耗时操作
触摸事件、拖拽事件、移动事件、组件区域变化事件、滑动事件等系统事件在应用程序运行过程中会被频繁触发,如果在这些回调接口中执行耗时操作,将导致卡顿问题。
3、2、4避免在aboutToReuse执行耗时操作
在滑动场景中,使用组件复用通常需要用生命周期回调aboutToReuse去更新组件的状态变量。在滑动时,aboutToReuse会被频繁调用。如果在aboutToReuse中进行了耗时操作,将导致卡顿。
3、2、5避免在aboutToAppear,aboutToDisappear中执行耗时操作
在需要频繁创建和销毁组件的场景中,会频繁调用组件生命周期回调aboutToAppear和aboutToDisappear。避免在aboutToAppear,aboutToDisappear中执行耗时操作。
3、2、6避免在LazyForEach的itemGenerator,keyGenerator,getData中执行耗时操作
在懒加载滑动场景中,框架会根据滚动容器可视区域按需创建组件,关于懒加载接口的描述如下
LazyForEach(
dataSource: IDataSource, // 需要进行数据迭代的数据源
itemGenerator: (item: Object, index: number) => void, // 子组件生成函数
keyGenerator?: (item: Object, index: number) => string // 键值生成函数
): void
在滑动时框架会频繁调用子组件生成函数itemGenerator,键值生成函数keyGenerator以及dataSource获取索引数据函数的getData函数。如果在itemGenerator,keyGenerator,getData中执行了耗时操作,就会导致卡顿。
3、2、7避免使用耗时接口
ResourceManager通过getXXXSync接口同步获取资源的方式有两种:
- 通过resource对象获取resourceManager.getStringSync($r('app.string.test'));
- 通过id获取resourceManager.getStringSync($r('app.string.test').id)。
第一种方式比第二种方式更耗时,推荐使用第二种方式。第一种方式获取的是拷贝对象,发生了一次深拷贝,第二种方式直接获取原对象的引用。
零宽空格是一个特殊的Unicode字符。它是一个不可见的字符,其宽度为零,允许在此位置断开行。如果文本需要自动换行,可以在零宽空格的位置折行,而不影响单词的完整性。它也可能引起问题,不注意这些看不见的字符可能导致搜索失败、数据不一致等问题。推荐使用wordBreak,wordBreak在使用性能方面优于零宽空格。推荐用法为:Text(this.diskName).wordBreak(WordBreak.BREAK_ALL)。
常见高耗时接口有:getInspectorByKey、getInspectorTree、sendEventByKey、sendTouchEvent、sendKeyEvent、sendMouseEvent。以上接口由于耗时长,建议仅用于应用测试阶段。