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

相关推荐
大耳猫4 小时前
主动测量View的宽高
android·ui
帅次6 小时前
Android CoordinatorLayout:打造高效交互界面的利器
android·gradle·android studio·rxjava·android jetpack·androidx·appcompat
枯骨成佛7 小时前
Android中Crash Debug技巧
android
用户3157476081357 小时前
成为程序员的必经之路” Git “,你学会了吗?
面试·github·全栈
布川ku子8 小时前
[2024最新] java八股文实用版(附带原理)---Mysql篇
java·mysql·面试
kim565912 小时前
android studio 更改gradle版本方法(备忘)
android·ide·gradle·android studio
咸芝麻鱼12 小时前
Android Studio | 最新版本配置要求高,JDK运行环境不适配,导致无法启动App
android·ide·android studio
无所谓จุ๊บ12 小时前
Android Studio使用c++编写
android·c++
csucoderlee13 小时前
Android Studio的新界面New UI,怎么切换回老界面
android·ui·android studio
kim565913 小时前
各版本android studio下载地址
android·ide·android studio