Jetpack Compose:理解composable的重组范围(Recomposition Scope)

本文深入探讨了Jetpack Compose中的Composable重绘机制,解释了Recomposition的工作原理及其优化策略,通过具体示例帮助读者理解如何确定有效的重绘范围。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

在这里插入图片描述

不少初学Compose的同学都会对ComposableRecomposition(官方文档中文译为"重组",而我更喜欢称为“重绘”)心生顾虑,担心大范围的Recomposition是否会影响性能。

Recomposition skips as much as possible
When portions of your UI are invalid, Compose does its best to recompose just the portions that need to be updated. – https://developer.android.com/jetpack/compose/mental-model#skips

如上,其实Compose的Compiler在编译期做了大量工作,保证了Recomposition的范围尽可能的小,从而避免无效开销。

那么Recomposition的范围究竟是怎样的呢?我们通过一个例子来了解一下

@Composable
fun Foo() {
    var text by remember { mutableStateOf("") }
    Log.d(TAG, "Foo")
    Button(onClick = {
        text = "$text $text"
    }.also { Log.d(TAG, "Button") }) {
        Log.d(TAG, "Button content lambda")
        Text(text).also { Log.d(TAG, "Text") }
    }
}

如上,当点击button时,State的变化会触发Recomposition。

先不公布答案,大家先想想此时的日志输出是怎样的。




答案在文章最后,与你的判断是否一致?


Compose如何确定重绘范围?


  • Compose在编译期分析出访问某state的代码,并记录其引用。当此state变化时,会根据引用找到这受影响的代码并标记为Invalid,在下一帧到来之前参与到Recomposition中。
  • 可以被标记为Invalide的代码一般是一个 @Composable的非inline且无返回值的lambda或者function
  • Invalid代码遵循影响范围最小化原则;

了解了Compose重绘范围的原则之后,我们试着解答一下下面的问题:


为什么必须是非inline且无返回值(返回Unit)?


  • 对于inline函数,由于在编译期在调用方中展开,因此无法再下次重绘时找到合适的调用入口,只能共享调用方的重绘范围
  • 对于有返回值的函数,新的返回值会影响调用方,因此无法单独重绘,必须连同调用方一同参与重绘。

为什么不只是Text参与重绘?


了解了Compose的重绘的原则之后,再回看上面的例子,当点击button后,MutableState发生变化,代码中唯一访问这个state的地方是Text(...),为什么不是重绘范围不只是Text(...),而是button的整个lambda?

首先要理解出现在Text(...)参数中的text 实际上是一个表达式

我们知道

println(“hello” + “world”)

val arg = “hello” + “world”
println(arg)

在执行顺序上是等价的:总是“hello” + “world”作为表达式先执行,然后才调用println方法。

在上面例子中,参数text作为表达式执行的调用处是Button的lambda,之后才传入Text()。所以此时最小重回范围是Button的lambda而不只是Text


Foo是否参加重绘


按照范围最小化原则, Foo中没有任何对state的访问,所以很容易理解Foo不应该参与重绘。

有一点需要注意的是,例子中Foo通过by的代理方式声明text,如果改为=直接为text赋值呢?

@Composable fun Foo() {
  val text: MutableState<String> = remember { mutableStateOf("") }

  Button(onClick = { 
  	 text = "$text $text"
  }) {
    Text(text.value)
  }
}

答案是一样的,仍然不会参与重绘。

第一,Compose关心的是代码块中是否有对state的read,而不是write。
第二,即使使用=,也不代表text的对象会发生变化,text指向的MutableState实例是永远不会变的,变得只是内部的value


为什么Button不参与重绘?


这个很好解释,Button的调用方Foo不参与重绘,Button自然也不会参与重绘,只对lambda重绘即可。


ButtononClick是否参与重绘?


重绘对象只能是@Composable的方法或lambda上,onClick是一个普通lambda,与重绘逻辑无关。


务必注意!重绘中的Inline陷阱!

前面讲了,只有非inline函数才有资格成为重绘的最小范围,理解这点特别重要!

我们将代码稍作改动,为Text()包裹一个Box{...}

@Composable
fun Foo() {

    var text by remember { mutableStateOf("") }

    Button(onClick = { text = "$text $text" }) {
        Log.d(TAG, "Button content lambda")
        Box {
            Log.d(TAG, "Box")
            Text(text).also { Log.d(TAG, "Text") }
        }
    }
}

上面的日志如下:

D/Compose: Button content lambda
D/Compose: Boxt
D/Compose: Text

为什么重绘范围不是从Box开始?

ColumnRowBox甚至是Layout 这类容器类Composable都是inline函数,因此它们只能共享调用方的Scope,也就是Button的lambda。

如果你希望缩小重绘范围,提高性能怎么办?

@Composable
fun Foo() {

    var text by remember { mutableStateOf("") }

	Button(onClick = { text = "$text $text" }) {
        Log.d(TAG, "Button content lambda")
        Wrapper {
            Text(text).also { Log.d(TAG, "Text") }
        }
    }
}

@Composable
fun Wrapper(content: @Composable () -> Unit) {
    Log.d(TAG, "Wrapper recomposing")
    Box {
        Log.d(TAG, "Box")
        content()
    }
}

如上,自定义非inline函数,使之满足Compose重绘范围最小化条件。


总结


Just don’t rely on side effects from recomposition and compose will do the right thing
– Compose Team

关于Recomposition的具体规则官方文档中没有做过多说明。因为开发者只需要牢记Compose的编译期优化保证了Recomposition永远按照最合理的方式运行,以最自然的方式开发就好了,无需再付出额外的理解成本。

尽管如此,作为开发者仍要谨记一点:

不要直接在Composable中写包含副作用(SideEffect)的逻辑!

副作用是不能跟随Recomposition反复执行的,而纯洁的Composable,无论启动编译期优化与否,在功能的正确性上都不会有问题。

你不能预设某个function/lambda一定不参与重绘,因而在里面执行某些副作用。因为我们不确定这里是否存在“inline陷阱”,即使能确定也不能保证现在的优化规则在未来不会变化。

所以最安全的做法是,将副作用写到LaunchedEffect{}DisposableEffect{}SideEffect{}中,并且使用remeber{}derivedStateOf{} 处理耗时的、不适合反复进行的运算。

FIN




开头例子的日志:

D/Compose: Button content lambda
D/Compose: Text
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

fundroid

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值