Compose编程思想 -- Compose中的附带效应以及协程使用

前言

这篇文章,我将会介绍SideEffect的使用,在官方文档的翻译中,SideEffect译为「副作用」、「附带效应」,如果用「副作用」解释,可能有歧义,其实在SideEffect在Compose中的副作用并不是坏的作用,而是除了主作用之外,额外带的一些附加效果,因此在这里我称之为「附带效应」。

1 SideEffect使用

附带效应,指的是发生在@Composable作用域外应用状态的变化,例如下面这个函数:

kotlin 复制代码
private var str:String = "A"
fun testA(){
    str = "B"
}

因为在testA函数中,对外部的变量做了修改,可能会导致应用的状态发生变化,因为其他地方可能会使用到这个值,所以testA函数具有附带效应。

kotlin 复制代码
fun testA(){
    var str:String = "A"
    str = "B"
}

例如在testA中,对局部变量进行了修改,但是并没有影响外部的状态,因此testA没有附带效应。在Compose中,@Composable函数应该没有附带效应,但是如果有需求需要更改应用的状态,需要使用SideEffect以可预测的方式执行这些附带效应。

1.1 一个附带效应的例子

例如一个列表的展示,通过外部的count计数,最终展示多少个字母,因为count是外部的成员变量,当Compose页面展示的时候,极可能因为某些情况导致重组,

kotlin 复制代码
var count = 0;
Column {

    val list = mutableListOf("A","B","C","D")
    for (num in list){
        Text(text ="当前字母:$num")
        count++
    }
    Text(text = "一共 $count 个字母")
}

例如Column作用域内部发生重组(这里只是举例子,因为Column是内联函数,重组作用域会扩大到外部的@Composable函数,这个例子实际上是没问题,因为重组count会被重新初始化),而count会被重新累加,虽然只有4个字母,最终展示的可能是有6个或者8个,这个不确定。

这个其实就是附带效应,导致了程序的应用状态发生变化,而且是异常的变化。

但是从实际的业务需求出发,这种代码逻辑其实是很平常的,而因为重组导致出现bug的这种问题,其实Compose已经给出了解决方案,就是使用SideEffect

1.2 SideEffect的作用

SideEffect保证的效果就是其中的代码在重组之后就会执行,即便是发生了多次重组,那么也只会执行一次,相当于它会在界面稳定之后,才会执行其中的代码逻辑。

kotlin 复制代码
var count = 0;

@Composable
fun TestSideEffect() {
    Column {
        val list = mutableListOf("A","B","C","D")
        for (num in list){
            Text(text ="当前字母:$num")
            SideEffect {
                count++
            }
        }
        Text(text = "一共 $count 个字母")
    }
}

例如1.1小节中的例子,因为Column会发生多次重组,导致count计数发生异常,那么可以使用SideEffect来包裹count计数的累加。但是这样有用吗?重组完成之后,所有的界面显示已经确定了,再进行count累加其实就没有意义了。

所以在实际的开发中,这种需求我们往往需要尽量地减少对外部变量的依赖,通过list.size做显示即可。

1.3 SideEffect和DisposableEffect的区别

前面我在介绍SideEffect的时候,其回调是在组合(重组)完成之后,才会回调,也可以理解为进入界面之后才会回调。

kotlin 复制代码
@Composable
fun TestSideEffect() {
    Column {
        val list = mutableListOf("A","B","C","D")
        for (num in list){
            Text(text ="当前字母:$num")
            SideEffect {
                Log.d(TAG, "TestSideEffect: $count")
                count++
            }
        }
        Text(text = "一共 $count 个字母")
    }
}

因为在for循环里执行了4次,所以SideEffect回调也会在重组完成之后,回调4次。

js 复制代码
2024-03-28 15:23:43.406 28496-28496 Compose                 com.lay.composestudy                 D  TestSideEffect: 0
2024-03-28 15:23:43.406 28496-28496 Compose                 com.lay.composestudy                 D  TestSideEffect: 1
2024-03-28 15:23:43.406 28496-28496 Compose                 com.lay.composestudy                 D  TestSideEffect: 2
2024-03-28 15:23:43.406 28496-28496 Compose                 com.lay.composestudy                 D  TestSideEffect: 3

DisposableEffect则是会在key发生变化,或者离开界面的时候做一些清理的操作。

kotlin 复制代码
@Composable
@NonRestartableComposable
fun DisposableEffect(
    key1: Any?,
    effect: DisposableEffectScope.() -> DisposableEffectResult
) {
    remember(key1) { DisposableEffectImpl(effect) }
}

通过源码可以看到,DisposableEffect在底层是通过remember(key)的方式监听key的变化。

kotlin 复制代码
@Composable
fun TestSideEffect() {
    Column {
        val list = mutableListOf("A", "B", "C", "D")
        for (num in list) {
            Text(text = "当前字母:$num")
        }
        Text(text = "一共 ${list.size} 个字母")

        DisposableEffect(key1 = list) {
            Log.d(TAG, "TestSideEffect: Column 进入界面")
            
            onDispose {
                Log.d(TAG, "TestSideEffect: Column离开界面")
            }
        }
    }
}

在使用DisposableEffect的时候,必须要添加onDispose作为最终语句,当key发生变化时,会先执行onDispose中的代码,然后再重新执行DisposableEffectScope作用域中的代码。

其实DisposableEffect就是一个加强版的SideEffectSideEffect的回调是在进入界面完成组合后回调,而DisposableEffect在进入界面完成组合后会回调,在离开界面的时候也会回调。

kotlin 复制代码
@Composable
fun HomeScreen(
    lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
    onStart: () -> Unit,
    onStop: () -> Unit
) {

    DisposableEffect(key1 = lifecycleOwner) {

        Log.d(TAG, "HomeScreen: DisposableEffect enter")
        val observer = LifecycleEventObserver { source, event ->
            when (event) {
                Lifecycle.Event.ON_RESUME -> {
                    onStart.invoke()
                }

                Lifecycle.Event.ON_STOP -> {
                    onStop.invoke()
                }

                else -> {}
            }
        }
        //注册
        lifecycleOwner.lifecycle.addObserver(observer)

        onDispose {
            Log.d(TAG, "HomeScreen: DisposableEffect exit")
            lifecycleOwner.lifecycle.removeObserver(observer)
        }
    }

}

看一个官网的例子,使用DisposableEffect来监听整个页面的生命周期,在进入到页面的时候,注册LifeCycleObserver,当lifecycleOwner发生变化,或者界面被移除了之后,会执行onDispose函数回调,移除监听。

kotlin 复制代码
Column {
    var count by remember {
        mutableStateOf(0)
    }
    if (count > 5){
        Text(text = "123")
    }else{
        HomeScreen(onStart = {
            Log.d(TAG, "onStart ----")
        }) {
            Log.d(TAG, "onStop ----")
        }
    }
    Button(onClick = { count++ }) {
        Text(text = "测试")
    }
}

因为在count大于5之后,HomeScreen在重组的时候被移除了界面,此时会回调onDispose函数,移除监听。

kotlin 复制代码
@Composable
fun TestSideEffect() {

    var showText by remember {
        mutableStateOf(false)
    }

    Column {
        if (showText){
            Text(text = "隐藏的彩蛋")
        }
        Button(onClick = { showText = !showText}) {
            Text(text = "测试")
        }
    }

//        SideEffect {
//            Log.d(TAG, "TestSideEffect: call ---- SideEffect ")
//        }

    DisposableEffect(Unit){
        Log.d(TAG, "TestSideEffect: call ---- DisposableEffect ")

        onDispose {
            Log.d(TAG, "TestSideEffect: call ---- DisposableEffect onDispose ")
        }
    }
}

如果使用SideEffect,那么在每次重组的时候都会无脑回调;而使用DisposableEffect,则只有在key发生变化或者组件移除界面时会回调,其他情况下不会回调。通过上面的例子,可以验证结论。

2 Compose中的协程

前面我介绍了SideEffectDisposableEffect的使用,主要是用于处理Compose中的附带效应,防止因为重组的过程中数据变化导致应用的状态出现异常,本节我将会介绍在Compose中如何使用协程,以及如何在协程中处理附带效应。

2.1 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 {
    // 通过CoroutineScope创建协程
    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
    }
}

LaunchedEffect其实是一个特殊的DisposableEffect,它的回调中是提供了一个协程作用域,通过CoroutineScope创建的协程。它会在界面组合完成之后,会创建一个协程。它会在key发生变化的时候,会取消协程,然后创建新的协程,从onRemembered函数中可以看到;如果从组合中移除的时候会取消协程。

kotlin 复制代码
@Composable
fun TestLaunchEffect() {

    var showFirst by remember {
        mutableStateOf(true)
    }

    Column {
        if (showFirst) {
            Text(text = "第一个组件")
        }
        Text(text = "第二个组件")
        LaunchedEffect(Unit, block = {
            delay(3_000)
            showFirst = false
        })
    }

}

例如当Column组合完成之后,LaunchedEffect会创建一个协程,其中delay函数是挂起函数,3s后将showFirst设置为false,发起重组,此时第一个组件消失,但是LaunchedEffect中的block将不会再次被执行,因为不满足重新执行的条件。

2.2 rememberUpdatedState

一般情况下,在页面刷新的时候,例如修改了showText的值后会立刻发生重组,Text组件需要拿到最新的值进行页面的展示。

kotlin 复制代码
@Composable
fun TestLaunchEffect() {

    var showText by remember {
        mutableStateOf("showText")
    }
    Column {
        Text(showText)
        Button(onClick = { showText = showText.uppercase() }) {
            Text(text = "更新文案")
        }
    }

}

但是在一些场景下,可能不需要立刻拿到最新的值,例如在LaunchedEffect中延迟3s,打印showText的值。如果在3s内,点击了按钮,将showText的值做了修改,那么我希望在3s后能够拿到修改的值。

kotlin 复制代码
@Composable
fun TestLaunchEffect() {

    var showText by remember {
        mutableStateOf("showText")
    }
    Column {
        Text(showText)
        Launch(name = showText)
        Button(onClick = { showText = "android" }) {
            Text(text = "更新文案")
        }
    }
}

@Composable
private fun Launch(name: String) {
    LaunchedEffect(Unit, block = {
        delay(3000)
        Log.d(TAG, "Launch: $name")
    })
}

现在这个场景下,在3s内点击按钮,此时会发生重组,那么Launch函数会重新调用,传入的name的值也发生了改变,但是此时打印的值还是老的值,而不是新的值。

原因就是:LaunchedEffect定义的时候,key为Unit,在重新执行的时候不会再次执行内部的代码块,因此还是打印了老的值,如果采用下面的这种方式,采用name作为key,那么内部就会重新创建协程再打印新的值。

kotlin 复制代码
@Composable
private fun Launch(name: String) {
    LaunchedEffect(name, block = {
        delay(3000)
        Log.d(TAG, "Launch: $name")
    })
}

但是,如何在不重新创建协程的情况下,能够在3s后拿到新修改的值,可以采用下面的这种方式:

kotlin 复制代码
@Composable
private fun Launch(name: String) {
    var newValue by remember {
        mutableStateOf(name)
    }
    newValue = name
    LaunchedEffect(Unit, block = {
        delay(3000)
        Log.d(TAG, "Launch: $newValue")
    })
}

根据函数参数,创建一个mutableStateOf成员变量,每次调用时都给这个变量重新赋值,那么在协程3s过后就会拿到新值,在Compose中提供了一个函数rememberUpdatedState,相当于对其进行了封装。

kotlin 复制代码
@Composable
fun <T> rememberUpdatedState(newValue: T): State<T> = remember {
    mutableStateOf(newValue)
}.apply { value = newValue }

那么针对这种场景,可以使用rememberUpdatedState更新值。

kotlin 复制代码
@Composable
private fun Launch(name: String) {
    val state = rememberUpdatedState(newValue = name)
    LaunchedEffect(Unit, block = {
        delay(3000)
        Log.d(TAG, "Launch: ${state.value}")
    })
}

所以使用rememberUpdatedState就是用来在效应内部引用的值发生变化的时候,不需要重启就能拿到最新的值。

2.3 rememberCoroutineScope

例如,我需要在某个按钮点击的时候,进行网络请求获取数据后,刷新页面。

像Compose中提供的LaunchedEffect是非常好用的,在进入界面的时候开启协程,在离开界面的时候关闭协程。但是LaunchedEffect是一个@Composable函数,它只能用到组合作用域内,而像clickable这种常规函数中,是无法使用的,这就需要我们自己创建一个协程。

kotlin 复制代码
@Composable
inline fun rememberCoroutineScope(
    crossinline getContext: @DisallowComposableCalls () -> CoroutineContext =
        { EmptyCoroutineContext }
): CoroutineScope {
    val composer = currentComposer
    val wrapper = remember {
        CompositionScopedCoroutineScopeCanceller(
            createCompositionCoroutineScope(getContext(), composer)
        )
    }
    return wrapper.coroutineScope
}

Compose当中为我们提供了rememberCoroutineScope函数,用于创建一个协程作用域,开发者可以主动调用launch函数开启协程,例如下面的例子:

kotlin 复制代码
@Composable
fun HomeScreen2(
    viewModel: HomeScreenViewModel
) {
    //数据
    val showText by viewModel.showText.observeAsState("")
    // 启动协程
    val scope = rememberCoroutineScope()

    Column {
        Text(text = showText)
        Button(onClick = {
            scope.launch {
                viewModel.getTextValue()
            }
        }) {
            Text(text = "更新数据")
        }
    }

}

当点击按钮时,会开启一个协程,这里模拟了网络请求,3s后将数据更新。

kotlin 复制代码
class HomeScreenViewModel : ViewModel() {


    private var _showText = MutableLiveData("初始值")
    val showText: LiveData<String>
        get() = _showText

    /**
     * 获取text
     */
    suspend fun getTextValue() {
        delay(3_000)
        _showText.value = "这是最新值"
    }

}

在HomeScreen中,通过observeAsState函数将LiveData转换为了mutableStateOf,其实原理很简单,就是在showText的数据发生变化时,更新mutableStateOf的value。

kotlin 复制代码
implementation("androidx.compose.runtime:runtime-livedata:1.6.4")

这个是Compose和LiveData生态做的融合,用于在Compose中使用LiveData,下面是对源码的解析。

kotlin 复制代码
@Composable
fun <R, T : R> LiveData<T>.observeAsState(initial: R): State<R> {
    val lifecycleOwner = LocalLifecycleOwner.current
    // 创建了`mutableStateOf`数据。
    val state = remember {
        @Suppress("UNCHECKED_CAST") /* Initialized values of a LiveData<T> must be a T */
        mutableStateOf(if (isInitialized) value as T else initial)
    }
    DisposableEffect(this, lifecycleOwner) {
        // 进入界面的时候注册数据变化的监听
        val observer = Observer<T> { 
            // 当LiveData数据发生变化时,更新state的值。
            state.value = it 
        }
        observe(lifecycleOwner, observer)
        onDispose { 
            // 离开页面移除监听
            removeObserver(observer) 
        }
    }
    return state
}

通过rememberCoroutineScope创建的协程作用域的生命周期是依附于当前组合函数,当组合函数从界面消失之后,协程就会被取消 ;而如果使用lifeCycleScope创建协程,会在整个Activity页面消失之后才会取消协程。

3 Compose状态迁移

在2.3 小节中,我通过一个demo示例介绍了将LiveData转换为Compose能够订阅的状态,为什么需要状态转换,其实很简单,在传统的View体系中,可以通过findViewById拿到对应的View的实例,在LiveData数据发生变化之后,调用TextViewsetText方法,更新UI。

kotlin 复制代码
val textView = findViewById<TextView>(R.id.text)
homeScreenViewModel.showText.observe(this){
    textView.text = it
}

但是对于Compose声明式UI的刷新逻辑,在第一篇Compose文章中介绍过,其实无法拿到组件的实例,只能通过订阅状态的变化发起重组,所以需要将传统View的状态转换为Compose状态。

3.1 状态迁移的方式

在将LiveData转为State时,使用了DisposableEffect

kotlin 复制代码
@Composable
fun <T> LiveData<T>.transformState(initialValue: T): State<T> {
    val owner = LocalLifecycleOwner.current
    val state = remember {
        mutableStateOf(initialValue)
    }
    DisposableEffect(Unit, effect = {
        //注册LiveData数据变化监听
        val observer = Observer<T> { value -> state.value = value }
        observe(owner, observer)
        onDispose {
            removeObserver(observer)
        }
    })
    return state
}

在之前的文章中:

Android进阶宝典 -- Google对于开发者的一些架构建议

Google建议开发者使用MVI单向数据流的架构模式,因为需要使用Flow来定义状态流,View层根据流的状态来做对应的UI展示。

kotlin 复制代码
class HomeScreenViewModel : ViewModel() {

    private var _homeState = MutableStateFlow<HomeState>(HomeState.LoadingState)
    val homeState: StateFlow<HomeState>
        get() = _homeState

    suspend fun getHomeContent() {
        _homeState.value = HomeState.LoadingState
        delay(2_000)
        _homeState.value = HomeState.ShowContent("获取到了最新内容")
    }

}

如果使用StateFlow,那么数据流的收集就必须在协程中调用,那么DisposableEffect就不能使用了,这个时候需要使用LaunchedEffect

kotlin 复制代码
@Composable
fun <T> StateFlow<T>.transformState(initialValue: T): State<T> {
    val state = remember {
        mutableStateOf(initialValue)
    }
    LaunchedEffect(Unit) {
        repeatOnLifecycle(Lifecycle.State.STARTED) {
            collect {
                // 更新数据
                state.value = it
            }
        }
    }
    return state
}

使用Stateflow的标准API就可以实现将Stateflow转换为mutableStateOf,有精力的伙伴可以看下官方提供的组件源码。

kotlin 复制代码
@Composable
fun HomeScreen3(
    viewModel: HomeScreenViewModel
) {
    //数据
    val homeState by viewModel.homeState.transformState(HomeState.LoadingState)

    // 启动协程
    val scope = rememberCoroutineScope()

    Column {
        Text(text = "标题")
        when (homeState) {
            is HomeState.LoadingState -> {
                Text(text = "加载中......")
            }

            is HomeState.ShowContent -> {
                Text(text = (homeState as HomeState.ShowContent).msg)
            }

            is HomeState.ErrorState -> {
                Text(text = "出错了~")
            }
        }
        Button(onClick = {
            scope.launch {
                viewModel.getHomeContent()
            }
        }) {
            Text(text = "请求接口")
        }
    }
}

那么在使用的时候,就可以使用MVI架构模式,在界面层根据状态展示不同的UI。

在Compose中其实提供了一个更便捷的api用于快速构建协程状态转换produceState,利用produceState可以对之前封装的transformState进行改造。

kotlin 复制代码
@Composable
fun <T> StateFlow<T>.transformState(initialValue: T): State<T> {
    return produceState(initialValue = initialValue, producer = {
        repeatOnLifecycle(Lifecycle.State.STARTED) {
            collect {
                value = it
            }
        }
    })
}

produceState在内部创建了mutableStateOf对象并返回,同样是通过LaunchedEffect创建了协程,提供的producer作用域内部可以直接操作mutableStateOf属性。

kotlin 复制代码
@Composable
fun <T> produceState(
    initialValue: T,
    producer: suspend ProduceStateScope<T>.() -> Unit
): State<T> {
    val result = remember { mutableStateOf(initialValue) }
    LaunchedEffect(Unit) {
        ProduceStateScopeImpl(result, coroutineContext).producer()
    }
    return result
}

3.2 snapshotFlow

snapshotFlow用于将Compose的状态,转换为Flow。当Compose的状态发生变化时,Flow的collect会感知到状态的变化并回调。

kotlin 复制代码
@Composable
fun HomeScreen3(
    viewModel: HomeScreenViewModel
) {
    //数据
    val homeState by viewModel.homeState.transformState(HomeState.LoadingState)

    val flow = snapshotFlow {
        homeState
    }

    LaunchedEffect(key1 = Unit, block = {
        flow.collect{
            Log.d(TAG, "state changed : $it")
        }
    })

    // 启动协程
    val scope = rememberCoroutineScope()

    Column {
        Text(text = "标题")
        when (homeState) {
            is HomeState.LoadingState -> {
                Text(text = "加载中......")
            }

            is HomeState.ShowContent -> {
                Text(text = (homeState as HomeState.ShowContent).msg)
            }

            is HomeState.ErrorState -> {
                Text(text = "出错了~")
            }
        }
        Button(onClick = {
            scope.launch {
                viewModel.getHomeContent()
            }
        }) {
            Text(text = "请求接口")
        }
    }
}

这个是干什么用的呢?我理解是这样的,因为Compose可以与原生的View交互,在原生的View界面中如果要感知Compose状态的变化,可以使用snapshotFlow

相关推荐
浪遏7 小时前
大文件上传👈 | React + NestJs |分片、断点续传、秒传🚀 , 你是否知道 ???
前端·面试·nestjs
dal118网工任子仪7 小时前
91,【7】 攻防世界 web fileclude
android·前端
taopi20247 小时前
android java 用系统弹窗的方式实现模拟点击动画特效
android
fanged7 小时前
Android学习19 -- 手搓App
android
dal118网工任子仪7 小时前
99,[7] buuctf web [羊城杯2020]easyphp
android·前端·android studio
tt5555555555558 小时前
每日一题——小根堆实现堆排序算法
c语言·数据结构·算法·面试·排序算法·八股文
村口老王12 小时前
鸿蒙开发——应用程序包及模块化设计
android·前端·harmonyos
6v6博客12 小时前
如何在 Typecho 中实现 Joe 编辑器标签自动填充
android·编辑器
fly spider14 小时前
每日 Java 面试题分享【第 20 天】
java·开发语言·面试·io
程序员牛肉16 小时前
为什么网络上一些表情包在反复传播之后会变绿?“电子包浆”到底是怎么形成的?
android