1、SideEffect
Google 官方文档对 side effect 有两种翻译,简体中文翻译为附带效应,繁体中文翻译为副作用。这两个翻译我们用哪个都行,关键是如何理解它的含义。
1.1 什么是副作用
我们在一样平常生活中听到的副作用大多是医学领域中的,药物的副作用是指除了预期治疗效果外,在利用过程中可能产生的额外不良反应或不良影响。可以理解为,副作用是目的作用为了见效而附带而来的作用,它并不是“负作用”那种完全欠好的、负面的作用。
在 Compose 中,副作用(或附带效应,后续出现两个词都以为是 side effect)通常指的是对界面以外的状态举行更改大概操纵的行为。在函数式编程中,强调函数的纯粹性,即函数的输出仅依靠于输入,不会对外部状态产生影响。然而,在 UI 开发中,通常需要与外部情况举行交互,比如修改变量、举行网络哀求等,这些会导致“副作用”。
比如说:
- fun a() {
- var value = 0
- value = 1
- }
- var flag = false
- fun b() {
- flag = true
- }
复制代码 函数 a 只修改了其内部的变量,因此没有副作用;而函数 b 修改了外部的变量 flag,因此它有副作用。
再看:
- fun c() {
- println("Compose")
- }
复制代码 想要确认函数 c 是否有副作用,通过判断是否修改函数内部变量的方式似乎不能确定,这里我们可以借助副作用的学术性定义:对于一个函数,如果用它的返回值替换函数本身,但不会对步伐有任何影响,那么该函数就没有副作用,如果产生了影响,两种效果之间的差异就是副作用。
对于 println() 这个函数而言,它本身没有返回值,大概说是一个 Unit,用返回值替换函数本身使得无法打印指定内容,这对步伐产生了影响,因此 println 这个函数是有副作用的,函数 c 也因此是有副作用的。
副作用这个词可以很好地描述函数的纯净性,可以称一个函数是“有/无副作用的函数”。Compose 就要求所有的组件函数都是无副作用的函数。Compose 的 @Composable 函数是用来显示界面内容的,应该只包罗界面显示工作,不应掺杂其他任何对外界有影响的工作,也就是不应该有副作用。因为 @Composable 函数的副作用会导致整个步伐产生不可预期的效果,这是由于 @Composable 函数的调用就具有不可预期性。
由于 Compose 框架对于重组过程的优化,一个 @Composable 函数可能在运行过程中被终断甚至干脆就没被实行,如允许能会出现影响外界的代码,有一部分被实行了,剩余的部分由于终断而没被实行,从而产生不可预期的效果。
此外,由于重组的次数不确定,具有副作用的 @Composable 函数的实行效果也是不可预期的:
- setContent {
- var seasonCount = 0
- Column {
- val seasons = arrayOf("Spring", "Summer", "Autumn", "Winter")
- seasons.forEach {
- Text(it)
- // 在重组之后确定 Column 仍会显示才执行代码块的内容,但此时
- // 最下面的 Text 要显示的内容已经确定了,无法再更改
- SideEffect {
- seasonCount++
- }
- }
- Text("Total season count: $seasonCount")
- }
- }
复制代码 按照假想,最后 Text 展示的 seasonCount 应该为 4,但如果代码运行过程中出现多次重组,那么 seasonCount 就不会是 4 了,效果不在预期中。
正是因为种种的不可预期性,Compose 建议开发者不要在 @Composable 函数中引入副作用代码。但很多时间,业务需求使得我们无法服从这个建议,比如在代码中埋点统计函数的实行数据,那难道因为 Compose 的建议就无法完成这些业务需求吗?当然不是,任何时间,业务都是优先的,没有贸易和业务支撑的代码创造不出任何代价,也就一文不值。
1.2 SideEffect 函数
Compose 提供了一些函数来满意业务需求(比如埋点),最直接与最简单的就是 SideEffect()。
SideEffect() 内的代码不会在实行到它时立刻被实行,而是先被生存起来举行等待,直到本轮重组过程完成,确定了 SideEffect 所在的组件会在界面上显示,才会实行其内部代码。如允许以保证没有实行完就被取消的 Composable 函数的副作用代码不会被实行,还可保证在一轮重组过程中被多次调用的 Composable 函数的代码只被实行一次。
那利用 SideEffect 是不是能办理所有的副作用相关的问题呢?当然不是,想通过 SideEffect 办理副作用问题有一个前提,就是引入副作用代码的这个需求必须是正常的,不能对外界造成不可预期影响的需求。比如照旧上面的例子,由于对 seasonCount 的自增操纵会对外界造成不可预期的影响,因此只是简单的为它包上一层 SideEffect 并不能到达预期的效果:
- setContent {
- var seasonCount = 0
- Column {
- val seasons = arrayOf("Spring", "Summer", "Autumn", "Winter")
- seasons.forEach {
- Text(it)
- // 在重组之后确定 Column 仍会显示才执行代码块的内容,但此时最下面的 Text
- // 要显示的内容已经确定了,无法再更改,再运行 seasonCount++ 已经晚了
- SideEffect {
- seasonCount++
- }
- }
- Text("Total season count: $seasonCount")
- }
- }
复制代码 真正的办理办法是你要把业务(可以简单的理解为数据处理)与界面显示分拆,在显示 UI 前把数据准备好,而不是滥用 SideEffect:
- setContent {
- var seasonCount = 0
- Column {
- val seasons = arrayOf("Spring", "Summer", "Autumn", "Winter")
- seasonCount = seasons.size()
- seasons.forEach {
- Text(it)
- /*SideEffect {
- seasonCount++
- }*/
- }
- Text("Total season count: $seasonCount")
- }
- }
复制代码 2、DisposableEffect
DisposableEffect 是 SideEffect 的升级版,增加了对离开界面的监听。比如:
- Button(onClick = { /*TODO*/ }) {
- DisposableEffect(Unit) {
- // Button 进入页面监听
- println("Button 进入页面")
- // 该 lambda 表达式必须返回一个 DisposableEffectResult,可以通过
- // 主动调用组件离开页面的监听函数 onDispose() 得到
- onDispose { println("Button 离开页面") }
- }
- }
复制代码 通过自动调用 onDispose() 设置对组件离开页面的监听内容,如许在所属组件离开页面(准确点说是离开组合 Composition)时就会回调 onDispose(),同理进入页面会回调 DisposableEffect() 的内容。
有两种场景实用 DisposableEffect:
- 埋点,统计用户进入以及退出了哪些界面
- 组件进入页面时在 DisposableEffect() 内为该组件设置监听器,组件离开页面时在 onDispose() 内取消监听器
此外,我们要看一下 DisposableEffect 的第一个参数 key1:
- @Composable
- @NonRestartableComposable
- fun DisposableEffect(
- key1: Any?,
- effect: DisposableEffectScope.() -> DisposableEffectResult
- ) {
- remember(key1) { DisposableEffectImpl(effect) }
- }
复制代码 它的作用是,当传入 DisposableEffect() 的 key1 发生厘革时,整个 DisposableEffect() 会举行一次重启,重启动作包括两步:
- 先对老的 key1 值实行一次离开回调 onDispose()
- 然后对新的 key1 值实行一次 effect 参数函数中的进入回调
如许的顺序可以保证步伐有一个合理的实行过程。比如 onDispose() 中会实行将对象 A 置为 null 的操纵,而 DisposableEffect() 会为 A 赋值。那么当 DisposableEffect 因为 key1 的厘革而重启时,就会先将 A 置为 null 然后再为它赋新值,而不是先为 A 赋了新值再置为 null。
反之,如果 key1 不变,岂论 DisposableEffect 所在的组件如何举行重组,DisposableEffect 都不会重启(避免资源消耗):
- @Composable
- fun DisposableEffectSample1() {
- var showText by remember { mutableStateOf(false) }
- Button(onClick = { showText = !showText }) {
- Text("点击")
- if (showText) {
- Text("Compose")
- }
- // 只要 Button 重组就会回调
- SideEffect {
- println("SideEffect")
- }
- // key1 不变不论 Button 如何重组,DisposableEffect 都不会重启
- DisposableEffect(Unit) {
- println("Button 进入页面")
- onDispose { println("Button 离开页面") }
- }
- }
- }
复制代码 不断点击 Button 让其发生重组,但是只有 SideEffect() 会跟随重组举行重启,由于 DisposableEffect() 的 key1 参数不变,所以只有首次进入页面时的 log 被输出:
- Button 进入页面
- SideEffect
- SideEffect
- SideEffect
复制代码 但将 DisposableEffect() 的 key1 参数换为 showText 之后,点击按钮会触发 DisposableEffect() 重启:
- Button 进入页面
- SideEffect
- Button 离开页面
- Button 进入页面
- SideEffect
- Button 离开页面
- Button 进入页面
- SideEffect
复制代码 并且你能看到,DisposableEffect 会先触发 onDispose() 回调,再回调本身内部的代码。
3、LaunchedEffect
LaunchedEffect 会在 Composable 组件完成显示之后启动协程,并在参数发生改变之后重启协程。
LaunchedEffect 从功能与底层实现上来讲是特殊形式的 DisposableEffect:
- @Composable
- @NonRestartableComposable
- fun DisposableEffect(
- key1: Any?,
- effect: DisposableEffectScope.() -> DisposableEffectResult
- ) {
- remember(key1) { DisposableEffectImpl(effect) }
- }
复制代码 key1 厘革才会实行 DisposableEffectImpl():
- private class DisposableEffectImpl(
- private val effect: DisposableEffectScope.() -> DisposableEffectResult
- ) : RememberObserver {
- private var onDispose: DisposableEffectResult? = null
- override fun onRemembered() {
- onDispose = InternalDisposableEffectScope.effect()
- }
- override fun onForgotten() {
- onDispose?.dispose()
- onDispose = null
- }
- override fun onAbandoned() {
- // Nothing to do as [onRemembered] was not called.
- }
- }
复制代码 而 LaunchedEffect 也是在 key1 厘革时才实行 LaunchedEffectImpl():
- @Composable
- @NonRestartableComposable
- @OptIn(InternalComposeApi::class)
- fun LaunchedEffect(
- key1: Any?,
- block: suspend CoroutineScope.() -> Unit
- ) {
- val applyContext = currentComposer.applyCoroutineContext
- remember(key1) { LaunchedEffectImpl(applyContext, block) }
- }
复制代码 LaunchedEffectImpl 也实现了 RememberObserver,并且实现内容都是基于协程的:
- internal class LaunchedEffectImpl(
- parentCoroutineContext: CoroutineContext,
- private val task: suspend CoroutineScope.() -> Unit
- ) : RememberObserver {
- private val scope = CoroutineScope(parentCoroutineContext)
- private var job: Job? = null
- override fun onRemembered() {
- job?.cancel("Old job was still running!")
- job = scope.launch(block = task)
- }
- override fun onForgotten() {
- job?.cancel()
- job = null
- }
- override fun onAbandoned() {
- job?.cancel()
- job = null
- }
- }
复制代码 何时会用到?实际上就是把组件显示到界面作为某种业务的触发逻辑时,实际上 DisposableEffect 也是这种逻辑,只不外 LaunchedEffect 是面向协程的。比如,某个组件在页面中显示 3 秒钟后消散(如刚进入视频播放页面后,视频播放的控制面板会显示几秒后消散)。
4、rememberUpdatedState
依靠但又不盼望重启协程
上两节我们分别讲了 DisposableEffect 与 LaunchedEffect,它俩有一个共同的功能,就是根据参数上传入的 key 是否发生厘革决定是否重启自身的实行。即参数 key 变了就重启,不变的话即便所在组件重组也不会重启,避免资源消耗。
以往我们碰到的大多数情况,都是组件依靠的状态发生厘革会在同一帧中立刻起到作用,比如以下这种极度简化过的代码:
- var text by remember { mutableStateOf("Compose") }
- ...
- Text(text)
复制代码 但有一些场景下,我们盼望被依靠的状态发生改变时,不去触发重组或 DisposableEffect 与 LaunchedEffect 的重启,也能在需要利用这个状态时拿到它最新的值:
- @Composable
- fun RememberUpdatedStateSample() {
- var welcome by remember { mutableStateOf("Initial value.") }
- Button(onClick = { welcome = "Jetpack Compose" }) {
- Text("点击")
- LaunchedEffect(Unit) {
- delay(3000)
- println("welcome: $welcome")
- }
- }
- }
复制代码 如果在 LaunchedEffect 的 3 秒延时之内点击按钮,那么 welcome 在打印时会输出更新后的 “Jetpack Compose”,而不是初始值。这种更新不需要将 welcome 作为 LaunchedEffect 的参数,在可以获取到 welcome 新值的同时,还避免了 LaunchedEffect 重启带来的性能损耗。
但如果将 LaunchedEffect 抽取到一个单独的函数中,即便在 3 秒内点击按钮,welcome 也只打印初始值:
- @Composable
- fun RememberUpdatedStateSample() {
- var welcome by remember { mutableStateOf("Initial value.") }
- Button(onClick = { welcome = "Jetpack Compose" }) {
- Text("点击")
- CustomLaunchedEffect(welcome)
- }
- }
- @Composable
- private fun CustomLaunchedEffect(welcome: String) {
- LaunchedEffect(Unit) {
- delay(3000)
- println("welcome: $welcome")
- }
- }
复制代码 为什么第一种情况可以,第二种情况不行呢?因为第一种情况的 welcome 通过 remember() + mutableStateOf() 实现了一个长期存储且可以将状态厘革通知到所有利用处的变量,因此它可以跨越重组通报到 Button 的内部,在发生厘革时可以同步给 LaunchedEffect()。而第二种情况,将 welcome 作为函数参数通报,那么 CustomLaunchedEffect() 中的 welcome 就是一个普通的变量,它的厘革不会同步给 LaunchedEffect() 内的 welcome,因此即便给 CustomLaunchedEffect() 的传参发生了厘革,但打印输出的 welcome 仍是协程最初拿到的初始值。
那如何办理呢?把 welcome 填到 LaunchedEffect() 的参数上?我们的要求是尽量避免让 LaunchedEffect() 重启,因此如许不行。所以照旧效仿第一种情况,用 remember() + mutableStateOf() 构造一个状态变量,然后把参数 welcome 传给该状态变量:
- @Composable
- private fun CustomLaunchedEffect(welcome: String) {
- var rememberedWelcome by remember { mutableStateOf(welcome) }
- rememberedWelcome = welcome
- LaunchedEffect(Unit) {
- delay(3000)
- println("welcome: $rememberedWelcome")
- }
- }
复制代码 LaunchedEffect() 之前的两个语句可以用 rememberUpdatedState() 平替:
- @Composable
- fun <T> rememberUpdatedState(newValue: T): State<T> = remember {
- mutableStateOf(newValue)
- }.apply { value = newValue }
复制代码 也就是:
- @Composable
- private fun CustomLaunchedEffect(welcome: String) {
- val rememberedWelcome by rememberUpdatedState(welcome)
- LaunchedEffect(Unit) {
- delay(3000)
- println("welcome: $rememberedWelcome")
- }
- }
复制代码 rememberUpdatedState() 除了可以用于 LaunchedEffect(),也可用于 DisposableEffect(),比如:
- @Composable
- fun CustomDisposableEffect(user: User) {
- DisposableEffect(Unit) {
- // 拿不到 user 的新值
- suscriber.subscribe(user)
- onDispose {
- suscriber.unsubscribe()
- }
- }
- }
复制代码 在 DisposableEffect() 举行订阅操纵时,拿不到参数 user 的新值,因此照旧要利用 rememberUpdatedState() 来办理:
- @Composable
- fun CustomDisposableEffect(user: User) {
- val updatedUser by rememberUpdatedState(user)
- DisposableEffect(Unit) {
- suscriber.subscribe(user)
- onDispose {
- suscriber.unsubscribe()
- }
- }
- }
复制代码 5、rememberCoroutineScope
rememberCoroutineScope() 是在 Compose 中除了 LaunchedEffect() 之外,另一种利用协程的方式。
在 Compose 中利用协程不能像通用的协程利用方法那样,比如不可以直接利用 lifecycleScope.launch(),因为 lifecycleScope 作为一个 CoroutineScope 是用来管理协程的,重要负责在与它绑定的具有生命周期的组件结束后,自动结束该组件中运行的协程。而 Composable 函数也是具有声明周期的,在 Composable 函数内启动的协程也应该在函数结束后自动结束,这意味着每个 Composable 函数都有本身的 CoroutineScope。
因此在 Composable 函数中应该利用相应的 CoroutineScope,而不是与 Activity 生命周期绑定的 lifecycleScope。利用 rememberCoroutineScope() 可以获取到与当前组合点绑定的 CoroutineScope,然后在 remember() 中可以直接用它启动协程:
- val coroutineScope = rememberCoroutineScope()
- // 不用 remember 包上 launch() 会报错,因为遇到重组时每次都会重新启动一次协程
- val coroutine = remember { coroutineScope.launch { } }
复制代码 同样是在 Compose 中启动一个协程,LaunchedEffect() 的内部实际上已经为利用者完成了 CoroutineScope 的获取与 remember() 的利用:
- @Composable
- @NonRestartableComposable
- @OptIn(InternalComposeApi::class)
- fun LaunchedEffect(
- key1: Any?,
- block: suspend CoroutineScope.() -> Unit
- ) {
- val applyContext = currentComposer.applyCoroutineContext
- remember(key1) { LaunchedEffectImpl(applyContext, block) }
- }
复制代码 因此,通常我们利用 LaunchedEffect() 启动协程就足够了。但如果想要在 Composable 组件的外面启动协程时,需要利用 rememberCoroutineScope():
- val coroutineScope = rememberCoroutineScope()
- // 点击 Box 触发 clickable 回调时才启动协程,这是在组件外部启动的协程
- Box(Modifier.clickable { coroutineScope.launch { } })
复制代码 6、协程或其他状态向 Compose 状态的转换
本节重要讲如何将非 Compose 状态转换为 Compose 状态。
6.1 DisposableEffect
之前说过 DisposableEffect() 可以用来做一些订阅工作,并且可以在它的 onDispose() 回调中取消订阅。这种用法也可以用在订阅数据更新上,比如说舆图上要显示一个坐标点,当坐标数据发生厘革时 UI 应自动更新:
- val geoManager: GeoManager = GeoManager()
- @Composable
- fun UpdatePoint() {
- var position by remember { mutableStateOf(Point(0, 0)) }
- DisposableEffect(Unit) {
- // PositionCallback 提供最新的坐标数据 newPos
- val callback = object : PositionCallback { newPos ->
- position = newPos
- }
- // 注册回调与取消回调注册
- geoManager.register(callback)
- onDispose {
- // 本组件不再显示时取消注册
- geoManager.unregister(callback)
- }
- }
- }
复制代码 PositionCallback 可以提供更新后的坐标数据,而 GeoManager 在注册回调后可以吸收到坐标厘革,这个厘革的坐标 newPos 本来是 Compose 无法识别的普通变量,经过赋值给 position 状态后,newPos 的厘革可以自动应用到界面上,这就是一种将普通数据转换为 Compose 状态的简单示例。
此外,相同的套路也可用在 LiveData 转换为 State 上:
- val positionData = MutableLiveData<Point>()
- @Composable
- fun UpdatePoint(owner: LifecycleOwner) {
- var position by remember { mutableStateOf(Point(0, 0)) }
- DisposableEffect(Unit) {
- val observer = Observer<Point> { newPos ->
- position = newPos
- }
- positionData.observe(owner, observer)
- onDispose {
- positionData.removeObserver(observer)
- }
- }
- }
复制代码 实际上,Compose 为 LiveData 提供了扩展函数 observeAsState() 就可以将 LiveData 转换为 State:
- // 需依赖 androidx.compose.runtime:runtime-livedata 方可使用
- @Composable
- fun <T> LiveData<T>.observeAsState(): State<T?> = observeAsState(value)
- @Composable
- fun <R, T : R> LiveData<T>.observeAsState(initial: R): State<R> {
- val lifecycleOwner = LocalLifecycleOwner.current
- // 用初始值创建一个 State 对象
- val state = remember { mutableStateOf(initial) }
- DisposableEffect(this, lifecycleOwner) {
- // 更新 state 值的 Observer
- val observer = Observer<T> { state.value = it }
- // 订阅
- observe(lifecycleOwner, observer)
- // 取消订阅
- onDispose { removeObserver(observer) }
- }
- return state
- }
复制代码 6.2 LaunchedEffect
对于用到了协程的外部状态,如 Flow,就不能用 DisposableEffect 举行转换了,而是要换成 LaunchedEffect:
- val positionState: StateFlow<Point> = TODO()
- @Composable
- fun UpdatePoint(owner: LifecycleOwner) {
- var position by remember { mutableStateOf(Point(0, 0)) }
- LaunchedEffect(Unit) {
- positionState.collect { newPos ->
- position = newPos
- }
- }
- }
复制代码 6.3 produceState()
produceState() 创建一个 MutableState 对象并在协程中更新它的值:
- @Composable
- fun <T> produceState(
- initialValue: T,
- @BuilderInference producer: suspend ProduceStateScope<T>.() -> Unit
- ): State<T> {
- val result = remember { mutableStateOf(initialValue) }
- LaunchedEffect(Unit) {
- ProduceStateScopeImpl(result, coroutineContext).producer()
- }
- return result
- }
复制代码 参数 producer 内定义获取状态值的代码,它会在协程中被实行用于获取最新的状态值:
- val positionState: StateFlow<Point> = TODO()
- @Composable
- fun UpdatePoint(owner: LifecycleOwner) {
- // 参数传入初始值
- val produceState = produceState(Point(0, 0)) {
- positionState.collect {
- // Flow 传来的新数据 it 赋值给 State 的真实数据对象 value
- value = it
- }
- }
- }
复制代码 相当于把 LaunchedEffect() 的写法封装到 produceState() 这个便捷函数中了。
produceState() 内还可以调用一个 awaitDispose(),它可以无限期挂起协程,重要用于转换不是协程提供的状态的情况。
最后要提一嘴,StateFlow 提供了扩展函数 collectAsState() 可以直接将一个 StateFlow 转换成 State:
- @Suppress("StateFlowValueCalledInComposition")
- @Composable
- fun <T> StateFlow<T>.collectAsState(
- context: CoroutineContext = EmptyCoroutineContext
- ): State<T> = collectAsState(value, context)
- @Composable
- fun <T : R, R> Flow<T>.collectAsState(
- initial: R,
- context: CoroutineContext = EmptyCoroutineContext
- ): State<R> = produceState(initial, this, context) {
- if (context == EmptyCoroutineContext) {
- collect { value = it }
- } else withContext(context) {
- collect { value = it }
- }
- }
复制代码 它内部就是用到了 produceState()。
7、把 Compose 的 State 转换成协程的 Flow
snapshotFlow() 可以把 Compose 的 State 转换成协程 Flow:
- setContent {
- var name by remember { mutableStateOf("Jack") }
- var age by remember { mutableStateOf(18) }
- val flow = snapshotFlow { "$name $age" }
- LaunchedEffect(Unit) {
- // snapshotFlow() 内任何一个状态发生变化,都会以新值执行一次 collect
- flow.collect { info ->
- println(info)
- }
- }
- }
复制代码 在Compose中,副作用通常发生在LaunchedEffect、DisposableEffect、SideEffect等函数中。这些函数用于处理可能会引起副作用的操纵,如启动协程、订阅数据、修改可变状态等。需要注意的是,在Compose中,副作用应该尽量被限定在特定的作用域内,以保持代码的可维护性和可预测性。
总的来说,Compose中的“副作用”指的是对外部状态举行更改或操纵的行为,通过符合的方式管理和控制副作用的产生,可以帮助确保应用的正确性和性能。
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。 |