Compose:从重组谈谈页面性能优化思路,狠狠优化一笔

作者:晴天小庭

前言:

随着越来越多的人使用Compose开发项目的组件或者页面,关于使用Compose构建的组件卡顿的反馈也愈发增多,特别是LazyColumn这些重组频率较高的组件,因此很多人质疑Compose的性能过差,这真的是Compose的性能问题吗。

当然Compose在当前的版本下依然存在许多优化空间,但是实际上我们的日常项目中并不会真的逼近Compose的理论性能上限,而是没有处理好一些状态的读取,导致了重组次数过多,在用户眼里那就是卡顿了,本文将为你提供一些优化思路,降低Compose页面的卡顿。

1.重组与重组作用域

注意:如果你已经了解重组和重组作用域的概念,可以跳过本节

我们看一下这个UI:

UI层级如下:

  • Example
    • Column
      • ComposableContainerA
        • ComposableBoxA
      • ComposableContainerB
      • Row
        • Button
        • Button

它对应的代码如下:

@Composable
@Preview
fun Example() {var valueA by remember { mutableStateOf(0) }var valueB by remember { mutableStateOf(0) }SideEffect {Log.d("重组观察","最外层容器进行了重组")}Column {ComposableContainerA(text = "$valueA")ComposableContainerB(text = "$valueB")Row {Button(onClick = { valueA++ }) {Text("A值加1")}Button(onClick = { valueB++ }) {Text("B值加1")}}}}@Composable
private fun ComposableContainerA(text: String,
) {SideEffect {Log.d("重组观察", "重组作用域A进行了重组")}Column(Modifier.background(Color.Black).padding(10.dp)) {Text(text = "我是重组作用域A,当前值${text}",color = Color.White)ComposableBoxA()}}@Composable
private fun ComposableBoxA() {SideEffect {Log.d("重组观察", "重组作用域A内部的容器进行了重组")}Text("我是A容器的内部组件", color = Color.White, modifier = Modifier.background(Color.Gray))
}@Composable
private fun ComposableContainerB(text: String,
) {SideEffect {Log.d("重组观察", "重组作用域B进行了重组")}Box(Modifier.background(Color.Red).padding(10.dp)) {Text(text = "我是重组作用域B,当前值${text}",color = Color.White)}
}
  • 使用SideEffect来观察每个组件的重组。

启动程序后,得到的日志如下:

D 最外层容器进行了重组 D 重组作用域A进行了重组 D 重组作用域A内部的容器进行了重组 D 重组作用域B进行了重组

不难理解,因为刚启动程序,所有UI都未初始化,于是所有UI层级的组件都进行了重组。

然后我们点击一下第一个按钮,让A值+1,得到的日志如下:

D 最外层容器进行了重组

D 重组作用域A进行了重组

我们发现了,虽然是容器A的传参发生了变化,为什么会导致最外层的容器也重组了呢,为什么容器A的子容器没有重组,容器B没有重组呢?

这里引入一个概念——重组作用域

Compose编译器做了大量的工作让重组的范围尽可能的小,它会在编译期间找出所有使用了State的代码块,如果State发生了变化,那么对应的代码块就会重组,这个受State影响的代码块就是所谓的重组作用域

回到Example代码,我们分析一下:

@Composable
@Preview
fun Example() {var valueA by remember { mutableStateOf(0) }//省略...SideEffect {Log.d("重组观察","最外层容器进行了重组")}Column {ComposableContainerA(text = "$valueA")//省略...Row {Button(onClick = { valueA++ }) {Text("A值加1")}//省略...}
}

UI层级(部分):

  • Example

    • Column

    • ComposableContainerA

仔细看有个问题:valueA不是在Column层级被使用吗,为什么valueA的变化,会让Example层级也发生了重组呢?

我们看看Column的源码:

@Composable
inline fun Column(//...
){//...
}

原来Column是一个内联函数,因此编译后Column不是一个函数(实际上RowBox等组件也是内联函数),因此实际的层级会变成这样:

  • Example

    • ComposableContainerA

那么一切就说的通了,valueA变化后,由于Example内部读取valueA的值,并将新值传递给了ComposableContainerA并导致了它重组,而ComposableContainerA内部的子容器没有发生参数变化,ComposableContainerB的参数也没有发生变化,因此他们没有发生重组。

我们可以总结出一个结论,组件会在2个条件下发生重组:

  1. 组件外部的传参发生了变化。
  2. 组件内部的State发生了变化,而且组件读取了这个状态。

注意第2点,只有读取State,组件才会因为State变化而进入了重组,如果只是声明了State而没有直接读取State的值,State变化后是不会导致当前组件重组的。

改造成这样之后,只有声明没有读取,则变成如下:

@Composable
@Preview
fun Example() {var valueA by remember { mutableStateOf(0) }SideEffect {Log.d("重组观察","最外层容器进行了重组")}Column {Row {Button(onClick = { valueA++ }) {Text("A值加1")}}}
}

无论我们点多少次按钮,让valueA增加,日志都只有如下一条:

D 最外层容器进行了重组

本节总结:只有受到State影响的代码块(即读取了State)会进入重组,而且重组的范围会尽可能小。

2.使用派生状态来降低重组次数

假设这样一个场景,有一个变化频率非常高的数值,但是我们只关心他的正负,数值为负的时候,组件的颜色是红色的,数值为正的时候,组件的颜色是绿色的。

@Composable
@Preview
private fun Example2() {var value by remember {mutableStateOf(0f)}SideEffect {Log.d("日志", "重组了")}Column {Row {Button(onClick = {Log.d("日志", "点击了+")value += 0.1f}) {Text("点我+0.1")}Button(onClick = {Log.d("日志", "点击了-")value -= 0.1f}) {Text("点我-0.1")}}Box(Modifier.size(50.dp).background(if (value >= 0) Color.Green else Color.Red))}
}

这里我们创建了2个按钮,一个加一个减,然后Box根据value的值变化颜色,如下:

每次按下按钮之后,就会更新value,然后触发Example2的重组(为什么是Example2重组呢,因为上文说了,BoxColumn这些组件都是内联函数,因此他们不算单独的重组作用域),然后Box的背景刷新。

相关的日志如下:

D 点击了+

D 重组了

D 点击了+

D 重组了

D 点击了+

D 重组了

可以看到,确实是每次点击按钮的时候发生了重组。

但是,我们重新思考一下,真的需要每次数值变化的时候都重组吗?

答案是不需要的,在Example2中,业务的逻辑是判断value的正负值,而不是具体的数值,因此value从0.1变成0.2,亦或者是0.2变成0.3这种情况,方块的颜色是不变的,然而却进行了重组,浪费了性能。

因此我们需要一个工具,让我们监听value的数值变化演变成监听value的正负,这里介绍本节的主角:派生状态(derivedStateOf)

把上述的代码改造成如下:

val listState = rememberLazyListState()LazyColumn(state = listState) {// ...
}val showButton by remember {derivedStateOf {listState.firstVisibleItemIndex > 0}
}AnimatedVisibility(visible = showButton) {ScrollToTopButton()
}

我们使用derivedStateOf来构建出一个是否是正数的属性isPositive,Box的颜色变化是根据isPositive来变化的,而不是之前的value

简单说说derivedStateOf,它的参数是一个lambda,该lambda可以监听State的变化,lambda内部任意一个State变化时,就会重新执行lambda并返回新值,是的,这个和重组作用域的概念非常接近。

于是当value进入到derivedStateOf的lambda内部的时候,外部的重组作用域就没有直接读取value了,从而导致value的变化不会直接影响组件的重组,相应的是,一旦value的值从正数变成负数,或者从负数变成正数时,isPositive就会变化,从而导致了重组。

我们把重组的时刻从「每次value的变化」变成了「value的正负值发生了变化」,排除掉了value从正数变成正数,从负数变成负数的情况,让重组次数极大的降低。

日志如下,只有发生了正负值的跃变的时刻才会触发重组:

D 点击了+

D 点击了+

D 点击了+

D 点击了-

D 点击了-

D 点击了-

D 点击了-

D 重组了

读者可能搞懂上述的案例了但是不懂实际项目的使用,笔者在这里引用一下官方的案例:

val listState = rememberLazyListState()LazyColumn(state = listState) {// ...
}val showButton by remember {derivedStateOf {listState.firstVisibleItemIndex > 0}
}AnimatedVisibility(visible = showButton) {ScrollToTopButton()
}

listStatefirstVisibleItemIndex是一个高频变化的属性,但是业务上只关注它是否大于0的情况,这种情况就非常适合可以使用派生状态。

本节总结:监听一个高频变化的State时,如果我们只关心State的部分变化,则可以使用派生属性来降低重组次数

3.使用lambda间接传值/跳过阶段

第2点解决的是单个组件内部的冗余重组的问题,还有一种场景使用派生状态是无法解决的,就是父组件向子组件传递高频变化的状态,例如下面这种场景:

@Composable@Previewfun Example3() {val scrollState = rememberScrollState()SideEffect {Log.d("重组监听","重组一次")}Column {ScrollStateChecker(scrollValue = scrollState.value)Column(Modifier.fillMaxSize().weight(1f, false).verticalScroll(scrollState)) {list.forEach {Text("我是第${it}个", modifier = Modifier.fillMaxWidth().background(Color.Red.copy(0.3f)).padding(vertical = 5.dp))}}}}@Composableprivate fun ScrollStateChecker(scrollValue: Int) {Text("scrollValue:$scrollValue")}

对应的UI如下:

底部一个滚动的列表,顶部是监听可滚动列表的已滚动的像素,当列表滑动的时候,scrollState.value的值会高频变化,因此整个组件会高频重组。

简单滑动之后,输出了一大堆日志:

D 重组一次

D 重组一次

D 重组一次

实际上,真正使用滑动偏移量的是ScrollStateChecker(),而不是父组件,而原代码中,偏移量的读取却是发生在父组件。

@Composable
@Preview
fun Example3() {val scrollState = rememberScrollState()//...Column {//                                     👇🏻父组件直接读取该值ScrollStateChecker(scrollValue = scrollState.value)//...}
}

这样的做法导致了2个后果:

  1. 父组件的没必要重组
  2. 子组件强制重组

这里说说第2点,为什么子组件强制重组是不好的呢,因为有时候组件并不一定需要重组,如果这个组件仅仅是希望拿到滑动偏移量之后做一些偏移量的操作,是不需要重组的,只需要重新执行布局阶段即可,这个后面会展开说。

先解决第1点的问题,父组件并不需要使用偏移量的值,因此父组件不要直接读取该值,那么如何间接传该值给子控件呢?

答案是lambda,修改代码如下:

@Composable
@Preview
fun Example3() {//...Column {//                                           👇🏻使用lambda让子控件读取ScrollStateChecker(scrollValueProvider = { scrollState.value })Column(//...) {//...}}
}@Composable
private fun ScrollStateChecker(scrollValueProvider: () -> Int) {//                          👇🏻使用lambda读取Text("scrollValue:${(scrollValueProvider())}")
}

ScrollStateChecker的参数改造为lambda,这样父组件就不用直接读取滚动偏移了,重新查看日志:

D 重组一次

除了初始化的一次重组,父组件不再参与scrollState.value导致的重组了。

子组件还能减少重组次数吗,可惜不行了,因为子组件是要输出滑动的偏移量的文案,因此我们在最大可能上做了优化。

但是,上文说了,大多数情况的业务并不是要把偏移量作为文案输出到屏幕上,而是根据偏移量做一些偏移操作(例如滑动布局顶部的吸顶Title),我们把ScrollStateChecker的代码改成如下:

@Composable
private fun ScrollStateChecker(scrollValueProvider: () -> Int) {val scrollXDp = with(LocalDensity.current) {scrollValueProvider().toDp()}Box(Modifier.size(50.dp).offset(x = scrollXDp).background(Color.Green))
}

当列表滑动的时候,会导致ScrollStateChecker往右移动,查看通过布局查看器看看重组次数:

滑动的过程中,ScrollStateChecke会不断重组,让布局不断进入重组-布局-绘制的流程,这里简单说说三个流程的差异:

  • 重组:有什么组件
  • 布局:组件的位置
  • 绘制:如何绘制组件

对于上述任务来说,我们只是希望做一个位置的偏移,是不需要重新进入重组流程的,因为没有组件出现或者消失了,因此跳过重组可以让UI的性能进一步提交,修改也非常简单:

@Composable
private fun ScrollStateChecker(scrollValueProvider: () -> Int) {Box(Modifier.size(50.dp).offset {IntOffset(x = scrollValueProvider(),y = 0)}.background(Color.Green))
}

修改之后,任意滑动列表,一次重组也没有出现,性能进一步提升了。

在Compose自带的关于偏移、可见度、大小变化的api中,都有一个lambda版本的,这个lambda的效率会比非lambda版本更高,因为可以跳过重组的过程。

graphicsLayout是一个不错的关于修改偏移、可见度、缩放的lambda版本Api,推荐使用,案例如下:

 @Composable
private fun ScrollStateChecker(scrollValueProvider: () -> Int) {Box(Modifier.size(50.dp).graphicsLayer {scaleY = scrollValueProvider() / 1000fscaleX = scrollValueProvider() / 1000ftranslationX = scrollValueProvider().toFloat()}.background(Color.Green))
}

另外一个关于背景颜色的场景,如果你的背景颜色高频变化,可以使用drawBehind来完成背景设置,完全可以跳过组合和布局阶段,仅仅需要绘制

val color by animateColorBetween(Color.Cyan, Color.Magenta)
Box(Modifier.fillMaxSize().drawBehind {drawRect(color)}
)

本节总结:子组件需要读取父组件上面的高频变化的State时,考虑使用lambda传值;实现偏移、缩放等操作时,考虑使用lambda版本的api,跳过重组、布局阶段。

Android 学习笔录

Android 性能优化篇:https://qr18.cn/FVlo89
Android 车载篇:https://qr18.cn/F05ZCM
Android 逆向安全学习笔记:https://qr18.cn/CQ5TcL
Android Framework底层原理篇:https://qr18.cn/AQpN4J
Android 音视频篇:https://qr18.cn/Ei3VPD
Jetpack全家桶篇(内含Compose):https://qr18.cn/A0gajp
Kotlin 篇:https://qr18.cn/CdjtAF
Gradle 篇:https://qr18.cn/DzrmMB
OkHttp 源码解析笔记:https://qr18.cn/Cw0pBD
Flutter 篇:https://qr18.cn/DIvKma
Android 八大知识体:https://qr18.cn/CyxarU
Android 核心笔记:https://qr21.cn/CaZQLo
Android 往年面试题锦:https://qr18.cn/CKV8OZ
2023年最新Android 面试题集:https://qr18.cn/CgxrRy
Android 车载开发岗位面试习题:https://qr18.cn/FTlyCJ
音视频面试题锦:https://qr18.cn/AcV6Ap

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.rhkb.cn/news/76904.html

如若内容造成侵权/违法违规/事实不符,请联系长河编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

Filebeat+ELK 部署

Node1节点(2C/4G):node1/192.168.8.10 Elasticsearch Kibana Node2节点(2C/4G):node2/192.168.8.11 Elasticsearch Apache节点:apache/192.168.8.13 …

神码ai伪原创工具【php源码】

大家好,小编为大家解答python炫酷烟花表白源代码的问题。很多人还不知道html代码烟花特效python,现在让我们一起来看看吧! 火车头采集ai伪原创插件截图: 目录 前言 环境准备 代码编写 效果展示 前言 Python实现浪漫的烟花特效 现在…

HTTP协议 和 HTTPS协议的区别(4点) HTTPS的缺点 HTTP如何使用SSL/TLS协议加密过程 CA证书干啥的

(一)HTTP协议 和 HTTPS协议的区别(4点): 1. HTTP协议的端口号是80, HTTPS协议的端口号是443 2. HTTP协议使用的URL是以 http:// 开头,HTTPS协议使用的URL是以https://开头 3. HTTP协议和HTTP…

PHP8的运算符-PHP8知识详解

运算符是可以通过给出的一或多个值(用编程行话来说,表达式)来产生另一个值(因而整个结构成为一个表达式)的东西。 PHP8的运算符有很多,按类型分有一元运算符、二元运算符、三元运算符。 一元运算符只对一…

ruby调试

如果下载 ruby-debug-ide gem install ruby-debug-ide vscode 下载 ruby扩展 1, ruby 2,修改launch.json

FBX SDK开发快速上手指南

一段时间以来,我一直想制作一个 FBX Exporter 将 FBX 文件转换为我自己的格式。 整个过程不是很顺利,主要是FBX的官方文档不是很清楚。 另外,由于 FBX 格式被许多应用程序使用,而不仅仅是游戏引擎,因此提供的示例代码没…

什么是注意力机制?注意力机制的计算规则

我们观察事物时,之所以能够快速判断一种事物(当然允许判断是错误的),是因为我们大脑能够很快把注意力放在事物最具有辨识度的部分从而作出判断,而并非是从头到尾的观察一遍事物后,才能有判断结果,正是基于这样的理论&a…

广州银行信用卡中心:强化数字引擎安全,实现业务稳步增长

广州银行信用卡中心是全国城商行中仅有的两家信用卡专营机构之一,拥有从金融产品研发至销售及后期风险控制、客户服务完整业务链条,曾获“2016年度最佳创新信用卡银行”。 数字引擎驱动业务增长 安全左移降低开发风险 近年来,广州银行信用卡…

java中使用Jsoup和Itext实现将html转换为PDF

1.在build.gradle中安装所需依赖: implementation group: com.itextpdf, name: itextpdf, version: 5.5.13 implementation group: com.itextpdf.tool, name: xmlworker, version: 5.5.13 implementation group: org.jsoup, name: jsoup, version: 1.15.32.创建工具…

贝业新兄弟:企业级应用在供应链物流领域的实践

一、老板的需求 先简单介绍一下我们公司,公司全称是贝业新兄弟,是一家供应链物流企业。现在我们服务的客户中有很多世界 500 强,比如科勒、惠氏、宜家等。我们公司的信息化分为两部分,一部分是核心业务系统OTWB,它是专…

智能卡通用安全检测指南 思度文库

范围 本标准规定了智能卡类产品进行安全性检测的一般性过程和方法。 本标准适用于智能卡安全性检测评估和认证。 规范性引用文件 下列文件对于本文件的应用是必不可少的。凡是注日期的引用文件,仅注日期的版本适用于本文件。凡是不注日期的引用文件,…

互联网宠物医院系统开发:数字化时代下宠物医疗的革新之路

随着人们对宠物关爱意识的提高,宠物医疗服务的需求也日益增加。传统的宠物医院存在排队等待、预约难、信息不透明等问题,给宠物主人带来了诸多不便。而互联网宠物医院系统的开发,则可以带来许多便利和好处。下面将介绍互联网宠物医院系统开发…

docker常用命令

docker常用命令 1.镜像与容器的关系2. 基本命令3. 容器操作4. 镜像操作5. 文件传输6. docker 登录与退出 1.镜像与容器的关系 镜像: 相当于一个类不可修改内容 容器: 对镜像类的实例,可以在环境中更新库容器可以保存为一个新的镜像再根据保存…

java+springboot+mysql法律咨询网

项目介绍: 使用javassmmysql开发的法律咨询网,系统包含超级管理员,系统管理员、用户角色,功能如下: 用户:主要是前台功能使用,包括注册、登录;查看法律领域;法律法规&a…

基于SpringBoot+Vue的在线考试系统设计与实现(源码+LW+部署文档等)

博主介绍: 大家好,我是一名在Java圈混迹十余年的程序员,精通Java编程语言,同时也熟练掌握微信小程序、Python和Android等技术,能够为大家提供全方位的技术支持和交流。 我擅长在JavaWeb、SSH、SSM、SpringBoot等框架…

推荐一款老化测试软件 Monitor.Analog

1. 数据采集模块: 该模块负责与下位机设备通信,实时采集模拟量数据。支持多种通信协议,如Modbus、OPC等,以适应不同类型的设备。数据采集模块还需要具备异常数据处理功能,例如数据丢失、错误数据等。 2. 数据存储模块…

HttpServletRequest和HttpServletResponse的获取与使用

相关笔记:【JavaWeb之Servlet】 文章目录 1、Servlet复习2、HttpServletRequest的使用3、HttpServletResponse的使用4、获取HttpServletRequest和HttpServletResponse 1、Servlet复习 Servlet是JavaWeb的三大组件之一: ServletFilter 过滤器Listener 监…

【maven】构建项目前clean和不clean的区别

其实很简单,但是百度搜了一下,还是没人能简单说明白。 搬用之前做C项目时总结结论: 所以自己在IDE里一遍遍测试程序能否跑通的时候,不需要clean,因为反正还要改嘛。 但是这个项目测试好了,你要打成jar包给…

Python系统学习1-3-变量,运算符

1、变量 变量:关联一个对象的标识符 学习目标:学会画变量的内存图 命名规则:字母数字下划线,所有单词小写,单词之间下划线隔开 赋值:创建一个变量或改变一个变量关联的数据。 语法:变量名数据&#xf…

carla中lka实现(一)

前言: 对于之前项目中工作内容进行总结,使用Carla中的车辆进行lka算法调试,整体技术路线: ①在Carla中生成车辆,并在车辆上搭载camera,通过camera采集图像数据; ②使用图像处理lka算法&#…