[译] Android 中的简易协程:viewModelScope

17,518 阅读4分钟

Virginia Poltrack 绘图

取消不再需要的协程(coroutine)是件容易被遗漏的任务,它既枯燥又会引入大量模版代码。viewModelScope结构化并发 的贡献在于将一项扩展属性加入到 ViewModel 类中,从而在 ViewModel 销毁时自动地取消子协程。

声明viewModelScope 将会在尚在 alpha 阶段的 AndroidX Lifecycle v2.1.0 中引入。正因为在 alpha 阶段,API 可能会更改,可能会有 bug。点这里报错。

ViewModel的作用域

CoroutineScope 会跟踪所有它创建的协程。因此,当你取消一个作用域的时候,所有它创建的协程也会被取消。当你在 ViewModel 中运行协程的时候这一点尤其重要。如果你的 ViewModel 即将被销毁,那么它所有的异步工作也必须被停止。否则,你将浪费资源并有可能泄漏内存。如果你觉得某项异步任务应该在 ViewModel 销毁后保留,那么这项任务应该放在应用架构的较低一层。

创建一个新作用域,并传入一个将在 onCleared() 方法中取消的 SupervisorJob,这样你就在 ViewModel 中添加了一个 CoroutineScope。此作用域中创建的协程将会在 ViewModel 使用期间一直存在。代码如下:

class MyViewModel : ViewModel() {

    /**
     * 这是此 ViewModel 运行的所有协程所用的任务。
     * 终止这个任务将会终止此 ViewModel 开始的所有协程。
     */
    private val viewModelJob = SupervisorJob()
    
    /**
     * 这是 MainViewModel 启动的所有协程的主作用域。
     * 因为我们传入了 viewModelJob,你可以通过调用viewModelJob.cancel() 
     * 来取消所有 uiScope 启动的协程。
     */
    private val uiScope = CoroutineScope(Dispatchers.Main + viewModelJob)
    
    /**
     * 当 ViewModel 清空时取消所有协程
     */
    override fun onCleared() {
        super.onCleared()
        viewModelJob.cancel()
    }
    
    /**
     * 没法在主线程完成的繁重操作
     */
    fun launchDataLoad() {
        uiScope.launch {
            sortList()
            // 更新 UI
        }
    }
    
    suspend fun sortList() = withContext(Dispatchers.Default) {
        // 繁重任务
    }
}

当 ViewModel 销毁时后台运行的繁重操作会被取消,因为对应的协程是由这个 uiScope 启动的。

但在每个 ViewModel 中我们都要引入这么多代码,不是吗?我们其实可以用 viewModelScope 来进行简化。

viewModelScope 可以减少模版代码

AndroidX lifecycle v2.1.0 在 ViewModel 类中引入了扩展属性 viewModelScope。它以与前一小节相同的方式管理协程。代码则缩减为:

class MyViewModel : ViewModel() {
  
    /**
     * 没法在主线程完成的繁重操作
     */
    fun launchDataLoad() {
        viewModelScope.launch {
            sortList()
            // 更新 UI
        }
    }
  
    suspend fun sortList() = withContext(Dispatchers.Default) {
        // 繁重任务
    }
}

所有的 CoroutineScope 创建和取消步骤都为我们准备好了。使用时只需在 build.gradle 文件导入如下依赖:

implementation “androidx.lifecycle.lifecycle-viewmodel-ktx$lifecycle_version

我们来看一下底层是如何实现的。

深入viewModelScope

AOSP有分享的代码。viewModelScope 是这样实现的:

private const val JOB_KEY = "androidx.lifecycle.ViewModelCoroutineScope.JOB_KEY"

val ViewModel.viewModelScope: CoroutineScope
    get() {
        val scope: CoroutineScope? = this.getTag(JOB_KEY)
        if (scope != null) {
            return scope
        }
        return setTagIfAbsent(JOB_KEY,
            CloseableCoroutineScope(SupervisorJob() + Dispatchers.Main))
    }

ViewModel 类有个 ConcurrentHashSet 属性来存储任何类型的对象。CoroutineScope 就存储在这里。如果我们看下代码,getTag(JOB_KEY) 方法试图从中取回作用域。如果取回值为空,它将以前文提到的方式创建一个新的 CoroutineScope 并将其加标签存储。

当 ViewModel 被清空时,它会运行 clear() 方法进而调用如果不用 viewModelScope 我们就得重写的 onCleared() 方法。在 clear() 方法中,ViewModel 会取消 viewModelScope 中的任务。完整的 ViewModel 代码在此,但我们只会讨论大家关心的部分:

@MainThread
final void clear() {
    mCleared = true;
    // 因为 clear() 是 final 的,这个方法在模拟对象上仍会被调用,
    // 且在这些情况下,mBagOfTags 为 null。但它总会为空,
    // 因为 setTagIfAbsent 和 getTag 不是
    // final 方法所以我们不用清空它。
    if (mBagOfTags != null) {
        for (Object value : mBagOfTags.values()) {
            // see comment for the similar call in setTagIfAbsent
            closeWithRuntimeException(value);
        }
    }
    onCleared();
}

这个方法遍历所有对象并调用 closeWithRuntimeException,此方法检查对象是否属于 Closeable 类型,如果是就关闭它。为了使作用域被 ViewModel 关闭,它应当实现 Closeable 接口。这就是为什么 viewModelScope 的类型是 CloseableCoroutineScope,这一类型扩展了 CoroutineScope、重写了 coroutineContext 并且实现了 Closeable 接口。

internal class CloseableCoroutineScope(
    context: CoroutineContext
) : Closeable, CoroutineScope {
  
    override val coroutineContext: CoroutineContext = context
  
    override fun close() {
        coroutineContext.cancel()
    }
}

默认使用 Dispatchers.Main

Dispatchers.MainviewModelScope 的默认 CoroutineDispatcher

val scope = CloseableCoroutineScope(SupervisorJob() + Dispatchers.Main)

Dispatchers.Main 在此合用是因为 ViewModel 与频繁更新的 UI 相关,而用其他的派发器就会引入至少2个线程切换。考虑到挂起方法自身有线程封闭机制,使用其他派发器并不合适,因为我们不想去取代 ViewModel 已有的功能。

单元测试 viewModelScope

Dispatchers.Main 利用 Android 的 Looper.getMainLooper() 方法在 UI 线程执行代码。这个方法在 Instrumented Android 测试中可用,在单元测试中不可用。

借用 org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version 库,调用 Dispatchers.setMain 并传入一个 singleThreadExecutor 来替换主派发器。不要用Dispatchers.Unconfined,它会破坏使用 Dispatchers.Main 的代码的所有假设和时间线。因为单元测试应该在隔离状态下运行完好且不造成任何副作用,所以当测试完成时,你应该调用 Dispatchers.resetMain() 来清理执行器。

你可以用以下体现这一逻辑的 JUnitRule 来简化你的代码。

@ExperimentalCoroutinesApi
class CoroutinesMainDispatcherRule : TestWatcher() {
  
  private val singleThreadExecutor = Executors.newSingleThreadExecutor()
  
  override fun starting(description: Description?) {
      super.starting(description)
      Dispatchers.setMain(singleThreadExecutor.asCoroutineDispatcher())
  }
  
  override fun finished(description: Description?) {
      super.finished(description)
      singleThreadExecutor.shutdownNow()
      Dispatchers.resetMain()
  }
}

现在,你可以把它加入你的单元测试了。

class MainViewModelUnitTest {
  
    @get:Rule
    var coroutinesMainDispatcherRule = CoroutinesMainDispatcherRule()
  
    @Test
    fun test() {
        ...
    }
}

请注意这是有可能变的。TestCoroutineContext 与结构化并发集成的工作正在进行中,详细信息请看这个 issue


如果你使用 ViewModel 和协程, 通过 viewModelScope 让框架管理生命周期吧!不用多考虑了!

Coroutines codelab 已经更新并使用它了。学习一下怎样在 Android 应用中使用协程吧。

如果发现译文存在错误或其他需要改进的地方,欢迎到 掘金翻译计划 对译文进行修改并 PR,也可获得相应奖励积分。文章开头的 本文永久链接 即为本文在 GitHub 上的 MarkDown 链接。


掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 AndroidiOS前端后端区块链产品设计人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划官方微博知乎专栏