[Android翻译]从LiveData迁移到Kotlin的Flow

1,251 阅读11分钟

原文地址:medium.com/androiddeve…

原文作者:medium.com/@JoseAlcerr…

发布时间:2021年5月17日 - 9分钟阅读

LiveData是我们在2017年时需要的东西。观察者模式让我们的生活更轻松,但当时RxJava等选项对初学者来说太复杂了。架构组件团队创建了LiveData:一个非常有主见的可观察数据持有者类,专为Android设计。该类保持简单,以便于入门,建议使用RxJava来处理更复杂的反应式流案例,利用两者之间的集成优势。

死数据?

LiveData仍然是我们针对Java开发者、初学者和简单情况的解决方案。对于其他人来说,一个好的选择是转向Kotlin Flows。Flows仍然有一个陡峭的学习曲线,但它们是Kotlin语言的一部分,由Jetbrains支持;而且Compose即将到来,这与反应式模型非常吻合。

我们已经讨论了一段时间使用流程来连接你的应用程序的不同部分,除了视图和ViewModel。现在我们有了一个更安全的方法来收集Android UIs的流量,我们可以创建一个完整的迁移指南。

在这篇文章中,你将学习如何将流暴露在视图中,如何收集它们,以及如何进行微调以适应特定的需求。

流程:简单的事情更难,复杂的事情更容易

LiveData只做了一件事,而且做得很好:它暴露了数据,同时缓存了最新的价值并理解了Android的生命周期。后来我们了解到,它还可以启动循环程序创建复杂的转换,但这是一个比较复杂的问题。

让我们来看看一些LiveData模式和它们的Flow对应物。

#1:用一个可变数据持有者暴露一次操作的结果

这是一个经典的模式,你用一个coroutine的结果来突变一个状态保持器。

image.png

用一个可变数据持有者(LiveData)暴露一次操作的结果

<!-- Copyright 2020 Google LLC.	
   SPDX-License-Identifier: Apache-2.0 -->

class MyViewModel {
    private val _myUiState = MutableLiveData<Result<UiState>>(Result.Loading)
    val myUiState: LiveData<Result<UiState>> = _myUiState

    // Load data from a suspend fun and mutate state
    init {
        viewModelScope.launch { 
            val result = ...
            _myUiState.value = result
        }
    }
}

为了对流程做同样的事情,我们使用(Mutable)StateFlow。

image.png

用一个可变数据持有者(StateFlow)来显示一次操作的结果。

class MyViewModel {
    private val _myUiState = MutableStateFlow<Result<UiState>>(Result.Loading)
    val myUiState: StateFlow<Result<UiState>> = _myUiState

    // Load data from a suspend fun and mutate state
    init {
        viewModelScope.launch { 
            val result = ...
            _myUiState.value = result
        }
    }
}

StateFlowSharedFlow(它是Flow的一种特殊类型)的一种特殊类型,最接近于LiveData。

  • 它总是有一个值。
  • 它只有一个值。
  • 它支持多个观察者(所以流是共享的)。
  • 它总是在订阅时复制最新的值,与活动观察者的数量无关。

当向视图暴露UI状态时,使用StateFlow。它是一个安全、高效的观察者,被设计用来保持UI状态。

#2:暴露一次操作的结果

这相当于前面的片段,暴露了一个没有可变支持属性的coroutine调用的结果。 在LiveData中,我们使用liveData coroutine builder来实现这一点。

image.png

暴露一次操作的结果(LiveData)

class MyViewModel(...) : ViewModel() {
    val result: LiveData<Result<UiState>> = liveData {
        emit(Result.Loading)
        emit(repository.fetchItem())
    }
}

由于状态持有者总是有一个值,所以用某种支持加载、成功和错误等状态的结果类来包装我们的UI状态是个好主意。

与Flow等价的东西要多一点,因为你必须做一些配置。

image.png

暴露一次操作的结果(StateFlow)

class MyViewModel(...) : ViewModel() {
    val result: StateFlow<Result<UiState>> = flow {
        emit(repository.fetchItem())
    }.stateIn(
        scope = viewModelScope, 
        started = WhileSubscribed(5000), // Or Lazily because it's a one-shot
        initialValue = Result.Loading
    )
}

stateIn是一个Flow操作符,可以将一个Flow转换为StateFlow。让我们暂时相信这些参数,因为我们需要更多的复杂性来正确解释它。

#3:带参数的一次性数据加载

假设你想加载一些取决于用户ID的数据,并且你从一个暴露了Flow的AuthManager那里获得这些信息。

image.png

带参数的一次性数据加载(LiveData)

使用LiveData,你会做一些类似的事情。

class MyViewModel(authManager..., repository...) : ViewModel() {
    private val userId: LiveData<String?> = 
        authManager.observeUser().map { user -> user.id }.asLiveData()

    val result: LiveData<Result<Item>> = userId.switchMap { newUserId ->
        liveData { emit(repository.fetchItem(newUserId)) }
    }
}

switchMap是一个转换,当userId发生变化时,其主体被执行,结果被订阅。

如果没有理由让userId成为LiveData,那么更好的替代方法是将流与Flow相结合,最后将暴露的结果转换为LiveData。

class MyViewModel(authManager..., repository...) : ViewModel() {
    private val userId: Flow<UserId> = authManager.observeUser().map { user -> user.id }

    val result: LiveData<Result<Item>> = userId.mapLatest { newUserId ->
       repository.fetchItem(newUserId)
    }.asLiveData()
}

用Flow做这个看起来非常相似。

image.png带> 有参数的一次性数据加载(StateFlow)

注意,如果你需要更多的灵活性,你也可以使用transformLatest并显式地发射项目。

    val result = userId.transformLatest { newUserId ->
        emit(Result.LoadingData)
        emit(repository.fetchItem(newUserId))
    }.stateIn(
        scope = viewModelScope, 
        started = WhileSubscribed(5000), 
        initialValue = Result.LoadingUser // Note the different Loading states
    )

#4:观察带有参数的数据流

现在让我们把这个例子变得更有反应性。数据不是被获取的,而是被观察的,所以我们将数据源的变化自动传播到用户界面。

继续我们的例子:我们不在数据源上调用fetchItem,而是使用一个假想的observeItem操作符,返回一个Flow。

通过LiveData,你可以将流转换为LiveData,并将所有的更新信息发射出去。

image.png

观察一个带参数的流(LiveData)

class MyViewModel(authManager..., repository...) : ViewModel() {
    private val userId: LiveData<String?> = 
        authManager.observeUser().map { user -> user.id }.asLiveData()

    val result = userId.switchMap { newUserId ->
        repository.observeItem(newUserId).asLiveData()
    }
}

或者,最好是使用flatMapLatest将两个流结合起来,只将输出转换为LiveData。

class MyViewModel(authManager..., repository...) : ViewModel() {
    private val userId: Flow<String?> = 
        authManager.observeUser().map { user -> user?.id }

    val result: LiveData<Result<Item>> = userId.flatMapLatest { newUserId ->
        repository.observeItem(newUserId)
    }.asLiveData()
}

Flow的实现类似,但它没有LiveData的转换。

image.png

观察一个带参数的流(StateFlow)

class MyViewModel(authManager..., repository...) : ViewModel() {
    private val userId: Flow<String?> = 
        authManager.observeUser().map { user -> user?.id }

    val result: StateFlow<Result<Item>> = userId.flatMapLatest { newUserId ->
        repository.observeItem(newUserId)
    }.stateIn(
        scope = viewModelScope, 
        started = WhileSubscribed(5000), 
        initialValue = Result.LoadingUser
    )
}

每当用户发生变化或用户在资源库中的数据发生变化时,暴露的StateFlow将收到更新。

#5 结合多个来源。MediatorLiveData -> Flow.combined

MediatorLiveData让你观察一个或多个更新源(LiveData观察者),并在它们获得新数据时做一些事情。通常情况下,你会更新MediatorLiveData的值。

val liveData1: LiveData<Int> = ...
val liveData2: LiveData<Int> = ...

val result = MediatorLiveData<Int>()

result.addSource(liveData1) { value ->
    result.setValue(liveData1.value ?: 0 + (liveData2.value ?: 0))
}
result.addSource(liveData2) { value ->
    result.setValue(liveData1.value ?: 0 + (liveData2.value ?: 0))
}

Flow的等价物就更直接了。

val flow1: Flow<Int> = ...
val flow2: Flow<Int> = ...

val result = combine(flow1, flow2) { a, b -> a + b }

你也可以使用combinedTransform函数,或者zip

配置暴露的StateFlow(stateIn操作符

我们之前使用stateIn将一个普通的流转换为StateFlow,但它需要一些配置。如果你现在不想细究,只需要复制粘贴,这个组合是我推荐的。

val result: StateFlow<Result<UiState>> = someFlow
    .stateIn(
        scope = viewModelScope, 
        started = WhileSubscribed(5000), 
        initialValue = Result.Loading
    )

然而,如果你对那个看似随机的5秒开始的参数不确定,请继续阅读。 stateIn有3个参数(来自文档)。

@param scope 启动共享的coroutine范围。

@param started 控制何时开始和停止共享的策略。

@param initialValue 状态流的初始值。

当使用带有`replayExpirationMillis'参数的[SharingStarted.WhileSubscribed]策略重置状态流时,也会使用这个值。

started可以取3个值。

  • 懒惰地:在第一个订阅者出现时开始,在范围取消时停止。
  • 急切地:立即开始,当范围被取消时停止。
  • WhileSubscribed。这很复杂。

对于一次性操作,你可以使用Lazily或Eagerly。然而,如果你在观察其他流程,你应该使用WhileSubscribed来做一些小但重要的优化,如下所述。

WhileSubscribed策略

当没有收集器时,WhileSubscribed会取消上游的流。使用stateIn创建的StateFlow将数据暴露给View,但它也在观察来自其他层或应用程序(上游)的流。保持这些流的活动可能会导致资源的浪费,例如,如果它们继续从其他来源读取数据,如数据库连接、硬件传感器等。当你的应用程序转到后台时,你应该做一个好公民,停止这些轮询程序。

WhileSubscribed需要两个参数。

public fun WhileSubscribed(
    stopTimeoutMillis: Long = 0,
    replayExpirationMillis: Long = Long.MAX_VALUE
)

停止超时

从它的文档来看。

stopTimeoutMillis配置最后一个用户的消失和上游流的停止之间的延迟(以毫秒为单位)。它的默认值是零(立即停止)。

这很有用,因为你不想在视图停止监听几分之一秒的时候取消上游流量。这种情况经常发生--例如,当用户旋转设备时,视图被快速销毁并重新创建。

在liveData coroutine构建器中的解决方案是添加一个5秒的延迟,之后如果没有订阅者,coroutine将被停止。WhileSubscribed(5000)正是这样做的。

class MyViewModel(...) : ViewModel() {
    val result = userId.mapLatest { newUserId ->
        repository.observeItem(newUserId)
    }.stateIn(
        scope = viewModelScope, 
        started = WhileSubscribed(5000), 
        initialValue = Result.Loading
    )
}

这种方法检查了所有的盒子。

  • 当用户将你的应用程序发送到后台时,来自其他层的更新将在5秒后停止,从而节省电池。
  • 最新的值仍将被缓存,因此当用户回来时,视图将立即拥有一些数据。
  • 订阅被重新启动,新的值会进来,在可用时刷新屏幕。

重放过期

如果你不想让用户在离开太久后看到陈旧的数据,而你更喜欢显示一个加载屏幕,请查看WhileSubscribed中的replayExpirationMillis参数。在这种情况下,它非常方便,而且还能节省一些内存,因为缓存的值会恢复到stateIn中定义的初始值。回到应用程序不会那么快,但你不会显示旧数据。

replayExpirationMillis--配置共享程序停止和重放缓存重置之间的延迟(以毫秒为单位)(这使得shareIn操作者的缓存为空,并将缓存值重置为stateIn操作者的原始初始值)。它的默认值是Long.MAX_VALUE(永远保持重放缓存,从不重置缓冲区)。使用零值来立即过期缓存。

从视图中观察StateFlow

正如我们到目前为止所看到的,对于视图来说,让ViewModel中的StateFlows知道它们不再监听是非常重要的。然而,就像所有与生命周期有关的事情一样,这不是那么简单。

为了收集一个流程,你需要一个coroutine。活动和片段提供了一堆的coroutine构建器。

  • Activity.lifecycleScope.launch:立即启动coroutine,并在活动被破坏时取消它。
  • Fragment.lifecycleScope.launch:立即启动循环程序,并在片段被销毁时取消它。
  • Fragment.viewLifecycleOwner.lifecycleScope.launch:立即启动coroutine并在片段的视图生命周期被销毁时取消它。如果你要修改用户界面,你应该使用视图生命周期。

LaunchWhenStarted, launchWhenResumed...

launch的专门版本,称为 launchWhenX,它将等到lifecycleOwner处于X状态,并在lifecycleOwner下降到X状态时暂停coroutine。值得注意的是,在其生命周期所有者被销毁之前,它们不会取消循环程序。

image.png

用 launch/launchWhenX 收集流是不安全的

在应用程序处于后台时接收更新可能会导致崩溃,这可以通过暂停视图中的收集来解决。然而,当应用程序在后台时,上游流保持活跃,可能会浪费资源。

这意味着我们到目前为止为配置StateFlow所做的一切都将是非常无用的;然而,有一个新的API在城里。

lifecycle.repeatOnLifecycle的救援

这个新的coroutine builder(可从lifecycle-runtime-ktx 2.4.0-alpha01中获得)正是我们所需要的:它在一个特定的状态下启动coroutine,当生命周期所有者低于该状态时,它就会停止它们。

image.png

不同的流量收集方法

例如,在一个Fragment中。

onCreateView(...) {
    viewLifecycleOwner.lifecycleScope.launch {
        viewLifecycleOwner.lifecycle.repeatOnLifecycle(STARTED) {
            myViewModel.myUiState.collect { ... }
        }
    }
}

这将在Fragment的视图被STARTED时开始收集,将持续到RESUMED,并在返回到STOPPED时停止。请在《从Android UIs收集流量的更安全的方法》中阅读全部内容。

将repeatOnLifecycle API与上面的StateFlow指导混合在一起,可以获得最佳性能,同时很好地利用设备的资源。

image.png

用WhileSubscribed(5000)暴露的StateFlow和用 repeatOnLifecycle(STARTED)收集的StateFlow

警告。最近添加到Data Binding的StateFlow支持使用 launchWhenCreated来收集更新,当它达到稳定时,它将开始使用 repeatOnLifecycle代替。

对于数据绑定,你应该在任何地方使用流,并简单地添加asLiveData()来将它们暴露给视图。数据绑定将在lifecycle-runtime-ktx 2.4.0稳定后进行更新。

总结

从ViewModel中暴露数据并从视图中收集数据的最好方法是。

  • ✔️ 暴露一个StateFlow,使用WhileSubscribed策略,有一个超时。[示例]
  • ✔️用 repeatOnLifecycle收集。[举例]

任何其他的组合都会使上游的流保持活跃,浪费资源。

  • ❌ 在lifecycleScope.launch/launchWhenX内使用WhileSubscribed和收集来表达。
  • ❌ 使用 Lazily/Eagerly 暴露,并通过 repeatOnLifecycle 收集。

当然,如果你不需要Flow的全部功能......就使用LiveData。:)

感谢Manuel, Wojtek, Yigit, Alex Cook, Florina和Chris!


www.deepl.com 翻译