阅读 593

设计 repeatOnLifecycle API 背后的故事

通过本文您将会了解到 Lifecycle.repeatOnLifecycle API 背后的设计决策,以及为什么我们会移除此前添加到 lifecycle-runtime-ktx 库 2.4.0 版本首个 alpha 版中的几个辅助函数。

纵观全文,您将了解到在某些场景中使用特定协程 API 的危险程度、为 API 命名的困难程度以及我们决定在函数库中只保留底层挂起 API 的原因。

同时,您会意识到所有的 API 决策都需要根据 API 的复杂度、可读性和容易出错程度进行权衡。

这里特别向 Adam PowelWojtek KalicińskiIan LakeYigit Boyar 致谢,感谢大家对 API 的反馈和讨论。

注意: 如果您在查找 repeatOnLifecycle 的使用指南,请查阅: 使用更为安全的方式收集 Android UI 数据流

repeatOnLifecycle

Lifecycle.repeatOnLifecycle API 最早是为了实现从 Android UI 层更安全地收集数据流而设计的。它的可重启行为充分考虑了 UI 生命周期,使其成为仅当 UI 在屏幕上处于可见时处理数据的最佳默认 API。

注意: LifecycleOwner.repeatOnLifecycle 也是可用的。它将此功能委托给其 Lifecycle 对象来实现。借此,所有已经属于 LifecycleOwner 作用域的代码都可以省略显式的接收器。

repeatOnLifecycle 是一个挂起函数 。就其本身而言,它需要在协程中执行。repeatOnLifecycle会将调用的协程挂起,然后每当生命周期进入 (或高于) 目标状态时在一个新的协程中执行您作为参数传入的一个挂起块。如果生命周期低于目标状态,因执行该代码块而启动的协程就会被取消。最后,repeatOnLifecycle 函数直到在生命周期处于 DESTROYED 状态时才会继续调用者的协程。

让我们在实例中了解这个 API 吧。如果您已经阅读过我此前的文章: 一种更安全的从 Android UI 当中获取数据流的方式 ,那您将不会对以下内容感到新奇。

class LocationActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // 由于 repeatOnLifecycle 是一个挂起函数,
        // 因此从 lifecycleScope 中创建新的协程
        lifecycleScope.launch {
            // 直到 lifecycle 进入 DESTROYED 状态前都将当前协程挂起。
            // repeatOnLifecycle 每当生命周期处于 STARTED 或以后的状态时会在新的协程中
            // 启动执行代码块,并在生命周期进入 STOPPED 时取消协程。
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                // 当生命周期处于 STARTED 时安全地从 locations 中获取数据
                // 当生命周期进入 STOPPED 时停止收集数据
                someLocationProvider.locations.collect {
                    // 新的位置!更新地图(信息)
                }
            }
            // 注意:运行到此处时,生命周期已经处于 DESTROYED 状态!
        }
    }
}
复制代码

注意 : 如果您对 repeatOnLifecycle 的实现方式感兴趣,可以访问 源代码链接

为什么是一个挂起函数?

由于可以保留调用上下文,所以 挂起函数 是执行重启行为的 最佳选择。它在调用协程时遵循 Job树。由于 repeatOnLifecycle 实现时在底层使用了 suspendCancellableCoroutine,它可以与取消操作共同运作: 取消发起调用的协程同时也可以取消 repeatOnLifecycle 和它重启执行的代码块。

此外,我们可以在 repeatOnLifecycle 之上添加更多的 API,比如 Flow.flowWithLifecycle 数据流操作符。更重要的是,它还允许您按照项目需求在此 API 的基础上创建辅助函数。而这也是我们在 lifecycle-runtime-ktx:2.4.0-alpha01 中加入 LifecycleOwner.addRepeatingJob API 时尝试做的事,不过在 alpha02 中我们将它移除了。

移除 addRepeatingJob API 的考量

在函数库首个 alpha 版本中加入而目前已经移除的 LifecycleOwner.addRepeatingJob API,早前是这样实现的:

public fun LifecycleOwner.addRepeatingJob(
    state: Lifecycle.State,
    coroutineContext: CoroutineContext = EmptyCoroutineContext,
    block: suspend CoroutineScope.() -> Unit
): Job = lifecycleScope.launch(coroutineContext) {
    repeatOnLifecycle(state, block)
}
复制代码

其作用是: 给定了 LifecycleOwner,您可以执行一个每当生命周期进入或离开目标状态时都会重启的挂起代码块。此 API 使用了 LifecycleOwnerlifecycleScope 来触发一个新的协程,并在其中调用 repeatOnLifecycle。

前面的代码使用 addRepeatingJob API 的写法如下:

class LocationActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        lifecycleOwner.addRepeatingJob(Lifecycle.State.STARTED) {
            someLocationProvider.locations.collect {
                // 新的位置!更新地图(信息)
            }
        }
    }
}
复制代码

一眼望去,您可能觉得代码更加简洁、精简了。然而,如果您不加以注意,其中的一些隐藏陷阱可能会让您搬起石头砸了自己的脚:

  • 虽然 addRepeatingJob 接受一个挂起代码块,addRepeatingJob 本身却不是一个挂起函数。因此,您不应该在协程内调用它!

  • 更少的代码?您在少写一行代码的同时,却用了一个容易出错的 API。

第一点看起来比较显而易见,但开发者们往往会掉入陷阱。而且讽刺的是,实际上它就是基于协程概念中最核心的一点: 结构化并发

addRepeatingJob 不是一个挂起函数,因此默认也就不支持结构化并发 (需要注意的是您可以通过使用另一个 coroutineContext 参数来让其支持)。由于 block 参数是一个挂起的 Lambda 表达式,当您将这个 API 与协程共用时,您可能很容易地写出这样的危险代码:

class LocationActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val job = lifecycleScope.launch {

            doSomeSuspendInitWork()

            // 危险!此 API 不会保留调用的上下文!
            // 它在父级上下文取消时不会跟着被取消!
            addRepeatingJob(Lifecycle.State.STARTED) {
                someLocationProvider.locations.collect {
                    // 新的位置!更新地图(信息)
                }
            }
        }

        //如果出现错误,取消上面已经启动的协程
        try {
            /* ... */
        } catch(t: Throwable) {
            job.cancel()
        }
    }
}
复制代码

这段代码出了什么问题?addRepeatingJob 执行了协程的工作,没有什么会阻止我在协程当中调用它,对吗?

因为 addRepeatingJob 创建了一个新的协程,并使用了 lifecycleScope (隐式调用于该 API 的实现中),这个新的协程既不会遵循结构化并发原则,也不会保留当前的调用上下文。因此,当您调用 job.cancel() 的时候它也不会被取消。这可能会导致您应用中存在非常隐蔽的错误,并且非常不好调试

repeatOnLifecycle 才是大赢家

addRepeatingJob 隐式使用的 CoroutineScope 正是让这个 API 在某些场景下不安全的原因。它是您在编写正确的代码时需要特别注意的隐藏陷阱。这一点正是我们关于是否要在函数库中避免在 repeatOnLifecycle 之上提供封装接口的争论所在。

使用挂起的 repeatOnLifecycle API 的主要好处是它默认能很好地按照结构化并发的原则执行,然而 addRepeatingJob 却不会这样。它也可以帮助您考虑清楚您想要这个重复执行的代码在哪一个作用域执行。此 API 一目了然,并且符合开发者们的期望:

  • 同其他的挂起函数一样,它会将当前协程的执行中断,直到特定事件发生。比如这里是当生命周期被销毁时继续执行。
  • 没有意外惊吓!它可以与其他协程代码共同作用,并且会按照您的预期工作。
  • 在 repeatOnLifecycle 上下的代码可读性高,并且对于新人来说更有意义: "首先,我需要启动一个跟随 UI 生命周期的新协程。然后,我需要调用 repeatOnLifecycle 使得每当 UI 生命周期进入这个状态时会启动执行这段代码"。

Flow.flowWithLifecycle

Flow.flowWithLifecycle 操作符 (您可以参考 具体实现) 是构建在 repeatOnLifecycle 之上的,并且仅当生命周期至少处于 minActiveState 时才会将来自上游数据流的内容发送出去。

class LocationActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        lifecycleScope.launch {
            someLocationProvider.locations
                .flowWithLifecycle(lifecycle, STARTED)
                .collect {
                    // 新的位置!更新地图(信息)
                }
        }
    }
}
复制代码

即使这个 API 也有一些小陷阱需要当心,我们仍然将其保留了,因为它是一个实用的 Flow 操作符。举个例子,它可以 在 Jetpack Compose 中轻松使用。即便您在 Jetpack Compose 中能够通过 produceStaterepeatOnLifecycle API 实现完全相同的功能,我们仍然将这个 API 保留在库中,以提供一种更加易用的方法。

如代码实现的 KDoc 中用文档说明的那样,这个小陷阱指的是您添加 flowWithLifecycle 操作符的顺序是有讲究的。当生命周期低于 minActiveState 时,在 flowWithLifecycle 操作符之前的应用的所有操作符都会被取消。然而,在其后应用的操作符即使没有发送任何数据也不会被取消。

如果您仍然感到好奇,此 API 的名字源于 Flow.flowOn(CoroutineContext) 操作符,因为 Flow.flowWithLifecycle 会通过改变 CoroutineContext 来收集上游数据流的数据,却不会影响到下游数据流。

我们该不该添加额外的 API?

考虑到我们已经有了 Lifecycle.repeatOnLifecycleLifecycleOwner.repeatOnLifecycleFlow.flowWithLifecycle API 了,我们该不该再添加额外的 API 呢?

新的 API 在解决设计之初的问题时,还可能会引入同样多的困惑。有许多的方式来支持不同的用例,并且哪一种是捷径很大程度取决于上下文代码。在您的项目中能用上的方式,在其他项目中可能不再适用。

这就是我们不想为所有可能的场景提供 API 的原因,越多可用的 API,对于开发者来说就越困惑,不知道究竟应该何种场景使用何种 API。因此我们决定仅保留最底层的 API。有时候,少即是多。

命名既重要又困难

我们要关注的不仅仅是需要支持哪些用例,还有怎样命名这些 API!API 的名字应该与开发者们的预期相同,并且遵循 Kotlin 协程的命名习惯。举个例子:

  • 如果此 API 隐式使用某个 CoroutineScope (比如在 addRepeatingJob 中用到的 lifecycleScope) 启动的新协程,它必须要在名称上反应出来这个作用域,以避免误用!这样一来,launch 就应该存在于 API 名字中。
  • collect 是一个挂起函数。如果某个 API 不是挂起函数,就不应该带有 collect 字样。

注意 : Jetpack Compose 的 collectAsState API 是一个特殊的例子,我们支持它这样命名。它不会和挂起函数混淆,因为在 Jetpack Compose 当中没有这样的 @Composable 的挂起函数。

其实 LifecycleOwner.addRepeatingJob API 命名很难定夺,因为它使用 lifecycleScope 创建了新的协程,那么它就应该用 launch 作为前缀命名。然而,我们想要表明它与底层采用协程实现无关,并且由于它附加上了新的生命周期观察者,其命名也与其他的 LifecycleOwner API 保持了一致。

其命名在某种程度上也受到了现有的 LifecycleCoroutineScope.launchWhenX 挂起 API 的影响。因为 launchWhenStartedrepeatOnLifecycle(STARTED) 提供了完全不同的功能 (launchWhenStarted 会中断协程的执行,而 repeatOnLifecycle 取消和重启了新的协程),如果它们的命名很相似 (比如用 launchWhenever 作为新 API 的名字),那么开发者们可能会感到困惑,甚至是因疏忽而张冠李戴误用两个 API。

一行代码收集数据流

LiveData 的 observe 函数可以感知生命周期,并且只会在生命周期至少已经启动之后才会处理发送的数据。如果您正要 从 LiveData 迁移到 Kotlin 数据流,那么您可能会想要有一种用一行替换就实现的好办法!您可以移除样板代码,迁移其实直接明了。

同样地,您可以像 Ian Lake 首次使用 repeatOnLifecycle API 时那样做。他创建了一个方便的封装函数,名字叫作 collectIn,比如下面的代码 (如果要符合此前的命名习惯,我会将其更名为 launchAndCollectIn):

inline fun <T> Flow<T>.launchAndCollectIn(
    owner: LifecycleOwner,
    minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
    crossinline action: suspend CoroutineScope.(T) -> Unit
) = owner.lifecycleScope.launch {
        owner.repeatOnLifecycle(minActiveState) {
            collect {
                action(it)
            }
        }
    }
复制代码

于是,您可以在 UI 代码中这样调用它:

class LocationActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        someLocationProvider.locations.launchAndCollectIn(this, STARTED) {
            // 新的位置!更新地图(信息)
        }
    }
}
复制代码

这个封装函数,虽然如同例子里那样看起来非常简洁和直接,但也存在同上文的 LifecycleOwner.addRepeatingJob API 一样的问题: 它不管调用的作用域,并且在用于其他协程内部时有潜在的危险。进一步说,原来的名字非常容易产生误导: collectIn 不是一个挂起函数!如前文提到的那样,开发者希望名字里带 collect 的函数能够挂起。或许,这个封装函数更好的名字是 Flow.launchAndCollectIn,这样就能避免误用了。

iosched 中的封装函数

在 Fragment 中使用 repeatOnLifecycle API 时必须同 viewLifecycleOwner 一道使用。在开源的 Google I/O 应用中,开发团队决定在 iosched 项目中创建一个封装器来避免于 Fragment 中误用此 API,它叫做: Fragment.launchAndRepeatWithViewLifecycle

注意 : 它的实现与 addRepeatingJob API 非常接近。并且当这个 API 实现时,使用的仍然是函数库的 alpha01 版本, alpha02 中加入的 repeatOnLifecycle API 语法检查器尚不可用。

您需要封装函数吗?

如果您需要在 repeatOnLifecycle API 之上创建封装函数以涵盖您的应用中更常见的应用场景,请一定问问自己是否真的需要它,或者是为什么需要它。如果您决意要继续这样做,我建议您选择一个直接明了的 API 名字来清楚说明这个封装器的作用,从而避免误用。另外,建议您清楚地进行文档标注,当新人加入时就能完全明白使用它的正确方法了。

希望通过本文的描述,可以帮助您了解我们内部对设计和实现 repeatOnLifecycle 时的考量和决策,以及未来可能会加入的更多的辅助方法。

欢迎您 点击这里 向我们提交反馈,或分享您喜欢的内容、发现的问题。您的反馈对我们非常重要,感谢您的支持!

文章分类
Android
文章标签