什么是 Side Effect?
Side Effect 的中文翻译是 “副作用”,在 Android 的简体中文官方文档中被翻译成了 “附带效应”,这两个词其实都对。
谈到副作用,一般人想到的都是药物副作用,使用药物时产生的不良效果,但有时它也是有益的。
啊?副作用还能有益了?
没错,因为此 “副作用” 非彼 “负作用” ,它的本义指的是在完成目标效果之外产生的非预期效果。它强调的是效果的 “附带性” 和 “次要性” 。而 “负作用” 则侧重于效果的负面性质,即有害的影响。
在计算机科学中,副作用是指当调用函数时,除了返回函数值之外,还对程序状态产生的附加影响。本质上,是函数内部影响到了函数外部。
具体来说,副作用包括:
- 修改全局变量:函数的内部修改到了函数外部定义的变量值
- 修改传入的参数:函数改变了传入的参数值
- 输入/输出操作:如读写文件、网络请求、屏幕显示等
- 抛出异常:中断正常的程序执行流程
- 调用其他具有副作用的函数:间接产生副作用
先来看看没有副作用的函数:
fun add(a: Int, b: Int): Int {
return a + b;
}
不管你调用它多少次,对于相同的输入,总是返回相同的输出,它不会影响到外部状态。
再看看具有副作用的函数:
var total: Int = 0
fun addToTotal(value: Int): Int {
total += value; // 修改了全局变量,产生副作用
return total;
}
其中修改到了函数外部的变量 total,所以具有副作用。
并且之前提到有输入、输出行为也算副作用函数,例如:
fun HelloWorld(): Unit {
println("Hello World!")
}
所以这也是一个具有副作用的函数,对吧。
但打印 "Hello World!" ,也改变了程序的状态吗?
这就不得不提到 参照透明性(Referential Transparency) 了,参照透明性是指函数可以被其返回值替换,而不改变程序的行为,对程序没有任何影响。
有副作用的函数是不具备这一特性的。所以我们再来看看上面的 HelloWorld() 函数,如果把它的函数调用,换为返回值 Unit,这时,会发现替换后的代码什么都不打印了,与替换之前存在着些许差异,这说明它不具备参照透明性。
HelloWorld() 函数在控制台上显示了文本,改变了控制台的状态,所以它具有副作用。
副作用在Compose中的问题
为什么要避免副作用?
知道了副作用的定义,那有什么用?
在 Jetpack Compose 中,Compose 团队对组件(Composable 函数)有一个重要要求,就是无副作用。Composable 函数应该专注于显示 UI 的工作,而不应该做任何对外界有影响的事情。因为Composable 函数的副作用可能会导致不可预测的行为。
原因在于Composable 函数的调用就是不可预期的,主要是 Compose 框架对 重组(Recomposition) 过程进行了优化, Composable 函数在重组过程中可能完全不被调用,也可能执行到一半时被取消,甚至可能被多次调用。这样可能会导致数据库操作被重复执行、组件的状态与实际的数据不一致和难以预测等严重问题。
错误示例
比如看下面这段代码:
@Composable
fun VisitCounter() {
var count by remember { mutableIntStateOf(0) }
val context = LocalContext.current
var visitCount by remember { mutableIntStateOf(0) }
Column {
Button(onClick = { count++ }) {
Text("点击重组")
}
println(count)
// 在Composable中读写SharedPreferences
val prefs = context.getSharedPreferences("app_prefs", Context.MODE_PRIVATE)
visitCount = prefs.getInt("visit_count", 0) + 1
prefs.edit().putInt("visit_count", visitCount).apply()
Text("这是您的第 $visitCount 次访问")
}
}
这段代码的作用是,每当用户进入软件,使用 VisitCounter() 展示用户访问的总次数,并且增加访问次数。
但这么写是不行的, VisitCounter() 可能因为各种原因触发重组(如配置更改、系统事件),每次重组都会增加用户的访问次数,导致访问次数不准确,远高于实际的访问次数,visitCount 的值变得不可预期。
比如我点击“点击重组”按钮,结果:
正是由于这种不可预期,所以 Compose 团队建议开发者不要在 Composable 函数中去写副作用代码,这样就不会导致我们意想不到的效果。
副作用函数
而我们往往是需要在 Composable 函数中写这种副作用代码的,比如进行文件操作。所以 Compose 团队也给我们提供一系列副作用函数,让我们可以在 Composable 函数中安全地写副作用代码。
SideEffect()
其中最简单的就是 SideEffect() 函数,它会确保每次成功重组(Recomposition)后再去执行代码。
SideEffect {
// ...
}
你在 SideEffect 的内部写的代码,每次重组时,并不是执行到 SideEffect() 函数就会执行内部的代码,而是会将代码暂存下来,等到整个界面的重组过程已经完成之后,这时候才会去执行 SideEffect 内部的代码。
比如:
@Composable
fun VisitCounter() {
val context = LocalContext.current
var visitCount by remember { mutableIntStateOf(0) }
val prefs = context.getSharedPreferences("app_prefs", Context.MODE_PRIVATE)
visitCount = prefs.getInt("visit_count", 0) + 1
prefs.edit().putInt("visit_count", visitCount).apply()
SideEffect {
Log.d("Composition", "VisitCounter composed,it show the $visitCount times to visit it")
}
Text("这是您的第 $visitCount 次访问")
Log.d("VisitCounter", "VisitCounter's code is over")
}
运行结果:
VisitCounter's code is over
VisitCounter composed,it show the 111 times to visit it
这样就能保证执行到一半时被取消的 Composable 函数内部的副作用代码,完全不会被执行。还能保证在一次重组过程中,被调用多次的 Composable 函数内部的副作用代码,只会在重组结束后执行一次。
上面两点作用中,用得最多的还是第一点,因为第二点,虽然能够保证一次重组中,副作用代码只被执行一次,但是重组是可能会发生多次的,这样会导致副作用也会多次执行。
比如在上面的示例中,我们是不指望这么写,能够消除它的副作用的:
@Composable
fun VisitCounter() {
var count by remember { mutableIntStateOf(0) }
val context = LocalContext.current
var visitCount by remember { mutableIntStateOf(0) }
Column {
Button(onClick = { count++ }) {
Text("点击重组")
}
println(count)
// 在Composable中读写SharedPreferences
val prefs = context.getSharedPreferences("app_prefs", Context.MODE_PRIVATE)
visitCount = prefs.getInt("visit_count", 0)
SideEffect {
prefs.edit().putInt("visit_count", visitCount + 1).apply()
}
Text("这是您的第 ${visitCount + 1} 次访问")
}
}
如果你要完成这个需求,你该怎么做?
你应该在 Composable 函数外部提前把数据处理好,然后把数据提供到界面中。
使用 ViewModel 来管理访问次数:
class VisitCounterViewModel(application: Application) : AndroidViewModel(application) {
private val prefs = application.getSharedPreferences("app_prefs", Context.MODE_PRIVATE)
private val _visitCount = MutableStateFlow(0)
val visitCount: StateFlow<Int> = _visitCount.asStateFlow()
init {
// 只在 ViewModel 初始化时读取和自增
val count = prefs.getInt("visit_count", 0) + 1
prefs.edit().putInt("visit_count", count).apply()
_visitCount.value = count
}
}
界面只适合展示:
@Composable
fun VisitCounter(viewModel: VisitCounterViewModel = viewModel()) {
val visitCount by viewModel.visitCount.collectAsState()
Text("这是您的第 $visitCount 次访问")
}
这样不会因重组而重复增加访问次数了。
使用
viewModel()函数,需要引入依赖:
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
DisposableEffect()
SideEffect() 实际上是组合完成并成功重组后的回调,而 DisposableEffect() 对于 Composable 进入 Composition、离开 Composition 都安插了回调。
简单来说就是组件首次在界面显示和从界面中移除的回调。
DisposableEffect(key1 = ){
// 组件首次显示时执行的副作用逻辑
onDispose {
// 组件被移除时执行的清理逻辑
}
}
比如:
@Composable
private fun DisposableEffectDemo() {
var subscribe by remember { mutableStateOf(false) }
Button(onClick = { subscribe = !subscribe }) {
if (subscribe) {
Text("已订阅")
DisposableEffect(key1 = Unit) {
println("“已订阅”文本组件进入界面")
onDispose {
println("“已订阅”文本组件离开界面")
}
}
} else {
Text("订阅")
DisposableEffect(key1 = Unit) {
println("“订阅”文本组件进入界面")
onDispose {
println("“订阅”文本组件离开界面")
}
}
}
}
}
每次点击按钮,subscribe 状态切换,当前显示的文本组件随之变化。每个文本组件进入和离开界面时,都会触发对应的打印。
DisposableEffect 的作用是在组件出现时,执行副作用,在组件被移除时,执行相应的清理逻辑。比如:添加/移除监听器、订阅/取消订阅消息。
它还有一个参数 key1,key1 决定了 DisposableEffect 是否重启。只要 key1 的值发生变化, DisposableEffect 会先执行清理(onDispose),再执行主逻辑。
比如:
@Composable
fun RestartDisposableEffectDemo() {
// 用于重启定时器的 key,每次点击+1
var timerKey by remember { mutableIntStateOf(0) }
// 计时器显示的秒数
var seconds by remember { mutableIntStateOf(0) }
Column {
Text("定时器:${seconds}s")
Button(onClick = {
// 每点击一次,重启定时器
timerKey++
}) {
Text("点击重启定时器")
}
// 只要 timerKey 变化,DisposableEffect 就会重新执行
DisposableEffect(timerKey) {
// 创建并启动定时器
val timer = object : CountDownTimer(Long.MAX_VALUE, 1000) {
override fun onTick(millisUntilFinished: Long) {
seconds++
}
override fun onFinish() {}
}
timer.start()
println("Timer 启动, key=$timerKey")
onDispose {
timer.cancel()
println("Timer 停止, key=$timerKey")
seconds = 0 // 重启时秒数归零
}
}
}
}
运行结果:
每次点击按钮,timerKey 变化,DisposableEffect 会自动清理上一个定时器并重启一个新的定时器,保证资源不会泄漏。
如果 key1 不变(比如为 Unit),即使 Composable 发生重组,副作用也不会被重复执行。
例如:
@Composable
fun DisposableEffectNotRecomposeDemo() {
var count by remember { mutableIntStateOf(0) }
Column {
Button(onClick = { count++ }) {
Text("点击重组:$count")
SideEffect {
println("SideEffect 执行(会多次出现)")
}
DisposableEffect(Unit) {
println("DisposableEffect 执行(只会出现一次)")
onDispose {
println("DisposableEffect 清理(只会出现一次)")
}
}
}
}
}
运行结果:
DisposableEffect 执行(只会出现一次)
SideEffect 执行(会多次出现)
SideEffect 执行(会多次出现)
SideEffect 执行(会多次出现)
SideEffect 执行(会多次出现)
SideEffect 执行(会多次出现)
LaunchedEffect()
在 Compose 中启动协程有两种方式,一种是 LaunchedEffect,当副作用代码需要在协程中运行时,就可以使用它;第二种方式是使用 rememberCoroutineScope() 函数,它可以获取一个协程作用域,使用这个作用域来启动协程。
而 LaunchedEffect 其实是一个特殊形式的 DisposableEffect,LaunchedEffect可以提供协程作用域。它们两个在底层实现是同一套机制,点击去可以看到。
LaunchedEffect 的底层实现:
internal class LaunchedEffectImpl(
parentCoroutineContext: CoroutineContext,
private val task: suspend CoroutineScope.() -> Unit
) : RememberObserver {
private val scope = CoroutineScope(parentCoroutineContext)
private var job: Job? = null
override fun onRemembered() {
// This should never happen but is left here for safety
job?.cancel("Old job was still running!")
job = scope.launch(block = task)
}
override fun onForgotten() {
job?.cancel(LeftCompositionCancellationException())
job = null
}
override fun onAbandoned() {
job?.cancel(LeftCompositionCancellationException())
job = null
}
}
DisposableEffect 的底层实现:
private class DisposableEffectImpl(
private val effect: DisposableEffectScope.() -> DisposableEffectResult
) : RememberObserver {
private var onDispose: DisposableEffectResult? = null
override fun onRemembered() {
onDispose = InternalDisposableEffectScope.effect()
}
override fun onForgotten() {
onDispose?.dispose()
onDispose = null
}
override fun onAbandoned() {
// Nothing to do as [onRemembered] was not called.
}
}
并且它们的 key 参数的作用也一样,都是决定是否重启的。所以 LaunchedEffect 的作用是在 Composable 组件进入 Composition 时启动一个协程,在Composable 组件离开 Composition 时取消协程。
当所依赖的 key 参数的值发生改变时,会重启协程。
什么时候会用到呢?
页面初始化时加载数据、倒计时、动画、延时跳转等需要协程的场景。
比如:
@Composable
fun DelayDemo() {
var alphaValue by remember { mutableFloatStateOf(1f) }
Box(Modifier
.clickable { alphaValue = 1f }
.size(40.dp)
.alpha(alphaValue)
.background(Color.Green))
LaunchedEffect(Unit) {
delay(2000)
alphaValue = 0f
}
}
运行结果:
绿色方块会在程序启动2秒后从界面“消失”,但点击它所在的区域,又会使它“重新出现”。