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的副作用。
特别注意:
-
在一个Compose项目中,当页面不再显示时,Composable节点会被立即销毁,不会像Activity或者Fragment那样在后台保存实例,所以即使Composable作为页面使用,也没有前后台切换的概念。
-
在Activity或Fragment中,你可以通过onSaveInstanceState等方法保存和恢复状态,但在Compose中没有类似的生命周期方法。Compose函数重新调用时,它会从头开始执行,而不会恢复之前的状态。简而言之,Composable函数是声明性的UI描述,而不是具有显式生命周期方法的类。每次Compose函数被调用时,都会重新计算并渲染UI,而不依赖于之前的状态。
-
当应用从前台切换到后台(或从后台切换到前台)时,Compose会重新计算如果有必要更新UI,以确保UI的正确性和一致性。
-
在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不能为空:
- 如果key为Unit或true这样的常量,则block只在OnActive时执行一次。
- 如果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分别对应上面代码中的url
和imageRepository
,创建的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 从入门到实战》
参考官网内容