【译】LiveData with Coroutines and Flow

1,257 阅读12分钟

这个系列我做了协程和Flow开发者的一系列文章的翻译,旨在了解当前协程、Flow、LiveData这样设计的原因,从设计者的角度,发现他们的问题,以及如何解决这些问题,pls enjoy it。

Part I: Reactive UIs

从Android的早期开始,我们就很快了解到Android的生命周期很难理解,充满了边缘案例,而保持理智的最好方法就是尽可能地避免它们。

为此,我们建议采用分层架构,这样我们就可以编写独立于UI的代码,而不用过多考虑生命周期。例如,我们可以添加一个持有业务逻辑的领域层(你的应用程序实际做什么)和一个数据层。

img

此外,我们了解到表现层可以被分成不同的组件,承担不同的责任。

  • View--处理生命周期的回调、用户事件和Activity或Fragment的导航
  • Presenter、ViewModel--为View提供数据,并且大多不知道在View中进行的生命周期。这意味着没有中断,也不需要在重新创建视图时进行清理。

撇开命名不谈,有两种机制可以将数据从ViewModel/Presenter发送到View。

  • 拥有对视图的引用并直接调用它。通常与Presenters的工作方式有关。
  • 将可观察的数据暴露给观察者。通常与ViewModels的工作方式有关。

这是一个在Android社区相当成熟的惯例,但你会发现有一些文章有不同意见。有数百篇博客文章以不同的方式定义Presenter、ViewModel、MVP和MVVM。我的建议是,你专注于你的表现层的特性,使用Android架构组件ViewModel。

  • 在配置变化中保存下来,如旋转、地域变化、窗口大小调整、黑暗模式切换等。
  • 有一个非常简单的生命周期。它有一个单一的生命周期回调,onCleared,一旦它的生命周期所有者完成,就会被调用。

ViewModel被设计为使用观察者模式来使用。

  • 它不应该有对视图的引用。
  • 它将数据暴露给观察者,但不知道这些观察者是什么。你可以使用LiveData来实现这一点。

当一个视图(一个Activity、Fragment或任何生命周期的所有者)被创建时,ViewModel被获得,它开始通过一个或多个LiveDatas暴露数据,而视图订阅了这些数据。

img

这个订阅可以用LiveData.observe设置,也可以用Data Binding库自动设置。

现在,如果设备被旋转,那么视图将被销毁(#1),并创建一个新的实例(#2)。

img

如果我们在ViewModel中有一个对Activity的引用,我们将需要确保。

  • 当视图被销毁时清除它
  • 如果视图处于transitional状态,避免访问。

但有了ViewModel+LiveData,我们就不必再处理这个问题了。这就是为什么我们在《应用程序架构指南》中推荐这种方法。

Scopes

由于Activities和Fragments比ViewModels有相等或更短的寿命,我们可以开始讨论操作的范围了。

操作是你在应用中需要做的任何事情,比如从网络上获取数据、过滤结果或计算一些文本的排列。

对于你创建的任何操作,你需要考虑其范围:从启动到取消的时间范围。让我们看两个例子。

  • 你在一个Activity的onStart中启动一个操作,你在onStop中停止它。
  • 你在ViewModel的initblock中启动一个操作,然后在onCleared()中停止它。

img

看一下这个图,我们可以找到每个操作的意义所在。

  • 在一个作用于Activity的操作中获取数据操作,将迫使我们在旋转后再次获取它,所以它应该被作用于ViewModel。
  • 而排列文本在作用于ViewModel的操作中是没有意义的,因为在旋转之后,你的文本容器可能已经改变了形状。

显然,现实世界中的应用可以有比这些更多的作用域。例如,在Android Dev Summit应用程序中,我们可以使用。

  • Fragment scopes,每个屏幕有多个
  • Fragment ViewModel作用域,每屏一个
  • Main Activity scopes
  • Main Activity ViewModel scope
  • Application scope

img

这可能会产生很多不同的作用域,所以管理所有的作用域会让人不知所措。我们需要一种方法来结构化这种并发性!

一个非常方便的解决方案是Kotlin Coroutines。

我们喜欢在Android中使用Coroutines有很多原因。其中一些是。

  • 很容易脱离主线程。Android应用为了获得流畅的用户体验而不断地在线程间切换,而Coroutines让这一切变得超级简单。
  • 有最小的代码模板。Coroutines被嵌入到语言中,所以使用诸如suspend功能的东西是很容易的。
  • 结构化的并发性。这意味着你不得不定义你的操作范围,而且你可以享受一些代码层面的保证,从而消除大量的模板代码,如清理代码等。你可以把结构化并发想象成“自动取消”。

如果你想了解coroutines的介绍,可以看看Android的介绍和Kotlin的官方文档。

Part II: Launching coroutines with Architecture Components

Jetpack的架构组件提供了一堆语法糖,所以你不必担心Jobs和它们的取消行为。你只需要选择你的操作范围。

ViewModel scope

这是启动coroutine最常见的方式之一,因为大多数数据操作都是从ViewModel开始的。使用viewModelScope扩展,当ViewModel被清除时,Job会自动取消。使用viewModelScope. launch来启动coroutine。

class MainActivityViewModel : ViewModel {

    init {
        viewModelScope.launch {
            // Do things!
        }    
    }
}

Activity and Fragment scopes

同样,如果你使用lifecycleScope.launch,你可以将操作的范围限定在一个视图的特定实例上。

如果你用launchWhenResumed、launchWhenStarted或launchWhenCreated,则会将操作限制在某一生命周期状态,你甚至可以有一个更窄的范围。

class MyActivity : Activity {
    override fun onCreate(state: Bundle?) {
        super.onCreate(savedInstanceState)

        lifecycleScope.launch {
            // Run
        }

        lifecycleScope.launchWhenResumed {
            // Run
        }
     }
 }

Application scope

全应用程序范围有很好的用例:

medium.com/androiddeve…

但是首先,如果你的代码最终必须被执行,你应该考虑使用WorkManager。

ViewModel + LiveData

到目前为止,我们已经看到了如何启动一个coroutine,但没有看到如何从它那里接收一个结果。你可以像这样使用一个MutableLiveData。

// Don't do this. Use liveData instead.
class MyViewModel : ViewModel() {
    private val _result = MutableLiveData<String>()
    val result: LiveData<String> = _result

    init {
        viewModelScope.launch {
            val computationResult = doComputation()
            _result.value = computationResult
          }
      }
  }

但是,由于你将把这个结果暴露给你的视图,你可以通过使用liveData coroutine builder来节省一些模板代码,它可以启动一个coroutine,让你通过一个不可变的LiveData来暴露结果。你可以使用emit()来向它发送更新。

class MyViewModel : ViewModel() {
    val result = liveData {
        emit(doComputation())
    }
}

LiveData Coroutine builder with a switchMap

在某些情况下,只要LiveData的值发生变化,你就想启动一个coroutine。例如,当你在开始数据加载操作之前,你需要一个ID参数。有一个方便的模式,那就是使用Transformations.switchMap。

private val itemId = MutableLiveData<String>()

val result = itemId.switchMap {
    liveData { emit(fetchItem(it)) }
}

result是一个不可变的LiveData,只要itemId有新的值,就会用调用fetchItem suspend函数的结果来更新数据。

Emit all items from another LiveData

这个功能不太常见,但也可以节省一些模板代码:你可以使用emitSource传递一个LiveData数据源。当你想先发射一个初始值,然后再发射一连串的值时,这很有用。

liveData(Dispatchers.IO) {
    emit(LOADING_STRING)
    emitSource(dataSource.fetchWeather())
}

Cancelling coroutines

如果你使用上面的任何一种模式,你就不必明确地取消Job。然而,有一件重要的事情要记住:coroutine的取消是协作式的。

这意味着,如果调用的coroutine被取消了,你必须帮助Kotlin停止一个Job。比方说,你有一个启动无限循环的suspend函数。Kotlin没有办法为你停止这个循环,所以你需要合作,定期检查这个Job是否在活动状态。你可以通过检查isActive属性来做到这一点。

suspend fun printPrimes() {
    while(isActive) {
        // Compute
    }
}

顺便说一下,如果你使用kotlinx.coroutines中的任何函数(如delay),你应该知道它们都是可取消的,这意味着它们会为你做这种检查。

suspend fun printPrimes() {
    while(true) { // Ok-ish because we call delay inside
        // Compute
        delay(1000)
    }
}

也就是说,我建议你无论如何都要添加这个检查,因为将来可能会有人删除这个延迟调用,在你的代码中引入一个微妙的错误。

One-shot vs multiple values

为了理解coroutines(以及反应式UI),我们需要对以下内容进行重要区分。

  • One-shot操作。它们只运行一次,可以返回一个结果
  • 返回多个值的操作。对一个数据源的订阅,可以在一段时间内发出多个值

img

One-shot operations with coroutines

img

使用suspend函数并使用viewModelScope或liveData{}调用它们是运行非阻塞操作的一种非常方便的方法。

class MyViewModel {
    val result = liveData {
        emit(repository.fetchData())
    }
}

然而,当我们在监听变化时,事情就变得有点复杂了。

Receiving multiple values with LiveData

我在《LiveData beyond the ViewModel》(2018)中谈到了这个话题,在那里我谈到了,LiveData从未被设计成一个功能齐全的流构建器这一事实。

medium.com/androiddeve…

img

现在,更好的方法是使用Kotlin的Flow(警告:有些部分仍在试验中)。Flow类似于RxJava中的反应式流功能。

然而,虽然轮子让非阻塞的一次性操作变得更容易,但这对Flow来说并不是同样的情况。Flow仍然是难以掌握的。不过,如果你想创建快速而可靠的反应式UI,我认为值得花时间来学习。由于它是语言的一部分,而且是一个小的依赖项,许多库都开始添加Flow支持(比如Room)。

因此,我们可以从数据源和存储库中暴露Flow,而不是LiveData,但ViewModel仍然暴露LiveData,因为它是生命周期感知的。

img

Part III: LiveData and coroutines patterns

ViewModel patterns

让我们看看一些可用于ViewModels的模式,比较一下LiveData和Flow的使用。

LiveData: Emit N values as LiveData

val currentWeather: LiveData<String> = dataSource.fetchWeather()

如果我们不做任何转换,我们可以简单地将一个分配给另一个。

Flow: Emit N values as LiveData

我们可以使用liveData coroutine builder和Flow上的collect(这是一个接收每个发射值的终端操作符)的组合。

// Don't use this
val currentWeatherFlow: LiveData<String> = liveData {
    dataSource.fetchWeatherFlow().collect {
        emit(it)
    }
}

但由于它有很多模板代码,所以我们添加了Flow.asLiveData()扩展函数,它可以在一行中做同样的事情。

val currentWeatherFlow: LiveData<String> = dataSource.fetchWeatherFlow().asLiveData()

LiveData: Emit 1 initial value + N values from data source

如果数据源暴露了一个LiveData,我们可以使用emitSource在用emit发射一个初始值后进行批量更新。

val currentWeather: LiveData<String> = liveData {
    emit(LOADING_STRING)
    emitSource(dataSource.fetchWeather())
}

Flow: Emit 1 initial value + N values from data source

同样,我们可以天真地做到这一点。

// Don't use this
val currentWeatherFlow: LiveData<String> = liveData {
    emit(LOADING_STRING)
    emitSource(
        dataSource.fetchWeatherFlow().asLiveData()
    )
}

但如果我们利用Flow自己的API,事情看起来就会整洁很多。

val currentWeatherFlow: LiveData<String> = 
    dataSource.fetchWeatherFlow()
        .onStart { emit(LOADING_STRING) }
        .asLiveData()

onStart设置初始值,这样做我们只需要向LiveData转换一次。

LiveData: Suspend transformation

比方说,你想对来自数据源的东西进行转换,但它可能是CPU密集型的,所以它是在一个suspend函数中。

你可以在数据源的LiveData上使用switchMap,然后用LiveData生成器创建coroutine。现在你只需对收到的每个结果调用emit即可。

val currentWeatherLiveData: LiveData<String> =
    dataSource.fetchWeather().switchMap {
         liveData { emit(heavyTransformation(it)) }
    }

Flow: Suspend transformation

这就是Flow与LiveData相比真正的优势所在。我们可以再次使用Flow的API来更优雅地做事情。在这种情况下,我们使用Flow.map来在每次更新时应用转换。这一次,由于我们已经在一个coroutine上下文中,我们可以直接调用它。

val currentWeatherFlow: LiveData<String> =
    dataSource.fetchWeatherFlow()
        .map { heavyTransformation(it) }
        .asLiveData()

Repository patterns

img

关于资源库没有什么好说的,因为如果你在使用Flow,你只需要使用Flow的API来转换和组合数据。

val currentWeatherFlow: Flow<String> =
    dataSource.fetchWeatherFlow()
        .map { ... }
        .filter { ... }
        .dropWhile { ... }
        .combine { ... }
        .flowOn(Dispatchers.IO)
        .onCompletion { ... }

Data source patterns

再次,让我们区分一下One-shot场景和Flow。

img

One-shot operations in the data source

如果你正在使用一个支持suspend函数的库,如Room或Retrofit,你可以简单地从你的suspend函数中使用它们。

suspend fun doOneShot(param: String) : String = retrofitClient.doSomething(param)

然而,有些工具和库还不支持coroutine,而是基于回调。

在这种情况下,你可以使用suspendCoroutine或suspendCancellableCoroutine。

(我不知道你为什么要使用不可取消的版本,但请在评论中告诉我!)

suspend fun doOneShot(param: String) : Result<String> =
    suspendCancellableCoroutine { continuation ->
        api.addOnCompleteListener { result ->
            continuation.resume(result)
        }.addOnFailureListener { error ->
            continuation.resumeWithException(error)
        }.fetchSomething(param)
      }

当你调用它时,你会得到一个continuation。在这个例子中,我们使用的API让我们设置了一个完成的监听器和一个失败的监听器,所以在它们的回调中,当我们收到数据或错误时,我们会调用continuation.resume或continuation.resumeWithException。

值得注意的是,如果这个coroutine被取消,resume将被忽略,所以如果你的请求需要很长的时间,这个coroutine将处于活动状态,直到其中一个回调被执行。

Exposing Flow in the data source

Flow builder

如果你需要创建一个假的数据源的实现,或者你只是需要一些简单的东西,你可以使用flow构造器,做一些类似的事情。

override fun fetchWeatherFlow(): Flow<String> = flow {
    var counter = 0
    while(true) {
        counter++
        delay(2000)
        emit(weatherConditions[counter % weatherConditions.size])
    }
}

这段代码每隔两秒就会发出一个天气状况。

Callback-based APIs

如果你想把基于回调的API转换为Flow,你可以使用callbackFlow。

fun flowFrom(api: CallbackBasedApi): Flow<T> = callbackFlow {
    val callback = object : Callback {
        override fun onNextValue(value: T) {
            offer(value)
        }
        override fun onApiError(cause: Throwable) {
            close(cause)
        }
        override fun onCompleted() = close()
    }
    api.register(callback)
    awaitClose { api.unregister(callback) }
}

它看起来令人生畏,但如果你把它拆开,你会发现它有很大的意义。

  • 当我们有一个新的Value时,我们调用offer方法
  • 当我们想停止发送更新时,我们调用close(cause?)
  • 我们使用awaitClose来定义流程关闭时需要执行的内容,这对于取消注册回调来说是非常完美的。

总之,coroutines和Flow将继续存在。但它们并不能在所有地方取代LiveData。即使是非常有前途的StateFlow(目前是实验性的),我们仍然有Java编程语言和DataBinding的用户需要支持,所以它在一段时间内不会被废弃 :)

原文链接:medium.com/androiddeve…

向大家推荐下我的网站 xuyisheng.top/ 专注 Android-Kotlin-Flutter 欢迎大家访问