设计 repeatOnLifecycle API 背后的故事
lifecycle-runtime-ktx
https://developer.android.google.cn/jetpack/androidx/releases/lifecycle
Adam Powel
https://twitter.com/adamwpWojtek Kaliciński
https://twitter.com/wkalicIan Lake
https://twitter.com/ianhlakeYigit Boyar
https://twitter.com/yigitboyar
注意: 如果您在查找 repeatOnLifecycle 的使用指南,请查阅: 使用更为安全的方式收集 Android UI 数据流。
repeatOnLifecycle
注意: LifecycleOwner.repeatOnLifecycle 也是可用的。它将此功能委托给其 Lifecycle 对象来实现。借此,所有已经属于 LifecycleOwner 作用域的代码都可以省略显式的接收器。
repeatOnLifecycle 是一个挂起函数。就其本身而言,它需要在协程中执行。repeatOnLifecycle 会将调用的协程挂起,然后每当生命周期进入 (或高于) 目标状态时在一个新的协程中执行您作为参数传入的一个挂起块。如果生命周期低于目标状态,因执行该代码块而启动的协程就会被取消。最后,repeatOnLifecycle 函数直到在生命周期处于 DESTROYED 状态时才会继续调用者的协程。
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 的实现方式感兴趣,可以访问源代码链接: https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:lifecycle/lifecycle-runtime-ktx/src/main/java/androidx/lifecycle/RepeatOnLifecycle.kt;l=63
为什么是一个挂起函数?
由于可以保留调用上下文,所以挂起函数是执行重启行为的最佳选择。它在调用协程时遵循 Job 树。由于 repeatOnLifecycle 实现时在底层使用了 suspendCancellableCoroutine,它可以与取消操作共同运作: 取消发起调用的协程同时也可以取消 repeatOnLifecycle 和它重启执行的代码块。
此外,我们可以在 repeatOnLifecycle 之上添加更多的 API,比如 Flow.flowWithLifecycle 数据流操作符。更重要的是,它还允许您按照项目需求在此 API 的基础上创建辅助函数。而这也是我们在 lifecycle-runtime-ktx:2.4.0-alpha01 中加入 LifecycleOwner.addRepeatingJob API 时尝试做的事,不过在 alpha02 中我们将它移除了。
Flow.flowWithLifecycle
https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:lifecycle/lifecycle-runtime-ktx/src/main/java/androidx/lifecycle/FlowExt.kt;l=87
移除 addRepeatingJob API 的考量
public fun LifecycleOwner.addRepeatingJob(
state: Lifecycle.State,
coroutineContext: CoroutineContext = EmptyCoroutineContext,
block: suspend CoroutineScope.() -> Unit
): Job = lifecycleScope.launch(coroutineContext) {
repeatOnLifecycle(state, block)
}
class LocationActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycleOwner.addRepeatingJob(Lifecycle.State.STARTED) {
someLocationProvider.locations.collect {
// 新的位置!更新地图(信息)
}
}
}
}
虽然 addRepeatingJob 接受一个挂起代码块,addRepeatingJob 本身却不是一个挂起函数。因此,您不应该在协程内调用它! 更少的代码?您在少写一行代码的同时,却用了一个容易出错的 API。
结构化并发
https://elizarov.medium.com/structured-concurrency-722d765aa952
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 才是大赢家
使用挂起的 repeatOnLifecycle API 的主要好处是它默认能很好地按照结构化并发的原则执行,然而 addRepeatingJob 却不会这样。它也可以帮助您考虑清楚您想要这个重复执行的代码在哪一个作用域执行。此 API 一目了然,并且符合开发者们的期望:
同其他的挂起函数一样,它会将当前协程的执行中断,直到特定事件发生。比如这里是当生命周期被销毁时继续执行。
没有意外惊吓!它可以与其他协程代码共同作用,并且会按照您的预期工作。
在 repeatOnLifecycle 上下的代码可读性高,并且对于新人来说更有意义: "首先,我需要启动一个跟随 UI 生命周期的新协程。然后,我需要调用 repeatOnLifecycle 使得每当 UI 生命周期进入这个状态时会启动执行这段代码"。
Flow.flowWithLifecycle
class LocationActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycleScope.launch {
someLocationProvider.locations
.flowWithLifecycle(lifecycle, STARTED)
.collect {
// 新的位置!更新地图(信息)
}
}
}
}
具体实现
https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:lifecycle/lifecycle-runtime-ktx/src/main/java/androidx/lifecycle/FlowExt.kt;l=87
即使这个 API 也有一些小陷阱需要当心,我们仍然将其保留了,因为它是一个实用的 Flow 操作符。举个例子,它可以在 Jetpack Compose 中轻松使用。即便您在 Jetpack Compose 中能够通过 produceState 和 repeatOnLifecycle API 实现完全相同的功能,我们仍然将这个 API 保留在库中,以提供一种更加易用的方法。
produceState
https://developer.android.google.cn/jetpack/compose/side-effects#producestate
如代码实现的 KDoc 中用文档说明的那样,这个小陷阱指的是您添加 flowWithLifecycle 操作符的顺序是有讲究的。当生命周期低于 minActiveState 时,在 flowWithLifecycle 操作符之前的应用的所有操作符都会被取消。然而,在其后应用的操作符即使没有发送任何数据也不会被取消。
如果您仍然感到好奇,此 API 的名字源于 Flow.flowOn(CoroutineContext) 操作符,因为 Flow.flowWithLifecycle 会通过改变 CoroutineContext 来收集上游数据流的数据,却不会影响到下游数据流。
我们该不该添加额外的 API?
考虑到我们已经有了 Lifecycle.repeatOnLifecycle、LifecycleOwner.repeatOnLifecycle 和 Flow.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 的挂起函数。
collectAsState
https://developer.android.google.cn/reference/kotlin/androidx/compose/runtime/package-summary#(kotlinx.coroutines.flow.StateFlow).collectAsState(kotlin.coroutines.CoroutineContext)
其实 LifecycleOwner.addRepeatingJob API 命名很难定夺,因为它使用 lifecycleScope 创建了新的协程,那么它就应该用 launch 作为前缀命名。然而,我们想要表明它与底层采用协程实现无关,并且由于它附加上了新的生命周期观察者,其命名也与其他的 LifecycleOwner API 保持了一致。
其命名在某种程度上也受到了现有的 LifecycleCoroutineScope.launchWhenX 挂起 API 的影响。因为 launchWhenStarted 和 repeatOnLifecycle(STARTED) 提供了完全不同的功能 (launchWhenStarted 会中断协程的执行,而 repeatOnLifecycle 取消和重启了新的协程),如果它们的命名很相似 (比如用 launchWhenever 作为新 API 的名字),那么开发者们可能会感到困惑,甚至是因疏忽而张冠李戴误用两个 API。
LifecycleCoroutineScope.launchWhenX
https://developer.android.google.cn/reference/kotlin/androidx/lifecycle/LifecycleCoroutineScope
一行代码收集数据流
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)
}
}
}
Ian Lake
https://twitter.com/ianhlake
于是,您可以在 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 语法检查器尚不可用。
iosched
https://github.com/google/ioschedFragment.launchAndRepeatWithViewLifecycle
https://github.com/google/iosched/blob/main/mobile/src/main/java/com/google/samples/apps/iosched/util/UiUtils.kt#L60
您需要封装函数吗?
免费中文系列课程下载
系统地学习使用 Kotlin 进行 Android 开发
☟ 即刻了解课程详情 ☟
推荐阅读