大话Compose筑基(5)

458 阅读9分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 10 天,点击查看活动详情

往期文章
大话Compose筑基(1) - 掘金 (juejin.cn)
大话Compose筑基(2) - 掘金 (juejin.cn)
大话Compose筑基(3) - 掘金 (juejin.cn)
大话Compose筑基(4) - 掘金 (juejin.cn)

前言

作为筑基篇的最后一章,内容围绕着协程在Compose的使用。所以学习这篇之前需要读者具备一定的协程基础,对协程不了解的话建议先学习协程或者跳过这篇。不然理解这篇的内容会比较吃力。

协程中文文档供大家参考。

协程在Compose的使用

官方文档和很多其他文章都经常提到副作用或者附带效应之类的字眼。这种叫法本身没有什么问题,但是为了更好的理解副作用、附带效应这些概念,为了有助记忆和理解,可以把它们简单的看做是协程在Compose中的不同效果的使用。因为提供这些副作用或附带效应的api主要也都是围绕协程来设计的。下面通过例子我们来认识一下主要的使用方法:

1. LaunchedEffect:在某个可组合项的作用域内运行挂起函数
  • LaunchedEffect 进入组合时,它会启动一个协程,并将代码块作为参数传递。(代码示例1)
  • LaunchedEffect 退出组合,协程将取消。(代码示例2)
  • 如果使用不同的键重组 LaunchedEffect,系统将取消现有协程,并在新的协程中启动新的挂起函数。(代码示例3)
//显示Snackbar的函数是一个挂起函数,必须在协程作用域调用
suspend fun showSnackbar(
    message: String,
    actionLabel: String? = null,
    withDismissAction: Boolean = false,
    duration: SnackbarDuration =
        if (actionLabel == null) SnackbarDuration.Short else SnackbarDuration.Indefinite
): SnackbarResult =
    showSnackbar(SnackbarVisualsImpl(message, actionLabel, withDismissAction, duration))

代码示例1

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    WindowCompat.setDecorFitsSystemWindows(window, true)
    setContent {
        val snackbarHostState = remember { SnackbarHostState() }
        var text by remember { mutableStateOf("") }
        LaunchedEffect(
            key1 = true,
            block = {
                //基于LaunchedEffect可组合项生命周期的协程作用域里调用挂起函数
                snackbarHostState.showSnackbar(
                    "需要页面显示Hello World吗?",
                    "是的",
                    duration = Indefinite,
                )
                text = "Hello World"

            },
        )

        Scaffold(
            snackbarHost = { SnackbarHost(snackbarHostState) },

            ) { _ ->
            Text(text = text)
        }
    }
}

运行上面的代码。页面弹出snackbar,再点击确定后挂起函数结束。text被赋值,页面的Text控件显示text。

代码示例2

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    WindowCompat.setDecorFitsSystemWindows(window, true)
    setContent {
        val snackbarHostState = remember { SnackbarHostState() }
        var text by remember { mutableStateOf("") }
        var toggle by remember { mutableStateOf(true) }
        if (toggle) {
            LaunchedEffect(
                key1 = true,
                block = {
                    snackbarHostState.showSnackbar(
                        "需要页面显示Hello World吗?",
                        "是的",
                        duration = Indefinite,
                    )
                    text = "Hello World"

                },
            )
        }
        lifecycleScope.launch {
            delay(3000)
            toggle=false
        }


        Scaffold(
            snackbarHost = { SnackbarHost(snackbarHostState) },

            ) { _ ->
            Text(text = text)
        }
    }
}

运行后页面显示snackbar,就算设置的显示时长是永远。3秒过后发生重组,LaunchedEffect退出组合,协程也会取消,snackbar照样消失。

代码示例3

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    WindowCompat.setDecorFitsSystemWindows(window, true)

    setContent {
        val snackbarHostState = remember { SnackbarHostState() }
        var text by remember { mutableStateOf("") }
        var clickTims by remember {
            mutableStateOf(0)
        }
        println("setContent")
        LaunchedEffect(
            key1 = clickTims,
            block = {
                println("block")
                val showSnackbar = snackbarHostState.showSnackbar(
                    "需要页面显示Hello World吗?",
                    "是的",
                    duration = Indefinite,
                )
                when (showSnackbar) {
                    Dismissed -> {
                        println("Dismissed")

                    }
                    ActionPerformed -> {
                        println("ActionPerformed")
                        text = "Hello World第${clickTims}次"
                        //这么干是不被推荐哈,业务代码不能这么写。这里为了演示方便在回调里面改变了key1值
                        clickTims++
                    }
                }


            },
        )

        Scaffold(
            snackbarHost = { SnackbarHost(snackbarHostState) },

            ) { _ ->
            Text(text = text)
        }
    }
}

运行后点击一次确定更新一次页面,并弹出一次snackbar

2. rememberCoroutineScope:获取组合感知作用域,以便在可组合项外启动协程

由于 LaunchedEffect 是可组合函数,因此只能在其他可组合函数中使用。所以可以用rememberCoroutineScope来实现在一个非组合函数里面调用挂起函数,并且也把协程作用域绑定到指定的组合函数的生命周期的需求。

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    WindowCompat.setDecorFitsSystemWindows(window, true)
    setContent {
        val rememberCoroutineScope = rememberCoroutineScope()
        val snackbarHostState = remember { SnackbarHostState() }
        Scaffold(
            snackbarHost = { SnackbarHost(snackbarHostState) },
        ) { _ ->
            Button(
                onClick = {
                    //注意此处的rememberCoroutineScope绑定的是setContent对应的作用域
                    //也就是说如果就算我们让Button退出组合,下面的协程也不会取消
                    rememberCoroutineScope.launch {
                        snackbarHostState.showSnackbar("snackbar显示")
                    }
                },
            ) {
                Text(text = "点我")
            }
        }
    }
}

上面代码运行后 点击按钮符合预期的弹出了snackbar。

override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        WindowCompat.setDecorFitsSystemWindows(window, true)
        setContent {
            val snackbarHostState = remember { SnackbarHostState() }
            val rememberCoroutineScope = rememberCoroutineScope()

            var showButton1 by remember {
                mutableStateOf(true)
            }
            Scaffold(
                snackbarHost = { SnackbarHost(snackbarHostState) },
            ) { padding ->
                Column {
                    Modifier.padding(padding)
                    if (showButton1) {
                        Button(
                            onClick = {
                                rememberCoroutineScope.launch {
                                    snackbarHostState.showSnackbar("snackbar显示", duration = Indefinite)
                                }
                            },
                        ) {
                            Text(text = "点我显示snackbar")
                        }
                    }

                    Button(
                        onClick = {
                            showButton1 = !showButton1
                        },
                    ) {
                        Text(text = "点我隐藏snackbar")
                    }
                }

            }
        }
    }

上面代码运行后,再点击隐藏snackbar按钮后,显示snackbar的按钮预期退出了组合,页面上没有显示。但是前面通过显示snackbar按钮show出来的snackbar没有消失。

所以继续改造一下实现我们的预期:

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    //延时初始化
    lateinit var rememberCoroutineScope: CoroutineScope
    WindowCompat.setDecorFitsSystemWindows(window, true)
    setContent {
        val snackbarHostState = remember { SnackbarHostState() }
        var showButton1 by remember {
            mutableStateOf(true)
        }
        Scaffold(
            snackbarHost = { SnackbarHost(snackbarHostState) },
        ) { padding ->
            Column {
                Modifier.padding(padding)
                if (showButton1) {
                    Button(
                        onClick = {
                            rememberCoroutineScope.launch {
                                snackbarHostState.showSnackbar(
                                    "snackbar显示",
                                    duration = Indefinite,
                                )
                            }
                        },
                    ) {
                         //在Button的可组合项作用域里面给协程作用域赋值来形成预期的绑定关系
                         rememberCoroutineScope = rememberCoroutineScope()
                        Text(text = "点我显示snackbar")
                    }
                }

                Button(
                    onClick = {
                        showButton1 = !showButton1
                    },
                ) {
                    Text(text = "点我隐藏snackbar")
                }
            }

        }
    }
}

运行代码,效果符合预期了

3. rememberUpdatedState:在效应中引用某个值,该效应在值改变时不应重启

rememberUpdatedState通常和DisposableEffectLaunchedEffect配套使用。这里拿LaunchedEffect为例,假设我们在的它的lambda里面引用了一个参数或值,并且这个参数或值有可能在外部更新,一种方法是把此参数或值变成可追踪状态并且把它当做是LaunchedEffect的key,这样一来该值在外部发生变化LaunchedEffect就会重组,获取到最新的值。如下面的例子:

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    WindowCompat.setDecorFitsSystemWindows(window, true)
    setContent {
        var content by remember {
            mutableStateOf("111")
        }
        DoSomethingAfterDelay(content)
        Column(modifier = Modifier) {
            Button(
                onClick = {
                    content = "222"
                },
            ) {
                Text(text = "触发重组")
            }

        }
    }
}
private suspend fun showContent(content: String) {
    println(content)
}
@Composable
fun DoSomethingAfterDelay(content: String) {
    LaunchedEffect(content) {
        showContent(content)
    }
}

点击按钮后打印出了222符合预期。但是要知道一般我们用到LaunchedEffect的时候大多会在里面执行一些耗时的挂起函数,假设我们在showContent里面先执行一个耗时操作或者像是一个像snackbar显示那样的挂起函数,再像上面的示例这么操作就不妥了。这个时候就可以用到rememberUpdatedState来实现不重启协程任务并在读取值的时候获取的是最新值。

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    WindowCompat.setDecorFitsSystemWindows(window, true)


    setContent {
        var content by remember { mutableStateOf("111") }
        DoSomethingAfterDelay(content)
        Column(modifier = Modifier) {
            Button(
                onClick = {
                    content = "222"
                },
            ) {
                Text(text = "触发重组")
            }

        }
    }
}

//参数类型改成可追踪内形State<T>,实现在挂起函数内获取该参数的最新值
private suspend fun showContent(mutableState: State<String>) {
    //模拟耗时操作
    println("开始倒计时10秒")
    delay(10000)
    println("打印结果:${mutableState.value}")
}

@Composable
fun DoSomethingAfterDelay(content: String) {
    val rememberUpdatedState = rememberUpdatedState(newValue = content)
    println("初始组合或重组DoSomethingAfterDelay")
    //用不变的Unit当key保证在重组后不重启协程
    LaunchedEffect(Unit) {
        showContent(rememberUpdatedState)
    }
}

可以看到我们在开始10秒倒计时的中途点击按钮来改变content的值触发重组,因为key的恒等,所以重组后LaunchedEffect的协程没有重启(没有再次打印“开始倒计时”可以验证),然后看日志的开始倒计时时间是01秒开始的,我们触发重组的时间是08秒,最终预期的在11秒打印出了新值222

2023-03-02 16:52:00.853  5976-5976  System.out              com...ck_white.app_compose_learning  I  初始组合或重组DoSomethingAfterDelay
2023-03-02 16:52:01.078  5976-5976  System.out              com...ck_white.app_compose_learning  I  开始倒计时10秒
2023-03-02 16:52:08.082  5976-5976  System.out              com...ck_white.app_compose_learning  I  初始组合或重组DoSomethingAfterDelay
2023-03-02 16:52:11.081  5976-5976  System.out              com...ck_white.app_compose_learning  I  打印结果:222
4. DisposableEffect:需要清理的效应

对于需要在键发生变化或可组合项退出组合后进行清理的附带效应,请使用 DisposableEffect如果 DisposableEffect 键发生变化,可组合项需要处理(执行清理操作)其当前效应,并通过再次调用效应进行重置。

例如,您可能需要使用 LifecycleObserver,根据 Lifecycle 事件发送分析事件。如需在 Compose 中监听这些事件,请根据需要使用 DisposableEffect 注册和取消注册观察器。

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    WindowCompat.setDecorFitsSystemWindows(window, true)
    setContent {
        var index by remember {
            mutableStateOf(0)
        }
        Box() {
            when (index) {
                0 -> {
                    HomeScreen(onStart = { println("首页Start") }) {
                        println("首页Stop")
                    }
                }
                1 -> {
                    DetailsScreen(onStart = { println("详情页Start") }) {
                        println("详情页Stop")
                    }
                }
                else -> {
                }
            }
            Button(
                onClick = {
                    index++
                },
            ) {
                Text(text = "点击跳转")
            }

        }

    }
}

@Composable
fun HomeScreen(
    lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
    onStart: () -> Unit, // Send the 'started' analytics event
    onStop: () -> Unit, // Send the 'stopped' analytics event
) {
    
    val currentOnStart by rememberUpdatedState(onStart)
    val currentOnStop by rememberUpdatedState(onStop)

    // 如果 `lifecycleOwner` 改变, 取消订阅并且重启效应
    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 {
            println("首页退出组合,注销生命周期的观察者")
            lifecycleOwner.lifecycle.removeObserver(observer)
        }
    }

    Text(modifier = Modifier.fillMaxSize(), textAlign = TextAlign.Center, text = "首页")

    
}

@Composable
fun DetailsScreen(
    lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
    onStart: () -> Unit, // Send the 'started' analytics event
    onStop: () -> Unit, // Send the 'stopped' analytics event
) {
    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)
        }
    }

    Text(modifier = Modifier.fillMaxSize(), textAlign = TextAlign.Center, text = "详情页")

    
}

运行代码后日志打印首页Start,app放到后台后日志打印首页Stop,再回到前台后再次打印首页Start,点击跳转按钮后首页可组合项退出组合,日志打印首页退出组合,注销生命周期的观察者详情页Start

5. SideEffect:将 Compose 状态发布为非 Compose 代码

如需与非 Compose 管理的对象共享 Compose 状态,请使用 SideEffect 可组合项,因为每次成功重组时都会调用该可组合项。假设这样一个场景,大范围内的引用一个用户信息的对象,然后很多页面显示的地方会用用户信息。当A用户切换到B用户后发生重组后自动取用新的用户信息。代码如下:

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    WindowCompat.setDecorFitsSystemWindows(window, true)
    setContent {
        //当前用户“A桑”
        var userName by remember {
            mutableStateOf("A桑")
        }
        //构建当前是用户信息对象,用户改变后通过side effect重新赋值对象不变
        val printer = rememberPrinter(userName)

        Column(modifier = Modifier) {
            //点击模拟切换用户
            Button(onClick = { userName = "B酱" }) {
                
                Text(text = "点击切换用户")
            }
            Spacer(modifier = Modifier.padding(top = 12.dp))
            //页面显示当前用户的名字
            Text(
                text = userName,
                Modifier.clickable {
                    //点击打印当前用户名字
                    println(printer.content)
                },
            )
        }

    }
}

@Composable
fun rememberPrinter(userName: String): Printer {
    val printer = remember {
        Printer(userName)
    }
    SideEffect {
        printer.content = userName
    }

    return printer
}

data class Printer(
    var content: String,
)
6. produceState:将非 Compose 状态转换为 Compose 状态

produceState 会启动一个协程,该协程将作用域跟调用的组合的生命周期绑定。使用此协程将非 Compose 状态转换为 Compose 状态,例如将外部订阅驱动的状态(如 FlowLiveData 或 RxJava)引入组合。

该制作工具在 produceState 进入组合时启动,在其退出组合时取消。设置相同的值不会触发重组。

即使 produceState 创建了一个协程,它也可用于观察非挂起的数据源。如需移除对该数据源的订阅,请使用 awaitDispose 函数。

   val currentPerson by produceState<Person?>(null, viewModel) {
       val disposable = viewModel.registerPersonObserver { person ->
           value = person
       }

      awaitDispose {
           disposable.dispose()
      }
   }
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    WindowCompat.setDecorFitsSystemWindows(window, true)
    setContent {
        var url by remember {
            mutableStateOf("abc")
        }
        //url如果改变了,此组合项也会重组,取消里面的协程并重新用新的url运行协程
        val loadNetworkImage = loadNetworkImage(url)
        Column(modifier = Modifier) {
            Button(
                onClick = {
                    url = "def"
                },
            ) {
                Text(text = "点我模拟加载")
            }

            ShowHash(loadNetworkImage)

        }

    }
}

@Composable
private fun ShowHash(loadNetworkImage: State<Result>) {
    when (val value = loadNetworkImage.value) {
        Error -> {
            println("提示错误信息")
        }
        Loading -> {
            println("显示加载中")
        }
        is Success -> {
            println("显示Text")

            Text(text = value.hashCode.toString())
        }
    }
}

sealed interface Result {
    object Loading : Result
    object Error : Result
    data class Success(val hashCode: Int) : Result
}

@Composable
fun loadNetworkImage(
    url: String,
): State<Result> {
    return produceState<Result>(initialValue = Result.Loading, url) {
        println("有的话取消之前的生产过程,没有不取消.开始生产状态,并倒计时5秒")
        //模拟耗时操作.注意这个地方也可以是flow livedata rxjava等的返回值
        delay(5000L)
        //返回一个结果
        val content = url.hashCode()
        value = if (content < 0) {
            Result.Error
        } else {
            Result.Success(content)
        }
    }
}
7. derivedStateOf:将一个或多个状态对象转换为其他状态

如果某个状态是从其他状态对象计算或派生得出的,请使用 derivedStateOf。使用此函数可确保仅当计算中使用的状态之一发生变化时才会进行计算。

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    WindowCompat.setDecorFitsSystemWindows(window, true)
    setContent {
        val oddList = remember {
            mutableStateListOf("1", "3", "5")
        }
        TodoList(oddList) {
            oddList.add((oddList.last().toInt() + 2).toString())

        }

    }
}


@Composable
fun TodoList(oddList: List<String>, block: () -> Unit) {
    val evenList = remember(oddList) {
        derivedStateOf {
            oddList.map { (it.toInt() + 1).toString() }
        }
    }

    Column(modifier = Modifier) {
        Button(
            onClick = {
                block()
            },
        ) {
            Text(text = "追加")
        }
        Text(text = oddList.joinToString())
        Text(text = evenList.value.joinToString())

    }
}

derivedStateOf的使用不难,但是和remember的配合使用可以有很多玩法来适应不同的场景,主要的关注点还是在触发重组的条件上,这个要综合实际的场景和性能来觉得是用key来触发重组还是改变引用的状态来触发重组。

8. snapshotFlow:将 Compose 的 State 转换为 Flow

使用 snapshotFlow 将 State<T> 对象转换为冷 Flow。snapshotFlow 会在收集到块时运行该块,并发出从块中读取的 State 对象的结果。当在 snapshotFlow 块中读取的 State 对象之一发生变化时,如果新值与之前发出的值不相等,Flow 会向其收集器发出新值(此行为类似于 Flow.distinctUntilChanged 的行为)。

下列示例显示了一项附带效应,是系统在用户滚动经过要分析的列表的首个项目时记录下来的:

val listState = rememberLazyListState()

LazyColumn(state = listState) {
    // ...
}

LaunchedEffect(listState) {
    snapshotFlow { listState.firstVisibleItemIndex }
        .map { index -> index > 0 }
        .distinctUntilChanged()
        .filter { it == true }
        .collect {
            MyAnalyticsService.sendScrolledPastFirstItemEvent()
        }
}

结尾

到此为止筑基篇就正式告一段落了,感谢陪伴一起走到这一步的xdm!没有你们的鼓励,也不会有这个系列。写的初衷主要是想帮助Compose新手能相对快的入门和夯实基础。奈何本人水平有限,逻辑不通,解释不清的地方随处可见,所以整个篇幅示例代码的占比较大,以备有人在读到不明白的地方可以通过在自己的环境下跟着代码运行总结。还是不明白的欢迎大家留言,看到后一定第一时间回复。

在筑基篇后,会继续出一个炼体篇用Compose来实现各种页面控件和布局。会通过多篇的一个系列来帮助已经筑基的xdm熟练的用Compose来编写界面。

开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 10 天,点击查看活动详情