12. 2 Compose 中的副作用及 Banner 自动轮播

480 阅读3分钟

Effects Api

函数的副作用可以理解成在本函数中做了与本函数无关的操作同时对函数外部产生了影响(修改了函数外的全局/局部变量)。由此可知副作用灵活但存在隐患,一旦滥用就跟滥用 EventBus一样弊远远大于利。

第一章介绍基本概念的时候提到过 Composable 函数应该是纯函数(幂等,无副作用),Composable 函数的副作用可以理解成在 Composable 函数中改变了其函数外部(Compose 树或其他 Compose 组件)的 State。

State 的改变又意味着重组,重组的  Composable 函数如果也有副作用 Emmm……. 

这样去描述副作用只是不希望其因为灵活而被滥用,毕竟 Compose 它不静态页面。

响应式 UI 其本质是异步 , Compose的异步使用协程来处理,所有异步运行在 Compose Scope 的协程中。Compose 提供了与 Compose Socpe 绑定的副作用 API,尽管如此官方还是建议我们不要滥用,最好只用来操作 UI ,不要操作数据。

Compose Scope 的协程:协程上下文在  ComposeView#onAttachedToWindow 第一次 Composition 时创建,有兴趣的可以看下源码。

LaunchedEffect

在 Composable 函数 Scope 中开启协程,并在协程中运行 block 参数的代码块。参数 key 可以是一个或多个,重组时如果 key 发生变化会取消上次开启的协程,重新开启新协程运行 block 代码块。

协程还跟调用 LaunchedEffect 的 Composable 函数绑定,如果重组时  Composable 函数在 Compose 树中移除,协程也会被取消。

@Composable
fun EffectTest() {
    var showLaunchEffect by remember { mutableStateOf(true) }
    Column {
        if (showLaunchEffect){
            launchEffect()
        }
        Button(onClick = {
            showLaunchEffect = !showLaunchEffect
            Log.e("EffectTest", " showLaunchEffect:$showLaunchEffect ")
        }) {
            Text(text = "Toggle LaunchEffect" )
        }
    }
}

@Composable
fun launchEffect(){
    var effectKey by remember { mutableStateOf(1)}
    LaunchedEffect(key1 = effectKey){
        Log.e("LaunchedEffectStart", "key:$effectKey")
        for (i in 1 .. 5 ){
            delay(1000)
            Log.e("LaunchedEffect", "key:$effectKey |<>| i: $i" )
        }
        Log.e("LaunchedEffectEnd", "key:$effectKey")
    }
    Button(onClick = {
        effectKey ++
        Log.e("Change Key", "key: $effectKey", )
    }) {
        Text(text = "Change Key" )
    }
}

正常流程打印 5 次后结束

image.png

点击 Change Key 按钮,上次协程被取消,重新执行

image.png

点击 Toggle LaunchEffect 按钮, showLaunchEffect 为 false 时 EffectTest 在 Compose 树中移除协程被取消,重新添加到 Compose 树时协程再次被启动。

image.png

DisposableEffect

相比 LaunchedEffect 多了一个 onDispose() (必须要有) ,来做一些清理工作。官方提供的生命周期相关的示例就特别好。

@Composable
fun HomeScreen(
    lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
    onStart: () -> Unit, // Send the 'started' analytics event
    onStop: () -> Unit // Send the 'stopped' analytics event
) {
    // Safely update the current lambdas when a new one is provided
    val currentOnStart by rememberUpdatedState(onStart)
    val currentOnStop by rememberUpdatedState(onStop)

    // If `lifecycleOwner` changes, dispose and reset the effect
    DisposableEffect(lifecycleOwner) {
        // Create an observer that triggers our remembered callbacks
        // for sending analytics events
        val observer = LifecycleEventObserver { _, event ->
            if (event == Lifecycle.Event.ON_START) {
                currentOnStart()
            } else if (event == Lifecycle.Event.ON_STOP) {
                currentOnStop()
            }
        }

        // Add the observer to the lifecycle
        lifecycleOwner.lifecycle.addObserver(observer)

        // When the effect leaves the Composition, remove the observer
        onDispose {
            lifecycleOwner.lifecycle.removeObserver(observer)
        }
    }

    /* Home screen content */
}

LaunchedEffect 和 DisposableEffect 都有一些限制:

  • 只能在 Composable 函数中使用
  • 无法手动取消他们开启的协程

rememberCoroutineScope

在 Composable 函数中调用并返回一个与其绑定的协程 Scope , 使用返回的 Scope 可以在非 Composable 函数(一般是回调函数)中启动一个协程。 启动协程会返回 Job 对象,这样还可以手动控制协程的取消。

@Composable
fun ScopeTest(){
    val scope = rememberCoroutineScope()
    var job:Job? by remember { mutableStateOf(null)}
    Column {
        Button(onClick = {
            job = scope.launch {
                Log.e("scope.launch ","Start")
                for (i in 1 .. 5 ){
                    delay(1000)
                    Log.e("scope.launch ", "i: $i" )
                }
                Log.e("scope.launch ","End")
            }

        }) {
            Text(text = "scope.launch")
        }

        Button(onClick = {
            job?.let {
                Log.e("scope.launch ","Cancel")
                it.cancel()
            }
        }) {
            Text(text = "Cancel Job")
        }
    }
}

EB792E58-A213-4350-AC24-5BEA4851EB56.png

Compose 组件会通过 State 提供一些改变其自身状态的 suspend 方法,这些方法都需要运行在 Compose Scope 的协程中,例如 LazyListState#animateScrollToItem()。View Layer 中 ComposeVmState 有一部分原因就是因为 ViewModel 中无法处理涉及到这些方法的 UIAction。

rememberUpdatedState

返回的 State 可以在不影响已运行协程的情况下更新其 value 的值。一般用于长时间运行且耗时的协程,不想通过 key 重启协程的情况下更新内部的值。比如解析一组文件时,使用这种方法在末尾追加一个文件,这样既能更新需要解析的文件,有不会影响到已经解析文件的结果。

@Composable
fun UpdateTest(){
    val filesState = remember { mutableStateOf(listOf("file1","file2")) }
    analysisFile(files = filesState.value)
    Column{
        Button(onClick = {
            Log.e("UpdateTest", "add File" )
            filesState.value = filesState.value + "file3"
        }) {
            Text(text = "Add File")
        }
    }
}

@Composable
fun analysisFile(files:List<String>){
    val filesState = rememberUpdatedState(newValue = files)
    //常量 key ,Composable 不被移出 Compose 树的情况下只会启动一次协程
    LaunchedEffect(Unit){
        var i = 0
        while (i < filesState.value.size){
            val file = filesState.value[i]
            Log.e("analysisFile", "file: $file  ......" )
            delay(5000)//模拟文件操作耗时
            i++
        }
        Log.e("analysisFile", "Done", )
    }
}

1210ADCC-7F80-48F5-9B39-32003DF9D941.png

函数是一等公民,格局要放大,这个 Api 也可以更新 lambda 。 

全部 Api 介绍请查看官网

实现自动轮播

修改 Banner 方法

    val pagerState = rememberPagerState()
    val scope = rememberCoroutineScope()
    
    if(items.isNotEmpty()){ 
        // 重组时 pagerState.currentPage 发生变化就会重新执行
        LaunchedEffect(pagerState.currentPage){
            delay(3000) 
            val nextPageIndex = (pagerState.currentPage + 1) % items.size
            // animateScrollToPage 方法会改变 pagerState.currentPage 触发重组
            // 重组时 LaunchedEffect key 变化,原来开启的协程被取消
            // 如果此时 animateScrollToPage 动画未执行完,pager 就会停在最后的位置
            // 所以 animateScrollToPage 需要在 rememberCoroutineScope 的协程中执行
            scope.launch {
                pagerState.animateScrollToPage(nextPageIndex)
            }
        }
    }

    HorizontalPager(
      //......