前言
这篇文章,我将会介绍SideEffect
的使用,在官方文档的翻译中,SideEffect
译为「副作用」、「附带效应」,如果用「副作用」解释,可能有歧义,其实在SideEffect
在Compose中的副作用并不是坏的作用,而是除了主作用之外,额外带的一些附加效果,因此在这里我称之为「附带效应」。
1 SideEffect使用
附带效应,指的是发生在@Composable
作用域外应用状态的变化,例如下面这个函数:
kotlin
private var str:String = "A"
fun testA(){
str = "B"
}
因为在testA
函数中,对外部的变量做了修改,可能会导致应用的状态发生变化,因为其他地方可能会使用到这个值,所以testA
函数具有附带效应。
kotlin
fun testA(){
var str:String = "A"
str = "B"
}
例如在testA
中,对局部变量进行了修改,但是并没有影响外部的状态,因此testA
没有附带效应。在Compose中,@Composable
函数应该没有附带效应,但是如果有需求需要更改应用的状态,需要使用SideEffect
以可预测的方式执行这些附带效应。
1.1 一个附带效应的例子
例如一个列表的展示,通过外部的count计数,最终展示多少个字母,因为count
是外部的成员变量,当Compose页面展示的时候,极可能因为某些情况导致重组,
kotlin
var count = 0;
Column {
val list = mutableListOf("A","B","C","D")
for (num in list){
Text(text ="当前字母:$num")
count++
}
Text(text = "一共 $count 个字母")
}
例如Column
作用域内部发生重组(这里只是举例子,因为Column是内联函数,重组作用域会扩大到外部的@Composable
函数,这个例子实际上是没问题,因为重组count会被重新初始化),而count会被重新累加,虽然只有4个字母,最终展示的可能是有6个或者8个,这个不确定。
这个其实就是附带效应,导致了程序的应用状态发生变化,而且是异常的变化。
但是从实际的业务需求出发,这种代码逻辑其实是很平常的,而因为重组导致出现bug的这种问题,其实Compose已经给出了解决方案,就是使用SideEffect
。
1.2 SideEffect的作用
SideEffect
保证的效果就是其中的代码在重组之后就会执行,即便是发生了多次重组,那么也只会执行一次,相当于它会在界面稳定之后,才会执行其中的代码逻辑。
kotlin
var count = 0;
@Composable
fun TestSideEffect() {
Column {
val list = mutableListOf("A","B","C","D")
for (num in list){
Text(text ="当前字母:$num")
SideEffect {
count++
}
}
Text(text = "一共 $count 个字母")
}
}
例如1.1小节中的例子,因为Column
会发生多次重组,导致count计数发生异常,那么可以使用SideEffect
来包裹count计数的累加。但是这样有用吗?重组完成之后,所有的界面显示已经确定了,再进行count累加其实就没有意义了。
所以在实际的开发中,这种需求我们往往需要尽量地减少对外部变量的依赖,通过list.size
做显示即可。
1.3 SideEffect和DisposableEffect的区别
前面我在介绍SideEffect
的时候,其回调是在组合(重组)完成之后,才会回调,也可以理解为进入界面之后才会回调。
kotlin
@Composable
fun TestSideEffect() {
Column {
val list = mutableListOf("A","B","C","D")
for (num in list){
Text(text ="当前字母:$num")
SideEffect {
Log.d(TAG, "TestSideEffect: $count")
count++
}
}
Text(text = "一共 $count 个字母")
}
}
因为在for循环里执行了4次,所以SideEffect
回调也会在重组完成之后,回调4次。
js
2024-03-28 15:23:43.406 28496-28496 Compose com.lay.composestudy D TestSideEffect: 0
2024-03-28 15:23:43.406 28496-28496 Compose com.lay.composestudy D TestSideEffect: 1
2024-03-28 15:23:43.406 28496-28496 Compose com.lay.composestudy D TestSideEffect: 2
2024-03-28 15:23:43.406 28496-28496 Compose com.lay.composestudy D TestSideEffect: 3
而DisposableEffect
则是会在key发生变化,或者离开界面的时候做一些清理的操作。
kotlin
@Composable
@NonRestartableComposable
fun DisposableEffect(
key1: Any?,
effect: DisposableEffectScope.() -> DisposableEffectResult
) {
remember(key1) { DisposableEffectImpl(effect) }
}
通过源码可以看到,DisposableEffect
在底层是通过remember(key)
的方式监听key的变化。
kotlin
@Composable
fun TestSideEffect() {
Column {
val list = mutableListOf("A", "B", "C", "D")
for (num in list) {
Text(text = "当前字母:$num")
}
Text(text = "一共 ${list.size} 个字母")
DisposableEffect(key1 = list) {
Log.d(TAG, "TestSideEffect: Column 进入界面")
onDispose {
Log.d(TAG, "TestSideEffect: Column离开界面")
}
}
}
}
在使用DisposableEffect
的时候,必须要添加onDispose
作为最终语句,当key发生变化时,会先执行onDispose
中的代码,然后再重新执行DisposableEffectScope
作用域中的代码。
其实DisposableEffect
就是一个加强版的SideEffect
,SideEffect
的回调是在进入界面完成组合后回调,而DisposableEffect
在进入界面完成组合后会回调,在离开界面的时候也会回调。
kotlin
@Composable
fun HomeScreen(
lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
onStart: () -> Unit,
onStop: () -> Unit
) {
DisposableEffect(key1 = lifecycleOwner) {
Log.d(TAG, "HomeScreen: DisposableEffect enter")
val observer = LifecycleEventObserver { source, event ->
when (event) {
Lifecycle.Event.ON_RESUME -> {
onStart.invoke()
}
Lifecycle.Event.ON_STOP -> {
onStop.invoke()
}
else -> {}
}
}
//注册
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
Log.d(TAG, "HomeScreen: DisposableEffect exit")
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
}
看一个官网的例子,使用DisposableEffect
来监听整个页面的生命周期,在进入到页面的时候,注册LifeCycleObserver
,当lifecycleOwner
发生变化,或者界面被移除了之后,会执行onDispose
函数回调,移除监听。
kotlin
Column {
var count by remember {
mutableStateOf(0)
}
if (count > 5){
Text(text = "123")
}else{
HomeScreen(onStart = {
Log.d(TAG, "onStart ----")
}) {
Log.d(TAG, "onStop ----")
}
}
Button(onClick = { count++ }) {
Text(text = "测试")
}
}
因为在count
大于5之后,HomeScreen
在重组的时候被移除了界面,此时会回调onDispose
函数,移除监听。
kotlin
@Composable
fun TestSideEffect() {
var showText by remember {
mutableStateOf(false)
}
Column {
if (showText){
Text(text = "隐藏的彩蛋")
}
Button(onClick = { showText = !showText}) {
Text(text = "测试")
}
}
// SideEffect {
// Log.d(TAG, "TestSideEffect: call ---- SideEffect ")
// }
DisposableEffect(Unit){
Log.d(TAG, "TestSideEffect: call ---- DisposableEffect ")
onDispose {
Log.d(TAG, "TestSideEffect: call ---- DisposableEffect onDispose ")
}
}
}
如果使用SideEffect
,那么在每次重组的时候都会无脑回调;而使用DisposableEffect
,则只有在key发生变化或者组件移除界面时会回调,其他情况下不会回调。通过上面的例子,可以验证结论。
2 Compose中的协程
前面我介绍了SideEffect
和DisposableEffect
的使用,主要是用于处理Compose中的附带效应,防止因为重组的过程中数据变化导致应用的状态出现异常,本节我将会介绍在Compose中如何使用协程,以及如何在协程中处理附带效应。
2.1 LaunchedEffect
如果想要在组合函数中调用挂起函数,那么就可以使用LaunchedEffect
。
kotlin
@Composable
@NonRestartableComposable
@OptIn(InternalComposeApi::class)
fun LaunchedEffect(
key1: Any?,
block: suspend CoroutineScope.() -> Unit
) {
val applyContext = currentComposer.applyCoroutineContext
remember(key1) { LaunchedEffectImpl(applyContext, block) }
}
// 具体实现类
internal class LaunchedEffectImpl(
parentCoroutineContext: CoroutineContext,
private val task: suspend CoroutineScope.() -> Unit
) : RememberObserver {
// 通过CoroutineScope创建协程
private val scope = CoroutineScope(parentCoroutineContext)
private var job: Job? = null
// 进入界面
override fun onRemembered() {
job?.cancel("Old job was still running!")
job = scope.launch(block = task)
}
// 离开界面
override fun onForgotten() {
job?.cancel()
job = null
}
override fun onAbandoned() {
job?.cancel()
job = null
}
}
LaunchedEffect
其实是一个特殊的DisposableEffect
,它的回调中是提供了一个协程作用域,通过CoroutineScope
创建的协程。它会在界面组合完成之后,会创建一个协程。它会在key发生变化的时候,会取消协程,然后创建新的协程,从onRemembered
函数中可以看到;如果从组合中移除的时候会取消协程。
kotlin
@Composable
fun TestLaunchEffect() {
var showFirst by remember {
mutableStateOf(true)
}
Column {
if (showFirst) {
Text(text = "第一个组件")
}
Text(text = "第二个组件")
LaunchedEffect(Unit, block = {
delay(3_000)
showFirst = false
})
}
}
例如当Column
组合完成之后,LaunchedEffect
会创建一个协程,其中delay
函数是挂起函数,3s后将showFirst
设置为false,发起重组,此时第一个组件消失,但是LaunchedEffect
中的block
将不会再次被执行,因为不满足重新执行的条件。
2.2 rememberUpdatedState
一般情况下,在页面刷新的时候,例如修改了showText
的值后会立刻发生重组,Text组件需要拿到最新的值进行页面的展示。
kotlin
@Composable
fun TestLaunchEffect() {
var showText by remember {
mutableStateOf("showText")
}
Column {
Text(showText)
Button(onClick = { showText = showText.uppercase() }) {
Text(text = "更新文案")
}
}
}
但是在一些场景下,可能不需要立刻拿到最新的值,例如在LaunchedEffect
中延迟3s,打印showText
的值。如果在3s内,点击了按钮,将showText
的值做了修改,那么我希望在3s后能够拿到修改的值。
kotlin
@Composable
fun TestLaunchEffect() {
var showText by remember {
mutableStateOf("showText")
}
Column {
Text(showText)
Launch(name = showText)
Button(onClick = { showText = "android" }) {
Text(text = "更新文案")
}
}
}
@Composable
private fun Launch(name: String) {
LaunchedEffect(Unit, block = {
delay(3000)
Log.d(TAG, "Launch: $name")
})
}
现在这个场景下,在3s内点击按钮,此时会发生重组,那么Launch
函数会重新调用,传入的name
的值也发生了改变,但是此时打印的值还是老的值,而不是新的值。
原因就是:LaunchedEffect
定义的时候,key为Unit,在重新执行的时候不会再次执行内部的代码块,因此还是打印了老的值,如果采用下面的这种方式,采用name
作为key,那么内部就会重新创建协程再打印新的值。
kotlin
@Composable
private fun Launch(name: String) {
LaunchedEffect(name, block = {
delay(3000)
Log.d(TAG, "Launch: $name")
})
}
但是,如何在不重新创建协程的情况下,能够在3s后拿到新修改的值,可以采用下面的这种方式:
kotlin
@Composable
private fun Launch(name: String) {
var newValue by remember {
mutableStateOf(name)
}
newValue = name
LaunchedEffect(Unit, block = {
delay(3000)
Log.d(TAG, "Launch: $newValue")
})
}
根据函数参数,创建一个mutableStateOf
成员变量,每次调用时都给这个变量重新赋值,那么在协程3s过后就会拿到新值,在Compose中提供了一个函数rememberUpdatedState
,相当于对其进行了封装。
kotlin
@Composable
fun <T> rememberUpdatedState(newValue: T): State<T> = remember {
mutableStateOf(newValue)
}.apply { value = newValue }
那么针对这种场景,可以使用rememberUpdatedState
更新值。
kotlin
@Composable
private fun Launch(name: String) {
val state = rememberUpdatedState(newValue = name)
LaunchedEffect(Unit, block = {
delay(3000)
Log.d(TAG, "Launch: ${state.value}")
})
}
所以使用rememberUpdatedState
就是用来在效应内部引用的值发生变化的时候,不需要重启就能拿到最新的值。
2.3 rememberCoroutineScope
例如,我需要在某个按钮点击的时候,进行网络请求获取数据后,刷新页面。
像Compose中提供的LaunchedEffect
是非常好用的,在进入界面的时候开启协程,在离开界面的时候关闭协程。但是LaunchedEffect
是一个@Composable
函数,它只能用到组合作用域内,而像clickable
这种常规函数中,是无法使用的,这就需要我们自己创建一个协程。
kotlin
@Composable
inline fun rememberCoroutineScope(
crossinline getContext: @DisallowComposableCalls () -> CoroutineContext =
{ EmptyCoroutineContext }
): CoroutineScope {
val composer = currentComposer
val wrapper = remember {
CompositionScopedCoroutineScopeCanceller(
createCompositionCoroutineScope(getContext(), composer)
)
}
return wrapper.coroutineScope
}
Compose当中为我们提供了rememberCoroutineScope
函数,用于创建一个协程作用域,开发者可以主动调用launch
函数开启协程,例如下面的例子:
kotlin
@Composable
fun HomeScreen2(
viewModel: HomeScreenViewModel
) {
//数据
val showText by viewModel.showText.observeAsState("")
// 启动协程
val scope = rememberCoroutineScope()
Column {
Text(text = showText)
Button(onClick = {
scope.launch {
viewModel.getTextValue()
}
}) {
Text(text = "更新数据")
}
}
}
当点击按钮时,会开启一个协程,这里模拟了网络请求,3s后将数据更新。
kotlin
class HomeScreenViewModel : ViewModel() {
private var _showText = MutableLiveData("初始值")
val showText: LiveData<String>
get() = _showText
/**
* 获取text
*/
suspend fun getTextValue() {
delay(3_000)
_showText.value = "这是最新值"
}
}
在HomeScreen中,通过observeAsState
函数将LiveData
转换为了mutableStateOf
,其实原理很简单,就是在showText
的数据发生变化时,更新mutableStateOf
的value。
kotlin
implementation("androidx.compose.runtime:runtime-livedata:1.6.4")
这个是Compose和LiveData生态做的融合,用于在Compose中使用LiveData,下面是对源码的解析。
kotlin
@Composable
fun <R, T : R> LiveData<T>.observeAsState(initial: R): State<R> {
val lifecycleOwner = LocalLifecycleOwner.current
// 创建了`mutableStateOf`数据。
val state = remember {
@Suppress("UNCHECKED_CAST") /* Initialized values of a LiveData<T> must be a T */
mutableStateOf(if (isInitialized) value as T else initial)
}
DisposableEffect(this, lifecycleOwner) {
// 进入界面的时候注册数据变化的监听
val observer = Observer<T> {
// 当LiveData数据发生变化时,更新state的值。
state.value = it
}
observe(lifecycleOwner, observer)
onDispose {
// 离开页面移除监听
removeObserver(observer)
}
}
return state
}
通过rememberCoroutineScope
创建的协程作用域的生命周期是依附于当前组合函数,当组合函数从界面消失之后,协程就会被取消 ;而如果使用lifeCycleScope
创建协程,会在整个Activity页面消失之后才会取消协程。
3 Compose状态迁移
在2.3 小节中,我通过一个demo示例介绍了将LiveData转换为Compose能够订阅的状态,为什么需要状态转换,其实很简单,在传统的View体系中,可以通过findViewById
拿到对应的View的实例,在LiveData数据发生变化之后,调用TextView
的setText
方法,更新UI。
kotlin
val textView = findViewById<TextView>(R.id.text)
homeScreenViewModel.showText.observe(this){
textView.text = it
}
但是对于Compose声明式UI的刷新逻辑,在第一篇Compose文章中介绍过,其实无法拿到组件的实例,只能通过订阅状态的变化发起重组,所以需要将传统View的状态转换为Compose状态。
3.1 状态迁移的方式
在将LiveData
转为State
时,使用了DisposableEffect
。
kotlin
@Composable
fun <T> LiveData<T>.transformState(initialValue: T): State<T> {
val owner = LocalLifecycleOwner.current
val state = remember {
mutableStateOf(initialValue)
}
DisposableEffect(Unit, effect = {
//注册LiveData数据变化监听
val observer = Observer<T> { value -> state.value = value }
observe(owner, observer)
onDispose {
removeObserver(observer)
}
})
return state
}
在之前的文章中:
Android进阶宝典 -- Google对于开发者的一些架构建议
Google建议开发者使用MVI单向数据流的架构模式,因为需要使用Flow来定义状态流,View层根据流的状态来做对应的UI展示。
kotlin
class HomeScreenViewModel : ViewModel() {
private var _homeState = MutableStateFlow<HomeState>(HomeState.LoadingState)
val homeState: StateFlow<HomeState>
get() = _homeState
suspend fun getHomeContent() {
_homeState.value = HomeState.LoadingState
delay(2_000)
_homeState.value = HomeState.ShowContent("获取到了最新内容")
}
}
如果使用StateFlow
,那么数据流的收集就必须在协程中调用,那么DisposableEffect
就不能使用了,这个时候需要使用LaunchedEffect
。
kotlin
@Composable
fun <T> StateFlow<T>.transformState(initialValue: T): State<T> {
val state = remember {
mutableStateOf(initialValue)
}
LaunchedEffect(Unit) {
repeatOnLifecycle(Lifecycle.State.STARTED) {
collect {
// 更新数据
state.value = it
}
}
}
return state
}
使用Stateflow
的标准API就可以实现将Stateflow
转换为mutableStateOf
,有精力的伙伴可以看下官方提供的组件源码。
kotlin
@Composable
fun HomeScreen3(
viewModel: HomeScreenViewModel
) {
//数据
val homeState by viewModel.homeState.transformState(HomeState.LoadingState)
// 启动协程
val scope = rememberCoroutineScope()
Column {
Text(text = "标题")
when (homeState) {
is HomeState.LoadingState -> {
Text(text = "加载中......")
}
is HomeState.ShowContent -> {
Text(text = (homeState as HomeState.ShowContent).msg)
}
is HomeState.ErrorState -> {
Text(text = "出错了~")
}
}
Button(onClick = {
scope.launch {
viewModel.getHomeContent()
}
}) {
Text(text = "请求接口")
}
}
}
那么在使用的时候,就可以使用MVI架构模式,在界面层根据状态展示不同的UI。
在Compose中其实提供了一个更便捷的api用于快速构建协程状态转换produceState
,利用produceState
可以对之前封装的transformState
进行改造。
kotlin
@Composable
fun <T> StateFlow<T>.transformState(initialValue: T): State<T> {
return produceState(initialValue = initialValue, producer = {
repeatOnLifecycle(Lifecycle.State.STARTED) {
collect {
value = it
}
}
})
}
produceState
在内部创建了mutableStateOf
对象并返回,同样是通过LaunchedEffect
创建了协程,提供的producer
作用域内部可以直接操作mutableStateOf
属性。
kotlin
@Composable
fun <T> produceState(
initialValue: T,
producer: suspend ProduceStateScope<T>.() -> Unit
): State<T> {
val result = remember { mutableStateOf(initialValue) }
LaunchedEffect(Unit) {
ProduceStateScopeImpl(result, coroutineContext).producer()
}
return result
}
3.2 snapshotFlow
snapshotFlow
用于将Compose的状态,转换为Flow。当Compose的状态发生变化时,Flow的collect会感知到状态的变化并回调。
kotlin
@Composable
fun HomeScreen3(
viewModel: HomeScreenViewModel
) {
//数据
val homeState by viewModel.homeState.transformState(HomeState.LoadingState)
val flow = snapshotFlow {
homeState
}
LaunchedEffect(key1 = Unit, block = {
flow.collect{
Log.d(TAG, "state changed : $it")
}
})
// 启动协程
val scope = rememberCoroutineScope()
Column {
Text(text = "标题")
when (homeState) {
is HomeState.LoadingState -> {
Text(text = "加载中......")
}
is HomeState.ShowContent -> {
Text(text = (homeState as HomeState.ShowContent).msg)
}
is HomeState.ErrorState -> {
Text(text = "出错了~")
}
}
Button(onClick = {
scope.launch {
viewModel.getHomeContent()
}
}) {
Text(text = "请求接口")
}
}
}
这个是干什么用的呢?我理解是这样的,因为Compose可以与原生的View交互,在原生的View界面中如果要感知Compose状态的变化,可以使用snapshotFlow
。