Jetpack Compose中的副作用Api

Posted 川峰

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Jetpack Compose中的副作用Api相关的知识,希望对你有一定的参考价值。

Compose的生命周期


每个Composable函数最终会对应LayoutNode节点树中的一个LayoutNode节点,可简单的为其定义生命周期:

  • onActive: 进入重组作用域, Composable对应的LayoutNode节点被挂接到节点树上
  • onUpdate:触发重组, Composable对应的LayoutNode节点被更新(0次或者多次)
  • onDispose: 离开重组作用域, Composable对应的LayoutNode节点从节点树上移除

Compose的副作用

副作用是指发生在可组合函数作用域之外的应用状态的变化。由于可组合项的生命周期和属性(例如不可预测的重组、以不同顺序执行可组合项的重组或可以舍弃的重组),可组合项在理想情况下应该是无副作用的。

无副作用的函数也被称为纯函数: 唯一确定的输入决定唯一确定的输出,不会因为运行次数的增加导致输出结果的不同。这对于React、Compose这类的声明式UI框架至关重要,因为它们都是通过函数(组件)的反复执行来渲染UI的,函数执行的时机和次数都不可控,但是函数的执行结果必须可控,因此,我们要求这些函数组件必须用纯函数实现。

虽然副作用是不应该出现的,但是有时副作用是合理的,必要的,例如,IO操作、日志处理、弹出toast提醒、页面跳转等等,这些操作在能感知Composable生命周期的受控环境中执行,否则有可能打断Compose的施法。为此 Compose提供了很多副作用API,使用这些API可以保证对应的操作在Composable的生命周期的特定阶段被执行,确保行为的可预期性。

DisposableEffect

DisposableEffect可以感知ComposableonActiveonDispose,允许通过副作用完成一些预处理和收尾工作。

例如监听处理系统返回键的例子:

@Composable
fun BackPressHandler(enabled: Boolean = true, onBackPressed: () -> Unit) 
    val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
    val currentOnBack by rememberUpdatedState(onBackPressed)

    val backCallback = remember 
        object : OnBackPressedCallback(enabled) 
            override fun handleOnBackPressed() 
                currentOnBack()
            
        
    

    // backDispatcher 发生变化时重新执行
    DisposableEffect(backDispatcher) 
        backDispatcher?.addCallback(backCallback) // onActive时添加回调
        // 当 Composable 进入 onDispose 时执行
        onDispose 
            backCallback.remove() // onDispose时移除回调 避免内存泄漏
        
    

Compose中自带的BackHandler组件内部就是基于DisposableEffect实现 的。

DisposableEffect的lambda中必须跟随一个onDispose...代码块的调用,否则会编译报错。onDispose一般常用于反注册接口回调,及一些资源清理工作,防止内存泄漏。当有新的副作用来临时,前一次的副作用就会执行onDispose...代码块中的代码。

DisposableEffect可以接受一个key作为参数,如果key是可变状态,当key发生变化时,会重新执行副作用中的代码块。如果keyUnittrue这样的常量,则副作用代码块只在onActive时执行一次。

SideEffect

SideEffect仅会在每次重组成功时执行,因此能正确的向外传递状态。其中不能用来处理耗时和异步任务。(注意Composable函数不一定每次都会执行重组也不一定每次重组都会执行成功)

@Composable
fun MyScreen(drawerTouchHandler: TouchHandler) 
    val drawerState = rememberDrawerState(DrawerValue.Closed)

    SideEffect  // 将 drawerState 通知外部
        drawerTouchHandler.enabled = drawerState.isOpen
    

@Composable
fun rememberAnalytics(user: User): FirebaseAnalytics 
    val analytics: FirebaseAnalytics = remember 
        /* ... */
    

    // On every successful composition, update FirebaseAnalytics with
    // the userType from the current User, ensuring that future analytics
    // events have this metadata attached
    SideEffect 
        analytics.setUserProperty("userType", user.userType)
    
    return analytics

LaunchedEffect

当副作用中需要处理异步任务的需求时,可以使用 LaunchedEffect, 在 Composable 进入 onActive 时,LaunchedEffect 会启动协程执行 block 中的内容,一般用来启动子协程或者调用挂起函数。

Composable 进入 onDispose 时,LaunchedEffect启动的协程会自动取消,因此 LaunchedEffect 不需要实现onDispose...

LaunchedEffect 设置的 key 发生变化时,当前协程自动结束,同时开启新的协程。

@Composable
fun MyApp(
    state: UiState<List<Movie>>,
    scaffoldState: ScaffoldState = rememberScaffoldState()
) 
    // 当 state 中包含错误时,显示一个 SnackBar,
    if (state.hasError) 
        // 显示一个 SnackBar 的显示需要一个协程环境,而 LaunchedEffect 会为其提供
        // 当 scaffoldState.snackbarHostState变化时,将启动一个新的协程, SnackBar重新显示一次
        // 当 state.hasError 变成 false 时,LaunchedEffect 进入 onDispose, 协程会被自动取消,SnackBar也会随之消失
        LaunchedEffect(scaffoldState.snackbarHostState) 
            scaffoldState.snackbarHostState.showSnackbar(
                message = "Error message",
                actionLabel = "Retry message"
            )
        
    

rememberCoroutineScope

由于 LaunchedEffect 是可组合函数, 因此只能在Composable函数中调用,如果想在非Composable环境中使用协程,例如Button的onClick方法中, 可以使用 rememberCoroutineScope,它会返回一个CoroutineScope,可以用来启动新的协程。 当 Composable 进入 onDispose 时,启动的协程会自动取消。如果您需要手动控制一个或多个协程的生命周期,请使用 rememberCoroutineScope,例如在用户事件发生时取消动画。

@Composable
fun MyApp(scaffoldState: ScaffoldState = rememberScaffoldState()) 
    // 创建一个绑定 MyApp 生命周期的协程作用域
    val scope = rememberCoroutineScope()
    Scaffold(scaffoldState = scaffoldState)  padding ->
        Column(Modifier.padding(padding)) 
            Button(
                onClick = 
                    // 点击按钮时创建一个新的协程作用域,用于显示Snackbar
                    scope.launch 
                        scaffoldState.snackbarHostState.showSnackbar("Something happened!")
                    
                
            ) 
                Text(text = "Press me")
            
        
    


rememberUpdatedState

rememberUpdatedState可以在不中断副作用的情况下感知外界的变化,一般用来获取观察状态的最新状态值。

@Composable
fun MyScreen(onTimeOut: () -> Unit)  
    val currentOnTimeout by rememberUpdatedState(onTimeOut)
    // key为Unit时不会因为MyScreen的重组重新执行
    LaunchedEffect(Unit) 
        delay(300)
        currentOnTimeout() // 总是能获取到最新的 onTimeOut
    

看下面的例子更加容易理解:

@Composable
private fun UpdatedRememberExample() 
    var myInput by remember  mutableStateOf(0) 

    Column(Modifier.height(100.dp)) 
        OutlinedButton(
            modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
            onClick =  myInput++ 
        ) 
            Text("Increase rememberInput: $myInput")
        
        Calculation(input = myInput)
    

@Composable
private fun Calculation(input: Int) 
    val rememberUpdatedStateInput by rememberUpdatedState(input)
    val rememberedInput by remember  mutableStateOf(input) 
    Text("updatedInput: $rememberUpdatedStateInput, rememberedInput: $rememberedInput")


可以看到在Calculation组件中,使用rememberUpdatedState方式的每次都能读取到外部更新后的最新的状态值,而普通方式则不行。当然这里也可以使用 remember(key) 的方式也能达到效果。

查看rememberUpdatedState可知它就是不断的将新值赋值给自身的value而已:

@Composable
fun <T> rememberUpdatedState(newValue: T): State<T> = remember 
    mutableStateOf(newValue)
.apply  value = newValue 

snapshotFlow

snapshotFlow 可以将 ComposeState 转换为 Flow。每当State变化时,flow就会发送新数据(但是冷流,调用collect才会发) snapshotFlow 会在收集到块时运行该块,并发出从块中读取的 State 对象的结果。当在 snapshotFlow 块中读取的 State 对象之一发生变化时,如果新值与之前发出的值不相等,Flow 会向其收集器发出新值(此行为类似于 Flow.distinctUntilChanged 的行为)。

@Composable
fun MyScreen2() 
    val pagerState = rememberPagerState()
    LaunchedEffect(pagerState) 
        snapshotFlow  pagerState.currentPage  .collect  page ->
            // currentPage发生变化
        
    
    HorizontalPager(
        count = 10,
        state = pagerState,
    )  page ->
        // ...
    

@Composable
fun SnapshotFlowDemo() 
    val scaffoldState = rememberScaffoldState()
    LaunchedEffect(scaffoldState) 
        snapshotFlow  scaffoldState.snackbarHostState 
            .mapNotNull  it.currentSnackbarData?.message 
            .distinctUntilChanged()
            .collect  message ->
                println("A SnackBar with message $message was shown")
            
    

produceState

produceStatederivedStateOf 都是状态创建的副作用API, 从本质上讲,remember 也是一种副作用API,只在组件OnActive时被创建一次,不跟随重组反复创建。

produceState可以将任意数据源转换成一个State供给Composable函数使用

produceState 会启动一个协程,该协程将作用域限定为可将值推送到返回的 State 的组合。使用此协程将非 Compose 状态转换为 Compose 状态,例如将外部订阅驱动的状态(如 Flow、LiveData 或 RxJava)引入组合。

该协程在 produceState 进入组合时启动,在其退出组合时取消。返回的 State 冲突;设置相同的值不会触发重组。

即使 produceState 创建了一个协程,它也可用于观察非挂起的数据源。

@Composable
fun loadNetWorkImage(
    url: String,
    imageRepository: ImageRepository
) : State<Result<Image>> 
    // produceState 观察 url 和 imageRepository 两个参数,当它们发生变化时,producer会重新执行
    // produceState的实现是通过 remember  mutableStateOf()  + LaunchedEffect (具有学习意义)
    // produceState 中的任务会随着 LaunchedEffect 的 onDispose 被自动停止。
    return produceState(initialValue = Result.Loading, url, imageRepository) 
        // 通过挂起函数请求图片
        val image = imageRepository.load(url)
        // 根据请求结果设置 Result
        // 当 Result 变化时,读取此 State 的 Composable 会触发重组
        value = if (image == null) 
            Result.Error
         else 
            Result.Success(image)
        
    

produceState 中使用 awaitDispose 清理资源避免内存泄漏:

val currentPerson by produceState(null, viewModel) 
    val disposeable = viewModel.registerPersonObserver  person ->
        value = person
    
    awaitDispose 
        // 当 Composable 进入 onDispose时,进入此处
        disposeable.dispose()
    

derivedStateOf

derivedStateOf 用来将一个或多个 State 转成另一个 StatederivedStateOf...block 中可以依赖其他 State 创建并返回一个 DerivedState, 当 block 中依赖的 State 变化时,会更新此 DerivedState,依赖 DerivedState 的所有 Composable 会随之重组。

以下示例展示了基本的“待办事项”列表,其中具有用户定义的高优先级关键字的任务将首先显示:

@Composable
fun TodoList(highPriorityKeywords: List<String> = listOf("Review", "Unblock", "Compose")) 

    val todoTasks = remember  mutableStateListOf<String>() 

    // Calculate high priority tasks only when the todoTasks or highPriorityKeywords
    // change, not on every recomposition
    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 */
    

在以上代码中,derivedStateOf 保证每当 todoTasks 发生变化时,系统都会执行 highPriorityTasks 计算,并相应地更新界面。如果 highPriorityKeywords 发生变化,系统将执行 remember 代码块,并且会创建新的派生状态对象并记住该对象,以代替旧的对象。由于执行过滤以计算 highPriorityTasks 的成本很高,因此应仅在任何列表发生更改时才执行,而不是在每次重组时都执行。

此外,更新 derivedStateOf 生成的状态不会导致可组合项在声明它的位置重组,Compose 仅会对返回状态为已读的可组合项(在本例中,指 LazyColumn 中的可组合项)进行重组。

该代码还假设 highPriorityKeywords 的变化频率显著低于 todoTasks。否则,该代码会使用 remember(todoTasks, highPriorityKeywords) 而不是 derivedStateOf

@Composable
fun SearchScreen() 
    val postList = remember  mutableStateListOf<String>() 
    val keyWord by remember  mutableStateOf("") 

    // 这里 postList 和 keyWord任意一个变化时,会更新 result
    val result by remember 
        derivedStateOf  postList.filter  it.contains(keyWord)  
    

    Box(modifier = Modifier.fillMaxSize()) 
        LazyColumn 
            items(result)  item ->
                Text(item)
            
        
    

也可以使用 remember 实现:

   val result2 = remember(postList, keyWord) 
        postList.filter  it.contains(keyWord) 
    

但是这样写意味着postListkeyWord任意一个变化时,Composable会重组,与之相比 derivedStateOf 只有当输出的 DerivedState变化才会导致Composable重组,所以当一个计算结果依赖较多的 State 时,derivedStateOf 有助于减少重组次数,提高性能。

derivedStateOf 只能监听block内的 state,一个非State类型的数据变化则可以通过rememberkey进行监听。

副作用的观察参数

很多副作用Api都允许指定观察参数key,当key变化时,执行中的副作用会终止。 key的频繁变化会影响执行效率。因此关于key的使用应当遵循以下原则: 当一个状态的变化需要造成副作用终止时,才将其添加为观察参数key, 否则应该将其通过rememberUpdatedState包装后,在副作用中使用,以避免打断执行中的副作用。

@Composable
fun HomeScreen(
    lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
    onStart: () -> Unit,
    onStop: () -> Unit,
) 
    val currentOnStart by rememberUpdatedState(onStart)
    val currentOnStop by rememberUpdatedState(onStop)

    DisposableEffect(lifecycleOwner) 
        val observer = LifecycleEventObserver  _, event ->
            // 回调 currentOnStart() 或 currentOnStop()
            when(event) 
                Lifecycle.Event.ON_START -> currentOnStart()
                Lifecycle.Event.ON_STOP -> currentOnStop()
                else ->  
            
        
        lifecycleOwner.lifecycle.addObserver(observer)
        onDispose 
            lifecycleOwner.lifecycle.removeObserver(observer)
        
    

上面的示例中,当 lifecycleOwner 变化时,需要终止对当前 lifecycleOwner 的监听,并重新注册Observer, 因此这里才将其添加为key。而 onStart 和 onStop 只要保证在回调它们时,可以获取最新的值即可,所以应该使用rememberUpdatedState包装,不应该作为观察参数因为它们的变动终止副作用执行。

什么情况下该用 derivedStateOf Or remember(key)

记住以下原则即可:

比如下图示例中,submitEnabled在输入框文本每次发生变化时都会被更新一次,每次都会触发重组流程

而如果换成 derivedStateOf 则只会触发三次更新:

下面代码模拟购物车增加商品数量:

@Composable
private fun DerivedStateOfExample() 
    var numberOfItems by remember  mutableStateOf(0) 

    Column(modifier = Modifier.padding(horizontal = 8.dp).height(100.dp)) 
        Surface 
            Row(verticalAlignment = Alignment.CenterVertically) 
                Text(text = "Amount to buy: $numberOfItems", modifier = Modifier.weight(1f))
                IconButton(onClick =  numberOfItems++ ) 
                    Icon(imageVector = Icons.Default.Add, contentDescription = "add")
                
                Spacer(modifier = Modifier.width(4.dp))
                IconButton(onClick =  if (numberOfItems > 0) numberOfItems-- ) 
                    Icon(imageVector = Icons.Default.Remove, contentDescription = "remove")
                
            
        

        Surface 
            val derivedStateMax by remember 
                derivedStateOf  numberOfItems > 5 
            

            if (derivedStateMax) 
                println("🤔 COMPOSING...")
                Text("You cannot buy more than 5 items",
                    color = Color(0xffE53935),
                    modifier = Modifier.fillMaxWidth().background(getRandomColor())
                )
            
        
        Surface 
            val derivedStateMax2 by remember(numberOfItems) 
                mutableStateOf(numberOfItems > 5)
            

            if (derivedStateMax2) 
                println("🤔 COMPOSING...2")
                Text("You cannot buy more than 5 items",
                    color = Color(0xffE53935),
                    modifier = Modifier.fillMaxWidth().background(getRandomColor())
                )
            
        
    


可以看出第一个derivedStateMax使用 derivedStateOf 方式的状态,当数量超过5之后,继续增加也不会再更新状态触发重组,而第二个derivedStateMax2 使用 remember(key) 方式的状态值每次都会更新状态触发重组。这个例子能很好的理解二者的区别。

还有一个典型的例子就是在列表中显示返回顶部的按钮:

@Composable
private fun DerivedStateOfSample2(scrollState: LazyListState) 
    val coroutineScope = rememberCoroutineScope()

    val firstItemVisible by remember 
        derivedStateOf  scrollState.firstVisibleItemIndex != 0 
    

    Box 
        LazyRow(
            state = scrollState,
            horizontalArrangement = Arrangement.spacedBy(8.dp),
            content = 
                items(places)  place: Place ->
                    PlacesToBookComponent(place = place)
                
            
        )

        if (firstItemVisible) 
            FloatingActionButton(
                onClick =  coroutineScope.launch  scrollState.animateScrollToItem(0)  ,
                modifier = Modifier.align(Alignment.BottomEnd),
                backgroundColor = Color(0xffE53935以上是关于Jetpack Compose中的副作用Api的主要内容,如果未能解决你的问题,请参考以下文章

Jetpack Compose 中的作用域状态

Jetpack Compose Effect 的作用

Jetpack Compose Effect 的作用

viewpager jetpack compose 中的垂直滚动不起作用

Jetpack Compose 中的重组作用域和性能优化

如何使用 Jetpack Compose UI 在 Android 上正确跟踪屏幕视图?