Compose Side Effect(附带效应)

一、什么是附带效应

根据官方文档,Side Effect(附带效应)指的是在可组合函数作用域之外发生的应用状态变化。这个定义相对抽象,接下来我们通过一个简单示例进行深入剖析,以便更好地理解。示例代码如下:

kotlin 复制代码
private var config: Config? = null
fun MainPage() {
    LoadConfig()
    Content()
}
fun LoadConfig() {
    config = Config()
}
fun Content() {
    if (config != null) {
    } else {

    }
}

在传统编程模式中,这段代码逻辑清晰易懂。然而,在基于声明式 UI 的 Compose 编程模式下,它却存在问题。上述对 config 变量的操作,实际上属于超出各个 Composable 函数作用域的状态变更,必然会产生 Side Effect。

官方描述在使用 Compose 时需要注意的一些事项:

developer.android.com/develop/ui/...

在使用 Compose 时,这类代码存在以下几点问题:

  1. LoadConfig 函数的执行具有不确定性。它可能不会执行,也可能被频繁执行,甚至可能无法完整执行完毕。
  2. LoadConfig 函数的执行顺序无法保证在 Content 函数之前。

鉴于此,Compose 为开发者提供了丰富的 Side - effects 函数,专门用于应对各类特殊的 Side Effect 场景,帮助开发者更有效地管理状态变更,确保程序按照预期运行,提升应用的稳定性和可靠性。

二、附带效应 API

2.1 LaunchedEffect

当我们需要在可组合项的生命周期内执行工作并能够调用挂起函数,LaunchedEffect 可组合项就派上用场了。当 LaunchedEffect 进入组合阶段,它会启动一个协程;而当它退出组合时,对应的协程将被取消。

先来看 LaunchedEffect 的源码:

kotlin 复制代码
@Composable
@NonRestartableComposable
@OptIn(InternalComposeApi::class)
fun LaunchedEffect(
    key1: Any?,
    block: suspend CoroutineScope.() -> Unit
) {
    val applyContext = currentComposer.applyCoroutineContext
    remember(key1) { LaunchedEffectImpl(applyContext, block) }
}
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() {
        // This should never happen but is left here for safety
        job?.cancel("Old job was still running!")
        job = scope.launch(block = task)
    }
    override fun onForgotten() {
        job?.cancel(LeftCompositionCancellationException())
        job = null
    }
    override fun onAbandoned() {
        job?.cancel(LeftCompositionCancellationException())
        job = null
    }
}

从源码可知,LaunchedEffect 至少需要设置一个参数 key。若无需传递特定参数,可传入 null 或者 Unit。这里有两种情况:

  1. 当 key 为 null 或者 Unit 时,其内部的挂起函数仅会执行一次。
  2. 一旦 key 发生变化,当前正在运行的协程会自动取消,同时启动一个新的协程。

通过以下代码来深入理解 LaunchedEffect 的特点:

kotlin 复制代码
@Composable
fun LaunchedEffectTest(
    snackbarHostState: SnackbarHostState,
    viewModel: LaunchedEffectViewModel
) {
    val snackbarCount = viewModel.snackbarCount.collectAsState()
    LaunchedEffect(snackbarCount.value) {
        Log.d("LaunchedEffect", "displaying launched effect for count ${snackbarCount.value}")
        try {
            snackbarHostState.showSnackbar("LaunchedEffect snackbar", "ok")
        } catch (e: Exception) {
            Log.d("LaunchedEffect", "launched Effect coroutine cancelled exception $e")
        }
    }
    Scaffold(
        snackbarHost = { SnackbarHost(hostState = snackbarHostState) }
    ) { contentPadding ->
        Column(Modifier.padding(contentPadding)) {
            Button(onClick = {
                viewModel.incrementCount()
            }) {
                Text(text = " ")
            }
            Text(text = "LaunchedEffect Test,count:${snackbarCount.value}")
        }
    }
}
class LaunchedEffectViewModel : ViewModel() {
    private var _snackbarCount = MutableStateFlow(1)
    val snackbarCount: StateFlow<Int> get() = _snackbarCount
    private var count = 1
    fun incrementCount() {
        count += 1
        _snackbarCount.value = count
    }
}

在上述代码中,每次点击 "Increment Count" 按钮,会修改 _snackbarCount 的值。此时 LaunchedEffect 监测到 snackbarCount.value 变化,便会启动一个新的协程,同时取消之前正在运行的协程,相关日志可直观反映这一过程。

了解了 LaunchedEffect 的基本原理,其使用场景也就清晰了:

  1. 希望在 Composable 函数中的某些代码仅执行一次。
  2. 在 Composable 函数中需要一个协程作用域来执行挂起函数。(这也解释了为何 LaunchedEffect 函数的参数变化时,会先取消前一次未执行完的逻辑。因为只有运行在协程中的代码才能够被取消,普通正在运行的代码无法随意被取消或中止)

2.1.1 rememberCoroutineScope

由于 LaunchedEffect 是可组合函数,只能在其他可组合函数中使用。若要在可组合项之外启动协程,同时希望该协程在退出组合后自动取消,这时就需要借助 rememberCoroutineScope。

rememberCoroutineScope 是一个可组合函数,它返回一个 CoroutineScope。这个 CoroutineScope 会绑定到调用它的组合点,一旦调用它的可组合项退出组合,该作用域就会被取消。

在实际开发中,很多场景需要在非 Composable 函数中创建协程作用域。比如下面这段代码:

ini 复制代码
@Composable
fun MyButton() {
    Button(onClick = {
        // 尝试调用一个挂起函数,例如:delay函数。但此处会报错,因为不在合适的作用域
    }) {
        Text(
            text = "This is Button",
            color = Color.White,
            fontSize = 26.sp
        )
    }
}

在上述代码中,Button 的点击事件回调不属于 Composable 函数的作用域,无法直接调用 LaunchedEffect 函数。这时,我们可以使用 rememberCoroutineScope 来解决:

ini 复制代码
@Composable
fun MyButton() {
    val coroutineScope = rememberCoroutineScope()
    Button(onClick = {
        // 调用一个挂起函数,例如:delay函数。
        coroutineScope.launch {
            delay(1000)
        }
    }) {
        Text(
            text = "This is Button",
            color = Color.White,
            fontSize = 26.sp
        )
    }
}

需要注意的是,LaunchedEffect 和 rememberCoroutineScope 的应用场景不同,不能相互替代。如果错误地使用 rememberCoroutineScope 来替代 LaunchedEffect,会出现问题。例如:

kotlin 复制代码
@Composable
fun Initialize(onFinished: () -> Unit) {
    val coroutineScope = rememberCoroutineScope()
    coroutineScope.launch {
        // 执行挂起函数,执行完成后回调完成
        onFinished()
    }
}

上述代码存在严重的 Side Effect 。因为每次 Initialize 函数重组时,都会启动一个新的协程并触发一次回调,这并非我们期望的效果。

2.1.2 rememberUpdatedState

在 2.1.1 节中,我们看到用 rememberCoroutineScope 替代 LaunchedEffect 会出现问题。现在将 rememberCoroutineScope 替换回 LaunchedEffect:

kotlin 复制代码
@Composable
fun Initialize(onFinished: () -> Unit) {
    LaunchedEffect(Unit) {
        // 执行挂起函数,执行完成后回调完成
        onFinished()
    }
}

这段代码用于初始化操作,由于初始化通常只需执行一次,放在 LaunchedEffect 中看似合理。在初始化结束后,通过调用传入的 onFinished 对象进行回调通知。

然而,这段代码存在隐患。在 LaunchedEffect 中的代码执行期间,onFinished 参数随时可能改变。若 LaunchedEffect 函数还在初始化过程中,onFinished 参数发生变动,旧的 onFinished 对象已失效,无法完成回调;新的 onFinished 对象也得不到回调,因为 LaunchedEffect 函数只执行一次。

使用 rememberUpdatedState 可以有效解决这个问题:

kotlin 复制代码
@Composable
fun Initialize(onFinished: () -> Unit) {
    val currentOnFinished by rememberUpdatedState(onFinished)
    LaunchedEffect(Unit) {
        // 执行挂起函数,执行完成后回调完成
        currentOnFinished()
    }
}

上述代码中,调用 rememberUpdatedState 函数并传入 onFinished 参数,得到一个新的 currentOnFinished 参数。这个 currentOnFinished 始终指向最新的 onFinished 参数。在 LaunchedEffect 函数的初始化任务完成后,调用 currentOnFinished 对象,就能确保最新的回调不会丢失。

2.2 DisposableEffect

在 Compose 开发中,妥善管理那些在键发生变化或者可组合项退出组合后需要清理的资源,是至关重要的开发实践。比如注册和解绑监听器、关闭文件流、取消订阅等操作,若能在组件销毁阶段及时释放相关资源,可有效规避内存泄漏问题,保障应用的性能和稳定性。

以可组合项中的生命周期监听为例,不能直接在 Composable 函数里添加 Observer。因为在 Compose 中,重组是常见的操作,若直接添加,每次重组都会新增一个 Observer,监听器数量会不断累积,最终导致应用出现性能问题甚至崩溃。而 DisposableEffect 则是解决此问题的有效工具,它能帮助我们合理地注册和取消注册观察器。

以下是具体的示例代码:

kotlin 复制代码
@Composable
fun HomeScreen(
    lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
    onStart: () -> Unit = {},
    onStop: () -> Unit = {}
) {
    val currentOnStart by rememberUpdatedState(onStart)
    val currentOnStop by rememberUpdatedState(onStop)
    DisposableEffect(lifecycleOwner) {
        val observer = LifecycleEventObserver { _, event ->
            if (event == Lifecycle.Event.ON_START) {
                currentOnStart()
            } else if (event == Lifecycle.Event.ON_STOP) {
                currentOnStop()
            }
        }
        lifecycleOwner.lifecycle.addObserver(observer)
        onDispose {
            lifecycleOwner.lifecycle.removeObserver(observer)
        }
    }
    /* Home screen content */
}

在上述代码中,DisposableEffect 将 observer 添加到 lifecycleOwner 的生命周期监听体系中。当 lifecycleOwner 发生变化,系统会重新处理该效应,并重启整个流程,确保资源的正确管理和更新。

特别需要注意的是,DisposableEffect 代码块中必须包含 onDispose 子句,并且要将其作为最终语句。若违反这一规则,IDE 会在构建阶段抛出错误,这是为了强制开发者对资源进行正确清理,维护应用的健康运行。

2.3 SideEffect

当需要将 Compose 管理的状态共享给非 Compose 管理的对象时,SideEffect 可组合项就能发挥重要作用。SideEffect 能够确保在每次成功重组之后执行特定的效果。需要注意的是,在成功重组之前执行效果是不可取的,直接在可组合项中编写效果逻辑就属于这种错误情况。

SideEffect 仅会在重组成功时执行,下面用一个虽不现实但能说明问题的例子来解释,代码如下:

kotlin 复制代码
@Composable
fun TestSideEffect() {
    SideEffect {
        doSafely()
    }
    doUnSafely()
    throw RuntimeException("异常")
}

在上述代码里,TestSideEffect 函数无法完整执行。不过 doUnSafely 函数仍会执行,而 doSafely 函数不会执行,这是因为 SideEffect 只有在可组合项成功重组后才会触发。

由于 SideEffect 能获取和 Composable 一致的最新状态,因此常被用于将 Compose 的内部状态同步到非 Compose 的外部系统。

正常使用示例:

kotlin 复制代码
//模拟下外部状态
var externalCount: Int? = null
@Composable
fun TestSideEffect(modifier: Modifier = Modifier) {
    var count by remember { mutableIntStateOf(0) }
    Column(
        modifier = modifier
            .fillMaxSize()
    ) {
        Text("Counter:$count", modifier = Modifier.padding(8.dp))
        Button(onClick = { count++ }) {
            Text("Increment")
        }
        // 使用 SideEffect 在每次重组后同步外部状态
        SideEffect {
            externalCount = count
            println("External State Synced: $externalCount")
        }
    }
}

在这个示例中,当 count 的值发生变化时,可组合项会进行重组。SideEffect 会在重组成功后执行,将 count 的最新状态同步到外部的 externalCount 变量。如果不把状态同步的逻辑放在 SideEffect 中执行,就可能会把错误的状态传递给外部系统。

2.4 produceState

在 Compose 开发中,produceState 函数是一个强大的工具,它专门用于启动一个协程,将非 Compose 管理的状态高效地转换为 Compose 能够管理的状态。接下来,我们通过一个加载网络图片的实际示例,来深入理解 produceState 的用法及其优势。

首先,我们定义一个用于表示加载结果的密封类 Result,以及一个模拟网络请求加载图片的挂起函数 loadImage:

kotlin 复制代码
sealed class Result<out T> {
    data object Loading : Result<Nothing>()
    data object Error : Result<Nothing>()
    class Success<T>(t: T) : Result<T>()
}
suspend fun loadImage(url: String): Image? {
    // 模拟网络请求
    delay(2000)
    return null
}

然后,我们来看通过 LaunchedEffect 实现加载网络图片功能的代码:

kotlin 复制代码
@Composable
fun NetworkImage(url: String) {
    var result by remember { mutableStateOf<Result<Image>>(Result.Loading) }
    LaunchedEffect(null) {
        val image = loadImage(url)
        result = if (image != null) {
            Result.Success(image)
        } else {
            Result.Error
        }
    }
    when (result) {
        is Result.Loading -> {
            // 显示加载中
        }
        is Result.Success -> {
            // 显示图片
        }
        is Result.Error -> {
            // 显示错误
        }
    }
}

这段代码虽然能够实现加载网络图片并根据加载结果展示不同 UI 的功能,但从代码结构和性能优化的角度来看,仍有提升空间。

此时,produceState 函数就派上用场了。produceState 函数不仅能够将非 Compose 的状态转换为 Compose 状态,还会提供一个协程作用域,使得代码结构更加清晰和简洁。它完全可以替代上述代码中 mutableStateOf 和 LaunchedEffect 这两部分内容。

下面是使用 produceState 函数优化后的代码::

kotlin 复制代码
@Composable
fun NetworkImageV2(url: String) {
    val result by produceState<Result<Image>>(initialValue = Result.Loading) {
        val image = loadImage(url)
        value = if (image != null) {
            Result.Success(image)
        } else {
            Result.Error
        }
    }
    when (result) {
        is Result.Loading -> {
            // 显示加载中
        }
        is Result.Success -> {
            // 显示图片
        }
        is Result.Error -> {
            // 显示错误
        }
    }
}

在这段优化后的代码中,通过 produceState 函数创建的状态对象可以声明为 val。这样做的好处是,避免了加载状态被意外修改的风险,使得代码在多线程环境下更加安全可靠。

进一步优化,我们还可以将 produceState 函数内部的逻辑单独抽离成一个函数,放置在项目中合适的位置,以实现更好的业务代码与 UI 代码分离。如下所示:

kotlin 复制代码
// 含返回值类型的可组合项,以小写字母开头。
@Composable
fun startLoadImage(url: String): State<Result<Image>> {
    return produceState<Result<Image>>(initialValue = Result.Loading) {
        val image = loadImage(url)
        value = if (image != null) {
            Result.Success(image)
        } else {
            Result.Error
        }
    }
}
@Composable
fun NetworkImageV3(url: String) {
    val result by startLoadImage(url = url)
    when (result) {
        is Result.Loading -> {
            // 显示加载中
        }
        is Result.Success -> {
            // 显示图片
        }
        is Result.Error -> {
            // 显示错误
        }
    }
}

通过这种方式,startLoadImage 函数专注于处理图片加载的业务逻辑,而 NetworkImageV3 函数则主要负责 UI 的展示,使代码的职责更加单一,维护和扩展也更加方便。

2.5 derivedStateOf

在 Compose 的运行机制中,当观察到的状态对象或者可组合项的输入发生变化时,重组操作就会随之启动。实际应用里,状态对象或输入的变化频率,有可能比界面真正需要更新的频率高得多,这种情况便会引发不必要的重组,对性能产生负面影响。

当可组合项输入的变化频率超出了实际所需的重组频率时,derivedStateOf 函数就成为了优化这类问题的有效工具。通过使用 derivedStateOf ,我们能够将频繁变化的输入转化为只有在关键条件满足时才更新的派生状态,从而精准控制重组的时机。

下面的代码片段清晰地展示了derivedStateOf 的适用场景:

kotlin 复制代码
@Composable
fun MessageList(messages: List<Message>) {
    Box {
        val listState = rememberLazyListState()
        LazyColumn(state = listState) {
            // ...
        }

        val showButton by remember {
            derivedStateOf {
                listState.firstVisibleItemIndex > 0
            }
        }
        AnimatedVisibility(visible = showButton) {
            ScrollToTopButton()
        }
    }
}

在上述代码中,每当第一个可见项发生变化时, firstVisibleItemIndex 都会跟着发生变化。例如,滚动过程中,其值会依次变为 0、1、2、3、4、5 等。然而,对于 AnimatedVisibility 组件而言,只有当 firstVisibleItemIndex 的值大于 0 时,才需要更新显示状态,进而触发重组。在这种更新频率存在明显差异的情况下,使用 derivedStateOf 来处理 showButton 的状态就显得尤为合适。

注意:derivedStateOf 的成本较高,只应该在结果没有变化时用来避免不必要的重组。

相关推荐
一只柠檬新1 小时前
Web和Android的渐变角度区别
android
志旭1 小时前
从0到 1实现BufferQueue GraphicBuffer fence HWC surfaceflinger
android
_一条咸鱼_1 小时前
Android Runtime堆内存架构设计(47)
android·面试·android jetpack
用户2018792831672 小时前
WMS(WindowManagerService的诞生
android
用户2018792831672 小时前
通俗易懂的讲解:Android窗口属性全解析
android
openinstall2 小时前
A/B测试如何借力openinstall实现用户价值深挖?
android·ios·html
二流小码农2 小时前
鸿蒙开发:资讯项目实战之项目初始化搭建
android·ios·harmonyos
志旭3 小时前
android15 vsync源码分析
android
志旭3 小时前
Android 14 HWUI 源码研究 View Canvas RenderThread ViewRootImpl skia
android
whysqwhw3 小时前
Egloo 高级用法
android