(译)从 LiveData 迁移到 Kotlin 的 Flow

4,340 阅读9分钟

Migrating from LiveData to Kotlin's Flow

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

DeadData?

LiveData 仍然是我们为 Java 开发人员、初学者和简单情况提供的解决方案。对于其余的,一个不错的选择是转向 Kotlin Flows。 Flows 仍然有一个陡峭的学习曲线,但它们是 Kotlin 语言的一部分,由 Jetbrains 提供支持;Compose 即将到来,它非常适合反应式模型。

我们一直在谈论使用 Flows 来连接应用程序的不同部分,除了视图和 ViewModel。现在我们有了一种从 Android UI 收集流的更安全的方法,我们可以创建一个完整的迁移指南。

在这篇文章中,您将学习如何将 Flows 暴露给一个视图,如何收集它们,以及如何对其进行微调以满足特定需求。

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

LiveData 做了一件事并且做得很好:它在缓存最新值和了解 Android 的生命周期的同时公开数据。

后来我们了解到它也可以启动协程并创建复杂的转换,但这有点复杂。

让我们看看一些 LiveData 模式和它们的 Flow 等价物:

#1: 使用可变数据持有者进行一次数据发射

这是经典模式,您可以使用协程的结果来改变状态持有者:

LiveData 实现

Untitled.png

<!-- 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
        }
    }
}

Flow 实现

Untitled 1.png

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
        }
    }
}

StateFlow 是一种特殊的 SharedFlow(它是一种特殊类型的 Flow),最接近 LiveData

  • 一直有值
  • 只有一个值
  • 支持多个观察者
  • 它总是发送订阅的最新值,与活跃观察者的数量无关。

发射一次数据

这和上面的行为是等价的,但是不用创建可变数据。

LiveData 实现

Untitled 2.png

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

由于状态持有者总是有一个值,因此最好将我们的 UI 状态包装在某种支持加载、成功和错误等状态的 Result 类中。

Flow 实现

Untitled 3.png

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 转换为 StateFlow 的 Flow 运算符。现在让我们相信这些参数,因为我们稍后需要更多的复杂操作来正确解释它。

传参的一次发射

假设您想加载一些依赖于用户 ID 的数据,并且您从暴露流的 AuthManager 获取此信息:

LiveData 实现

Untitled 4.png

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实现非常相似

Untitled 5.png

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

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

请注意,如果您需要更大的灵活性,您还可以使用 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:创建一个传参的数据流

现在让我们让这个例子更具反应性。数据不是获取的,而是观察到的,因此我们将数据源中的更改自动传播到 UI。

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

使用 LiveData,您可以将流转换为 LiveData 并发出所有更新:

Untitled 6.png

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 实现

Untitled 7.png

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.combine

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 }

您还可以使用 combineTransform 函数或 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 the coroutine scope in which sharing is started.

@param
started the strategy that controls when sharing is started and stopped.

@param
initialValue the initial value of the state flow.

This value is also used when the state flow is reset using the [SharingStarted.WhileSubscribed] strategy with the `replayExpirationMillis` parameter.

started 可以采用 3 个值

  • Lazily: 在第一个订阅者出现时开始,在 scope 取消时停止。
  • Eagerly:立即开始,在 scope 取消时停止。
  • WhileSubscribed: 比较复杂***.***

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

WhileSubscribed 策略

WhileSubscribed 在没有收集器时取消上游流。使用 stateIn 创建的 StateFlow 向 View 公开数据,但它也在观察来自其他层或应用程序(上游)的流。保持这些流处于活动状态可能会导致资源浪费,例如,如果它们继续从其他来源(如数据库连接、硬件传感器等)读取数据。当您的应用程序进入后台时,您应该成为一个好公民并停止这些协程。

WhileSubscribed 有两个参数:

public fun WhileSubscribed(

stopTimeoutMillis: Long = 0,

replayExpirationMillis: Long = Long.MAX_VALUE

)

Stop timeout

From its documentation:

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

这很有用,因为如果视图停止侦听几分之一秒,您不想取消上游流。这一直发生——例如,当用户旋转设备并且视图被快速连续地破坏和重新创建时。

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

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

这种方式解决了这些问题:

  • 当用户将您的应用程序退出到后台时,来自其他层的更新将在 5 秒后停止,从而节省电量。
  • 最新的值仍然会被缓存,这样当用户回到它时,视图会立即有一些数据。
  • 订阅重新启动,新值将出现,可用时刷新屏幕。

Replay expiration

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

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

Observing StateFlow from the view

到目前为止,我们已经看到,让视图让 ViewModel 中的 StateFlows 知道它们不再监听是非常重要的。然而,与生命周期相关的所有事情一样,事情并没有那么简单。

为了收集流,您需要一个协程。Activity 和 提供了一堆协程构建器:

  • Activity.lifecycleScope.launch

    : 立即启动协程,并在活动销毁时取消它。

  • Fragment.lifecycleScope.launch

    : 立即启动协程,并在片段销毁时取消协程。

  • Fragment.viewLifecycleOwner.lifecycleScope.launch

    : 立即启动协程,并在片段的视图生命周期被销毁时取消它。如果您正在修改 UI,则应该使用视图生命周期。

    LaunchWhenStarted、launchWhenResumed…

    特殊版本的launch调用launchWhenX将等到lifecycleOwner处于 X 状态并在lifecycleOwner低于 X 状态时暂停协程。需要注意的是,在他们的生命周期所有者被销毁之前他们不会取消协程

Untitled 8.png

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

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

# lifecycle.repeatOnLifecycle to 来救援

这个新的协程构建器(可从生命周期运行时-ktx 2.4.0-alpha01 获得)完全满足我们的需要:它在特定状态下启动协程,并在生命周期所有者低于它时停止它们。

Untitled 9.png

这将在 Fragment 的视图为 时开始收集`STARTED`,将继续通过`RESUMED`,并在返回到 时停止`STOPPED`。在[从 Android UI 收集流的更安全方式中](https://medium.com/androiddevelopers/a-safer-way-to-collect-flows-from-android-uis-23080b1f8bda)阅读所有相关信息。

**将*`repeatOnLifecycle`*API 与上述 StateFlow 指南相结合,可以在充分利用设备资源的同时获得最佳性能。**

Untitled 10.png

警告:Stateflow的支持,最近添加到数据绑定使用launchWhenCreated收集的更新,它会开始使用repeatOnLifecycle',而不是当它达到稳定。对于Data Binding,您应该在任何地方使用 Flows 并简单地添加asLiveData()以将它们公开给视图。数据绑定将在lifecycle-runtime-ktx 2.4.0稳定时更新。

概括

从 ViewModel 公开数据并从视图收集数据的最佳方法是:

- ✔️StateFlow使用WhileSubscribed策略公开 a并超时。[例子]
- ✔️ 收集repeatOnLifecycle. [例子]

任何其他组合都会使上游 Flows 保持活动状态,从而浪费资源:

- ❌ 暴露使用WhileSubscribed和收集内lifecycleScope.launch/launchWhenX
- ❌ 使用Lazily/公开Eagerly并使用repeatOnLifecycle

当然,如果您不需要 Flow 的全部功能……只需使用 LiveData。:)