副作用 (Side Effect) 详解

571 阅读6分钟

什么是 Side Effect?

Side Effect 的中文翻译是 “副作用”,在 Android 的简体中文官方文档中被翻译成了 “附带效应”,这两个词其实都对。

谈到副作用,一般人想到的都是药物副作用,使用药物时产生的不良效果,但有时它也是有益的。

啊?副作用还能有益了?

没错,因为此 “副作用” 非彼 “负作用” ,它的本义指的是在完成目标效果之外产生的非预期效果。它强调的是效果的 “附带性” 和 “次要性” 。而 “负作用” 则侧重于效果的负面性质,即有害的影响。

在计算机科学中,副作用是指当调用函数时,除了返回函数值之外,还对程序状态产生的附加影响。本质上,是函数内部影响到了函数外部。

具体来说,副作用包括:

  1. 修改全局变量:函数的内部修改到了函数外部定义的变量值
  2. 修改传入的参数:函数改变了传入的参数值
  3. 输入/输出操作:如读写文件、网络请求、屏幕显示等
  4. 抛出异常:中断正常的程序执行流程
  5. 调用其他具有副作用的函数:间接产生副作用

先来看看没有副作用的函数:

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() 展示用户访问的总次数,并且增加访问次数。

image.png

但这么写是不行的, VisitCounter() 可能因为各种原因触发重组(如配置更改、系统事件),每次重组都会增加用户的访问次数,导致访问次数不准确,远高于实际的访问次数,visitCount 的值变得不可预期。

比如我点击“点击重组”按钮,结果:

image.gif

正是由于这种不可预期,所以 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 的作用是在组件出现时,执行副作用,在组件被移除时,执行相应的清理逻辑。比如:添加/移除监听器、订阅/取消订阅消息。

它还有一个参数 key1key1 决定了 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 // 重启时秒数归零
            }
        }
    }
}

运行结果:

image.gif

每次点击按钮,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 其实是一个特殊形式的 DisposableEffectLaunchedEffect可以提供协程作用域。它们两个在底层实现是同一套机制,点击去可以看到。

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
    }
}

运行结果:

image.gif

绿色方块会在程序启动2秒后从界面“消失”,但点击它所在的区域,又会使它“重新出现”。