在Android应用中可以通过定义可组合函数来搭建应用界面。应用界面的更新往往是与可组合函数内部定义的状态值相关联的。当界面的状态值发生变更,会导致应用界面进行更新。在Android笔记(九):Compose组件的状态,对Compose组件的状态进行详细地介绍。理想状态下,可组合函数使用是定义范围内的状态值,通过内部状态的变更,修改可组合项构成的界面。但是,在有些特殊场景下,需要在可组合项中运行一些在可组合函数作用域外的一些应用状态。简单地说,附带效应就是在可组合函数中一些可组合函数作用域外的应用状态的变化。这些效应在可组合函数范围外,对于可组合函数来说并不好控制,容易造成过度使用。因此需要结合Effect API来对这些效应进行可预测地控制和处理。
一、什么是附带效应
在前述中对附带效应进行简单说明,在下面需要通过应用实例来解释:
运行下列代码块:
//全局变量,在MainScreen作用域外
val timer = mutableIntStateOf(0)@Composable
fun MainScreen(){//函数作用域内var runningState = remember{mutableStateOf(false)}val scope = rememberCoroutineScope()Box(modifier = Modifier.fillMaxSize(),contentAlignment = Alignment.Center){Column(modifier = Modifier.fillMaxWidth(),horizontalAlignment = Alignment.CenterHorizontally,verticalArrangement = Arrangement.Center){Text(text = "计时器",fontSize=30.sp,color=MaterialTheme.colorScheme.primary)Text(text = "${timer.value}秒",fontSize = 24.sp)Row(modifier = Modifier.fillMaxWidth(),horizontalArrangement = Arrangement.Center){IconButton(modifier = Modifier.width(100.dp),onClick={runningState.value = truescope.launch {while(runningState.value){delay(1000)timer.value +=1Log.d("TAG","${timer} m")}}}){Row{Icon(imageVector = Icons.Filled.Start,tint = Color.Green,contentDescription = null)Text("计时")}}IconButton(modifier = Modifier.width(100.dp),onClick={runningState.value = false}){Row{Icon(imageVector = Icons.Filled.Stop,tint = Color.Green,contentDescription = null)Text("计时")}}}}}
}
在上述代码中,有几点需要注意:
1.rememberCoroutineScope函数
rememberCoroutineScope函数可以获取感知作用域,在可组合项外启动协程。在上述代码中定义的IconButton中的点击事件onClick的,处理timerState值的变化。因为下列的代码段:
while(runningState.value){
delay(1000)
timer.value +=1
Log.d(“TAG”,"${timer} m")
}
delay函数是一个挂起函数,表示地是在不阻塞线程的情况下将协程延迟给定时间(例如代码中设置的1秒),并在指定时间后恢复。因此需要在协程的范围中调用delay这个挂起函数,因此利用rememberCoroutineScope函数获得一个CorountineScope协程范围,在该范围可以对挂起函数进行调用。
val scope = rememberCoroutineScope()
scope.launch{
while(runningState.value){
delay(1000)
timer.value +=1
Log.d(“TAG”,"${timer} m")
}
}
2.关于外部量的变化
定义了状态值timerState和MainScreen()这个可组合函数。timerState这个可变的状态值是一个全局量,在MainScreen()函数的作用域外。需要将timerState的值进行修改,会发生在两种情况:
(1)MainScreen()可组合函数调用时;
(2)MainScreen()函数进行重组时会调用timerState
而在第二种情况中,可组合函数重组时所触发timerState状态值的变化而变化的情况就是发生了附带效应。但是,上述代码定义的timerState值的变化是不可预知的,因为它的作用域在MainScreen函数外,外界也有可能存在促使timerState值变化的情况。因此需要一些方法对timerState值的变更情况做出预测并做出相应的处理。
图1 附带效应示意
二、LaunchedEffect
LaunchedEffect函数可以在某个可组合项的作用域内运行挂起函数时,它会启动内部的代码块到协程上下文CoroutineContext中。当函数的key1的值发生变化,会重构LaunchedEffect。这时,LaunchedEffect原来启动的协程会被取消然后又重新启动。当LaunchedEffect退出组合项时,协程会被取消。LaunchedEffect可组合函数定义如下:
@Composable @NonRestartableComposable
@OptIn(InternalComposeApi::class) fun LaunchedEffect(
key1: Any?,
block: suspend CoroutineScope.() -> Unit ) {
val applyContext = currentComposer.applyCoroutineContext
remember(key1) { LaunchedEffectImpl(applyContext, block)
}
}
其中,LaunchedEffect函数中的参数:
key1:表示关键字可以是任何类型,如果是可变的状态值时,可以根据可变状态值的变化,取消原有协程并启动新的协程。 当key1为Unit或true时,LaunchedEffect函数将与当前重组函数保持一致的生命周期。
block:表示要调用的挂起函数。需要在协程范围中运行;
修改上述计时器的代码段,代码如下所示:
//全局变量,在MainScreen作用域外
val timer = mutableIntStateOf(0)@Composable
fun MainScreen(){//函数作用域内var runningState = remember{mutableStateOf(false)}val scope = rememberCoroutineScope()LaunchedEffect(key1 = timer.value){ //一旦发生修改,不断取消原有协程,创建新的协程,delay(1000)timer.value +=1 //会使得LaunchedEffect不断重构}Box(modifier = Modifier.fillMaxSize(),contentAlignment = Alignment.Center){Column(modifier = Modifier.fillMaxWidth(),horizontalAlignment = Alignment.CenterHorizontally,verticalArrangement = Arrangement.Center){Text(text = "计时器",fontSize=30.sp,color=MaterialTheme.colorScheme.primary)Text(text = "${timer.value}秒",fontSize = 24.sp)Row(modifier = Modifier.fillMaxWidth(),horizontalArrangement = Arrangement.Center){IconButton(modifier = Modifier.width(100.dp),onClick={runningState.value = truetimer.value +=1 //点击按钮控制第一次值的修改}){Row{Icon(imageVector = Icons.Filled.Start,tint = Color.Green,contentDescription = null)Text("计时")}}IconButton(modifier = Modifier.width(100.dp),onClick={runningState.value = falsetimer.value +=1}){Row{Icon(imageVector = Icons.Filled.Stop,tint = Color.Green,contentDescription = null)Text("停止")}}}}}
}
在上述代码中可以发现,当点击计时按钮时,修改了LaunchedEffect函数中的键参数timer.value的值,使得,LaunchedEffect函数timer.value的值的变化,使得原有协程取消,启动新的协程,并在新的协程中修改了timer.value的值,LaunchedEffect函数的不断重构,使得状态值不断修改,导致计时界面MainScreen不断重构和刷新。通过这样的方式,达到对timer.value修改的可预测控制。但是这种不断切换协程的方式并不可取,可以修改代码成如下的形式:
//全局变量,在MainScreen作用域外
val timer = mutableIntStateOf(0)@Composable
fun MainScreen(){//函数作用域内var runningState = remember{mutableStateOf(false)}val scope = rememberCoroutineScope()LaunchedEffect(key1 = runningState.value){while(runningState.value){delay(1000)timer.value +=1Log.d("TAG","${timer} m")}}Box(modifier = Modifier.fillMaxSize(),contentAlignment = Alignment.Center){Column(modifier = Modifier.fillMaxWidth(),horizontalAlignment = Alignment.CenterHorizontally,verticalArrangement = Arrangement.Center){Text(text = "计时器",fontSize=30.sp,color=MaterialTheme.colorScheme.primary)Text(text = "${timer.value}秒",fontSize = 24.sp)Row(modifier = Modifier.fillMaxWidth(),horizontalArrangement = Arrangement.Center){IconButton(modifier = Modifier.width(100.dp),onClick={runningState.value = true}){Row{Icon(imageVector = Icons.Filled.Start,tint = Color.Green,contentDescription = null)Text("计时")}}IconButton(modifier = Modifier.width(100.dp),onClick={runningState.value = false}){Row{Icon(imageVector = Icons.Filled.Stop,tint = Color.Green,contentDescription = null)Text("停止")}}}}}
}
运行效果是:
在上述代码段中,按钮的点击动作只是修改了可组合函数内部的runningState状态值。但是,
LaunchedEffect(key1 = runningState.value){
scope.launch {
while(runningState.value){
delay(1000)
timer.value +=1
Log.d(“TAG”,"${timer} m")
}
}
}
如果点击按钮,会修改键参数runingState.value的值。而在上述代码的LaunchedEffect函数中监控到runningState.value值的变化会将原来的协程取消,然后再重新启动新的协程,在新的协程中修改MainScreen可组合函数外部的timerState状态值,从而达到计时的功能实现。在这里,是通过控制runningState来达到修改外部timerState的目的。如果runningState.value值没有发生任何变化,那么原有的协程不会取消,会继续运行。如果runningState.value的值为false,那么界面将不会发生变化。通过这样的方式,LaunchedEffect监控key1的变化,使得对timerState.value修改可以控制。
三、rememberUpdatedState
在上述代码中,LaunchedEffect函数会根据关键字的值的变化,重启协程。但是,在某些情况下,并不希望LaunchedEffect重启,但是却需要LaunchedEffect函数中变更的状态的值。因此,可以考虑使用rememberUpdatedState函数用于创建可捕获和更新该值的引用。但是注意这样的处理方式代价会比较高昂。
//全局变量,在MainScreen作用域外
val timer = mutableIntStateOf(0)@Composable
fun MainScreen(){//函数作用域内var runningState = remember{mutableStateOf(true)}val scope = rememberCoroutineScope()val timerState = rememberUpdatedState(newValue = timer)LaunchedEffect(Unit){while(runningState.value){delay(1000)timerState.value.value +=1Log.d("TAG","${timer.value} m")}}Box(modifier = Modifier.fillMaxSize(),contentAlignment = Alignment.Center){Column(modifier = Modifier.fillMaxWidth(),horizontalAlignment = Alignment.CenterHorizontally,verticalArrangement = Arrangement.Center){Text(text = "计时器",fontSize=30.sp,color=MaterialTheme.colorScheme.primary)Text(text = "${timerState.value.value}秒",fontSize = 24.sp)Row(modifier = Modifier.fillMaxWidth(),horizontalArrangement = Arrangement.Center){IconButton(modifier = Modifier.width(100.dp),onClick={runningState.value = true}){Row{Icon(imageVector = Icons.Filled.Start,tint = Color.Green,contentDescription = null)Text("计时")}}IconButton(modifier = Modifier.width(100.dp),onClick={runningState.value = falsetimer.value +=1}){Row{Icon(imageVector = Icons.Filled.Stop,tint = Color.Green,contentDescription = null)Text("停止")}}}}}
}
在上述代码中,MainScreen可组合函数内部定义:
val timerState = rememberUpdatedState(newValue = timer)
而在LaunchedEffect代码部分定义为:
LaunchedEffect(Unit){while(runningState.value){delay(1000)timerState.value.value +=1Log.d("TAG","${timer.value} m")}}
LaunchedEffect函数中的键参数为Unit,这表示在MainScreen函数被调用时或重新组合时,才会加载LaunchedEffect函数。并不存在键参数值的变化重新加载协程代码的可能。但是,通过rememberUpdatedState(newValue = timer)函数,一致可以通过timerState.value来获取变化的状态timer.
因此,在上述代码中,由于runningState.value初始值为true,因此一启动MainScreen,就会显示显示动态计时的效果。但是,当点击停止按钮runningState.value的值设置为false,导致代码段停止运行。然后再点击"计时“按钮,可以发现,LaunchedEffect函数并没有重启。
四、DisposableEffect清理效应
对于需要在键发生变化或可组合项退出组合后进行清理的附带效应,可以通过DisposableEffect来实现。DisposableEffect是组合的附效应。DisposableEffect函数定义如下:
@Composable
@NonRestartableComposable
fun DisposableEffect(
key1: Any?,
effect: DisposableEffectScope.() -> DisposableEffectResult ) {
remember(key1) {
DisposableEffectImpl(effect)
}
}
当key1是键参数,可以为任何值。当key1的值发生变化或者DisposableEffect离开可组合项,则必须执行撤销或清除操作。
//全局变量,在MainScreen作用域外
val timer = mutableIntStateOf(0)@Composable
fun MainScreen(){//函数作用域内var runningState = remember{mutableStateOf(true)}val scope = rememberCoroutineScope()LaunchedEffect(Unit){while(runningState.value){delay(1000)timer.value+=1Log.d("TAG","${timer.value} m")}}DisposableEffect(key1 = timer.value){onDispose {timer.value = 0runningState.value = false}}Box(modifier = Modifier.fillMaxSize(),contentAlignment = Alignment.Center){Column(modifier = Modifier.fillMaxWidth(),horizontalAlignment = Alignment.CenterHorizontally,verticalArrangement = Arrangement.Center){Text(text = "计时器",fontSize=30.sp,color=MaterialTheme.colorScheme.primary)Text(text = "${timer.value}秒",fontSize = 24.sp)Row(modifier = Modifier.fillMaxWidth(),horizontalArrangement = Arrangement.Center){IconButton(modifier = Modifier.width(100.dp),onClick={runningState.value = true}){Row{Icon(imageVector = Icons.Filled.Start,tint = Color.Green,contentDescription = null)Text("计时")}}IconButton(modifier = Modifier.width(100.dp),onClick={runningState.value = falsetimer.value +=1}){Row{Icon(imageVector = Icons.Filled.Stop,tint = Color.Green,contentDescription = null)Text("停止")}}}}}
}
上述代码的界面没有任何变化,这是因为下列代码
LaunchedEffect(Unit){
scope.launch {
while(runningState.value){
delay(1000)
timer.value+=1
Log.d(“TAG”,"${timer.value} m")
}
}
}DisposableEffect(key1 = timer.value){onDispose {timer.value = 0runningState.value = false} }
启动MainScreen时,timer.value修改为1,但是DisposableEffect发现到timer.value变更,就会执行清除操作,修改timer.value=0和runningState.value=false。在后续中将无法动态显示计时效果。DisposableEffect往往和生命周期进行关联,当键参数为生命周期拥有者对象时,可以利用DisposableEffect对生命周期拥有者的变化执行撤销或清理的工作。
在下面定义两个活动MainActivity和OtherActivity,这两个活动分别为不同的生命周期拥有者,具有不同的生命周期对象。具体定义如下:
(1)MainActivity的定义
首先定义依附MainActivity的界面HomeScreen的定义
@Composable
fun HomeScreen(lifecycleOwner:LifecycleOwner = LocalLifecycleOwner.current,onStart:(MutableState<Int>)->Unit,onStop:(MutableState<Int>)->Unit){val startAction by rememberUpdatedState(newValue = onStart)val stopAction by rememberUpdatedState(newValue = onStop)val context = LocalContext.currentvar timer = remember{mutableIntStateOf(0)}//以键参数为生命周期拥有者lifecycleOwnerDisposableEffect(key1 = lifecycleOwner){//定义生命周期观察者val observer = LifecycleEventObserver{_,event->if(event == Lifecycle.Event.ON_RESUME){startAction(timer)}else if(event == Lifecycle.Event.ON_STOP){stopAction(timer)}}//生命周期拥有者lifecycleOwner的生命周期lifecycle加入新的观察者observer,观察者可以观察生命周期的变化lifecycleOwner.lifecycle.addObserver(observer)onDispose {//当离开可组合项执行清理处理Log.d("TAG","清理完毕")timer.value = 0lifecycleOwner.lifecycle.removeObserver(observer)}}Box(contentAlignment= Alignment.Center,modifier = Modifier.fillMaxSize()){Column{Text(text = "${timer.value}秒",fontSize=30.sp)Button(onClick = {//跳转到其他活动,即修改了生命周期拥有者对象val intent = Intent(context, OtherActivity::class.java)context.startActivity(intent)}){Text("跳转到其他活动")}}}
}
再定义主活动MainActivity
class MainActivity : ComponentActivity() {override fun onCreate(savedInstanceState: Bundle?) {super.onCreate(savedInstanceState)setContent {Ch06_DemoTheme {// A surface container using the 'background' color from the themeSurface(modifier = Modifier.fillMaxSize(),color = MaterialTheme.colorScheme.background) {HomeScreen(onStart = ::onStartCall, onStop = ::onStopCall )}}}}fun onStartCall(timer: MutableState<Int>){Log.d("TAG","startCall")thread{while(timer.value<100){timer.value++Thread.sleep(1000)}}}fun onStopCall(timer: MutableState<Int>){//start停止Log.d("TAG","stopCall")}
}
(2)OtherActivity的定义
class OtherActivity : ComponentActivity() {override fun onCreate(savedInstanceState: Bundle?) {super.onCreate(savedInstanceState)setContent{Box(contentAlignment = Alignment.Center,modifier = Modifier.wrapContentSize()){Text("OtherActivity",fontSize = 30.sp)}}}
}
运行情况说明:
(1)点击运行的效果如下图所示:
(2)当点击”跳转其他活动“按钮,跳转到OtherActivity的界面,跟踪的日志如下:
日志显示
(3)当彻底退出关闭这个应用,则在日志中显示:
这是因为,在跳转到OtherActivity时,LifecycleOwner虽然已经切换到OtherActivity,但是界面HomeScreen已经在后台,因此并没有执行onDispose的代码块。只有在彻底退出应用,彻底离开可组合函数HomeScreen,可组合函数HomeScreen中的DisposableEffect中监测到代码生命周期拥有者的对象发生了变化,因此执行onDispose代码块。
五、SideEffect
可组合函数进行重组时并不是每次都成功,如果出现界面状态值发生变化,但是界面会进行重组。但是,在重组的过程中,一些状态数据又发生了变化,导致上次重组没有完全完成。这就使得一些与界面重组无关的数据和代码也会被多次调用,这种情况显然是没有必要的。在这样的前提下,可以使用SideEfffect。
SideEffect表示“副作用”,它将 Compose 的状态发布为非 Compose 代码。如需与非 Compose 管理的对象共享 Compose 状态,可使用 SideEffect 中可组合项,因为只有每次成功重组时才会调用该可组合项。
SideEffect函数定义如下:
@Composable
@NonRestartableComposable
@OptIn(InternalComposeApi::class)
fun SideEffect(effect: () -> Unit ) {
currentComposer.recordSideEffect(effect)
}
观察下列代码运行结果和日志:
@Preview
@Composable
fun SideScreen(){//可组合的状态var timer by remember{ mutableIntStateOf(0) }var running by remember{mutableStateOf(false)}Log.d("TAG","在SideEffect前的日志")SideEffect{Log.d("TAG","执行SideEffect函数:running:${running}")if(running) {timer++Thread.sleep(1000)}}Log.d("TAG","在SideEffect函数后的日志")Box(contentAlignment= Alignment.Center,modifier = Modifier.fillMaxSize()){Column{Text(text = "${timer}秒",fontSize=30.sp)Row(horizontalArrangement = Arrangement.Center){Button(onClick = {running = trueLog.d("TAG","running:${running}")}){Text("计时")}Button(onClick = {running = falsetimer = 0Log.d("TAG","running:${running}")}){Text("停止")}}}}
}
上述代码的运行结果如下所示:
并没有发生动态更新计时时间的显示。这是因为,running的值为false,在条件判断时,使得timer状态值无法发生变化,状态值没有变化,使得界面只会在调用时刷新界面。即使点击”计时“按钮,修改了running的值为true,因为影响界面重组的状态值timer并没有变化,因此界面没有发生重组。这与日志显示是一致的。下图显示的日志就是这样的效果。
观察日志,可以发现调用可组合函数SideScreen时,重组成功后,才会调用SideEffect函数。因为日志输出SideEffect函数内部的Lambda代码段中的日志是最后调用的。
再来观察下列代码
(1)定义数据类Timer
/*** counter:Int记录已经计时的时间* timerInterval:Int时间间隔* /
data class Timer(var counter:Int,val timeInterval:Int=1000)
(2)定义将Compose状态返回非Compose的值
@Composable
fun rememberTimer(counter:Int):Timer{//定义可组合的对象val timer = remember{Timer(0)}SideEffect{timer.counter = counter}return timer
}
在上述的可组合函数rememberTimer中生成并返回了一个非Compose的Timer对象
(3)在界面中进行测试
@Preview
@Composable
fun SideScreen(){//非状态的对象var timer = rememberTimer(counter = 0)val scope = rememberCoroutineScope()var running by remember{mutableStateOf(true)}Box(contentAlignment= Alignment.Center,modifier = Modifier.fillMaxSize()){Column{Text(text = "${timer.counter}秒",fontSize=30.sp)Row(horizontalArrangement = Arrangement.Center){Button(onClick = {running = truescope.launch {while(running){delay(1000)timer.counter++Log.d("TAG",""+timer.counter)}}Log.d("TAG","running:${running}")}){Text("计时")}Button(onClick = {running = falsetimer.counter = 0Log.d("TAG","running:${running}")}){Text("停止")}}}}
}
运行效果如上图一致。
执行点击”计时“按钮,即使running的值为true,界面不会重组。这是因为影响界面的timer已经是一个非Compose的对象,并不能对界面的重组起到作用。观察日志,可以发现timer.counter值的确每秒进行变更了。
参考文献
1.Compose 中的附带效应
https://developer.android.google.cn/jetpack/compose/side-effects?hl=zh-cn