原标题: repeatOnLifecycle API design story
原文地址: repeatOnLifecycle API design story
原文作者:Manuel Vivo
前言
众所周知,Google
发布一个新的Library
都要经历alpha
,beta
,rc
,release
等多个版本的迭代
在这个漫长的迭代过程中,通常会有Bug
的修复,代码与功能的增删等等,哪些代码应该该增加,哪些代码应该删除,这就是考验设计者的地方
本文主要讲述了Google
在设计及迭代repeatOnLifecycle API
过程中的设计与决策
repeatOnLifecycle
主要用于在UI
中收集flow
,关于它的用法可见:使用更为安全的方式收集 Android UI 数据流
通过本文你将了解以下内容:
repeatOnLifecycle API
背后的设计决策- 为什么
alpha
版本中添加的addRepeatingJob API
会被移除? - 为什么
flowWithLifecycle API
会被保留? - 为什么
API
命名是重要且困难的 - 为什么只保留库中最基础的几个
API
。
译文
repeatOnLifecycle
介绍
Lifecycle.repeatOnLifecycle API
主要是为了在UI
层进行更安全的Flow
收集
比如lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED)
,会在onStart
时启动协程,在onStop
时取消协程
然后在Activity
重新回到onStart
时重新启动一个协程
这种特性与UI
生命周期的可重启性比较契合,让它成为仅当UI
可见时才收集flow
的完美默认API
repeatOnLifecycle
是一个挂起函数,repeatOnLifecycle
会挂起调用协程- 每次给定的生命周期达到目标状态或更高时,都会启动一个新的协程,运行传入的
block
- 如果生命周期状态低于目标,则为块启动的协程将被取消。
- 最后,在生命周期被销毁之前,
repeatOnLifecycle
函数本身不会恢复调用协程。
下面来看一下这个API
的示例,关于它的用法更详细的介绍可见:使用更为安全的方式收集 Android UI 数据流
class LocationActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 从lifecycleScope创建一个新的协程
// 因为repeatOnLifecycle 是一个挂起函数
lifecycleScope.launch {
// 阻塞协程,直到生命周期到达DESTOYED
// repeatOnLifecycle会在每次生命周期处于 STARTED 状态(或更高状态)时,启动一个新的协程运行传入的block
// 并在 STOPPED 时取消协程。
repeatOnLifecycle(Lifecycle.State.STARTED) {
// 安全的collect flow当生命周期到达STARTED
// 当生命周期到达STOPPED 时停止collect
someLocationProvider.locations.collect {
// 收集到新的位置,更新UI
}
}
// 注意,当运行到这的时候,说明lifecycle已经是DESTROYED
}
}
}
如果你对repeatOnLifecycle
是怎么实现的感兴趣,可以查看源码:repeatOnLifecycle源码
为什么repeatOnLifecycle
是挂起函数?
针对repeatOnLifecycle
可重启的特性,挂起函数是最佳选择
- 因为它保留了调用的上下文,即
CoroutineContext
- 同时
repeatOnLifecycle
内部使用了suspendCancellableCoroutine
,因此它支持取消,当取消协程时,repeatOnLifecycle
与它的子协程都会被取消
此外,我们可以在repeatOnLifecycle
之上扩展更多API
,例如Flow.flowWithLifecycle
流操作符。
更重要的是,如果您的项目需要,它还允许您在此API
基础上扩展封装辅助函数.这就是我们尝试使用LifecycleOwner.addRepeatingJob API
做的事情
我们在 lifecycle-runtime-ktx:2.4.0-alpha01
中添加了该 API
,但是在alpha02
中删除了该API
。
为什么移除addRepeatingJob API
?
LifecycleOwner.addRepeatingJob API
在alpha01
中添加,但是在alpha02
中被移除了
为什么呢?我们先来看看实现
public fun LifecycleOwner.addRepeatingJob(
state: Lifecycle.State,
coroutineContext: CoroutineContext = EmptyCoroutineContext,
block: suspend CoroutineScope.() -> Unit
): Job = lifecycleScope.launch(coroutineContext) {
repeatOnLifecycle(state, block)
}
可以看出代码很简单,本质上就是对repeatOnLifecycle
的封装,传入state
与block
就能实现与repeatOnLifecycle
同样的效果
之所以引入这个API
是为了简化调用方式,一起看下代码:
class LocationActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycleOwner.addRepeatingJob(Lifecycle.State.STARTED) {
someLocationProvider.locations.collect {
//...
}
}
}
}
乍一看,您可能认为这段代码更简洁,需要的代码更少。 但是,如果您不密切注意,使用这个API
会带来一些隐藏的陷阱
- 尽管
addRepeatingJob
需要传入一个挂起的block
,但addRepeatingJob
不是挂起函数。 因此,你不应该在协程中调用它!!! - 再来看收益,您只节省了一行代码,代价是拥有了一个更容易出错的
API
。
第一点可能有些同学会奇怪,为什么不应该在协程中调用非挂起函数?
实际上是因为协程最核心的概念之一:结构化并发
什么是结构化并发?
要了解结构化并发,我们先来看看线程,线程的并发是非结构化的
可以想想这几个问题在线程中要怎么解决:
1.结束一个线程时,怎么同时结束这个线程中创建的子线程?
2.当某个子线程在执行时需要结束兄弟线程要做怎么做?
3.如何等待所有子线程都执行完了再结束父线程?
当然这些问题,都可以通过共享标记位等方式解决,但是这几个问题说明,线程间没有级联关系;
所有线程执行的上下文都是整个进程,多个线程的并发是相对整个进程的,而不是相对某一个父线程。
这就是线程并发的「非结构化」。
但与此同时,业务的并发通常是结构化的
通常,每个并发操作都是在处理一个任务单元,这个任务单元可能属于某个父任务单元,同时它也可能有子任务单元。
而每个任务单元都有自己的生命周期,子任务的生命周期理应继了父任务的生命周期。
这就是业务的「结构化」。
因此协程中引入结构化并发的概念,在结构化并发中,每个并发操作都有自己的作用域,并且:
1.在父作用域内新建作用域都属于它的子作用域;
2.父作用域和子作用域具有级联关系;
3.父作用域的生命周期持续到所有子作用域执行完;
4.当主动结束父作用域时,会级联结束它的各个子作用域。
Kotlin
的协程就是结构化的并发,它有 「协程作用域(CoroutineScope
)」 的角色。
全局的 GlobalScope
是一个作用域,每个协程自身也是一个作用域。新建的协程对象和父协程保持着级联关系。
关于结构化并发的更多内容可见:什么是 结构化并发 ?
addRepeatingJob
的问题
addRepeatingJob
不是挂起函数,因此默认情况下不支持结构化并发
由于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 {
// 更新ui
}
}
}
// 如果发生错误,取消上面启动的协程
try {
/* ... */
} catch(t: Throwable) {
job.cancel()
}
}
}
这段代码有什么问题? addRepeatingJob
是处理协程相关的东西,没有什么能阻止我在协程中调用它,对吧?
因为addRepeatingJob
不是挂起函数,在内部实现中会调用lifecycleScope
来启动一个新的协程
因此不会保留调用协程的上下文,也不支持结构化并发,即当调用job.cancel()
时不会取消addRepeatingJob
中创建的协程
这是非常不符合预期的,也很容易导致难以调试的不可预知的BUG
在addRepeatingJob
内部隐式调用了CoroutineScope
,导致这个API
在某些情况下使用是不安全的。
用户如果要正确使用这个API
,还需要了解一下额外的知识,这是不可接收的,这也是移除这个API
的原因
而repeatOnLifecycle
的主要好处在于它默认支持结构化并发,它还可以帮助您思考您希望重复工作在哪个生命周期内发生。 API
一目了然,符合开发人员的期望
为什么保留Flow.flowWithLifecycle
?
Flow.flowWithLifecycle
运算符构建在repeatOnLifecycle
之上
并且仅在生命周期至少处于minActiveState
时才发出上游流发送的元素,当生命周期低于minActiveState
会取消上游流
class LocationActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycleScope.launch {
someLocationProvider.locations
.flowWithLifecycle(lifecycle, STARTED)
.collect {
//更新UI
}
}
}
}
尽管这个API
也有一些隐式的需要注意的问题,但我们决定保留它,因为它作为一个Flow
操作符很实用。
例如,它可以轻松地用于Jetpack Compose
。尽管您可以通过使用produceState
和repeatOnLifecycle API
在Compose
中实现相同的功能,我们将此API
保留在库中,作为一个替代方案。
flowWithLifecycle
需要注意的问题是,添加flowWithLifecycle
运算符的顺序很重要。
当生命周期低于minActiveState
时,在flowWithLifecycle
运算符之前添加的运算符将被取消。
但是,即使没有发送任何元素,在flowWithLifecycle
之后添加的运行符也不会被取消。
因此,这个API
命名参照了Flow.flowOn(CoroutineContext)
运算符
因为此API
更改了用于收集上游流的CoroutineContext
,同时使下游不受影响,与flowOn
类似。
我们应该添加更多的API
吗?
鉴于我们已经拥有了Lifecycle.repeatOnLifecycle
、LifecycleOwner.repeatOnLifecycle
和 Flow.flowWithLifecycle API
。
我们还应该添加任何其他API
吗?
新API
可能会带来与它们解决的问题一样多的混乱。支持不同用例的方式有多种,最好的方式取决于你的业务代码是怎样的.对您的项目有效的方法可能对其他人无效。
这就是为什么我们不想为所有可能的情况提供API
,可用的 API
越多,开发人员就越不知道何时使用什么。
因此,我们决定只保留最底层的 API
。有时,少即是多。
API
命名是重要且困难的
API
命名是重要的,命名应符合开发人员的期望并遵循Kotlin
协程的约定。例如:
- 如果
API
中隐式地使用CoroutineScope
启动新的协程,则必须在名称中反映出来,以避免错误的期望!在这种情况下,launch
应该以某种方式包含在命名中。 collect
是一个挂起函数。如果API
不是挂起函数,则不要在API
命名中加上collect
。
LifecycleOwner.addRepeatingJob API
也很难命名。API
内部使用CoroutineScope
创建新的协程时,看起来它应该以launch
为前缀。
但是,我们想将此API
与内部的协程分离开来,同时因为它添加了一个新的Lifecycle Observer
,因此命名与其他LifecycleOwner API
更加一致。
命名也受到现有的LifecycleCoroutineScope.launchWhenX API
的影响。
因为launchWhenStarted
和repeatOnLifecycle(STARTED)
提供完全不同的功能(launchWhenStarted
挂起协程的执行,而repeatOnLifecycle
取消并重新启动一个新的协程)
如果新API
的名称相似(例如,使用launchWhenever
作为重新启动的API
) ,开发人员可能会感到困惑,甚至在没有注意到的情况下混淆使用它们。
一行代码实现flow
收集
目前的收集方法还是有些烦琐的,如果你是从LiveData
迁移到Flow
,你可能会觉得要是可以一行代码实现collect
就好了
这样你就可以删除模板代码,并且使迁移变得简单
因此,您可以像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) {
// 更新UI
}
}
}
这个包装器在这个例子中看起来很好很简单,但遇到了我们之前提到的LifecycleOwner.addRepeatingJob
相同的问题。它不支持结构化并发,在其他协程中使用可能很危险。
此外,原来的名称确实具有误导性:collectIn
不是挂起函数!如前所述,开发人员预期collect
会挂起。
也许,这个包装器的更好名称可能是Flow.launchAndCollectIn
以防止不良用法。
你需要一个API
包装器吗?
如果您需要在repeatOnLifecycle API
之上创建包装器来方便开发,请问问自己是否真的需要它,以及为什么需要它。
如果你确信需要,我建议你选择一个非常明确的API
命名来清楚地定义包装器的行为,以避免误用。此外,要非常清楚地记录它,以便新手可以完全理解使用它的含义。
阅读源码的小技巧
当我们查看源码的时候,已经是API
完成的状态了,其实我们可以查看API
迭代开发过程的源码,看看在迭代过程中都发生了什么,这些都是开源的
比如repeatOnLifecycle API
是在lifecycle-runtime-ktx
库中,我们可以看下它的git log
,lifecycle-runtime-ktx库git历史如下图所示:
从上面我们可以看出repeatOnLifecycle
功能是怎样一步一步被引入并修改的
我们甚至可以看看代码的review
过程,看看reviewr
有提出什么意见,比如addRepeatingJob Review过程如下图所示:
通过查看功能引入的git log
,我们可以学习Google
是怎么一步一步引入一个新功能并迭代的,相信对我们学习或者开发API
都有所帮助
总结
本文主要讲解了repeatOnLifecycle API
在开发与迭代过程中的一些设计与思考,总结如下:
API
决策通常需要在复杂性、可读性以及API
容易出错的程度方面进行一些权衡思考- 之所以移除
addRepeatingJob API
是因为它不支持结构化并发,在协程中使用可能会带来不可预期的错误 API
命名是重要且困难的,命名应符合开发人员的期望并遵循原有API
的规范- 我们不可能为所有情况提供
API
, 可用的API
越多,开发人员反倒不知道何时使用什么,因此我们只需保留最底层的API
,有时少就是多 - 我们可以通过查看新
API
引入的git log
,来学习理解新API
的引入与迭代过程
更文不易,如果本文对你有所帮助,欢迎点赞收藏~