Compose - 可组合函数 @Composable

一、概念

使用 @Composable 注解的函数叫可组合函数,它是一个组合项可被用于组合成界面(只是个函数,不是任何View控件,底层用的是Canvas),函数内部只需要描述界面形状和数据依赖而不用关心界面的更新。
  • 函数名首字母需要大写:为了辨识度。
  • 只能由其它组合函数调用。
  • 返回值Unit:用来描述控件的组合而不是构造控件。
  • 默认内部控件的排列为堆叠。
  • 复用率高的建议写在顶层函数。
@Composable
fun Show(str: String) {
    Text(text = "Hello ${str}!")
}

二、重组 Recomposition

当状态发生变化(ViewModel通过形参传入组合项的、组合项内部remrember自己持有的),只有内部代码读取了该状态的组合项才会重组来更新显示,读取代码所在的代码块就是重组作用域。(注意:由于 Column、Row、Box等是内联函数,编译后不是一个函数,如果内部有读取状态的行为,实际是外层在读取和重组,会引起不必要的外层重组。)详见State状态文章中关于状态这两种使用方式的重组性能优化。

2.1 智能重组(跳过重组)

根据入参是否变化来决定是否重组,入参和上次相同的话(指结构相等,Kotlin中的==)就会跳过重组,否则随父组合项重组而重组。当状态发生改变时,以接收该状态的组合项为起点,根据其所调用的子组合项的参数是否发生变化,来判断是否重组,并以此向下递归,实现高效重组(局部刷新)。(使用数据类型具备稳定性来增强效果)

@Composable
fun Demo(
    title: String,
    items: List<String>,
    onItemClick: (String) -> Unit
) = Column {
    //title值改变时Text会重组,items值改变时不会引发
    Text(text = title)
    //items中某个元素改变时onItemClick()会重组,title值改变时不会引发
    LazyColumn {
        items(items) {item ->
            onItemClick(item)
        }
    }
}

2.2 重组可以舍弃(乐观操作)

当重组还未完成时候,由于状态再次变化引起新的重组,会取消之前的重组(舍弃界面树)执行新的重组,导致中途打断代码再重新执行,要避免重组影响。(使用使用Effect API解决)

2.3 可能执行非常频繁(不可预测性)

刷新界面的次数和时机是不确定的,重组的执行可能非常频繁,耗时操作会导致卡顿(使用Effect API解决),多次调用函数会使内部的局部变量被初始化(内部状态丢失,使用remember和MutableState解决)。

2.4 非顺序执行

如果组合函数之间存在调用关系(父子关系),那么是顺序执行的,如果是并行排列(书写),Compose会识别哪些元素优先级更高从而优先绘制,因此要保证它们之间相互独立,不要读取和修改同一全局变量。(使用CompositionLocal解决)。

@Composable
fun Demo() {
    AScreen()
    BScreen()
}

2.5 并发执行

Compose可以利用多核心通过并行执行组合函数来优化重组,意味着组合函数可能执行在线程池中,因此调用方和被调方可能在不同线程上,会出现多线程并发问题,修改组合函数中的局部变量会得到错误值(使用Effect API解决)。

//Box和Text可能会在不同线程执行,这样num显示可能是错的
@Composable
fun Demo() {
    var num = 0
    Row {
        Box {
            repeat(10) { num ++ }
        }
        Text("$num")
    }
}

三、重组作用域 & 性能优化

参考文章

只有读取可变状态的作用域才会被重组:即只有读取 mutableStateOf() 函数生成的状态值的组合函数才会被重新执行。

3.1 内联组合项的重组作用域与其调用者相同

        一般情况下,读取某个状态的组合项和未读取的组合项,它们的重组作用域是隔离的互不影响。但是内联组合项除外(Column、Row、Box等),由于 inline 函数的特性,内部所有子组合项都会在编译期插入到外层中,所以 Column 内部组合项读取状态的行为实际是发生在它的外层中(即重组作用域被放大),因此子组合项重组会引发不必要的父组合项重组。

        Compose本身已经极为优秀了,正常情况下也不会出现太大的性能问题,一般也不需要这么做。只有在你想要鸡蛋里挑骨头、追求极致性能体验的情况下,才需要留意这些。

3.1.1 隔离重组作用域(采用非inline组合项)

当业务代码已经使用了大量的 Column 、Row、Box,就需要隔离重组作用域,将 inline 换成非 inline。具体很简单,创建一个自定义组合项包装一下,将之前的内容传入就行,然后用自定义的组合项去替换 Colum。(不必担心嵌套会影响性能,Compose不被允许多次测量,组合函数非顺序执行、并发执行甚至跳过执行,并不像 View 那样逐层往下再返回,顶多增加编译时间和dex包大一些)。

@Compose
fun ColumnWraper(content: @Composable () -> Unit) {
    Column { content }
}

3.1.2 使用 Lambda 往子组合项中传参

不要在父组合项中读取状态后再传递值给子组合项,而是子组合项的参数类型使用 Lambda,让子组合项去读取。简而言之就是尽可能将读取状态的行为延后。

@Composable
fun Demo(
    viewModel: DemoViewModel = viewModels()
) {
    //以下两种方式都是外部直接读取后传给子组合项
    var data = viewModel.demoUiState.data
    Content1(data = data)
    Content1(data = viewModel.demoUiState.data)
    //推荐方式
    Content2(data = { viewModel.demoUiState.data })
}

@Composable
private fun Content1(
    data: String
) {...}

@Composable
private fun Content2(
    data: () -> String    //通过Lambda传参
) {...}
fun main() {
    val value = 1 + 2
    val lambda: () -> Int = { 1 + 2 }
    println(value)  //打印:3
    println(lambda) //打印:Function0<java.lang.Integer>
    println(lambda())   //打印:3(计算被延迟到调用时)
}

3.1.3 优化 Lambda 传参使用函数引用

在 Compose 中 Lambda 传参被用来实现回调控制(将事件处理交给调用处实现),但在 Kotlin 中 Lambda 实际会被编译成一个匿名内部类对象,由于 Compose 编译器会检查数据类型的稳定性,如果在 Lambda 中调用了不稳定类型(如ViewModel),就会被视为不稳定类型导致 Lambda 中所有子组合项全部重组。

@Composable
fun Demo(
    val viewModel: DemoViewModel = viewModels()
){
    Content(onValueChange = viewModel::onValueChange)
}

@Composable
fun Content(
    onValueChange: (Data) -> Unit 
) {...}

3.2 容器组合项重组时,内部组合项只要有一个读取了外部参数,全部都会发生重组。 

实际开发中,容器组合项会向外暴露一个 @Composable Lambda 的 content 参数,一般情况下 content 中任何子组合项都不会因为容器的重组而发生重组,如果有子组合项访问了外部数据,就会引起整个 content 重组,未读取外部数据的子组合项也会重组。

@Composable
fun Demo() {
    var counter by remember { mutableStateOf(0) }
    Button(
        onClick = { counter ++ }
    ) {
        //文字1读取了外部变量,文字2未读取,最终还是会引起整个content重组,点击按钮文字2背景色也会变
        Text(text = "Text1:$counter", modifier = Modifier.background(getRandomColor()))
        Text(text = "Text2", modifier = Modifier.background(getRandomColor()))
    }
}

fun getRandomColor() =  Color(
    red = Random.nextInt(256),
    green = Random.nextInt(256),
    blue = Random.nextInt(256),
    alpha = 255
)

3.2.1 将未读取外部数据的子组合项用一个无参的组合函数包裹

@Composable
fun Demo() {
    var counter by remember { mutableStateOf(0) }
    Button(
        onClick = { counter ++ }
    ) {
        Text(text = "Text1:$counter", modifier = Modifier.background(getRandomColor()))
        TextWraper()
    }
}

@Composable
fun TextWraper() {
    Text(text = "Text2", modifier = Modifier.background(getRandomColor()))
}

3.3 优化 Modifier 读取状态

应该尽量避免在 Modifier 修饰符上读取状态,因为它并不是用来显示数据的地方。标准的 Modifier 函数是一定会在组合期间被执行的,当状态变化时会重新创建 Modifier 实例,组合树会先删除旧的再添加新的实例,而组合树的变化会导致重组,每次重组都可能触发 Composition→Layout→Drawing 三个阶段,若读取的是一个频繁变化的状态非常要命(动画或滚动)。

val color by animateColorBetween(Color.Blue, Color.Red)
Box(
    modifier = Modifier.background(color)    //Box必须在每一帧上重组,因为每一帧的颜色值都在变化
)

3.3.1 使用带 Lambda 参数版本的 Modifier 函数

有时不可避免需要一直读取一个不断变化的值(offset、size、background、padding)从而达到变化的效果。带 Lambda 参数版本的 Modifier 函数不会在 Composition 阶段被执行(延迟到了 Layout 阶段),Modifier不会改变,因此组合数不会变化,Compose只会在需要的时候调用 Lambda,也就可以跳过不必要的重组了。

@Composable
fun Demo() { 
    var offsetX by remember { mutableStateOf(0f) }
    val modifier1 = Modifier.offset(x = offsetX.dp)    //直接读取
    val modifier2 = Modifier.offset{ val newX = offsetX.dp }    //在Lambda中读取
}

3.3.2 提取 Modifier 重复使用

在可组合项中观察频繁变化的状态(如动画或滚动)时,可能会发生大量重组。在这种情况下,Modifier 会在每次重组时获得分配,并可能分配给每一帧。可以提取 Modifier 到可组合项外面来重用。

val myModifier = Modifier.size(10.dp)    //分配在这里进行

@Composable
fun LoadingScreen() {
    val animatedState = animateFloatAsState(/*...*/)
    LoadingWheel(
//        modifier = Modifier.size(10.dp),    //Modifier的创建和分配将在动画的每一帧中进行!
        modifier = myModifier,    //此处不会分配,因为只是重复使用同一实例
        animateState = anianimateState
    )
}

3.4 列表中指定条目唯一标识 key

Compose编译器会在每个组合项的函数体中插入一个group,每个 group 都会使用一个 key 作为唯一标识,使用组合项在源码中的位置信息来生成的(由Compose runtime生成)。 一般情况下组合项在源码中的位置是不会变的,但是使用列表的情况除外。当代码中会生成一个组合项列表时,对Compose runtime来说,分配唯一标识是很困难的。

3.4.1 循环添加子组合项

        以下代码每次都从相同位置调用 Item(data),每个 Item 表示列表中不同的子组合项,因此是组合树上的不同节点,在这种情况下 Compose runtime 依赖于调用顺序生成唯一的 key 并仍能够区分它们。

        当将一个新元素插入到列表尾部时,这段代码仍能正常工作,因为其余的调用保持在相同的位置。但若将一个新元素插入到列表头部或者中间时,Compose runtime 会重组该元素位置后面所有的Item,因为它们改变了位置,即便内容没有发生变化,这是非常低效的而且本应该跳过重组。

        Compose提供了 key() 函数用来设置唯一标识符,来感知 Item 的位移、新增、删除( 对于内容是否变化能自动通过元素对象自身的equals来感知)以便跳过重组。

@Composable
fun Demo() {
    Column {
        for(data in datas) {
//          key(data.id) {    //使用唯一值当作key
                Item(data)
//          }
        }
    }
}

现象:点击按钮从列表头部插入新元素,结果打印了最后一个元素,初始化的4个元素都重组了一次。

原因:调用 forEach() 遍历时,生成4个 Item(子组合项)并依次设置 Element(列表元素),LaunchEffect() 也就输出了4次,但是由于 Item 和 Element 没有一一对应,当列表头部新插入一个 Element 时,会把新 Element 设置到第一个 Item 上,其余 Element 依次设置到后面的 Item 上,最后一个 Element 设置到新生成的 Item 上,因此前四个 Item 发生了重组而最后一个是新增的就不存在重组了。

 

@Composable
fun Demo() {
    val list = remember {
        mutableStateListOf<DataBean>().apply {
             for(i in 0 until 5) { add(DataBean(i)) }
        }
    }
    Column {
        Button(onClick = { list.add(0, DataBean(0)) }) {
            Text(text = "从头插入新元素")
        }
 
        list.forEach { element ->
//            key(element.id) {
                LaunchedEffect(key1 = Unit) { Log.e("添加元素", "元素ID:${element.id}") }
                Text(text = "元素ID:${element.id}")
//            }
        }
    }
}

3.4.2 列表组合项中设置子组合项

items(
    items = dataList,
    key = { it.id }    //it是dataBean
) {...}

3.5 使用 derivedStateOf() 降低重组次数

一个状态基于另一个或多个状态得出,即对条件状态经过计算后得出结果状态。对条件状态进行过滤,避免每次条件状态更新都要连带自己重组(将高频变化的状态转换成低频变化的状态)。通常使用remember的key可以实现(当UI刷新频率和key一致时使用),有些情况的状态无法用作key,例如 Element 改变了而 List 没变。

//每次重组遍历titles看是否包含关键字的标题,非常耗性能
//只在每次更新titles的时候去遍历过滤出包含关键字的标题
@Composable
fun Demo(
    keywords:List<String> = listOf("关键字1", "关键字2", "关键字3")
) {
    val titles = remember { mutableStateListOf<String>() }
    val result = remember(keywords) {
        derivedStateOf {
            titles.filter { keywords.contains(it) }
        }
    }
    LazyColumn(modifier = Modifier.fillMaxWidth()) {
        items(result) { ... }   //包含关键字的标题的列表
        items(titles) { ... }   //全部标题的列表
    }
}
@Composable
fun Demo() {
    val list = remember { mutableStateListOf<String>() }
    val showText by remember { derivedStateOf{ list.size.toString() } }
}

3.6 避免打印日志造成重组

读取状态会造成重组,有时候需要日志打印状态值,可以使用附带效应API,还可以通过扩展函数简化调用。

@Composable
fun Demo() {
	var listState = rememberLazyListState()
    //方式一
    SideEffect {
        Log.d(TAG, "List recompose ${listState.firstVisibleItemIndex}")
	}
    //方式二
    TAG.printLog { "List recompose ${listState.firstVisibleItemIndex}" }
}

@Composable
fun String.printLog(block: () -> String) {
    SideEffect {
        Log.d(this, block())
    }
}

3.7 避免反向写入(Avoid backwards write.)

在读取了状态之后,在可组合项中又更新了状态,会因为之前读取的状态过期又重新读取导致重组,如此无限循环。

@Composable
fun Demo() {
    var count by remember { mutableStateOf(0) }
    Button(
        onClick = { count++ },    //正确写入方式
        Modifier.wrapContentSize()
    ) {
        Text("点击触发重组")
    }
    Text("$count")
    count++ //错误写入方式:此处反向写入
}

四、数据类型的稳定性 Stability

参考文章1

参考文章2

4.1 稳定类型

识别入参是否变化来决定是否重组(智能重组),进行比较的前提是参数类型必须是稳定类型。

  • 不可变类型 Immutable:对于类型 T 的两个实例 a 和 b,如果 a.equals.(b) 的结果是长期不变的,即属性的类型是 Immutable 的,那么 T 是一个稳定类型。
  • 可观察类型 MutableState:如果类型 T 存在 public 的可变属性(var声明),且所有 public 属性的变化都能被感知并正确反映到 Compositioin,即属性的类型是 MutableState 的,那么 T 是一个稳定类型。(就是把该class中var声明的public属性都用 mutableStateOf() 包装以满足可观察性)
  • 稳定类型的所有 public 属性也必须是稳定类型。因为有可能你对 equals() 进行了重写造成某个 public 属性不参与比较,但属性却有可能在 Composition 中被引用,为了保证引用的正确性,则要求它也必须是稳定的。

4.2 自动识别的稳定类型

Compose编译器在编译时,会识别组合函数的参数是否是稳定类型,以确定可跳过性(Skippable)。

  • 基本类型(Int、Long、Float、Double、Boolean、Char)、String 类型、函数类型(Lambda)。
  • 所有 public 属性都是 final (val 声明)且类型是不可变类型或可观察类型。

4.3 手动注解成稳定类型 @Stable @Immutable

  • 仅仅代表约定,注解一个类会覆盖编译器对该类的推断,比较结果恒定为 true 不会重组,使用不当修饰了不稳定类型会造成无法更新显示。
  • 修饰 interface 派生的子类都会被当做稳定类型。

@Stable

该类型是可变的,但如果任何public属性或方法会产生与先前调用不同的结果,Compose 运行时将收到通知(虽然对象内部的数值虽然会发生变化,但是这种变化可以被Compose识别)。
@Immutable该类型中任何属性的值在构造对象后都不会改变,并且所有方法都是引用透明的。(也许会被废弃,优先使用@Stable,除了修饰类还能修饰属性和函数,使用场景更广泛)
//1. 不可变类型:String
@Composable
fun showString(string: String) { 
    Text(text = "Hello ${string}")
}
​
//2. 可变类型:有可变的属性
class MutableString(var data: String)
@Composable
fun showMutableString(string: MutableString) {
    Text(text = "Hello ${string.data}")
}
​
//3. 不可变类型:成员属性全是 final 
class ImmutableString(val data: String)
@Composable
fun showImmutableString(string: ImmutableString) {
    Text(text = "Hello ${string.data}")
}
​
//4. 可变类型加 @Stable 注解
@Stable
class StableMutableString(var data: String)
@Composable
fun showStableMutableString(string: StableMutableString) {
    Text(text = "Hello ${string.data}")
}
​
//5. 变化可被追踪
class MutableString2(val data: MutableState<String> = mutableStateOf(""))
@Composable
fun showMutableString2(string: MutableString2) {
    Text(text = "Hello ${string.data}")
}

4.4 使用不可变集合

        接口会被视为不稳定类型(如List<T>),Compose编译器在处理时虽然看到了 val 声明(不可重新赋值),但不知道它的实现类是可变(mutableListOf)还是不可变(listOf)方式创建的,其内部数据是否可变无法保证,便将其视为不稳定。

        被 @Immutable 修饰的 data class 有一个 val 属性是 List 类型,虽然注解强调该类型是不可变的,但内部属性还是可变的。

        Compose编译器1.2版本后,可将 Kotlinx 的 Immutable 集合识别为稳定类型,即便它们是接口。  

mutableListOf("A","B","C").toImmutableList()
@Composable
fun Demo(
    list: ImmutableList<String>
) {...}
  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值