协程模式在Android中的应用及工作原理
在Android开发中,很多开发者通过代码模式学习协程,通常这已经足够应付了。但这种学习方式忽略了协程背后的精髓,事实上,它们的原理非常简单。那么,是什么使得这些模式起作用呢?
拿起你的工具,我们来揭开一些常见的协程模式,这些模式你可能已经见过很多次,并惊叹于它们背后的奥妙。
当然,如果你对协程还不太熟悉,那么欢迎!以下是一些对Android开发者来说非常值得学习的模式。
模式1:挂起函数
就像做吐司一样简单。你可能已经知道了:
- 把面包放进烤箱里。
- 等一会儿。
- 从烤箱里拿出烤好的面包。
下面是用Kotlin写的这个过程:
suspend fun makeToast() {
println("把面包放进烤箱")
delay(2000)
println("面包现在是烤好的了")
}
如果你回顾一下整个过程,你会发现你大部分时间都是在等待面包变成烤面包。只有很少的时间你真正在活动。
那么在等待的时候你可以做些什么呢?嗯,任何你喜欢的事情。你可以在待办事项上勾掉另一项任务。只要你及时回来处理烤好的面包,就没问题。
这就是挂起函数的作用。在等待的过程中,协程被挂起,这告诉协程库(具体来说是调度器)它可以做其他的事情。
所以,这是关键部分——当你调用这个挂起函数时,底层线程并没有被阻塞。协程库高效地利用了等待的时间,让线程继续工作。
当然,对于调用上面的makeToast()函数的代码来说,这些细节并不重要。你调用makeToast(),函数稍后返回,一旦面包烤好,它就会通知你。无论它是坐着等面包,还是做其他工作,都不影响你的调用。
模式2:从主线程调用挂起函数
这就是为什么通常可以安全地从主/UI线程调用挂起函数的原因。因为挂起函数不会阻塞主线程,所以主线程可以继续进行UI操作。
下面是一个示例。点击按钮后,我们会显示一个PIN码10秒钟,然后再隐藏它:
//MainActivity.kt
@Composable
fun PlanetsScreen(...) {
val revealPIN by viewModel.isShowingPin.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
Column {
Button(
onClick = {
scope.launch {
// Here we call a function which takes at least 10 seconds to run,
// directly from the main thread. Safe because the thread isn't blocked.
viewModel.revealPinBriefly()
}
}
) {
Text("Reveal PIN")
}
if (revealPIN) {
Text(text = "Your PIN is 1234")
}
}
}
//MyViewModel.kt
val isShowingPin = MutableStateFlow(false)
// This function suspends the coroutine for a long time, but
// doesn't block the calling thread. So it can be called from
// the main/UI thread safely.
suspend fun revealPinBriefly() {
isShowingPin.value = true
delay(10_000)
isShowingPin.value = false
}
这是完全安全的,因为它不会阻塞用户界面线程。在这10秒的延迟期间,用户界面仍然可以响应。
模式3:切换上下文
许多挂起函数大部分时间都是处于挂起状态。一个很好的例子是从互联网获取数据:建立连接很容易,但等待数据下载占据了大部分时间。
那么,在用户界面线程上执行挂起的网络任务是否安全?不!根本不安全。
调用线程只在挂起任务实际被挂起的时间内(即等待期间)解除阻塞。
网络任务涉及各种等待之外的工作:设置连接、加密、解析响应等。它们可能只需要几毫秒的时间,但这是用户界面线程被阻塞的几毫秒。
出于性能原因,你需要确保用户界面线程持续更新界面。不要中断它,否则你的应用程序性能会受到影响。
因此,我们有了“切换上下文”的模式:
//NotesRepository.kt
suspend fun saveNote(note: Note) {
withContext(Dispatchers.IO) {
notesRemoteDataSource.saveNote(note)
}
}
上面的withContext
确保该挂起函数在IO线程池上运行。有了这个设置,可以安全地从用户界面线程调用saveNote
函数。
作为一个通用规则:确保挂起函数在需要时切换上下文,以便可以从用户界面线程调用它们。
模式4:在作用域中运行协程
这不是一个具体的模式,因为所有的协程都需要在某个上下文中运行。
但以下面的例子为例,像这样的代码实际上是什么意思?
viewModelScope.launch {
// Do something
}
让我们从简单的角度来看:协程的作用域表示它的生命周期。实际上还有更多细节,我会在以后的文章中详细介绍,但这是一个很好的起点。
所以,通过使用viewModelScope.launch
,你是在说:“启动一个协程,它的生命周期受到viewModelScope的限制”。
因此,这里的viewModelScope
就像是一个容器,用来保存View Model的协程,包括上面的那个协程。当容器被清空时——也就是当viewModelScope
被取消时,其中的内容也将被取消。以实际情况来说,这意味着你可以编写代码,而不必担心何时关闭它。
模式5:在挂起函数中执行多个操作
我们首先接触到了viewModelScope
。还有许多其他的,例如:
在Compose中有rememberCoroutineScope()
,它提供了一个作用域,持续时间与@Composable
在屏幕上的时间相同。(上面的模式1有一个示例)
在Android视图中有viewLifecycleOwner.lifecycleScope
,它持续时间与Activity/Fragment相同
GlobalScope
永远持续(因此通常是一个不好的主意™,但并非总是如此)
或者,你可以像下面这个模式一样自己创建:
//NotesRepository.kt
suspend fun deleteAllNotes() = withContext(...) {
// Create a scope. The suspend function will return when *all* the
// scope's child coroutines finish.
coroutineScope {
launch { remoteDataSource.deleteAllNotes() }
launch { localDataSource.deleteAllNotes() }
}
}
那么为什么你想这样做呢?well,coroutineScope
是一个特殊的函数,它创建一个新的协程作用域,并挂起,直到它内部的所有子协程都完成。
所以上面的模式意味着“并行执行这些任务,当它们全部完成时再返回”。
例如,在具有本地和远程数据源的仓库类中,这非常有用,因为你经常希望同时对两个数据源执行某些操作。只有当两个操作都完成时,才认为该操作已完成。
模式6:无限循环
现在我们理解了协程作用域,我们可以看到为什么这种模式实际上是可行的:
//MyViewModel.kt
fun flashTheLights() {
viewModelScope.launch {
// This seems like an unsafe infinite loop, but in fact
// it'll shut down when the viewModelScope is cancelled.
while(true) {
delay(1_000)
lightState = !lightState
}
}
}
在5年前,while(true)
这样的代码会被认为是一个巨大的问题,但在这种情况下实际上是安全的。一旦viewModelScope
被取消,启动的协程也会被取消,这样这个“无限”循环就会停止。
但它停止的原因非常有趣…
调用delay()
函数会让出线程给协程调度器。这意味着它允许协程调度器检查是否有其他任务需要执行,并且可以进行处理。
但同时,协程调度器也会检查协程是否已被取消,如果是的话,会抛出CancellationException
异常。虽然你不需要对此异常进行处理,但结果是堆栈展开,while(true)
这部分的代码会被丢弃。
反模式:一个不会挂起的挂起函数
因此,让出线程给协程调度器是非常重要的。你可以放心地使用Room、Retrofit和Coil等库,因为它们会在需要时将任务交给调度器处理。
但这也是为什么永远不应该编写这样的协程代码的原因:
//main.kt
// !!!!! DON'T DO THIS !!!!!
suspend fun countToAHundredBillion_unsafe() {
var count = 0L
// This suspend fun won't be cancelled if the coroutine
// that's running it gets cancelled, because it doesn't
// ever yield.
while(count < 100_000_000_000) {
count++
}
}
这个程序需要很长时间才能运行完毕。而且一旦开始,就无法停止。
为了确保协程的安全性,可以使用yield()
函数。yield()
有点像运行delay()
函数,但并不会真正延迟执行,它会让出给调度器,并在需要停止时接收到CancellationException
异常。
下面是一个安全版本的函数:
//main.kt
suspend fun countToAHundredBillion() {
var count = 0L
while(count < 100_000_000_000) {
count++
// Every 10,000 we yield to the coroutine
// dispatcher, allowing this loop to be
// cancelled if needed.
if (count % 10_000 == 0) {
yield()
}
}
}
所以,这里总共有六种使用协程的模式和一种反模式。最重要的是,我们了解了它们为什么有效以及背后的原理。