• Jetpack Compose Effect 的作用


    1. 概述

    1.1 背景

    Jetpack Compose 的可组合项理应该是一个纯函数,不会影响到外部,就是说一个理想的可组合项应该是下面这样的:

    @Composable
    fun HelloContent() {
        Column(modifier = Modifier.padding(16.dp)) {
            var name by rememberSaveable { mutableStateOf("rikka") } // 1. 状态
            
            Text(
                text = "Hello, $name!",
                modifier = Modifier.padding(bottom = 8.dp)
            )
    
            OutlinedTextField(
                value = name,
                onValueChange = { name = it },  // 2. 行为
                label = { Text("Name") }
            )
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    注释1、2处的状态和行为只和 HelloContent 这个可组合项有关,它们不会改变外界的任何东西,只影响内部的 Text, 整个 HelloContent 是一个闭环, 这样一个可组合项近乎完美,因为它可以被复用,可以和任意别的可组合项组合,它就是一个“纯函数”。

    但是在日常开发中,我们有时候是需要副作用的

    例如,我们知道 name 是会随着 OutlinedTextField 的输入而改变的, 然后我们有这么一个需求:除了初始绘制,还要在每次输入新值而导致可组合项重组时,做埋点上报,那我们可能就会这样写代码:

    @Composable
    fun HelloContent() {
        Column(modifier = Modifier.padding(16.dp)) {
            var name by rememberSaveable { mutableStateOf("rikka") }
            
            Log.d("HelloContent", "new Value -> $name")   // 1
            CoroutineScope(Dispatchers.IO).launch { // 2
                logInput(name)
            }
            //...
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    在原有代码中,新增了如下代码:

    • 注释1: 打印 Log
    • 注释2:启动一个协程,调用 logInput 函数, 这个函数可能是做网络请求,也可能是更新数据库

    因为新增的代码和绘制功能无关,所以它们都是副作用。 在我们日常开发中,时常会有类似的述求,让我们除了绘制UI之外,做类似这种事情。

    这样的代码会有什么样的问题呢? 其实对于注释1这样的代码来说,基本没有问题,它既不耗时,又不占用和更改其它资源,就算因为异常没有执行,也无关痛痒。

    但是注释2 处的代码就有待商榷了, 请看下一节的分析。

    1.2 副作用代码的问题

    副作用的问题,官方已经帮忙总结了,有以下五点:

    • 可组合函数可以按任何顺序执行
    • 可组合函数可以并行执行
    • 重组会跳过尽可能多的可组合函数和 lambda
    • 重组是乐观的操作,可能会被取消
    • 可组合函数可能会像动画的每一帧一样非常频繁地运行

    下面来逐一解释。

    1.2.1 可组合函数可能会像动画的每一帧一样非常频繁地运行

    简单的说,不太恰当的比喻, @Composable 函数有点像是 Android 原生的 View.draw 方法,这样大家可能就突然清晰了:因为 View 的 draw 函数可能会被频繁的执行,所以我们会避免在自定义 draw 函数里面做耗时操作。

    在上面的代码中, 我们不断的在 OutlinedTextField (就是 EditText) 中输入文字时,因为 onValueChange 会更改 name 的值,而 name 是一个可观察 State,被 Text 引用, 所以会触发 HelloContent 的重组。

    假设你输入的非常快,那么 HelloContent 的调用会非常的频繁:
    在这里插入图片描述
    如上图所示,HelloContent 在 1s 不到的时间内被调用了7次,如果你每次都要执行网络请求,肯定会非常浪费资源,对性能来很有可能会产生灾难性的影响!

    1.2.2 可组合函数可以按任何顺序执行

    可组合函数中如果调用了其它的可组合函数,它可能会以任意顺序执行它们。例如下面代码:

    @Composable
    fun myApp() {
        StartApp()
        HelloContent()
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    StartAppHelloContent 的可以任意顺序执行的, 所以它们不能互相依赖。

    举个例子, HelloContent 中我们需要执行网络请求,但是网络请求是建立在网络库SDK已经初始化好的情况下进行的。

    而网络库 SDK 的初始化(例如设置请求头、设置 host、设置 HttpDns等必要信息)则是在 StartApp 里面做的。

    这个时候就可能会出现, 先执行 HelloContent,而此时网络SDK没有初始化好,导致网络请求异常的情况, 这将是我们预料之外的。

    1.2.3 可组合函数可以并行运行

    假设还是同样的例子:

    @Composable
    fun myApp() {
        StartApp()
        HelloContent()
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    StartApp() 可以和 HelloContent() 并行的运行,假设两个可组合项同时修改一个全局共享变量,如果没有做好同步处理,会出现修改冲突的异常。

    当然实际情况还有各种各样的并行问题, 并不是我们所期望发生的那样。

    1.2.4 重组会跳过尽可能多的内容

    请看下面示例,我们在 HelloContent 中插入一个可组合项:

    @Composable
    fun HelloContent() {
        Column(modifier = Modifier.padding(16.dp)) {
            var name by remember { mutableStateOf("") }
            Text(
                text = "Hello, $name!",
                modifier = Modifier.padding(bottom = 8.dp),
            )
            Greeting(name = "aa")   //  新增的
            OutlinedTextField(
                value = name,
                onValueChange = { name = it },
                label = { Text("Name") }
            )
        }
    }
    
    @Composable
    fun Greeting(name: String) {
        Log.d("HelloContent", "调用 Greeting")
        Text(text = "Hello $name!")
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22

    无论在 OUtlinedTextField 输入什么, Text 是会重组的,而 Greeting 不会,所以如果此时我们希望 Greeting 里设置一些副作用,它是不会发生的。

    1.2.5 重组是乐观的操作

    重组是乐观的, 在可组合项重组过程中, 如果依赖状态发生了变化,可组合项会取消本次正在进行的重组,并重新再次重组。

    重组无非就是再调用一次函数。

    如果我们的副作用没有依赖可组合项的生命周期,很有可能会在取消重组后继续运行。

    如下图所示:

    在这里插入图片描述
    副作用1如果没有随第一次重组的取消而取消,那么可能会产生影响,例如和副作用2产生并发冲突、状态共享等问题。

    1.3 Effect 的出现

    综上所述,我们不能随意在代码中使用副作用的最大最大最大因数就是重组!!!

    Compose 则提供了 Effect api, 让我们在可组合项中使用副作用, 它根据场景,弄出了很多个api,虽然五花八门,都是大方向都是为了解决 Compose 中使用副作用的问题。

    2. Effect api

    2.1 LaunchedEffect

    可组合项做了限制,不能直接在 @Composable 创建一个协程,而让我们使用 LaunchedEffect 来取代:
    在这里插入图片描述

    LaunchedEffect 是一个可组合项,可以创建一个协程,它有以下特点:

    • 进入别的可组合项时,会启动一个协程
    • 退出别的可组合项 / 别的可组合项关闭时, 会取消协程
    • 会传入一个 key,这个 key 如果改变了,LaunchedEffect 才会重组,即关闭上个协程,重新开启协程

    一个示例如下:

    @Composable
    fun MyScreen(
        state: UiState<List<Movie>>,
        scaffoldState: ScaffoldState = rememberScaffoldState()
    ) {
    
        // 如果 UIState 有错误状态,则展示 snackBar
        if (state.hasError) {
    
            // 如果 key - scaffoldState.snackbarHostState 改变了, LaunchedEffect 会取消并重新启动
            LaunchedEffect(scaffoldState.snackbarHostState) {
                // 使用一个协程来展示 snackbar, 当协程被取消时,snackBar 会自动消失
                // 该协程会在任何 state.hasError 等于 false 的时候取消, 只有在等于 true 的时候才启动
                // 或者在 state.hasError 且 scaffoldState.snackbarHostState 改变的时候启动
                scaffoldState.snackbarHostState.showSnackbar(
                    message = "Error message",
                    actionLabel = "Retry message"
                )
            }
        }
        Scaffold(scaffoldState = scaffoldState) {
            /* ... */
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24

    上面 showSnackbar是 Scaffold 提供的一个能力, 因为它会改变 ScaffoldState 的状态,所以它是一个副作用,并且由于是一个 suspend 挂起函数,所以我们可以使用 LaunchedEffect 来包装它。

    假设 state 发生了变化, MyScreen 会发生重组,但是只要 state.hasErrorscaffoldState.snackbarHostState 保持不变, LaunchedEffect所创造的协程就还将继续。

    思考一下,如果这里的 key 是一个永远不会改变的值(抛开if语句), 那么 LaunchedEffect 的生命周期就和 MyScreen 一样长了。

    2.2 rememberCoroutineScope

    LaunchedEffect 有一个限制点,就是它本身是一个可组合项,被 @Composable 所修饰,所以它只能在可组合项中被使用。 如果是在像 Button(onClick = { ... }) 的 lambda 表达式里面使用,是会报错的。

    当然我们可以在这个地方自己创建协程,但是它难以管理,例如组合退出,这个协程不会被取消。

    所以 Effect 提供了 rememberCoroutineScope 函数,创建一个绑定了当前可组合项生命周期的协程作用域,在可组合项退出时 / 重组时,能够取消、重启协程,我们不用管理其生命周期。

    用法如下:

    @Composable
    fun MoviesScreen(scaffoldState: ScaffoldState = rememberScaffoldState()) {
    
        // 创建一个协程作用域,它会绑定 MoviesScreen 的生命周期
        val scope = rememberCoroutineScope()
    
        Scaffold(scaffoldState = scaffoldState) {
            Column {
                /* ... */
                Button(
                    onClick = {
                        // 启动一个协程协程来展示 Snackbar
                        scope.launch {
                            scaffoldState.snackbarHostState.showSnackbar("Something happened!")
                        }
                    }
                ) {
                    Text("Press me")
                }
            }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22

    2.3 rememberUpdatedState

    上面说到, LaunchedEffect 在 key 不更新时不会重组, 这是一件好事,因为你可能在副作用中做耗时操作,频繁重组对性能会是致命打击。

    但是有这么一种情况,就是副作用中使用到了可能会更新的外界的值,请看下面代码:

    @Composable
    fun LandingScreen() {
        var name by remember { mutableStateOf("") }
        LandingScreen(name = name, onValueChange = {
            name = it
        })
    }
    
    @Composable
    fun LandingScreen(name: String, onValueChange: (String) -> Unit) {
    
        LaunchedEffect(true) {  // 1 这里设置 key 为 true
            delay(2000)
            Log.d("HelloContent", "2000 ms Logged: -> $name")  // 2 这里的name是什么?
        }
    
        OutlinedTextField(
            value = name,
            onValueChange = onValueChange,
        )
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21

    展示 LandingScreen(),在注释1处启动一个协程,里面使用 delay,用于模拟耗时操作,最后在注释2处打印 name。

    这个 name 是函数 LandingScreen 的入参,可以随着 OutlinedTextField 的输入而改变,所以在 delay 的2s内,你可以输入任意字符,2s 时此时打印的 name 是你在输入框上输入的字符么?

    不是的, 它是初始值,也就是第一次被调用时的副本,是一个空串。 原因是 LandingScreen 虽然一直在重组,但是 LaunchedEffect 并没有重组。

    而我们的业务场景一般是希望里面的 name 是最新的,即我们的输入, 然后我们可能会写成这样:

    // 随 name 的变化而变化
    LaunchedEffect(name) {
        delay(2000)
        Log.d("HelloContent", "2000 ms -> $name")
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    这样就出现了更大的问题, 虽然 name 每次都能打印最新的值,但是每次都发生了重组,协程不断被创建,然后等待2s才打印。 从表现和性能上来说都显然是有问题的。

    这个时候就能使用 rememberUpdatedState 了,它是一个引用而非副本,总是指向最新的那个值。

    @Composable
    fun LandingScreen(name: String, onValueChange: (String) -> Unit) {
    
        val nameUpdated by rememberUpdatedState(name)
    
        LaunchedEffect(true) {
            delay(2000)
            Log.d("HelloContent", "2000 ms -> $nameUpdated")
        }
        // ...
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    这里使用 nameUpdated 记住了 name, 在副作用的 2s 内无论我们输入什么, nameUpdated 总是指向最新的那个 name。

    由于它只是引用而非拷贝,所以它性能很好,也被广泛使用。

    2.4 DisposableEffect

    因为 LaunchedEffect 的生命周期可以和别的可组合项一样长,可以做到无论依附的可组合项怎么重组,都只执行一次。

    基于这个特性,我们会做一些一次性操作, 例如注册监听器:

    LaunchedEffect(Unit) {
        registerListener()
    }
    
    • 1
    • 2
    • 3

    有注册就有反注册,但是 LaunchedEffect 好像没有提供一个时机让我们去反注册, 就是纯纯一次性的。

    所以,Effect api提供了一个 DisposableEffect 来解决这个问题, 可以在内部执行 onDispose 函数来处理当副作用退出时的操作。

    下面代码中,以 LifeCycle 的注册与反注册为示例:

    @Composable
    fun HomeScreen(
        lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
        onStart: () -> Unit,
        onStop: () -> Unit
    ) {
        // 保证回调函数是最新的
        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 {
                lifecycleOwner.lifecycle.removeObserver(observer)
            }
        }
    
        /* Home screen content */
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32

    注意, onDispose 表达式必须作为 Disposable 的最终语句。否则,IDE 将显示构建时错误。

    2.5 SideEffect

    SideEffect 的特点是没有 key 值,在调用它的可组合项上每次重组完成时,都会调用 SideEffect。即使重组操作失败了,也不会让 SideEffect 的状态和调用它的可组合项的状态不一致。

    示例代码如下:

    @Composable
    fun rememberAnalytics(user: User): FirebaseAnalytics {
        val analytics: FirebaseAnalytics = remember {
            /* ... */
        }
    
        //在每次重组成功时,才会去用最新的 user 更新 FirebaseAnalytics 的 userType
        SideEffect {
            analytics.setUserProperty("userType", user.userType)
        }
        return analytics
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    2.6 produceState

    produceState 被设计用来在可组合项中使用协程来获取一个数据。

    数据是一个非 Composable 的,而用通过 produceState,它变成了 Composable 的,所以可以运用到可组合项中去。

    @Composable
    fun loadNetworkImage(
        url: String,
        imageRepository: ImageRepository
    ): State<Result<Image>> {
    
        // 创建一个 State> 类型,带有初始值,表示图片加载的结果
        // 如果 url 或者 imageRepository 改变了, 协程将会取消并重启
        return produceState<Result<Image>>(initialValue = Result.Loading, url, imageRepository) {
    
            // 在协程中,可以使用挂起函数
            val image = imageRepository.load(url)
    
            // 更新状态,这会触发使用这个 State 的可组合项的重组
            value = if (image == null) {
                Result.Error
            } else {
                Result.Success(image)
            }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21

    2.7 derivedStateOf

    当一个状态是由多个状态推导构成时,我们可以使用 derivedStateOf 来构成这个新的状态。

    如下所示,highPriorityTasks 这个状态是由 todoTasks 过滤的状态推导出来的:

    @Composable
    fun TodoList(highPriorityKeywords: List<String> = listOf("Review", "Unblock", "Compose")) {
    
        val todoTasks = remember { mutableStateListOf<String>() }
    
        // 只有当 todoTasks 或 highPriorityKeywords,才会去计算并更新 highPriorityTask,而不是每次重组的时候
        val highPriorityTasks by remember(highPriorityKeywords) {
            derivedStateOf { todoTasks.filter { it.containsWord(highPriorityKeywords) } }
        }
    
        Box(Modifier.fillMaxSize()) {
            LazyColumn {
                items(highPriorityTasks) { /* ... */ }
                items(todoTasks) { /* ... */ }
            }
            /* Rest of the UI where users can add elements to the list */
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    2.8 snapshotFlow

    snapshotFlow可以将 composable 转化成 flow。 这个就没有什么好说的了。

    代码示例如下:

    val listState = rememberLazyListState()
    
    LazyColumn(state = listState) {
        // ...
    }
    
    LaunchedEffect(listState) {
        snapshotFlow { listState.firstVisibleItemIndex }
            .map { index -> index > 0 }
            .distinctUntilChanged()
            .filter { it == true }
            .collect {
                MyAnalyticsService.sendScrolledPastFirstItemEvent()
            }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    参考

    官网: Compose 中的附带效应
    初探 Jetpack Compose — 渲染機制(Rendering)
    Using rememberCoroutineScope() vs LaunchedEffect
    Compose基础-Side-effect(一)

  • 相关阅读:
    python使用unittest进行单元测试
    计算机组成原理——计算机系统层次结构
    linux网卡驱动注册与接受数据处理
    [NET毕业设计源码]基于NET实现的旅游景点推荐系统[包运行成功]
    使用HoloLens 2调用深度相机和前置摄像头
    2022牛客多校十 E-Reviewer Assignment(最小费用流)
    问问吾问无为谓
    ubuntu下安装Qt和添加Qt快捷启动方式
    springboot http代理 简易版本
    Java面向对象笔记
  • 原文地址:https://blog.csdn.net/rikkatheworld/article/details/126108946