大家好,写给初学者的Jetpack Compose教程又更新了。
这是本系列教程的第9篇文章,我们也已经渐渐从学习Compose的基础知识慢慢转变成学习Compose的高级技巧了,而Side Effect(副作用)就是其中你必须要掌握的一环。
事实上,要理解Side Effect,还是得要先理解重组才行,因为其实就是因为有重组的存在,才会导致出现Side Effect。可以说重组就是整个Compose能够工作的最核心的机制。
对重组还不够了解的朋友,直接学习Side Effect肯定是非常困难的,建议还是先从本系统的第一篇文章开始看起 写给初学者的Jetpack Compose教程,为什么要学习Compose?
再往下看,就说明你已经非常了解重组概念了,那么我们就正式开始学习Side Effect。
什么是Side Effect?
官方文档对于Side Effect的定义描述还是比较抽象的,根据官方文档的说法,Side Effect指的就是,在一个Composable函数的内部发生了超出其作用域的状态变更。
要怎么理解这句话呢?我们来看下面这段代码:
private var mInit = false
@Composable
fun MyApp() {
Initialize()
MainScreen()
}
@Composable
fun Initialize() {
mInit = true
}
@Composable
fun MainScreen() {
if (mInit) {
// 处理MainScreen逻辑
...
}
}
这段代码的核心诉求还是比较好理解的,就是在MyApp()这个Composable函数的内部,我们先调用Initialize()函数对mInit变量进行初始化,完成了初始化之后再在MainScreen()函数中开始处理主页面的逻辑。
这种思维在传统的编程模式下是可以的,但是在Compose这种基于声明式UI的编程模式下是不行的。
上述对mInit变量的操作其实就是属于超出了各个Composable函数作用域的状态变更,因此一定会发生Side Effect。
为了帮助大家更加清晰地了解上述代码犯下的错误,我们来看一看官网 Think in Compose 这篇文章中描述的Compose重组可能具备哪些特性。
- 重组会尽可能跳过不必要的代码
- 重组会导致Composable函数频繁重复执行
- Composable函数可能会并行运行
- Composable函数可以以任意顺序执行
这几条特性都决定着上述代码是无法正常工作的,因为我们设想的Initialize()函数一定在MainScreen()函数之前执行这个前提条件在Compose中根本就不成立。
类似的Side Effect情况其实还有很多,比如说如果你想要在Compose中编写一个计数器效果,并写出了如下代码:
@Composable
fun Counter(modifier: Modifier = Modifier) {
var count = 0
Column {
Text(text = "$count")
Button(onClick = {
count++ }) {
Text(text = "Click me")
}
}
}
很遗憾,这段代码也是无法正常运行的,因为这里对count变量的赋值又是属于超出了Composable函数作用域的状态变更,所以再次出现了Side Effect。
想要避免Side Effect其实并不难,Compose中引入的State概念就可以让Composable函数非常容易地管理各个变量的状态变更。对于State还不了解的朋友可以去参考 写给初学者的Jetpack Compose教程,使用State让界面动起来 这篇文章。
但我们还是有可能会遇到一些特殊的Side Effect场景是State解决不了的。没有关系,Compose给开发者提供了非常丰富的Side-effects函数,专门用于解决各类特殊的Side Effect场景。
那么本篇文章,我们就将主要聚焦在这些Side-effects函数上,对它们的用法以及应用场景进行学习。
LaunchedEffect
LaunchedEffect应该是最常用的一个Side-effects函数了,它主要用于解决两个问题。
第一,让你在Composable函数中的某些代码只执行一遍。
刚才我们有说到重组的其中一个特性,就是可能会导致Composable函数频繁重复执行。而如果有些代码你并不想让它们在每次重组的时候都重新执行一遍,就可以使用LaunchedEffect函数来解决。
比如说,我们有段初始化的代码,在整个Compose的生命周期里只需要执行一遍就行了,那么就可以这样写:
@Composable
fun MyApp() {
LaunchedEffect(Unit) {
// 对程序进行初始化
}
MainScreen()
}
这样,写在LaunchedEffect函数中的代码,就可以保证不会在每次MyApp()函数发生重组时重新执行,它只会执行一遍。
另外你可能发现了,LaunchedEffect函数怎么还接收了一个Unit参数?
其实基于这个参数的用法有很多,因为你可以传递任意类型的参数给LaunchedEffect函数。
而它的作用是,LaunchedEffect函数中的逻辑会在每次参数发生变化时重新执行。
如果参数发生变化时前一次LaunchedEffect函数中的逻辑还没有执行完,那么则会先取消前一次,然后再次执行LaunchedEffect函数中的逻辑。
基于这个特性虽然能做很多的事情,不过大多场景都比较小众。如果你想要的只是让你的某些代码只执行一遍,记得传入Unit就好了。
接下来我们再来看LaunchedEffect函数解决的第二个问题,就是在Composable函数中提供一个协程作用域。
Compose是基于Kotlin的一个声明式UI框架,既然是基于Kotlin的,那么就一定有很多代码是用协程写的,因此需要在协程作用域里面才能调用这些代码。
而Composable函数默认是不带协程作用域的,因此如果你想要在一个Composable函数中调用delay函数,那么将会直接编译报错:
@Composable
fun MyApp() {
delay(1000) // 编译报错
MainScreen()
}
所以,为了解决Composable函数没有协程作用域的难题,Compose提供了LaunchedEffect函数,当你有挂起函数需要在Composable函数中调用时,只需要这样写就可以了:
@Composable
fun MyApp() {
LaunchedEffect(Unit) {
delay(1000) //编译通过
// 对程序进行初始化
}
MainScreen()
}
也就是说,LaunchedEffect函数中代码其实都是在协程作用域当中执行的。
这也就解释了为什么前面说当LaunchedEffect函数的参数发生变化时,会先取消前一次未执行完的逻辑。因为只有运行在协程中的代码才有可能被取消,不然一段正在运行的正常代码是无论如何无法被取消或中止的。
rememberUpdatedState
rememberUpdatedState几乎总是配合着LaunchedEffect函数一起使用的,因此这里将它们俩放在一块讲解。
rememberUpdatedState函数主要用于解决,在使用LaunchedEffect函数时,可能存在的一些回调丢失的风险。
那么什么情况下会导致回调丢失呢?我们来看下面这个例子:
@Composable
fun Initialize(callback: () -> Unit) {
LaunchedEffect(Unit) {
delay(1000)
callback()
}
}
这里我们在Initialize()函数中调用LaunchedEffect函数去进行初始化操作,由于初始化操作只需要执行一次就可以了,因此非常适合放在LaunchedEffect函数中去执行。然后在初始化结束之后,我们再调用参数中传入的callback对象来进行回调通知。
那么这段代码有没有什么问题呢?
其实是有的,因为在LaunchedEffect中的代码执行期间,callback参数随时是有可能改变的,如果LaunchedEffect函数还在初始化过程中,callback参数变动了,那么老的callback对象已经不存在了,自然无法回调成功,新的callback对象也无法得到回调,因为LaunchedEffect函数只会执行一次。
怎么解决这个问题呢?使用rememberUpdatedState函数就可以了。我们来直接看下用法吧:
@Composable
fun Initialize(callback: () -> Unit) {
val currentCallback by rememberUpdatedState(callback)
LaunchedEffect(Unit) {
delay(1000)
currentCallback()
}
}
这里调用rememberUpdatedState函数,并将callback参数传递给它,从而得到了一个新的currentCallback参数。这个currentCallback可以保证永远是指向的是最新的callback参数。
然后,我们只需要在LaunchedEffect函数中的初始化任务执行结束后,调用这个currentCallback对象,就可以保证最新的回调不会丢失了。
rememberCoroutineScope
rememberCoroutineScope这个Side-effects函数也是用于提供协程作用域的。
可能你会觉得,刚才LaunchedEffect函数已经能够提供协程作用域了,为什么还需要这个rememberCoroutineScope函数呢?
因为它们俩其实是一个互补关系,只要拥有这两个函数,基本就可以在Compose中覆盖所有的协程场景了。
先来说一下LaunchedEffect函数所存在的局限性,虽说LaunchedEffect函数能够提供协程作用域,但由于它本身是一个Composable函数,我们可以通过观察它的源码来确认这一点:
@Composable
@NonRestartableComposable
@OptIn(InternalComposeApi::class)
fun LaunchedEffect(
key1: Any?,
block: suspend CoroutineScope.() -> Unit
) {
val applyContext = currentComposer.applyCoroutineContext
remember(key1) {
LaunchedEffectImpl(applyContext, block) }
}
可以看到,带了@Composable注解的就说明这是一个Composable函数。
而Composable函数只能在另一个Composable函数中调用,也就是说,如果我们要在一个非Composable函数中创建协程作用域的话,那么LaunchedEffect函数就无法做到了。
什么场景下会需要在非Composable函数中创建协程作用域呢?其实场景非常多,观察如下代码:
@Composable
fun MyButton() {
Button(onClick = {
delay(1000) // 这里会编译报错
}) {
Text(
text = "This is Button",
color = Color.White,
fontSize = 26.sp
)
}
}
这里我们定义了一个Button,并且在Button的点击事件中调用了挂起函数delay()。
这段代码肯定是无法编译通过的,因为Button的点击事件回调中没有协程作用域,自然无法调用挂起函数。
这个时候你会失去发现用LaunchedEffect函数是解决不了问题的,因为Button的点击事件回调不属于Composable函数的作用域,你在这里根本无法调用LaunchedEffect函数。
所以这个时候就需要rememberCoroutineScope函数登场了,代码如下:
@Composable
fun MyButton() {
val coroutineScope = rememberCoroutineScope()
Button(onClick = {
coroutineScope.launch {
delay(1000) // 编译通过
}
}) {
Text(
text = "This is Button",
color = Color.White,
fontSize = 26.sp
)
}
}
首先我们通过调用rememberCoroutineScope函数能够得到一个CoroutineScope对象,这个CoroutineScope对象和我们在View系统中使用的CoroutineScope别无二致。
那么接下来就很简单了,调用CoroutineScope的launch函数开启一个协程,然后就能非常自由地调用挂起函数了。
注意LaunchedEffect和rememberCoroutineScope这两函数谁都无法取代时,它们的应用场景各不相同。
如果你觉得rememberCoroutineScope函数更加好用,想用来替代LaunchedEffect函数的话,那就错得离谱了。比如下面这段代码:
@Composable
fun Initialize(callback: () -> Unit) {
val coroutineScope = rememberCoroutineScope()
coroutineScope.launch {
delay(1000)
callback()
}
}
这段代码会产生严重的Side Effect,因为每次Initialize()函数发生重组都会导致开启一个新的协程并触发一次回调,这绝对不是你想要的。
因此我们一定要在合适的场景下选择正确的Side-effects函数,不然反而可能会产生更严重的Side Effect,这就本末倒置了。
DisposableEffect
DisposableEffect应该可以算是和LaunchedEffect对称的一个Side-effects函数,它的作用是对不再使用的资源进行安全合理地回收。
DisposableEffect函数的基本语法结构是这个样子的:
DisposableEffect(param) {
onDispose {
}
}
DisposableEffect函数允许接收一个或多个参数,在参数不变的情况下,DisposableEffect函数中的内容只会执行一次。从这点特性上来说,DisposableEffect和LaunchedEffect函数是非常类似的。
不同点在于,DisposableEffect函数不会提供协程作用域,同时DisposableEffect函数中必须要再提供一个onDispose函数。每当DisposableEffect函数的任意一个参数发生变化时,onDispose函数中的内容就会执行,我们可以在这里进行资源释放。然后DisposableEffect函数会使用新的参数内容再次重复上述逻辑。
下面我们来看一个具体的例子吧,DisposableEffect函数用的最多的场景就在是Composable函数中进行生命周期监听了,代码如下所示:
@Composable
fun MyApp() {
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver {
_, event ->
when (event) {
Lifecycle.Event.ON_CREATE -> {
// Handle onCreate
}
Lifecycle.Event.ON_START -> {
// Handle onStart
}
Lifecycle.Event.ON_RESUME -> {
// Handle onResume
}
Lifecycle.Event.ON_PAUSE -> {
// Handle onPause
}
Lifecycle.Event.ON_STOP -> {
// Handle onStop
}
Lifecycle.Event.ON_DESTROY -> {
// Handle onDestroy
} else -> {
// Handle other events
}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
}
想要监听Activity的生命周期,我们可以使用AndroidX的Lifecycle组件,并通过给它添加Observer的方式来实现。
但是很明显我们不能直接在Composable函数中去添加Observer,不然每次只要一重组就会添加一个新的Observer,那监听器就要爆炸了。
虽然我们前面学习的LaunchedEffect函数可以解决这个问题,但是LaunchedEffect函数并不适用监听器的场景,因为它只负责添加,却不能删除,这样资源就无法回收了。
而DisposableEffect函数就是为了这种场景而设计的,我们可以看到上述代码中,lifecycleOwner作为参数传递给了DisposableEffect函数,并给Lifecycle组件添加了一个新的Observer。这样整个Activity的生命周期内,我们都是可以监听到诸如onStart、onResume这些生命周期回调的。
同时,如果我们离开了当前Activity,那么lifecycleOwner就会发生变化,此时会触发onDispose函数的执行。那么在这里,我们对刚才添加的Observer进行了移除,这样也就完成了资源释放。
produceState
produceState函数的作用,是将一个非Compose的State转换成Compose的State,因此这是一个关于State的Side-effects函数。
如果你对Compose中的State概念还并不熟悉,一定要先去阅读 写给初学者的Jetpack Compose教程,使用State让界面动起来 这篇文章。
下面我们来编写一个页面加载效果,分别有加载中、加载成功和加载失败这三种页面状态。代码如下所示:
@Composable
fun HomePage(success: Boolean) {
var loadingStatus = 0
LaunchedEffect(Unit) {
loadingStatus = Status.LOADING
delay(1000)
loadingStatus = if (success) {
Status.SUCCESS
} else {
Status.ERROR
}
}
when (loadingStatus) {
Status.LOADING -> {
LoadingContent()
}
Status.SUCCESS -> {
HomePageContent()
}
Status.ERROR -> {
ErrorContent()
}
}
}
这段代码想表达的意图是,首先调用LaunchedEffect函数创造一个协程作用域,在这里执行加载逻辑。
一开始将状态设置为加载中,然后调用delay()函数延迟一下用于模拟加载的效果,之后再根据加载的结果将状态设置为成功或者是失败。当然这里只是模拟一下,所以加载的结果我就用参数的方式传入进来了,这样可以随时通过修改参数来模拟效果。
最后,再根据当前状态的值来决定是显示HomePageContent()、LoadingContent()还是ErrorContent()函数中的内容。
不用说,这段代码肯定是无法正常为工作的,因为再次出现了Side Effect。LaunchedEffect函数中对加载状态的修改,属于超出了Composable函数作用域的状态变更。
至于怎么解决这个问题,只要你看过了上面那篇文章就一定难不倒你,因为使用State就可以避免Side Effect的出现了。代码如下所示:
@Composable
fun HomePage(success: Boolean) {
var loadingStatus by remember {
mutableIntStateOf(0) }
LaunchedEffect(Unit) {
loadingStatus = Status.LOADING
delay(1000)
loadingStatus = if (success) {
Status.SUCCESS
} else {
Status.ERROR
}
}
when (loadingStatus) {
Status.LOADING -> {
LoadingContent()
}
Status.SUCCESS -> {
HomePageContent()
}
Status.ERROR -> {
ErrorContent()
}
}
}
这里我们通过mutableIntStateOf函数创建了一个Compose的State对象,之后所有的状态变更都是针对这个State对象进行操作的,这样自然就不会出现Side Effect了。
虽然上述代码确实可以解决问题,但是不代表我们不能把代码写得更好。而produceState函数就是用来优化这部分场景的。
刚才已经说了,produceState函数用于将一个非Compose的State转换成Compose的State。除此之外,produceState函数还将提供一个协程作用域。
因此,它完全可以替代上述代码中的mutableIntStateOf和LaunchedEffect这两部分内容。
下面我们来看看使用produceState函数优化过后的代码吧:
@Composable
fun HomePage(success: Boolean) {
val loadingStatus by produceState(initialValue = Status.LOADING) {
delay(1000)
value = if (success) {
Status.SUCCESS
} else {
Status.ERROR
}
}
when (loadingStatus) {
Status.LOADING -> {
LoadingContent()
}
Status.SUCCESS -> {
HomePageContent()
}
Status.ERROR -> {
ErrorContent()
}
}
}
这段代码应该很好理解,因为主体结构和上面的代码是一致的。
但是相比之下,使用produceState函数的版本要更加清爽一些。
我们不用像刚才那样还需要在调用mutableIntStateOf函数时传入一个无意义的状态0用作于初始化。produceState函数允许通过initialValue参数来设置初始值,这样每个状态都是有意义的。
同时,使用produceState函数得到的转换后的State对象是可以声明成val的,这样可以避免一些加载状态被误改的情况,从而让代码变得更加安全。
另外,你还可以将produceState函数里的这段逻辑单独抽离成一个函数并放在任何你想放的位置,这样可以更好地将业务代码和UI代码分离开,如下所示:
@Composable
fun startLoading(success: Boolean): State<Int> {
return produceState(initialValue = Status.LOADING) {
delay(1000)
value = if (success) {
Status.SUCCESS
} else {
Status.ERROR
}
}
}
@Composable
fun HomePage(success: Boolean) {
val loadingStatus by startLoading(success)
when (loadingStatus) {
Status.LOADING -> {
LoadingContent()
}
Status.SUCCESS -> {
HomePageContent()
}
Status.ERROR -> {
ErrorContent()
}
}
}
SideEffect
是的,讲了一整篇的Side Effect,你可能没有想到,还真有一个Side-effects函数的名字就叫做SideEffect。
这个函数应该是所有Side-effects函数里面最不常用的一个,我个人其实没想到太多实际应用的场景,所以待会我就用Android官方文档上的例子给大家讲解了。
首先说一下SideEffect函数的作用是什么吧,它可以让其函数中的代码在Composable函数每次重组的时候执行一次。
我们已经知道,重组的特性决定着Composable函数可能会非常频繁地执行。你不知道什么时候会发生重组,发生了多少次重组。
但有了SideEffect函数,我们就可以知道这些了。
所以我甚至觉得SideEffect函数最大的作用是用于当做学习理解重组概念的调试函数。
观察下面这段代码:
@Composable
fun MyButton() {
var count by remember {
mutableIntStateOf(0) }
Button(onClick = {
count++
}) {
Text(
text = "Count is $count!",
)
SideEffect {
Log.d("linguo2aaa", "Recomposition happened.")
}
}
}
这里我们给按钮增加了一个计数器功能,每点击一次按钮计数就会加1。
由于使用了State对象来控制计数,并通过Text控件对计数值进行了展示。因此我们可以知道,每点击一次按钮都会触发一次重组行为,以更新界面上的最新数据。
但之前我们只是知道而已,重组这个行为对于开发者而言仍然是看不见摸不着一般。除了界面上的数据已经更新,我们找不到其他什么证据来证明重组行为已经发生了。
现在有了SideEffect函数就不一样了,我们在函数中打印一行日志,就可以知道重组行为有没有发生了。
上述代码中,每点击一次按钮,Button和Text控件都会发生一次重组,但是MyButton不会发生重组。
将SideEffect函数放置在不同的位置,将更好地帮助你理解重组行为。
上面的例子是将SideEffect当成调试函数来使用的,那么在实际环境当中SideEffect函数又能起到什么作用呢?这里我就直接贴上Android官方的示例代码了:
@Composable
fun rememberFirebaseAnalytics(user: User): FirebaseAnalytics {
val analytics: FirebaseAnalytics = remember {
FirebaseAnalytics()
}
// On every successful composition, update FirebaseAnalytics with
// the userType from the current User, ensuring that future analytics
// events have this metadata attached
SideEffect {
analytics.setUserProperty("userType", user.userType)
}
return analytics
}
这是一段Firebase的数据上报代码。
这段代码想要实现的效果是,每次函数重组,我们都更新一下用户类型这个字段,以确保Firebase上统计到的用户类型数据是最新的。
至于为什么是每次函数重组的时候更新,我想可能是因为rememberFirebaseAnalytics函数只接受一个User参数,因此基本上只有当User参数发生变化的情况下才会触发rememberFirebaseAnalytics函数的重组。
这个例子我个人认为还是有点牵强的,因为想要实现类似功能有很多种写法,并不一定非要借助SideEffect函数来实现。
但这是官方文档中给出的例子,或许已经是SideEffect函数比较好的应用场景了。
derivedStateOf
derivedStateOf这个函数非常有用,可以大幅改善Compose代码的运行效率。
其实我在之前就已经专门写过一篇文章讲解了derivedStateOf函数的详细用法,只是当时写的时候我还不知道derivedStateOf也是属于Side-effects函数中的一种,毕竟我也是Compose的初学者。
因此这里我就直接附上链接供大家参考吧,写给初学者的Jetpack Compose教程,用derivedStateOf提升性能 。
好的,关于Side Effect所有要讲的内容就到这里,希望大家能掌握好本篇文章的知识,从而让自己的Compose代码摆脱副作用。
我们下篇文章再见。
Compose是基于Kotlin语言的声明式UI框架,如果想要学习Kotlin和最新的Android知识,可以参考我的新书 《第一行代码 第3版》,点击此处查看详情。