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

news2024/9/27 19:22:10

作者:晴天小庭

前言:

随着越来越多的人使用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
    @Preview
    fun 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)
                    )
                }
            }
        }
    }
    
    @Composable
    private 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() / 1000f
                scaleX = scrollValueProvider() / 1000f
                translationX = 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.coloradmin.cn/o/838298.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

flask服务生成证书文件,采用https访问,开启用户密码验证

openssl req -x509 -newkey rsa:4096 -nodes -out cert.pem -keyout key.pem -days 3072开启用户密码验证 auth.verify_password def verify_password(username, password):if username abcdefg and password 1234546:return usernameapp.route(/post_request, methods[POST…

HDFS中的Federation联邦机制

HDFS中的Federation联邦机制 当前HDFS体系架构--简介局限性 联邦Federation架构简介好处配置示例 当前HDFS体系架构–简介 当前的HDFS结构有两个主要的层: 命名空间(namespace) 由文件,块和目录组成的统一抽象的目录树结构。由n…

JavaScript |(五)DOM简介 | 尚硅谷JavaScript基础实战

学习来源:尚硅谷JavaScript基础&实战丨JS入门到精通全套完整版 文章目录 📚DOM简介📚DOM查询🐇文档的加载🐇节点🐇节点的属性🐇元素节点的子节点🐇获取父节点和兄弟节点&#x1…

4,链表【p5】

链表 4.1哈希表简介4.2有序表简介4.3链表4.3.1例1-反转单向和双向链表4.3.2例2-打印两个有序链表的公共部分4.3.3面试时链表解题的方法论4.3.4例3-判断一个链表是否为回文结构4.3.4.1快慢指针 4.3.5例4-将单向链表按某值划分成左边小、中间相等、右边大的形式4.3.6例5-复制好友…

redis 原理 7:开源节流 —— 小对象压缩

Redis 是一个非常耗费内存的数据库,它所有的数据都放在内存里。如果我们不注意节约使用内存,Redis 就会因为我们的无节制使用出现内存不足而崩溃。Redis 作者为了优化数据结构的内存占用,也苦心孤诣增加了非常多的优化点,这些优化…

【JavaEE初阶】了解JVM

文章目录 一. JVM内存区域划分二. JVM类加载机制2.1 类加载整体流程2.2 类加载的时机2.3 双亲委派模型(经典) 三. JVM垃圾回收机制(GC)3.1 GC实际工作过程3.1.1 找到垃圾/判定垃圾1. 引用计数(不是java的做法,Python/PHP)2. 可达性分析(Java的做法) 3.1.2 清理垃圾1. 标记清除2…

CMake:检测python解释器和python库

CMake:检测python解释器和python库 导言检测python解释器CMakeLists.txt输出附录 检测python库项目结构CMakeLists.txt相关源码附录 导言 python是一种非常流行的语言。许多项目用python编写的工具,从而将主程序和库打包在一起,或者在配置或构建过程中使…

企业集团员工内部食堂餐厅食材预定订餐统计系统开发

内部食堂是针对员工食堂设计的预定系统 可以提前进行点餐,统计餐食人数 定量制作,避免浪费食材。 首页预定菜单提前显示一周菜单 用户可以提前预定想要购买的餐品 在用餐时取餐核销 食堂平台方用餐时对用户的菜品进行核销 通过后台预订信息 根据报餐统计做出对应数量…

Example: Beam Allocation in Multiuser Massive MIMO阅读笔记一

文章目录 A Machine Learning FrameworkApplication of Supervised Learning to Resource AllocationResearch Challenges and Open IssuesLow-Complexity ClassifierMulti-BS CooperationFast Evolution of Scenarios Conclusion A Machine Learning Framework 对于现有的云计…

python在函数中更改外部变量值

目录 前言 列表、字典(可变对象) 元组(不可变对象) 全局变量 前言 今天在写LeetCode题时,发现一个问题我并没有掌握,那就是如何在Python的函数中更改变量值(包括列表,字典&…

C++ - 模板分离编译

模板分离编译 我们先来看一个问题&#xff0c;我们用 stack 容器的声明定义分离的例子来引出这个问题&#xff1a; // stack.h // stack.h #pragma once #include<deque>namespace My_stack {template<class T, class Container std::deque<T>>class stack…

Python 开发工具 Pycharm —— 使用技巧Lv.3

单步执行调试 1&#xff1a; 鼠标左键单击红点是断点行 2&#xff1a;甲虫样式是进行调试方式运行&#xff0c;鼠标左键单击点击 3&#xff1a; 单步运行图标&#xff0c;点击让程序运行一行 4&#xff1a; 步入步出&#xff0c;可以进入当前代码行函数内 5&#xff1a;重新运行…

JSON.stringify()与JSON.parse()没有你想的那样简单

重新学习这两个API的起因 在本周五有线上的项目&#xff0c;16:30开始验证线上环境。 开始都是顺顺利利&#xff0c;一帆风顺。 大概17:50左右&#xff0c;我正在收拾东西。 准备下班去王者峡谷骑着我的船溜达一圈。 可是天降意外&#xff0c;给我派了一个bug。 测试给我说&am…

山西电力市场日前价格预测【2023-08-06】

日前价格预测 预测明日&#xff08;2023-08-06&#xff09;山西电力市场全天平均日前电价为411.77元/MWh。其中&#xff0c;最高日前电价为457.52元/MWh&#xff0c;预计出现在19: 30。最低日前电价为370.37元/MWh&#xff0c;预计出现在13: 15。 价差方向预测 1&#xff1a; 实…

海外应用商店优化实用指南之关键词

和SEO一样&#xff0c;关键词是ASO中的一个重要因素。就像应用程序标题一样&#xff0c;在Apple App Store和Google Play中处理应用程序关键字的方式也有所不同。 关键词研究。 对于Apple&#xff0c;我们的所有关键词只能获得100个字符&#xff0c;Google Play没有特定的关键…

数据结构10 -查找_树表查找

创建二叉搜索树 二叉搜索树 二叉搜索树是有数值的了&#xff0c;二叉搜索树是一个有序树。 若它的左子树不空&#xff0c;则左子树上所有结点的值均小于它的根结点的值&#xff1b; 若它的右子树不空&#xff0c;则右子树上所有结点的值均大于它的根结点的值&#xff1b; 它…

47.Linux学习day01 基础命令详解1(很全面)

目录 一、Linux和Windows的区别 二、Linux系统目录结构 常见目录说明 三、Linux常见的基础命令 1.pwd 2.cd 3.ls 4.man 5. touch 6.mkdir 7. rmdir 今天正式学习了linux的一些基础操作和基础知识&#xff0c;以及linux和windows的区别。 一、Linux和Windows的区…

SpringMVC基于SpringBoot的最基础框架搭建——包含数据库连接

SpringMVC基于SpringBoot的最基础框架搭建——包含数据库连接 背景目标依赖配置文件如下项目结构如下相关配置如下启动代码如下Controller如下启动成功接口调用成功 背景 工作做了一段时间&#xff0c;回忆起之前有个公司有线下笔试&#xff0c;要求考生做一个什么功能&#x…

建设全球研发中心城市,长沙与人才何以双向奔赴?

跨越山涧峡谷&#xff0c;需要搭建钢铁桥梁&#xff0c;跨越文化沟通&#xff0c;需要搭建互相理解的桥梁&#xff0c;那么&#xff0c;一座城市与人才之间的联系&#xff0c;应该搭建怎样的桥梁&#xff1f; 近日&#xff0c;长沙出台了重磅文件《中共长沙市委长沙市人民政府…

XSS漏洞原理及利用跨站请求伪造CSRF

XSS漏洞原理及利用&跨站请求伪造CSRF XSS一、案例二、什么是XSS三、XSS危害四、XSS的分类4.1、反射型XSS4.1.1、介绍4.1.2、利用过程 4.2、存储型XSS4.2.1、介绍4.2.2、利用过程4.2.3、案例 4.3、DOM型XSS4.3.1、介绍4.3.2、常用的DOM方法4.3.3、案例4.3.3.1、代码分析4.3.…