Jetpack Compose(十二)生命周期与副作用-

Compose的DSL很形象地描述了UI的视图结构,其背后对应这一视图树的结构体,我们称这棵视图树为Composition。Composition在Composable初次执行时被创建,在Composable中访问State时,Composition记录其引用,当State变化时,Composition触发对应的Composable进行重组,更新视图树的节点,显示中的UI得到刷新。

一、Composable的生命周期

Composable函数执行会得到一颗视图树,每一个Composable组件都对应着树上的一个节点。反编译后的代码中多了startXXXGroup/endXXXGroup等代码,start/end就像是栈操作中的push/pop,栈的深度就是视图树中子树的深度,Composable的执行就像是一个基于栈的深度优先遍历逻辑来创建和更新视图树。

围绕着节点在视图树上的添加和更新,可以为Composable定义它的生命周期,如图所示。

  • OnActive(添加到视图树):即Composable被首次执行,在视图树上创建对应的节点。
  • OnUpdate(重组):Composable跟随重组不断执行,更新视图树上的对应节点。
  • OnDispose(从视图树移除):Composable不再被执行,对应节点从视图树上移除。

为Composable定义生命周期将有助于更好地管理Compose的副作用。

特别注意:

  1. 在一个Compose项目中,当页面不再显示时,Composable节点会被立即销毁,不会像Activity或者Fragment那样在后台保存实例,所以即使Composable作为页面使用,也没有前后台切换的概念

  2. 在Activity或Fragment中,你可以通过onSaveInstanceState等方法保存和恢复状态,但在Compose中没有类似的生命周期方法。Compose函数重新调用时,它会从头开始执行,而不会恢复之前的状态。简而言之,Composable函数是声明性的UI描述,而不是具有显式生命周期方法的类。每次Compose函数被调用时,都会重新计算并渲染UI,而不依赖于之前的状态。

  3. 当应用从前台切换到后台(或从后台切换到前台)时,Compose会重新计算如果有必要更新UI,以确保UI的正确性和一致性。

  4. 在Compose中,每当UI的数据发生变化,Compose会触发重新计算和绘制UI的过程。这种声明式的UI编程模型允许你专注于描述UI应该是什么样子,而不必过多关注底层的生命周期管理。这种设计有助于简化UI的构建和维护。

二、Composable的副作用

Composable在执行过程中,凡是会影响外界的操作都属于副作用(Side-Effects),比如弹出Toast、保存本地文件、访问远程或本地数据等。我们已经知道,重组可能会造成Composable频繁反复执行,副作用显然是不应该跟随重组反复执行的。为此,Compose提供了一系列副作用API,可以让副作用API只发生在Composable生命周期的特定阶段,确保行为的可预期性。

三、Composable的副作用API

1、DisposableEffect

DisposableEffect可以感知Composable的onActive和onDispose,允许通过副作用完成一些预处理和收尾处理。下面来看一个注册和注销系统返回键的例子,进入时注册,在Composable销毁时注销,按一次触发返回键,按二次返回键间隔小于1秒关闭当前页面,代码如下:

kotlin 复制代码
@Composable
fun Greeting() {
    var lastBackPressedTime = 0L
    backPressHandler {
        //按返回键间隔小于1秒关闭当前页面
        if (System.currentTimeMillis() - lastBackPressedTime < 1000) {
            "Greeting:finish".logd()
            finish()
        } else {
            //正常执行返回键逻辑
            "Greeting:onBackPressed".logd()
            lastBackPressedTime = System.currentTimeMillis()
        }
    }
}

@Composable
fun backPressHandler(enabled: Boolean = true, onBackPressed: () -> Unit) {
    val backDispatcher = checkNotNull(LocalOnBackPressedDispatcherOwner.current) {
        "No OnBackPressedDispatcherOwner was provided via LocalOnBackPressedDispatcherOwner"
    }.onBackPressedDispatcher
    //监听返回键的触发
    val backCallback = remember {
        object : OnBackPressedCallback(enabled) {
            override fun handleOnBackPressed() {
                "backPressHandler:handleOnBackPressed".logd()
                onBackPressed()
            }
        }
    }
    DisposableEffect(key1=backDispatcher) {//backDispatcher发生变化时重新执行
        //日志
        "backPressHandler:addCallback".logd()
        //添加监听
        backDispatcher.addCallback(backCallback)
        //Composeable销毁
        onDispose {
            "backPressHandler:backCallback.remove".logd()
            //当Composable进入onDispose时进行
            //移除backCallback避免泄露
            backCallback.remove()
        }
    }
}

日志如下:

kotlin 复制代码
//进入页面
backPressHandler:addCallback(Composeable节点生成时注册)

//按一次返回键
backPressHandler:handleOnBackPressed
Greeting:onBackPressed

//连续按二次返回键
backPressHandler:handleOnBackPressed
Greeting:onBackPressed
backPressHandler:handleOnBackPressed
Greeting:finish
backPressHandler:backCallback.remove(Composeable节点销毁时注销)

上述代码在remember中创建了一个OnBackPresedCallBack回调处理返回键事件,使用remember包裹避免其在重组时被重复创建,从某种角度上来看,remember也是一种副作用API。

接下来在DisposableEffect后的block内向OnBackPressedDispatcher中注册返回键事件回调。DisposableEffect像remember一样可以接受观察参数key,但是它的key不能为空:

  1. 如果key为Unit或true这样的常量,则block只在OnActive时执行一次。
  2. 如果key为其他变量,则block在OnActive以及参数变化时的OnUpdate中执行,比如例子中当dispatcher变化时,block再次执行,注册新的backCallback回调;如果dispatcher不变,则block不会跟随重组执行。

DisposableEffect{...}的最后必须跟随一个OnDispose代码块,否则会出现编译错误。OnDispose常用来做一些副作用的收尾处理,例如例子中用来注销回调,避免泄露。

当Composble进入OnDispose时会执行OnDispose,此外当有新的副作用到来时,前一次的副作用也会执行OnDispose。 新的副作用到来,即DisposableEffect因为key的变化再次执行。参数key也可以被认为是代表一个副作用的标识。backPressHandler通过副作用API完成了监听返回键的逻辑,可以在Composable组件中方便地使用它,且不用担心泄漏。

Tips:backPressHandler这样与UI无关的Composable,这里可以当作普通Kotlin函数那样,函数名使用首字母小写即可。

2、SideEffect

SideEffect在每次成功重组时都会执行,所以不能用来处理那些耗时或者异步的副作用逻辑。

重组会触发Composable重新执行,但是重组不一定会成功结束,有的重组可能会中途失败。

SideEffect仅在重组成功时才会执行,举一个不可能存在但能说明问题的例子:

kotlin 复制代码
@Composable
fun TestSideEffect() {
    SideEffect {
        doThingSafely()
    }
    doThingUnsafely()
    throw RuntimeException("oops")
}

在上面的代码中,虽然TestSideEffect并不能成功执行到最后,但是doThingUnsafely仍然会执行,而doThingSafely只有在重组成功完成后才执行。因为SideEffect能够获取与Composable一致的最新状态,它可以用来将当前State正确地暴露给外部:

kotlin 复制代码
@Composable
fun MyScreen(drawerTouchHandler: TouchHandler) {
    val drawerState = rememberDrawerstate(DrawerValue.Closed)
    
    //重组成功将drawerState状态通知外部
    SideEffect {
        drawerTouchHandler.enabled = drawerstate.isOpen
    }
    ...
}

上面的代码中,当drawState发生变化时,会将最新状态通知到外部的TouchHandler。如果不放到SideEffect中执行,则有可能会传出一个错误状态。

四、Composable的异步处理的副作用API

1、LaunchedEffect

当副作用中有处理异步任务的需求时,可以使用LaunchedEffect。在Composable进入OnActive时,LaunchedEffect会启动协程执行block中的内容,可以在其中启动子协程或者调用挂起函数。当Composable进入OnDispose时,协程会自动取消,因此LaunchedEffect不需要实现OnDispose{}

LaunchedEffect支持观察参数key的设置,当key发生变化时,当前协程自动结束,同时开启新协程。下面是示例代码:

kotlin 复制代码
@Composable
fun MyScreen(
    state: UiState<List<Movie>>,
    scaffoldState: ScaffoldState = rememberScaffoldState()
) {
    //当state中包含错误时,Snackbar显示
    //如果state中的错误已经解除, LaunchedEffect协程结束, Snackbar消失
    if (state.hasError) {
        //当scaffoldState.snackbarHostState变化时(key发生变化),之前的Snackbar消失
        //新Snackbar显示
        LaunchedEffect(key1 = scaffoldState.snackbarHostState) {
            scaffoldState.snackbarHostState.showSnackbar(
                message = "Error message",
                actionLabel = "Retry message"
            )
        }
        Scaffold(scaffoldState = scaffoldState) {
            ...
        }
    }
}

在上面的代码中,当state中包含错误时,显示一个SnackBar。SnackBar的显示需要使用协程环境,而LaunchedEffect会为其提供。当scaffoldState.snackbarHostState变化时,将启动一个新协程,SnackBar重新显示一次。当state.hasError变为false时,LaunchedEffect则会进入OnDispose,协程会被取消,此时正在显示的SnackBar也会随之消失。

当副作用中有耗时任务时,应该优先使用LaunchedEffect处理副作用。

2、rememberCoroutineScope

LaunchedEffect虽然可以启动协程,但是LaunchedEffect只能在Composable中调用,如果想在非Composable环境中使用协程,例如在Button的OnClick中使用协程显示SnackBar,并希望其在OnDispose时自动取消,此时可以使用rememberCoroutineScope。

rememberCoroutineScope将会返回一个CoroutineScope,可以在当前Composable进入OnDispose时自动取消,如下面的代码所示。

kotlin 复制代码
@Composable
fun MyScreen(scaffoldState: ScaffoldState = rememberScaffoldState()) {
    //创建一个绑定MoviesScreen生命周期的协程作用域
    val scope = rememberCoroutineScope()
    Scaffold(scaffoldState = scaffoldState) {
        Column {
            ...
            Button(
                onClick = {
                    //在按钮单击事件中创建一个新的协程作用域,用于显示snackBar
                    scope.launch {
                        scaffoldState.snackbarHostState.showSnackbar("Something happened!")
                    }
                }) {
                Text("Press me")
            }
        }
    }
}

DisposableEffect配合rememberCoroutineScope可以实现LaunchedEffect同样的效果,一般情况下这没有任何意义,仅当需要自定义OnDispose实现时,可以考虑这样使用。

kotlin 复制代码
//LaunchedEffect
LaunchedEffect(key) {
    //副作用处理
}

//DisposableEffect + rememberCoroutineScope
val scope = rememberCoroutineScope()
DisposableEffect(key) {
    val job = scope.launch {
        //副作用处理
    }
    onDispose {
        job.cancel()
    }
}

3、rememberUpdatedState

LaunchedEffect会在参数key变化时启动一个协程,但有时我们并不希望协程中断,只要能够实时获取最新状态即可,此时可以借助rememberUpdatedState实现。代码如下所示:

kotlin 复制代码
@Composable
fun MyScreen(onTimeout: () -> Unit) {
    val currentonTimeout by rememberUpdatedState(onTimeout)
    //此副作用的生命周期同MyScreen一致
    //不会因为MyScreen的重组重新执行
    LaunchedEffect(Unit) {
        delay(SplashWaitTimeMillis)
        currentOnTimeout()//总是能取到最新的onTimeOut
    }
    ...
}

将LaunchedEffect的参数key设置为Unit, block一旦开始执行,就不会因为MyScreen的重组而中断。但是当它执行到currentOnTimeout()时,仍然可以获取最新的OnTimeout实例,这是由rememberUpdatedState确保的。

看一下rememberUpdatedState的实现就明白其中原理了,其实就是remember和mutableStateOf的组合使用,代码如下:

kotlin 复制代码
@Composable
fun <T> rememberUpdatedState(newValue: T): State<T> = remember {
    mutableStateOf(newValue)
}.apply { value = newValue }

remember确保了MutableState实例可以跨越重组存在,副作用里访问的实际是MutableState中最新的newValue。总结来说,rememberUpdatedState可以在不中断副作用的情况下感知外界的变化。

4、snapshotFlow

前面学习到在LaunchedEffect中可以通过rememberUpdatedState获取最新状态,但是当状态发生变化时,LaunchedEffect无法第一时间收到通知,如果通过改变观察参数key来通知状态变化,则会中断当前执行中的任务,成本太高。简言之,LaunchedEffect缺少轻量级的观察状态变化的机制。

snapshotFlow可以解决这个问题,它可以将状态转化成一个Coroutine Flow,代码如下所示。

kotlin 复制代码
val pagerState = rememberPagerState()

LaunchedEffect(pagerState) {
    //pagerSate转换为Flow
    snapshotFlow { pagerState.currentPage }
        .collect { page ->
            //currentPage发生变化
        }
}
HorizontalPager(
    pageCount = 10,
    state = pagerState
) { page ->
    ...
}

snapshotFlow{...}内部在对State访问的同时,通过"快照"系统订阅其变化,每当State发生变化时,flow就会发送新数据。如果State无变化则不发射,类似于Flow.distinctUntilChanged的作用。需要注意的是snapshotFlow转换的Flow是个冷流,只有在collect之后,block才开始执行。

在上面的代码中,snapshotFlow内部订阅了标签页状态pagerState,当切换标签页时,pagerState的值发生变化并通知到下游收集器进行处理。注意pagerState在这里虽然作为LaunchedEffect()的key使用,但是实例对象没有变化,基于equals的比较无法感知变化,所以不必担心协程的中断。

当一个LaunchedEffect中依赖的State会频繁变化时,不应该使用State的值作为key,而应该将State本身作为key,然后在LaunchedEffect内部使用snapshotFlow依赖状态。使用State作为key是为了当State对象本身变化时重启副作用。

五、状态创建的副作用API

我们已经知道,在Stateful Composable中创建状态时,需要使用remember包裹,状态只在OnActive时创建一次,不跟随重组反复创建,所以remember本质上也是一种副作用API。接下来介绍其他几个用来创建状态的副作用API。

1、produceState

前面学习了SideEffect,它常用来将Compose的State暴露给外部使用,而produceState则相反,可以将一个外部的数据源转成State。外部数据源可以是一个LiveData或者RxJava这样的可观察数据,也可以是任意普通的数据类型。

LiveData或RxJava等转换成Compose State有专用的API可供使用。而produceState的使用范围更加广泛,任意类型的数据都可以通过它转成State。

下面的代码展示了一个produceState的使用场景:

kotlin 复制代码
/**
 * 请求图片
 */
@Composable
fun loadNetworkImage(url: String, imageRepository: ImageRepository): State<Result<Image>> {
    return produceState<Result<Image>>(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转换为State<Result<Image>>,当Image获取失败时,会返回Result. Error。produceState观察url和imageRepository两个参数,当它们发生变化时,producer会重新执行获取新图片。看一下源码:

kotlin 复制代码
@Composable
fun <T> produceState(
    initialValue: T,
    key1: Any?,
    key2: Any?,
    producer: suspend ProduceStateScope<T>.() -> Unit
): State<T> {
    val result = remember { mutableStateOf(initialValue) }
    LaunchedEffect(key1, key2) {
        ProduceStateScopeImpl(result, coroutineContext).producer()
    }
    return result
}

key1和key2分别对应上面代码中的urlimageRepository,创建的LaunchedEffect观察了这两个key。produceState的实现非常简单,实际上就是使用remember创建了一个MutableState,然后在LaunchedEffect中对它进行异步更新。

produceState的实现具有学习和参考意义,可以在项目中利用remember与LaunchedEffect等API封装自己的业务逻辑并暴露Satae。在Compose项目中,要时刻带着数据驱动的思想来实现业务逻辑。

produceState中的协程任务会随着LaunchedEffect的OnDispose被自动停止。但是produceState{...},内也可以处理不基于协程的逻辑,比如注册一个回调,此时你可能需要一个时机做一些后处理以避免泄露,此时可以使用awaitDispose{...},代码如下:

kotlin 复制代码
val currentPerson by produceState<Person?>(null, viewModel) {
    val disposable = viewModel.registerPersonObserver { person ->
        value = person
    }
    awaitDispose {
        //当Composable进入onDispose时,进入此处
        disposable.dispose()
    }
}

2、derivedStateOf

erivedStateOf用来将一个或多个State转成另一个State。derivedStateOf{...}的block中可以依赖其他State创建并返回一个DerivedState,当block中依赖的State发生变化时,会更新此DerivedState,依赖此DerivedState的所有Composable会因其变化而重组。示例代码如下:

kotlin 复制代码
@Composable
fun SearchScreen() {
    val postList = remember { mutableStateListOf<String>() }
    val keyword by remember { mutableStateOf("") }

    val result by remember {
        derivedStateOf { postList.filter { it.contains(keyword, false) } }
    }
    Box(Modifier.fillMaxWidth()) {
        LazyColumn {
            items(result) { ... }
        }
        ...
    }
}

在上面的例子中,对一组数据基于关键字进行了搜索,并展示了搜索结果。带检索数据和关键字都是可变的状态,我们在derivedStateOf{...}的block内部实现了检索逻辑,当postList或者keyword任意变化时,result会更新。

使用remember也可以实现derivedStateOf同样的效果:

kotlin 复制代码
val postList by remember { mutableStateOf(emptyList()) }
val keyword by remember { mutableStateOf("") }

val result by remember(postList, keyword) {
    postList.filter { it.contains(keyword, false) }
}

但这样写意味着postList和keyword二者只要任何一个发生变化,Composable就会发生重组,而使用derivedStateOf只有当DerivedState变化才会触发重组,所以当一个计算结果依赖较多State时,derivedStateOf有助于减少重组次数,提高性能

derivedStateOf只能监听block内的State,一个非State类型数据的变化则可以通过remember的key进行监听。示例:

kotlin 复制代码
var text: String = "hello, world"
val rememberText: State<String> = remember {
    mutableStateOf(text)
}

六、副作用API的观察参数

不少副作用API都允许指定观察参数key,例如LaunchedEffect、ProduceState、DisposableEffect等。当观察参数变化时,执行中的副作用会终止,key的频繁变化会影响执行效率。反之,如果副作用中存在可变值,但没有指定为key,有可能因为没有及时响应变化而出现Bug。因此,关于参数key的添加可以遵循以下原则,兼顾效率与避免故障的需求:当一个状态的变化需要造成副作用终止时,才将其添加为观察参数key,否则应该将其使用rememberUpdatedState包装后,在副作用中使用,以避免打断执行中的副作用。

kotlin 复制代码
@Composable
fun HomeScreen(
    lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
    onStart: () -> Unit,
    onStop: () -> Unit
) {
    //These values never change in Composition
    val currentOnStart by rememberUpdatedState(onStart)
    val currentOnStop by rememberUpdatedState(onStop)

    DisposableEffect(lifecycleOwner) {
        val observer = LifecycleEventObserver { _, event ->
            //回调onStart或者onStop
        }
        lifecycleOwner.lifecycle.addObserver(observer)
        onDispose {
            lifecycleOwner.lifecycle.removeObserver(observer)
        }
    }
}

在上面的代码中,当LifecycleOwner变化时,需要终止对当前LifecycleOwenr的监听,并重新注册Observer,因此必须将其添加为观察参数。而currentOnState和currentOnStop只要保证在回调它们的时候可以获取最新值即可,所以应该通过rememberUpdatedState包装后在副作用中使用,不应该因为它们的变动终止副作用。

参考内容

本文为学习博客,内容来自书籍《Jetpack Compose 从入门到实战

参考官网内容

相关推荐
曾经的三心草39 分钟前
Mysql之约束与事件
android·数据库·mysql·事件·约束
guoruijun_2012_45 小时前
fastadmin多个表crud连表操作步骤
android·java·开发语言
Winston Wood5 小时前
一文了解Android中的AudioFlinger
android·音频
B.-6 小时前
Flutter 应用在真机上调试的流程
android·flutter·ios·xcode·android-studio
有趣的杰克6 小时前
Flutter【04】高性能表单架构设计
android·flutter·dart
大耳猫12 小时前
主动测量View的宽高
android·ui
帅次14 小时前
Android CoordinatorLayout:打造高效交互界面的利器
android·gradle·android studio·rxjava·android jetpack·androidx·appcompat
枯骨成佛15 小时前
Android中Crash Debug技巧
android
kim565920 小时前
android studio 更改gradle版本方法(备忘)
android·ide·gradle·android studio
咸芝麻鱼20 小时前
Android Studio | 最新版本配置要求高,JDK运行环境不适配,导致无法启动App
android·ide·android studio