ArkUI性能优化实践
布局性能优化
布局流程简介
当在使用UI描述进行布局时,若布局使用不当(如嵌套层级过多、使用不恰当的布局组件)会导致用户在使用应用时遇到卡顿、掉帧、响应慢等问题,会影响用户体验。
性能差的布局也很有可能使用过多的内存资源,导致占用过多的内存,使设备性能下降,可能导致应用或系统崩溃。
优化布局性能可以使界面的响应速度更快,减少延迟和卡顿,同时节约资源消耗 ,从而提升用户体验,提高应用的可用户和吸引力。
@Entry
@Component
struct Index {
build() {
Row() {
InfoView()
Text('文字描述')
...
}
}
}
@Component
struct InfoView {
build() {
Stack() {
Image()
}
}
}
图1&图2 页面节点树和渲染树
页面节点树中包含了处理UI组件属性更新、布局测算、事件处理等逻辑,其中Custom Node为自定义组件节点,用于处理自定义组件相关业务逻辑,例如执行自定义函数build();FrameNode节点是系统组件节点,Row、Text组件等会生成对应FrameNode。
渲染树由RenderNode渲染节点组成,描述了具体的元素在屏幕上的布局信息,包含了位置、大小以及一些其他属性。
应用会根据上述代码的前端UI描述创建后端的页面节点树,在这个过程中UI线程会对每个元素进行测算(Measure)和布局(Layout),其中Measure阶段负责确定组件对象的测量宽高,也就是该组件元素需要占用屏幕的大小,然后会在Layout阶段确定组件的最终宽高和四个顶点的位置。
确定了具体元素的节点位置信息后,会根据页面节点树生成当前的界面描述数据结构——渲染树,最后渲染服务的渲染线程会根据渲染树的信息执行相应的绘制工作。
在布局阶段中,若视图嵌套层级深,节点树过多会导致Measure和Layout的过程中,通过遍历测量组件大小和边界的时间过长造成额外的计算,所以优化布局性能可以从减少节点数或减少测算布局耗时方面来考虑
精简节点数
Row() {
Row() {
...
// 10、100、500、1000层Row容器嵌套
Row() {
Text('Inner Text')
}
}
}
图3&图4 Measure时间对比和Layout时间对比
分别在10、100、500和1000层Row容器嵌套的情况下,通过IDE的Profiler工具抓取Launch数据,查看对应的Measure和Layout时间进行对比,可以看出随着组件数量增加,性能呈现线性增长的劣化趋势。
图5 容器嵌套与容器平铺Measue时间与Layout时间对比、
如图5所示,说明平铺和嵌套在相同组件个数时性能差异不大,且整体趋势保持一致。
由此得出真正影响布局性能的因素是参与布局的节点数量
减少总节点数1的两个优化方向:
- 移除冗余的节点
图6 冗余节点移除
- 使用扁平化布局减少节点数
可以使用更高级的布局使得页面扁平化,如RelativeContainer、Grid布局等。
图6 线性布局与相对布局
合理控制元素显示与隐藏
控制元素显示与隐藏有两种方式
- if条件渲染
Row() {
Text("Hello World")
if(this.visible){
Column() {
...
// 一百张Image组件
}
}
}
- 使用visibility属性
Row() {
Text('Hello World')
Column() {
...
// // 一百张Image组件
}
.visibility(this.visible ? Visibility.Visible : Visibility.None)
}
if条件渲染与visibility属性的区别:
- if条件判断控制显示与隐藏的方式加载时,会根据条件值为true或false判断是否创建对应组件内容,当条件为false时,对应的组件内容不参与Measure和Layout阶段
- visibility属性控制显示与隐藏的方式,无论visibility的值为
Visibility.Visiable
还是Visibility.None
都会创建对应组件内容,visibility属性为Visibility.None
时,对应的组件不会参与Layout阶段
图7 初次加载时Measure和Layout时间
图8 切换显示状态时Measure和Layout时间
通过分别查看对比if条件渲染与visibility属性在初次加载时和切换显示状态时二者对应的Measure和Layout时间可得出以下结论:
- 在对性能要求过高,并且会频繁切换元素的显示与隐藏的情况下,应该避免使用if条件判断,而改为通过visibility的属性控制。
- 如果组件的创建非常消耗资源,且不会立即使用,也并非频繁切换交互的情况下,只在特定条件下才会出现时,可以通过if条件渲染来进行内容的显示与隐藏控制,达到懒加载的效果。
建议遵循以上原则来使用控制方式
给定组件的宽高
Column() {
Button("修改宽度")
.onClick(() => {
this.testWidth = '90%'
})
.height('20%')
Row() {
// 400条文本数据
}
}.width(this.testWidth)
我们通过这段代码中点击修改外层Column的宽度来模拟缩放场景。
对比给内层Row设置宽度的三种情况:
- 设置宽高分别为固定值300和400
- 设置宽高分别为100%和70%
- 不设置宽高属性
图9 初次加载与重绘时,三种方式的Measure与Layout时间变化
对比在初次加载和点击修改宽高的情况下的Measure、Layout耗时。
可以看出,初次加载时,Measure、Layout阶段的三种情况的数据差别不大;在重新绘制时,限定容器宽高为固定值的情况下,性能提升明显。
对于组件的宽高不需要自适应的情况下,当其组件外部的容器尺寸发生变化时,建议给定组件的宽高数值。
使用推荐的布局组件
图10 不同布局组件性能对比
在布局深度和节点数相同的情况下,使用基础组件如Column和Row容器的性能明显高于其他布局局
在使用布局时尽量遵循以下原则:
- 在相同嵌套层级的情况下,如果多种布局方式可以实现相同布局效果、优选低耗时的布局,如使用Column、Row替代Flex实现相同的单行布局;
- 在能够通过其他布局大幅优化节点数的情况下,可以使用高级组件替代,例如使用RelativeContainer替代Row、Column实现扁平化布局,此时其收益大于布局组件本身的性能差距;
- 仅在必要的场景下使用高耗时的布局组件,如使用Flex实现折行布局、使用Grid实现二维网格布局等。
长列表加载性能优化
概述
针对长列表加载这一场景,对列表渲染时间、页面滑动帧率、应用内存占用等方面带来优化,提升性能和用户体验的手段有如下4种:
- 懒加载:提供列表数据按需加载能力,解决一次性加载长列表数据耗时长、占用过多资源的问题,可以提升页面响应速度。
- 缓存列表项:提供屏幕可视区域外列表项长度的自定义调节能力,配合懒加载设置可缓存列表项参数,通过预加载数据提升列表滑动体验。
- 组件复用:提供可复用组件对象的缓存资源池,通过重复使用已经创建过并缓存的组件对象,降低相同组件短时间内频繁创建和销毁的开销,提升组件渲染效率
- 布局优化:使用扁平化布局方案,减少视图嵌套层级和组件数,避免过度绘制,提升页面渲染效率。
在进行长列表性能加载测量的时候,需要测量如下三个关键指标:
- 完全显示所用时间(Time To Full Display,TTFD):表示应用生成具有完整内容的第一帧所用的时间,包括在第一帧之后异步加载的内容。
- 丢帧率(Janky Frames):表示一个时间周期内的丢帧比率,是指一个时间周期内有问题的帧比例。HarmonyOS系统要求每一帧都要在11.1ms(90Hz刷新率)内绘制完成,如果页面没有在11.1ms内完成这一帧内的绘制,就会出现丢帧。部分丢帧一般用户肉眼是感知不到的,只有出现连续丢帧用户才有明显感知。
- 独占内存(Unique Set Size,USS):一个进程所占用的私有内存,即该进程独占的内存。它反映了运行一个特定进程真实的边际成本(增量成本)。
优化1:懒加载
HarmonyOS应用框架为容器类组件的数据加载和渲染提供了2种方式
方式一:ForEach循环渲染:适合于对小数据量的渲染
ForEach(
arr: Array,
itemGenerator:(item:Array,index?:number) => void,
keyGenerator?:(item:Array,index?:number):string =>string
)
方式二:LazyForEach数据懒加载:适合于对大数据量的渲染
LazyForEach(
dataSource:IDataSource,
itemGenerator::(item:any) => void,
keyGenerator?:(item:any):string => string
)
数据加载流程:ForEach
ForEach会从列表数据源一次性加载全量数据。
如果列表数据较少,数据一次性全量加载不是性能瓶颈时,可以直接使用ForEach。
图11 ForEach数据加载示意图
在HarmonyOS系统中,有三棵树的概念,第一颗树代表着数据源,第二棵树代表着组件树,第三棵树代表着渲染页面。
在如上图所示的渲染页面中,可视区域仅展示了三个组件,所以n-3个数据的渲染实际上是多余的,并不需要一次性全量的加载这样一些数据。
数据加载流程:LazyForEach
LazyForEach实现了按需加载,针对列表数据量大、列表组件复杂的场景,减少了页面首次启动时一次性加载组件的时间消耗,减少了内存峰值。
图12 LazyForEach数据加载示意图
对比案例:不同数据量下ForEach和LazyForEach性能对比
针对长列表这一场景,在本地模拟10、100、1000、10000条数据,分别使用ForEach、LazyForEach,来测试关闭和开启懒加载情况下的完全显示所用时间、列表挂载时间、独占内存、丢帧率。得到的数据如下所示:
图13 不同数据量下ForEach
和LazyForEach
性能对比
可以看出当数据量特别大的时候,需要去正确的选择使用LazyForEach
进行数据的加载;
当数据量比较小的时候,选择使用ForEach
进行加载已足够,因为ForEach在代码实现上来看还是相对简单。
滑动白块现象
在上面如图12所示示意图中,页面移除可以显示3条数据,如果不提前缓存部分数据,当下滑到列表最底端时,再快速下滑,可能会引起“滑动白块”的现象。这是因为上一次只请求了屏幕上的3条数据,如果滑动速度过快,则会导致数据来不及加载而出现白块。在追求极致性能的同时,应该避免这样糟糕的用户体验。
优化2:缓存列表项
LazyForEach
懒加载可以通过设置cachedCount
来指定缓存数量,在设置cachedCount
后,除屏幕内显示的ListItem
组件外,还会预先将屏幕可视区外指定数量的列表项数据缓存。这样当一个屏幕数据加载完成后,再次向下滑动时,会先加载上一次请求的数据,加载完成后,在加载本地请求的数据。
图14 缓存列表项示意图
@Component
struct MyComponent {
build() {
list() {
...
LazyForEach(...)
}
.cachedCount(n/2)
}
}
我们一般将cachedCount的值设置为n/2(n为一屏显示的列表数)
不同cachedCount对列表滑动帧率的影响
一般而言,缓存的cachedCount=n/2的时候,效果较好。在实际开发中也要根据实际场景合理去设置缓存数量:
- 例如列表项中需要显示网络数据,而网络数据加载较慢,为了提升列表信息的浏览效率和浏览体验,我们可以适当的多设置一些缓存数量(countCount大于n/2);
- 如果列表项中需要加载一些大图或者视频等,这些数据占用的内存较大,为了减少内存占用,我们需要适当的减少缓存数量的设置(countCount小于n/2)。
图15 丢帧率与cachedCount大小对比
优化3:组件复用
概述
HarmonyOS应用框架提供了组件复用能力,可复用组件从组件树上移除时,会进入到一个回收缓存区。后续创建新组件节点时,会复用缓存区中的节点,节约组件重新创建的时间。尤其在列表等场景下,其自定义组件具有相同的组件布局结构,列表更新仅有状态变量等数据差异(划出可视区域时,只销毁组件的数据而保留组件的样式)。通过组件复用可以提高干列表页面的加载速度和响应速度。
图16 组件复用示意图
组件复用关键代码
假设在长列表中,需要服用的组件是ArticleCardView,需要完成三个步骤的设置:
- 使用@Reusable标识标识其具备组件复用的能力。
- 实现自定义组件的生命周期回调函数,设置数据如何填充的逻辑。
- 设置可复用组件的reuseId如果有多个可复用的组件,要设置多个reuseId。
@Component
// 1. 使用@Reusable标识标识其具备组件复用的能力
@Resuable
export struct ArticleCardView {
@Prop isCollected: boolean = false;
@Prop isLiked: boolean = false;
@Prop articleItem: LearningResource = new LearningResource();
onCollected?: () => void;
onLiked?: () => void;
aboutToReuse(params: Record<string, Object>): void {
this.onCollected = params.onCollected as () => void;
this.onLiked = params.onLiked as () => void;
}
build() {
...
}
}
@Component
export struct DiscoverView {
private data: ArticleListData = new ArticleListData();
build() {
List() {
Column() {
LazyForEach(this.data, (item: LearningResource) => {
ListItem() {
Column() {
ArticleCardView({
articleItem: item
})
}
}
// 3. 设置可复用组件的reuseId如果有多个可复用的组件,要设置多个reuseId。
.reuseId('ArticleCardView')
},(item: LearningResource) => item.id)
}
}
}
}
对比1:组件复用前长列表性能分析
之前已经将ForEach改造为了LazyForEach,并且添加了缓存项(cachedCount=3),当匀速滑动这个列表时,可以发现每隔若干帧时会稳定的丢帧,且会规律、重复的出现这个问题,如图16所示
图16
图16与图17中红色区域出现了丢帧,这是因为缓存区中的最上面的一个ListItem渲染到页面上时,会执行BuildLazyItem操作,此部分会耗时10.277ms,导致本帧总体耗时达到了13.430ms,超过了11.1ms而丢帧。
图17
对比2:组件复用后长列表性能分析
将代码进行改造,对复用组件ArticleCardView添加@Reusable
注解,启用组件复用的相关代码后,以相同均匀速度滑动这个列表,得到的应用帧率检测情况如下:
可以见到,上图中列表快速滑动了15.8s,泳道中全是绿色表示无丢帧出现,丢帧率为0%。
此部分的BuildLazyItem仅仅只耗时0.749ms,远远低于之前未复用的10.277ms
组件复用前、后长列表性能对比
List列表开启了组件复用,不会执行BuildLazyItem这个耗时操作(耗时10.277ms),后续创建新组件节点时,会直接复用缓存区中的节点(耗时0.97ms),这样就大幅节约了组件重新船舰的时间。
优化4:布局优化
列表不同于其他布局,包含了大量重复循环的Listltem,所以对每一个Listltem的布局优化格外重要。错误的布局方式可能会导致组件树和嵌套层数过多,在创建和布局绘制阶段产生较大的性能开销,导致界面卡顿。合理使用布局,减少嵌套层数,能提高布局效率。