Flow出现后,LiveData仍然可以用,并且可以彼此转换。
在Android应用程序中加载UI数据可能会很具有挑战性。需要考虑各个屏幕的生命周期以及配置更改导致活动销毁和重新创建。
应用程序的各个屏幕会不断在互动和隐藏之间切换,因为用户在应用程序中导航,从一个应用程序切换到另一个应用程序,或者设备屏幕锁定或解锁。每个组件需要公平竞争,并且只有在获得“球”时才执行活动工作。
配置更改发生在各种情况下:当更改设备方向、将应用程序切换到多窗口模式或调整其窗口大小时、更改默认语言环境或字体大小等时。
要实现在Activity和Fragment中有效地加载数据,以实现最佳用户体验,应考虑以下问题:
为了帮助开发人员使用可管理复杂性的代码实现这些目标,Google在2017年发布了第一个架构组件库,即ViewModel和LiveData。这是在Kotlin成为开发Android应用程序的推荐编程语言之前。
ViewModel是跨配置更改保存的对象。它们对实现目标1和3很有用:在配置更改期间可以在其中运行不间断的加载操作,而所得到的数据可以缓存在其中,并与当前附加到其中的一个或多个Fragment / Activity共享。
LiveData是一个简单的可观察数据持有器类,也是生命周期感知的。只有当其生命周期至少处于STARTED(可见)状态时,才会向观察者分派新值,观察者会自动取消注册,这非常方便,以避免内存泄漏。LiveData对实现目标1和2很有用:它缓存其所持有的数据的最新值,该值将自动分派给新观察者。此外,当STARTED状态中没有更多已注册的观察者时,它会被通知,从而可以避免执行不必要的后台工作。
如果您是经验丰富的Android开发人员,您可能已经知道所有这些。但重要的是要回顾这些功能,以便将其与Flow的功能进行比较。
与RxJava等响应式流解决方案相比,LiveData本身非常有限:
它只处理将数据传递到主线程和从主线程传递数据,而将管理后台线程的负担留给开发人员。
值得注意的是,map()运算符在主线程上执行其转换函数,无法用于执行I / O操作或重度CPU工作。在这种情况下,需要与手动启动后台线程组合使用switchMap()
运算符,即使只需要在主线程上发布单个值也是如此。
LiveData仅提供3个变换运算符:map()
,switchMap()
和distinctUntilChanged()
。如果需要更多,请使用MediatorLiveData自己实现它们。
为了帮助克服这些限制,Jetpack库还提供了LiveData到其他技术(如RxJava或Kotlin的协程)的桥梁。
在我看来,最简单和最优雅的桥梁是androidx.lifecycle:lifecycle-livedata-ktx Gradle依赖项提供的LiveData协程构建器函数。此函数类似于Kotlin Coroutines库中的flow {}
构建器函数,并允许将协程智能地包装为LiveData实例:
val result: LiveData<Result> = liveData {
val data = someSuspendingFunction()
emit(data)
}
emit()
或emitSource()
暂停函数向LiveData观察者分派新值;总之, 通过使用LiveData coroutines builder,默认情况下获得最佳表现和最简单的代码。
如果存储库提供以Flow形式返回值流的挂起函数,而不是返回单个值的挂起函数,该怎么办?使用asLiveData()扩展函数也可以将其转换为LiveData,并利用所有上述功能:
val result: LiveData<Result> = someFunctionReturningFlow().asLiveData()
在幕后,asLiveData()还使用LiveData协程构建器创建一个简单的协程,该协程在LiveData处于活动状态时收集Flow:
fun <T> Flow<T>.asLiveData(): LiveData<T> = liveData {
collect {
emit(it)
}
}
但让我们停顿一会儿——Flow到底是什么,它能否完全替代LiveData?
查理·卓别林(Charlie Chaplin)背对他的妻子,标签为LiveData,看着一个标签为Flow的迷人女人
Flow是比较新潮的类似于异步计算流的值流类,属于Kotlin的Coroutines库,于2019年推出。它的概念与RxJava Observables类似,但基于协程,并且具有更简单的API。
首先,只有冷流可用:无状态的流,在每次观察者在协程范围内开始收集值时根据需要创建。每个观察者都有自己的值序列,它们不共享。
后来,新增了新的热流子类型 SharedFlow 和 StateFlow,并且在 Coroutines 库的 1.4.0 版本中作为稳定的 API 发布。
SharedFlow 允许发布值,这些值将广播给所有观察者。它可以管理可选的重放缓存和/或缓冲区,并基本上替代了所有已弃用的 BroadcastChannel API 的变种。
StateFlow 是 SharedFlow 的一种专门优化的子类,它仅存储并重放最新的值。听起来很熟悉吧?
StateFlow 和 LiveData 有很多共同点:
.catch()
操作符也不行。但它们也有重要的区别:
MutableSharedFlow(replay = 1)
来模拟没有初始值的 MutableStateFlow,但其实现效率稍低)。Any.equals()
方法来过滤掉重复的相同值,LiveData 除非与 distinctUntilChanged()
操作符结合使用,否则不会(注意:SharedFlow 也可用于防止这种行为)。从 Activity 或 Fragment 观察 LiveData 实例非常简单:
viewModel.results.observe(viewLifecycleOwner) { data ->
displayResult(data)
}
这是一次性操作,LiveData 会确保将流与观察者的生命周期同步。
与此对应的 Flow 操作称为收集(collecting),并且收集必须在协程中完成。因为 Flow 本身不具备生命周期感知能力,所以与生命周期的同步责任被转移到了收集 Flow 的协程。
要创建一个在 Activity/Fragment 处于 STARTED 状态时进行收集并在 Activity/Fragment 销毁时自动取消收集的生命周期感知协程,可以使用以下代码:
viewLifecycleOwner.lifecycleScope.launchWhenStarted {
viewModel.result.collect { data ->
displayResult(data)
}
}
但是,这段代码存在一个主要限制:它只适用于不由通道或缓冲区支持的冷流。这样的流仅由收集它的协程驱动:当 Activity/Fragment 进入 STOPPED 状态时,协程将暂停,Flow 生产者也将暂停,直到协程恢复为止,期间不会发生其他任何事情。
然而,还有其他类型的流:
对于这些情况,即使挂起了收集 Flow 的协程,底层的流生成器也将保持活动状态,在后台缓冲新的结果。这样会浪费资源,无法实现目标 #2。
像一位坐在长凳上的福雷斯特·冈普说的:“生活就像一盒巧克力,你永远不知道要收集哪种流。”
需要实现一种更安全的收集任何类型流的方式。执行收集的协程在 Activity/Fragment 变为不可见时必须被取消,并在再次变为可见时重新启动,就像 LiveData 协程构建器所做的那样。为此,在编写本文时,lifecycle:lifecycle-runtime-ktx:2.4.0
(仍为 alpha 版本)中引入了新的 API:
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)
}
}
正如您所见,为了在Activity或Fragment中以相同的安全性和效率观察结果,使用LiveData更简单。
让我们回到ViewModel。我们已经确定使用LiveData是一种简单高效的方法来异步获取数据:
val result: LiveData<Result> = liveData {
val data = someSuspendingFunction()
emit(data)
}
如果我们想要使用StateFlow来达到相同的效果,应该怎么实现呢?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秒后被取消,这样可以避免在后台执行不必要的工作,同时也考虑到了配置更改。
不幸的是,与LiveData协程构建器相反,当新的观察者在此闲置期间订阅时,共享协程会自动重新启动上游流,即使在先前的收集过程中已经达到末尾。对于上面的示例,这意味着如果Activity/Fragment在隐藏超过5秒后变为可见,someSuspendingFunction()
将始终再次运行。
目标#1未实现:数据的确进行了缓存(StateFlow将存储并重放最新值),但这不会阻止其被加载和传递第二次。
看起来我们实现了3个目标中的2个,并使用了稍微复杂一些的代码来复制LiveData的大部分行为。
还有另一个小的关键区别:每次启动新的流收集时,StateFlow始终立即向观察者传递最新的结果。即使在前一个收集过程中已经将相同的结果传递给了同一个Activity/Fragment。因为与LiveData不同,StateFlow不支持版本控制,每次流收集都被视为全新的观察者。
这有问题吗?对于这个简单的用例来说,实际上并没有问题:Activity或Fragment可以通过额外的检查来避免在数据未更改时更新视图。
viewLifecycleOwner.lifecycleScope.launch {
viewModel.result
.flowWithLifecycle(viewLifecycleOwner.lifecycle, Lifecycle.State.STARTED)
.distinctUntilChanged()
.collect { data ->
displayResult(data)
}
}
但在更复杂的实际用例中可能会出现问题,我们将在下一节中看到。
常见的情况是在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协程构建器,所以前一个LiveData源会在从其观察者断开连接后自动取消关联的协程,时间为5秒钟。通过一小段延迟,可以避免使用过时的值的问题。
由于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()
变换函数,该函数对于单个返回值(对于多个返回值,应使用flatMapLatest()
)等效于LiveData的switchMap()
。
mapLatest()
的工作方式类似于map()
,但是它不会按顺序完全执行所有输入值的转换,而是立即消耗输入值,并且转换在单独的协程中异步执行。当在上游流中发出新值时,如果先前的值的转换协程仍在运行,则会立即取消它,并启动一个新的值来替换它。这样,可以避免在过时的值上进行工作。
到目前为止都很好。然而,这里有代码的一个主要问题:由于StateFlow不支持版本控制,当流集合重新启动时,触发器将重新发出最新的值。每次Activity / Fragment再次可见时,它都会发生超过5秒钟的时间。
当触发器重新发出相同的值时,mapLatest()
转换将再次运行,还会用相同的参数再次调用存储库,即使结果已经被交付和缓存了!错过了目标#1:不应再次加载仍然有效的数据。
接下来的问题是:我们应该阻止这种重新发射,怎么办?StateFlow已经处理了从流集合内部去重的值,并且distinctUntilChanged()
操作符也可以对其他类型的流执行相同的操作。但是,不存在用于在同一流的多个集合之间去重值的标准运算符,因为流集合应该是自包含的。这是LiveData的一个主要区别。
特定情况下,在使用stateIn()
操作符共享多个观察者之间的Flow时,发出的值将被缓存,并且任何给定时间只会有最多一个协程收集源Flow。看起来很诱人,可以在某些操作符函数周围进行黑客攻击,以记住以前集合的最新值,以便在开始新集合时跳过它:
//不要在家里(或工作中)做这件事
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)将立即返回,同时转换在异步执行。这意味着没有办法知道downstream flow是否完全处理了值。如果流集合在异步转换的中途被取消,则需要在流集合重新启动时重新发射最新的值,以便恢复该转换,否则该值将丢失!
简而言之:在ViewModel中使用StateFlow作为触发器会导致每次Activity / Fragment再次可见时重复执行工作,而且没有简单的方法可以避免它。
这就是为什么在ViewModel中将LiveData用作触发器时,LiveData优于StateFlow的原因,即使这些差异未在Google的“Kotlin Flow的高级协程”codelab中提到,这表明Flow实现与LiveData一样的方式相同。 它不是。