Kotlin 函数作用域 - 深入理解 Compose 中的 DisposeEffect 副作用函数

前段时间一直在忙鸿蒙相关的工作,最近忙里抽闲,总结一下之前在写 ComposeHooks  项目的一些小小心得。

DSL,其实在这里更多指的是利用作用域概念,限定函数闭包内的函数调用行为(下面称之为作用域内行为)。

在之前的文章:# 在 Kotlin 中巧妙的使用 DSL 封装 SpannableStringBuilder

提到了编写 DSL 的一些小心得,总结如下:

期望一个闭包得内部行为,编写相应的作用域接口:

1
2
3
4
5
interface 作用域名称接口 {
//作用域内行为:增加一段文字
fun addText(text: String)
}

这里得作用域名称接口建议通过 XxxScope 这种格式命名。


我们回到 Compose 源码 - DisposeEffect 中继续学一下,看看 Compose 团队是如何使用这种编码技巧的:

1
2
3
4
5
6
7
8
@Composable
@NonRestartableComposable
fun DisposableEffect(
key1: Any?,
effect: DisposableEffectScope.() -> DisposableEffectResult
) {
remember(key1) { DisposableEffectImpl(effect) }
}

这里关注两个类型:DisposableEffectScope 是这里的作用域DisposableEffectResult是副作用 effect 函数的返回值,他们的源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class DisposableEffectScope {
/**
* Provide [onDisposeEffect] to the [DisposableEffect] to run when it leaves the composition
* or its key changes.
*/
inline fun onDispose(
// 传入 onDispose 的闭包
crossinline onDisposeEffect: () -> Unit
): DisposableEffectResult = object : DisposableEffectResult {
override fun dispose() {
// 真正调用传入的 onDispose 闭包
onDisposeEffect()
}
}
}


interface DisposableEffectResult {
fun dispose()
}

DisposableEffectScope 作用域不是一个接口而是一个具体的类,其中只有一个内联函数 onDispose,也就是说在我们的 DisposableEffect 闭包中仅可以调用这一个作用域内行为

同时由于 effect 副作用函数 effect: DisposableEffectScope.() -> DisposableEffectResult 要求必须返回 DisposableEffectResult,这就实际上规范了我们必须要在最后一行调用这个内联函数 onDispose(因为其返回值也正是我们副作用函数限定的返回值类型DisposableEffectResult)。

DisposableEffectResult 实际上是对 onDispose函数接收的闭包的一个包装类型!

我们传入的闭包函数是通过DisposableEffectImpl 这个类实现的相关重组逻辑,他其实是RememberObserver的一个实现类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//compose内部私有的作用域实例
private val InternalDisposableEffectScope = DisposableEffectScope()
private class DisposableEffectImpl(
// 副作用effect函数被传递到了实现类中
private val effect: DisposableEffectScope.() -> DisposableEffectResult
) : RememberObserver {
private var onDispose: DisposableEffectResult? = null

override fun onRemembered() {
// remember 被记住时,此时创建作用域实例,执行作用域函数
onDispose = InternalDisposableEffectScope.effect()
}

override fun onForgotten() {
onDispose?.dispose()
onDispose = null
}

override fun onAbandoned() {
// Nothing to do as [onRemembered] was not called.
}
}

第一步:在 onRemembered 执行时我们传入的闭包effect会被执行(此时他的作用域,或者说函数的接收者是InternalDisposableEffectScope),还记得这个副作用 effect 函数的签名是什么吗?

答案: effect: DisposableEffectScope.() -> DisposableEffectResult

也就说我们闭包内的代码会执行,通过 onDispose 函数创建的DisposableEffectResult实例缓存到:var onDispose

这里实际缓存的也就是 onDispose 函数接收的卸载时执行的闭包函数。

第二步:当我们组件卸载时,记住的内容被忘记,onForgotten 回调执行。此时会调用var onDispose 中的 dispose 函数。

还记得这个函数会做什么吗?

1
2
3
4
5
6
7
8
9
inline fun onDispose(
// 传入 onDispose 的闭包
crossinline onDisposeEffect: () -> Unit
): DisposableEffectResult = object : DisposableEffectResult { //包装传入的闭包
override fun dispose() {
// 真正调用传入的 onDispose 闭包
onDisposeEffect()
}
}

也就是实际在执行我们写在 onDispose 函数中的卸载执行的闭包


再次加深记忆:

在 DSL 这种编码模式下,我们需要:

  1. 确定作用域内行为,对应抽象成类、接口;

    1
    2
    3
    4
    5
    6
    7
    // 入口
    fun TextView.buildSpannableString(init: DslSpannableStringBuilder.() -> Unit)
    //作用域内的行为声明
    interface DslSpannableStringBuilder {
    //增加一段文字
    fun addText(text: String, method: (DslSpanBuilder.() -> Unit)? = null)
    }
  2. 如果一个行为必须要被执行,我们可以设置一个特殊的XxxResult类型,要求作用域函数以此类型作为返回值:

    1
    fun TextView.buildSpannableString(init: DslSpannableStringBuilder.() -> XxxResult)

    在作用域内声明一个必须被执行的函数:

    1
    2
    3
    4
    5
    6
    interface DslSpannableStringBuilder {
    //增加一段文字
    fun addText(text: String, method: (DslSpanBuilder.() -> Unit)? = null)
    //必须执行的行为
    fun mustCall():XxxResult
    }
  3. 如果作用域是接口,要有对应的实现类;使用接口+实现类的方式可以隐藏内部;

理解了上面的这些知识之后,像这样“不好看的代码”,想必你也能理解为什么可以不调用onDispose了吧:

1
2
3
4
5
6
7
8
9

@Composable
fun useUnmount(block: () -> Unit) = DisposableEffect(Unit) {
object : DisposableEffectResult {
override fun dispose() {
block()
}
}
}