ViewModel-Flow-LiveData,我们还是好朋友

3,206 阅读16分钟

在Android应用程序中加载UI数据可能是一个挑战。各种屏幕的生命周期需要被考虑在内,还有配置的变化导致Activity的破坏和重新创建。

当用户在一个应用程序中进一步或后退,从一个应用程序切换到另一个应用程序,或者设备屏幕被锁定或解锁时,应用程序的各个屏幕会在互动和隐藏之间不断切换。每个组件都需要公平竞争,只有在给了资源的情况下才执行积极的工作。

配置变化发生在不同的场合:当改变设备方向、将应用程序切换到多窗口模式或调整其窗口大小、切换到黑暗或光明模式、改变默认区域或字体大小等等。

Goals of efficiency

为了在Activities和Fragments中实现高效的数据加载,从而获得最佳的用户体验,应该考虑以下几点。

  • 缓存:已经成功加载并且仍然有效的数据应该立即交付,而不是第二次加载。特别是,当一个现有的Activity或Fragment再次变得可见时,或在一个Activity因配置改变而被重新创建后。
  • 避免后台工作:当一个Activity或Fragment变得不可见时(从STARTED移动到STOPPED状态),任何正在进行的加载工作应该暂停或取消,以节省资源。这对于像位置更新或任何类型的定期刷新这样的无休止的数据流尤其重要。
  • 在配置改变期间不中断工作:这是第二个目标的例外。在配置变更期间,一个Activity被一个新的实例所取代,同时保留其状态,所以当旧的实例被摧毁时,取消正在进行的工作,在新的实例被创建时立即重新启动,会产生副作用。

Today: ViewModel and LiveData

为了帮助开发者以可管理的复杂度的代码实现这些目标,谷歌在2017年以ViewModel和LiveData的形式发布了第一个架构组件库。这是在Kotlin被引入为开发Android应用程序的推荐编程语言之前。

ViewModel是跨越配置变化而保留的对象。它们对于实现目标#1和#3很有用:在配置变化期间,加载操作可以不间断地在其中运行,而产生的数据可以缓存在其中,并与当前连接到它的一个或多个Fragment/Activity共享。

LiveData是一个简单的可观察数据持有者类,也是生命周期感知的。只有当观察者的生命周期至少处于STARTED(可见)状态时,新的值才会被派发给观察者,而且观察者会自动取消注册,这对于避免内存泄漏很方便。LiveData对于实现目标#1和#2很有用:它缓存了它所持有的数据的最新值,并且该值会自动派发给新的观察者。另外,当在STARTED状态下没有更多的注册观察者时,它会得到通知,这可以避免执行不必要的后台工作。

A graph illustrating the ViewModel Scope in relation to the Activity lifecycle

如果你是一个有经验的Android开发者,你可能已经知道所有这些了。但有必要回顾一下这些功能,以便与Flow的功能进行比较。

LiveData + Coroutines

与RxJava等反应式流解决方案相比,LiveData本身是相当有限的。

  • 它只处理与主线程之间的数据传递,把管理后台线程的重任留给了开发者。值得注意的是,map()操作符在主线程上执行其转换功能,不能用于执行I/O操作或重型CPU工作。在这种情况下,需要使用switchMap()操作符,并结合在后台线程上手动启动异步操作,即使只有一个值需要在主线程上发布回来。
  • LiveData只提供了3个转换操作:map()、switchMap()和distinctUntilChanged()。如果需要更多,你必须自己使用MediatorLiveData来实现它们。

为了帮助克服这些限制,Jetpack库还提供了从LiveData到其他技术的桥梁,如RxJava或Kotlin的coroutines。

在我看来,最简单、最优雅的桥梁是androidx.lifecycle:lifecycle-livedata-ktx Gradle依赖项提供的LiveData coroutine builder函数。这个函数类似于Kotlin Coroutines库中的flow {} builder函数,可以将一个coroutine巧妙地包装成一个LiveData实例。

val result: LiveData<Result> = liveData {
    val data = someSuspendingFunction()
    emit(data)
}
  • 你可以使用coroutine和coroutine上下文的所有功能,以同步的方式编写异步代码,不需要回调,根据需要在线程之间自动切换。
  • 通过从coroutine中调用emit()或emitSource()挂起函数,将新值派发给主线程上的LiveData观察者。
  • coroutine使用一个特殊的范围和生命周期与LiveData实例相联系。当LiveData变得不活跃时(在STARTED状态下不再有观察者),coroutine将自动被取消,这样就可以在不做任何额外工作的情况下达到目标2。
  • 在LiveData变得不活跃之后,coroutine的取消实际上将被延迟5秒,以便优雅地处理配置变化:如果一个新的Activity立即取代了旧的Activity,并且LiveData在超时之前再次变得活跃,那么取消将不会发生,并且可以避免不必要的重启成本(目标#3)。
  • 如果用户回到屏幕上,并且LiveData再次变得活跃,那么coroutine将自动重启,但前提是它在完成之前被取消了。一旦该程序完成,它就不会再重启,这样就可以避免在输入没有变化的情况下两次加载相同的数据(目标1)。

结论:通过使用LiveData coroutines构建器,你可以用最简单的代码获得默认的最佳行为。

如果资源库提供了以Flow形式返回数值流的函数,而不是暂停返回单一数值的函数,那该怎么办?也可以通过使用asLiveData()扩展函数将其转换为LiveData并利用上述所有特性。

val result: LiveData<Result> = someFunctionReturningFlow().asLiveData()

在SDK里,asLiveData()还使用了LiveData coroutines builder来创建一个简单的coroutine,在LiveData处于活动状态时对Flow进行collect操作。

fun <T> Flow<T>.asLiveData(): LiveData<T> = liveData {
    collect {
        emit(it)
    }
}

但是,让我们暂停一下--究竟什么是Flow,是否可以用它来完全替代LiveData?

Introducing Kotlin’s Flow

Charlie Chaplin turning his back on his wife labeled LiveData to look at an attractive woman labeled Flow

Flow是Kotlin的Coroutines库在2019年推出的一个类,它代表了一个异步计算的数据流。它的概念类似于RxJava的Observables,但基于coroutines,有一个更简单的API。

起初,只有冷流可用——无状态的流,每次观察者开始在coroutine的范围内collect他们的值时,都会按需创建。每个观察者得到它自己的值序列,它们不被共享。

后来,新的热流子类型SharedFlow和StateFlow被添加,并在Coroutines库的1.4.0版本中作为稳定的API毕业。

SharedFlow允许发布被广播给所有观察者的值。它可以管理一个可选的重放缓存和/或缓冲区,并且基本上取代了所有被废弃的BroadcastChannel API。

StateFlow是SharedFlow的一个专门和优化的子类,它只存储和重放最新的值。听起来很熟悉?

StateFlow和LiveData有很多共同点。

  • 它们是可观察类
  • 它们存储并向任何数量的观察者广播最新的值
  • 它们迫使你尽早捕获异常:LiveData回调中未捕获的异常会停止应用程序。热流中未捕获的异常会结束流,即使使用.catch()操作符,也不可能重新启动它。

但是它们也有重要的区别。

  • MutableStateFlow需要一个初始值,MutableLiveData不需要(注意:MutableSharedFlow(replay = 1)可以用来模拟一个没有初始值的MutableStateFlow,但是它的实现效率有点低
  • StateFlow总是使用Any.equals()进行比较来过滤相同值的重复,而LiveData则不会,除非与distinctUntilChanged()操作符相结合(注:SharedFlow也可以用来防止这种行为)。
  • StateFlow不是生命周期感知的。然而,一个Flow可以从一个生命周期感知的coroutine中collect,这需要更多的代码来设置,而不需要使用LiveData(更多细节见下文)。
  • LiveData使用版本管理来跟踪哪个值已经被派发到哪个观察者。这可以避免在回到STARTED状态时,将相同的值分派给同一个观察者两次。
  • StateFlow没有版本控制。每次一个coroutinecollect一个Flow,它都被认为是一个新的观察者,并且将总是首先接收最新的值。这可能会导致执行重复的工作,我们将在下面的案例研究中看到。

Observing LiveData vs Collecting Flow

从Fragment的一个Activity中观察一个LiveData实例是很直接的。

viewModel.results.observe(viewLifecycleOwner) { data ->
    displayResult(data)
}

这是一个一次性的操作,LiveData负责将流与观察者的生命周期同步起来。

对于Flow来说,相应的操作被称为collect,collect需要通过一个协程来完成。因为Flow本身不具有生命周期意识,所以与生命周期同步的责任被转移到collectFlow的coroutine上。

要创建一个生命周期感知的coroutine,在一个Activity/Fragment处于STARTED状态时collect一个Flow,并在Activity/Fragment被销毁时自动取消collect,可以使用以下代码。

viewLifecycleOwner.lifecycleScope.launchWhenStarted {
    viewModel.result.collect { data ->
        displayResult(data)
    }
}

但是这段代码有一个主要的限制:它只能在没有通道或缓冲区支持的冷流中正常工作。这样的流只由collect它的coroutine驱动:当Activity/Fragment移动到STOPPED状态时,coroutine将暂停,Flow producer也将暂停,在coroutine恢复之前不会发生其他事情。

然而,还有其他类型的流。

  • 热流,它总是处于活动状态,并将把结果分派给所有当前的观察者(包括暂停的观察者)。
  • 基于回调的或基于通道的冷流,当collect开始时订阅一个Activity的数据源,只有当collect被取消(不暂停)时才停止订阅。

对于这些情况,即使Flow collect的coroutine被暂停,底层的Flow生产者也会保持活跃,在后台缓冲新的结果。资源被浪费了,目标#2被错过了。

Forrest Gump on a bench saying “Life is like a box of chocolates, you never know which kind of Flow you’re going to collect.”

需要实现一种更安全的方式来collect任何类型的流。当Activity/Fragment变得不可见时,执行collect的coroutine必须被取消,并在它再次变得可见时重新启动,这与LiveData coroutine builder的做法完全一样。为此,在lifecycle:lifecycle-runtime-ktx:2.4.0中引入了新的API(在写这篇文章时仍处于alpha状态)。

viewLifecycleOwner.lifecycleScope.launch {
    viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.result.collect { data ->
            displayResult(data)
        }
    }
}

或者说是。

viewLifecycleOwner.lifecycleScope.launch {
    viewModel.result
        .flowWithLifecycle(viewLifecycleOwner.lifecycle, Lifecycle.State.STARTED)
        .collect { data ->
            displayResult(data)
        }
}

正如你所看到的,为了达到同样的安全和效率水平,用LiveData观察Activity或Fragment的结果更简单。

你可以在Manuel Vivo的文章《以更安全的方式从Android UIscollect流量》中了解更多关于这些新的API。

Replacing LiveData with StateFlow in ViewModels

让我们回到ViewModel。我们确立了这是一种使用LiveData异步获取数据的简单而有效的方法。

val result: LiveData<Result> = liveData {
    val data = someSuspendingFunction()
    emit(data)
}

我们怎样才能用StateFlow代替LiveData达到同样的效果?Jose Alcérreca写了一个很长的迁移指南来帮助回答这个问题。长话短说,对于上述用例,等效的代码是。

val result: Flow<Result> = flow {
    val data = someSuspendingFunction()
    emit(data)
}.stateIn(
    scope = viewModelScope,
    started = SharingStarted.WhileSubscribed(5000L),
    initialValue = Result.Loading
)

stateIn()操作符将我们的冷流转换为热流,能够在多个观察者之间共享一个结果。由于SharingStarted.WhileSubscribed(5000L)的存在,热流在第一个观察者订阅时被懒散地启动,并在最后一个观察者退订后5秒被取消,这样可以避免在后台做不必要的工作,同时也考虑到了配置变化。此外,一旦上游流到达终点,它就不会被共享的coroutine自动重启,所以我们避免做两次相同的工作。

看起来我们成功地实现了我们的3个目标,并使用更复杂一点的代码复制了几乎与LiveData相同的行为。

但是仍然有一个小的关键区别:每次一个Activity/Fragment再次变得可见时,一个新的流集合将开始,StateFlow总是通过立即向观察者提供最新的结果来启动流。即使这个结果在之前的集合中已经被传递给了同一个Activity/Fragment。因为与LiveData不同,StateFlow不支持版本控制,每一个流程集合都被认为是一个全新的观察者。

这有问题吗?对于这个简单的用例,并没有:一个Activity或Fragment可以只是执行一个额外的检查,以避免更新视图,如果数据没有改变。

viewLifecycleOwner.lifecycleScope.launch {
    viewModel.result
        .flowWithLifecycle(viewLifecycleOwner.lifecycle, Lifecycle.State.STARTED)
        .distinctUntilChanged()
        .collect { data ->
            displayResult(data)
        }
}

但在更复杂的、真实的使用案例中可能会出现问题,我们将在下一节看到。

Using StateFlow as trigger in a ViewModel

一个常见的情况是使用基于触发器的方法在ViewModel中加载数据:每次触发器的值被更新时,数据就会被刷新。使用MutableLiveData,效果非常好。

class MyViewModel(repository: MyRepository) : ViewModel() {
    private val trigger = MutableLiveData<String>()

    fun setQuery(query: String) {
        trigger.value = query
    }

    val results: LiveData<SearchResult>
            = trigger.switchMap { query ->
        liveData {
            emit(repository.search(query))
        }
    }
}
  • 在刷新时,switchMap()操作符会将观察者连接到一个新的底层LiveData源,替换掉旧的。而且,由于上述例子使用了LiveData的coroutine构建器,先前的LiveData源将在与观察者断开连接的5秒后自动取消其相关的coroutine。在过时的值上工作可以通过一个小的延迟来避免。
  • 因为LiveData有版本控制,MutableLiveData触发器将只向switchMap()操作符分派一次新值,只要至少有一个活跃的观察者。之后,当观察者变得不活跃和再次活跃时,最新的底层LiveData源的工作就会在它停止的地方继续进行。

这段代码足够简单,并且达到了所有效率的目标。

现在让我们看看是否可以用MutableStateFlow代替MutableLiveData来实现同样的逻辑。

天真的方法:

class MyViewModel(repository: MyRepository) : ViewModel() {
    private val trigger = MutableStateFlow("")

    fun setQuery(query: String) {
        trigger.value = query
    }

    val results: Flow<SearchResult> = trigger.mapLatest { query ->
        repository.search(query)
    }.stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5000L),
        initialValue = SearchResult.EMPTY
    )
}

MutableLiveData和MutableStateFlow的API非常接近,触发代码看起来几乎相同。最大的区别是mapLatest()转换函数的使用,对于单个返回值,它相当于LiveData的switchMap()(对于多个返回值,应该使用flatMapLatest())。

mapLatest()的工作原理与map()类似,但不是依次对所有输入值完全执行转换,而是立即消耗输入值,在一个单独的coroutine中异步执行转换。当一个新的值在上游流程中发出时,如果之前的值的转换循环程序仍在运行,它将被立即取消,一个新的循环程序将被启动以取代它。这样一来,就可以避免在过时的值上工作。

到目前为止还不错。然而,这段代码的主要问题来了:因为StateFlow不支持版本控制,当流程集合重新启动时,触发器将重新发送最新的值。每当Activity/Fragment在不可见超过5秒后再次变得可见时就会发生这种情况。

Britney Spears singing “Oops!… I emit again”

而当触发器再次发出相同的值时,mapLatest()转换将再次运行,用相同的参数再次冲击存储库,尽管结果已经被传递和缓存了!

目标1被错过了:仍然有效的数据不应该被第二次加载。

Preventing re-emission of the latest trigger value

接下来想到的问题是:我们是否应该防止这种重新加载,以及如何防止?StateFlow已经处理了从流程集合中扣除值的问题,而distinctUntilChanged()操作符对其他类型的流程也做了同样的处理。但是没有标准的操作符来重复同一流程的多个集合的值,因为流程集合应该是独立的。这是与LiveData的一个主要区别。

在使用stateIn()操作符的多个观察者之间共享Flow的特定情况下,发射的值将被缓存,并且在任何给定的时间,最多只有一个collect源Flow的coroutine。看起来很有诱惑力的是,黑掉一些运算符函数,这些运算符函数会记住以前collect的最新值,以便在新的collect开始时能够跳过它。

// Don't do this at home (or at work)
fun <T> Flow<T>.rememberLatest(): Flow<T> {
    var latest: Any? = NULL
    return flow {
        collectIndexed { index, value ->
            if (index != 0 || value !== latest) {
                emit(value)
                latest = value
            }
        }
    }
}

备注:一位细心的读者注意到,同样的行为可以通过将MutableStateFlow替换成Channel(capacity = CONFLATED),然后用receiveAsFlow()将其变成一个Flow来实现。通道永远不会重新释放值。

不幸的是,上面的逻辑是有缺陷的,当下游的流转换在完成之前被取消时,将不能按预期的那样工作。

代码假设在emit(value)返回后,该值已经被处理,如果流程集合重新开始,就不应该再被发射,但这只有在使用无缓冲的Flow操作符时才是真的。像mapLatest()这样的操作符是有缓冲的,在这种情况下,emit(value)会立即返回,而转换是异步执行的。这意味着没有办法知道一个值何时被下游的流完全处理。如果流collect在异步转换的中间被取消,我们仍然需要在流collect重新启动时重新发射最新的值,以便恢复该转换,否则该值将丢失。

TL; DR:在ViewModel中使用StateFlow作为触发器会导致每次Activity/Fragment再次变得可见时的重复工作,并且没有简单的方法来避免它。

这就是为什么在ViewModel中使用LiveData作为触发器时,LiveData要优于StateFlow,尽管在Google的 "Advanced coroutines with Kotlin Flow "代码实验室中没有提到这些差异,这意味着Flow的实现方式与LiveData的实现方式完全相同。事实并非如此。

Conclusion

以下是我基于上述演示的建议。

  • 在你的Android UI层和ViewModels中继续使用LiveData,特别是用于触发器。尽可能地使用它来暴露数据,以便在Activities和Fragments中消耗:它将使你的代码既简单又高效。
  • LiveData coroutine builder函数是你的朋友,在许多情况下可以取代ViewModels中的Flows。
  • 当你需要时,你仍然可以使用Flow运算符的力量,然后将产生的Flow转换为LiveData。
  • Flow比LiveData更适用于应用程序的所有其他层,如存储库或数据源,因为它不依赖于Android特定的生命周期,而且更容易测试。

现在你知道了,如果你还想完全 "随波逐流",将LiveData从你的Android UI层中铲除,你愿意做哪些取舍了。

原文翻译自:bladecoder.medium.com/kotlins-flo…

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