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 从入门到实战

参考官网内容

相关推荐
没有了遇见2 小时前
Android 通过 SO 库安全存储敏感数据,解决接口劫持问题
android
hsx6662 小时前
使用一个 RecyclerView 构建复杂多类型布局
android
hsx6662 小时前
利用 onMeasure、onLayout、onDraw 创建自定义 View
android
守城小轩2 小时前
Chromium 136 编译指南 - Android 篇:开发工具安装(三)
android·数据库·redis
whysqwhw2 小时前
OkHttp平台抽象机制分析
android
hsx6663 小时前
Android 内存泄漏避坑
android
whysqwhw3 小时前
OkHttp之okhttp-bom模块的分析
android
餐桌上的王子3 小时前
Android 构建可管理生命周期的应用(二)
android
幽你一默4 小时前
Android 版本差异速查表(开发者视角)
android
不萌4 小时前
android 项目中的屏幕适配方案
android