协程模式在Android中的应用及工作原理

协程模式在Android中的应用及工作原理

在Android开发中,很多开发者通过代码模式学习协程,通常这已经足够应付了。但这种学习方式忽略了协程背后的精髓,事实上,它们的原理非常简单。那么,是什么使得这些模式起作用呢?

拿起你的工具,我们来揭开一些常见的协程模式,这些模式你可能已经见过很多次,并惊叹于它们背后的奥妙。

当然,如果你对协程还不太熟悉,那么欢迎!以下是一些对Android开发者来说非常值得学习的模式。

模式1:挂起函数

就像做吐司一样简单。你可能已经知道了:

  1. 把面包放进烤箱里。
  2. 等一会儿。
  3. 从烤箱里拿出烤好的面包。

下面是用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()
        }
    }
}

所以,这里总共有六种使用协程的模式和一种反模式。最重要的是,我们了解了它们为什么有效以及背后的原理。

  • 22
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Calvin880828

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值