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
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识别)。 |
该类型中任何属性的值在构造对象后都不会改变,并且所有方法都是引用透明的。(也许会被废弃,优先使用@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)