文章

JetpackCompose笔记(8) - SideEffect 与协程及状态转换

副作用是什么

SideEffect 官方翻译成「副作用」或「附带效应」。

首先应该明确的是,副作用只是除了目标效果之外,附带的效果,不见得是不好的。不要把副作用和坏作用混为一谈。大概官方翻译成「附带效应」也是这个用意吧。

在编程上副作用普遍指,一个函数修改了外部状态(比如修改全局变量或打印输出)。更加精准的定义是:如果把一个函数删掉,用它的返回值替代过来,是否影响逻辑?对于没有返回值的函数更简单:能不能直接把它删掉?如果这种操作影响了程序,那么两者的差别就是它的副作用。显然,打印标准输出是一种副作用。

Compose 要求所有组件函数都是无副作用的。 在这个语境中意思是,组件函数只应该执行这个组件相关的代码,不要对外界产生影响(比如修改全局变量、启动协程、启动 Service 等)。因为 Compose 会重组,组件函数的调用时机与次数都是不可预期的。

如果确实有副作用的需求呢? 比如埋点分析经常要和 UI 绑定记录用户的操作。这就需要用到 Compose 提供的副作用 API。

SideEffect 是最基本的副作用函数。它保证里面的 lambda 只有在组合/重组完成后才被执行,若界面重组则再次执行。

DisposableEffect

DisposableEffectSideEffect 的升级版。后者在 UI 尘埃落定后执行。我们知道如果在打开页面时启动了一些东西,最好退出页面时及时关闭,不然轻则浪费资源,重则内存泄漏。

DisposableEffect 提供了这样一个释放资源的渠道,除了进入界面时回调一次之外,如果 Compose 组件不复存在也会有回调

用法如下:

Column {
  var show by remember { mutableStateOf(true) }
  Button(onClick = { show = !show }) {
    Text(text = "切换")
  }
  if (show) {
    Text(text = "啦啦啦啦")
    DisposableEffect(Unit) {
      // 进入界面的回调
      Log.i("Compose", "显示文本")
      onDispose {
        // 离开界面的回调
        Log.i("Compose", "隐藏文本")
      }
    }
  }
}

除此之外 DisposableEffect() 还提供 key 参数,其效果和 remember() 的参数一样。如果 key 发生变化就重启这个副作用。「重启」有两个含义:1. 先关闭当前的副作用,也就是回调上一次的 onDispose 代码块。 2. 启动一个新的,即再次执行 lambda。若 key 没有变化即使重组也不会重启

rememberUpdatedState()

刚刚说到 DisposableEffect() 如果 key 不变,即使重组也不会重启 lambda,这样可以减少重复计算提高性能。于是极左主义分子想,要不追求极致性能,即使 key 改变也不重启?这显然不行,会影响程序的正确性。梦想家提出,要不即使不重启副作用,也让 key 的修改生效? 猛一听似乎不可能。其实在某些情况下,的确可以实现。

一般来说,UI 依赖数据,如果数据变了就得更新 UI。还有一种情况,如果这个数据不是立即被使用,比如...在协程中。举个例子:

var message by remember { mutableStateOf("Hello") }
Text(text = "啦啦啦啦")
LaunchedEffect(message) { // 可以改成 LaunchedEffect(Unit)
  delay(3000)
  println(message)
}

message 数据的确有被使用到,但不是立即使用。这种情况下,如果 message 改变,即使协程不重启,也可以输出最新的值。所以这里把 message 作为 key 有点浪费了,完全可以改成 Unit。

但是这种写法不适用于参数:

@Composable
fun test() {
    var message by remember { mutableStateOf("Hello") }
    Text(text = "啦啦啦啦")
    delayPrint(text = message)
}

@Composable
fun delayPrint(text: String) {
    LaunchedEffect(Unit) {
        delay(3000)
        println(text)
    }
}

上面这样 message 的变更不会反映到输出。核心原因是 text 是 message 的一个拷贝,之前启动的协程使用的还是旧的参数。

解决方案如下:

@Composable
fun delayPrint(text: String) {
    var rememberedText by remember { mutableStateOf(text) }
    rememberedText = text // 这一行不能漏
    LaunchedEffect(key1 = Unit) {
        delay(3000)
        println(rememberedText)
    }
}

如此一来,rememberedText 总是同一个对象,而它的内容会保持最新。好像有一点麻烦呀...终于进入正题,Compose 针对这种场景推出了简化写法:rememberUpdatedState 这个函数完全等价于 remember state + 赋值操作。用法如下:

@Composable
fun delayPrint(text: String) {
    val rememberedText by rememberUpdatedState(newValue = text) // 注意这一行
    LaunchedEffect(key1 = Unit) {
        delay(3000)
        println(rememberedText)
    }
}

用法很简单,关键是理解这个 API 的意义、它解决的是什么问题。

总结:rememberUpdatedState 用于包装稍后使用的参数,从而无需重启副作用。一般来说,「稍后使用」的场景有两个:

  1. LaunchedEffect 中的协程
  2. DisposableEffect 中的 onDispose 回调

协程相关

LaunchedEffect

前面介绍的两个函数用于一般的副作用业务需求,LaunchedEffect 则更专注与协程,它是特殊的 DisposableEffect,以相同的触发逻辑启动(重启)一个协程,在协程中执行 lambda。而退出 Compose 的回调也封装成了取消协程。

LaunchedEffect 的使用场景也和 DisposableEffect 一样:当需要把组件的显示作为触发器时使用,唯一的区别是要触发的工作是否需要协程。

比如希望组件显示后的 3 秒自动消失就能这么写:

var show by remember { mutableStateOf(true) }
if (show) {
  Text(text = "三秒后消失")
  LaunchedEffect(key1 = Unit) {
    delay(3000)
    show = false
  }
}

默认情况下 3 秒后文字消失,但如果因为其他原因 show 被提前置为 false,相应的协程会自动取消。

rememberCoroutineScope

在 Compose 中使用协程有两个途径,一个是刚刚说的最常用的 LaunchedEffect{} 第二个就是这一节的 rememberCoroutineScope

研究这个函数之前,先来思考为什么 Compose 中不能用原生的写法启动协程?(其实编译上是可以的)宏观上一开始就提到了,Compose 的调用不可预期,所以不应该产生副作用。微观上,kotlin 要求协程必须属于一个 Scope,Scope 结束时协程能够被及时取消。在 Compose 中,每一个组件都有自己的生命周期,这一点和传统 View 不太一样。View 中我们更习惯绑定 Activity/Fragment 的生命周期,经常说「Fragment 销毁了」。虽然 View 本身也有 OnAttach 事件,有 Visibitity 属性,但开发中并不把它视为生命周期。Compose 中就不同了,组件没有 Visibility 的概念,没有了就是没有了,在内存中也「没有了」。所以各个组件自己的生命周期不再可忽略。

所以 Compose 中协程的 Scope 应该和它所处的组件绑定,而不是 Activity/Fragment 的 lifecycleScoperememberCoroutineScope 正是提供了这样一个 Scope!

机智的小伙伴可能立即尝试这么写:

@Composable
fun test() {
  val scope = rememberCoroutineScope()
  // Calls to launch should happen inside a LaunchedEffect and not composition
  scope.launch {  } 
}

可惜还是报错。因为 rememberCoroutineScope() 虽然可以在这个组件销毁时取消协程,但不能防止重组时多次启动协程。怎么防止重组时重复执行代码?remember 嘛!改一下代码:

@Composable
fun test() {
  val scope = rememberCoroutineScope()
  remember { scope.launch { } }
}

这就可以了!LaunchedEffect 内部也是这么实现的。

那... rememberCoroutineScope() 有啥用?有用的!有时候启动协程的代码不是直接写在 Compose 组件内,而是一个回调内,比如 clickable 回调。

显然 clickable 调用时机是确定的,即使发生重组也不会有意外的触发。并且希望这个协程在组件销毁时能及时取消,就得手动拿到 Scope 了

写法如下:

@Composable
fun TestBox() {
  val scope = rememberCoroutineScope()
  Box(modifier = Modifier.clickable { 
    scope.launch { /*...*/ }
  })
}

Modifier.clickable{} 的回调没有 @Composable 注解,其内部无法使用 LaunchedEffect 等函数。

也因此如果在 clickable 回调中使用原生的启动协程 API IDE 不会报错。要根据需求衡量应该用哪种写法。即:这个协程与谁的生命周期绑定?

非 Compose 的状态转换

在 Compose 出现之前就已经有很多可以被订阅的对象,这些对象改变时需要通知订阅者刷新。显然这些对象不能直接适应 Compose 的机制。所以需要状态转换。

普通状态

普通状态指不需要协程的状态,比如 LiveData。传统 View 中应该监听 LiveData,在数值改变回调中更新 UI,界面销毁时会自动取消监听。对应到 Compose 中就是:在组件创建的时候监听,在回调中修改 MutableState 的状态来更新 UI,组件销毁时取消监听。

明显,这里的「监听」和「取消监听」是一次性的操作,应该用副作用函数实现。如下:

val messageData: LiveData<String>

@Composable
fun TestBox() {
  var message by remember { mutableStateOf(messageData.value) }
  val lifecycleOwner = LocalLifecycleOwner.current
  DisposableEffect(Unit) {
    val observer = Observer<String> { message = it }
    messageData.observe(lifecycleOwner, observer)
    onDispose { messageData.removeObserver(observer) }
  }
}

事实上便捷函数 Observer.observeAsState() 也是这么写的。

协程状态

StateFlow 是典型的协程状态。对它的订阅必须在协程环境下才能完成。

@Composable
fun TestBox() {
    var message by remember { mutableStateOf(messageFlow.value) }
    LaunchedEffect(Unit) {
        messageFlow.collect { message = it }
    }
}

与普通状态转换唯一的区别就是需要 LaunchedEffect 提供协程环境,用起来反而更简单了,因为协程会自动取消。


协程状态到 Compose 的转换自然也有便捷写法,就是 produceState(),它内部使用了上面的手动实现。State 需要有默认值,Flow 当前可能是没有值的。手动实现中我们自己创建的 State,顺便也就给出默认值了。produceState() 函数内部创建 State,默认值自然需要通过参数给出,这就是它第一个参数的意义。另外 produceState() 用于从协程读取值,而不仅仅是 Flow,所以具体怎么取值也需要我们来实现,这就是第二个参数。用法如下:

@Composable
fun TestBox() {
  val message by produceState("") {
    // value 是 produceState() 内部创建的 state
    messageFlow.collect { value = it }
  }
}

在 lambda 内部,还提供了一个 awaitDispose{} 函数,作用是立即挂起这个协程,直到被取消的时候恢复并回调。在 Flow 的这个用法中没有什么意义,因为 Flow.collect{} 本来就挂起了,之后的代码不会执行。这个奇怪的函数适用于协程取消不会自动取消订阅的场景,比如用 produceState() 去转换 LiveData。...? 恩...比较奇怪,真正的使用场景有待探索。

produceState() 针对的是协程状态转换通用场景,如果明确了是 Flow,也有更便捷的函数:Flow.collectAsState()

snapshotFlow()

snapshotFlow() 用于把 Compose 的状态转换成 Flow。每当 Compose State 的值发生改变都会在这个 Flow 中生成一个新值,从而让外界也能感知 Compose 的状态。

var message by remember { mutableStateOf("Hello") }
val messageFlow = snapshotFlow { message }
// 每次 message 更新,messageFlow 都会有新值

仔细看看,snapshotFlow() 接收的不是一个 State,而是 lambda。它可以把多个 State 转换成一个 Flow! 比如这样:

var name by remember { mutableStateOf("Chenhe") }
var message by remember { mutableStateOf("Hello") }
val messageFlow = snapshotFlow { "$message $name" }
// name 或 message 更新都会触发 messageFlow

使用场景呢?snapshotFlow() 是给外部用的,也就是说写 UI 的时候用不到这个东西。当需要从外部读取 Compose 内部状态的时候自然就派上用场了。