热搜:前端 nest neovim nvim

从 LiveData 迁移到 Kotlin 的 Flow

lxf2023-05-18 01:04:45

原文链接

LiveData ,是我们退回到 2017 年才需要的东西。观察者模式,的确简化了我们的工作方式,但 RxJava 等选项,对于当时的初学者来说实在是太复杂了。因此 Architecture Components 团队创建了 LiveData :这是个非常 “有主见的” 可观察数据持有者类,并且是专门为 Android 设计的。它保持简单明了,这让它易于上手,建议呢是将 RxJava 用于更复杂的 响应流 案例,以充分利用这两者之间的整合。

死数据?

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

一段时间以来,我们一直在讨论使用 Flows 连接 app 的不同部分,但 view 和 ViewModel 除外。现在我们有了更安全的方法从 Android UI 收集 flows,我们可以创建一个完整的迁移指南。

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

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

LiveData 做了一件很漂亮的事儿:它 公开数据,同时缓存最新值,并知晓 Android 的生命周期。后来我们了解到它也可以 启动协程,并 创建复杂的转换,但这就有点复杂了。

让我们看一些 LiveData 模式及其 Flow 等价替换:

#1: 使用 Mutable(可变)数据持有者,公开 一次性操作 的结果

这是经典模式,在这种模式中,你能用 协程的结果 来改变 状态持有者:

从 LiveData 迁移到 Kotlin 的 Flow

使用 Mutable(可变)数据持有者 (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

    // 从 suspend fun 加载数据并转变状态
    init {
        viewModelScope.launch { 
            val result = ...
            _myUiState.value = result
        }
    }
}

为了对 Flows 执行同样的操作,我们使用(Mutable 可变的)StateFlow:

从 LiveData 迁移到 Kotlin 的 Flow

使用 可变数据容器(StateFlow),公开 一次性操作 的结果

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

    // 从 suspend fun 加载数据并转变状态
    init {
        viewModelScope.launch { 
            val result = ...
            _myUiState.value = result
        }
    }
}

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

  • 它总有一个值。
  • 它只有一个值。
  • 它支持多个观察者(因此 flow 是 共享的)。
  • 它总是在订阅时,replay 最新的值,与 活跃观察者 的数量无关。

向 view 公开 UI 状态时,请使用 StateFlow。它是一个安全高效的观察者,旨在持有 UI 状态。

#2: 公开 一次性操作 的结果

这等效于前面的代码段,在没有可变的 后备属性 的情况下,公开协程调用的结果。

对于 LiveData,我们使用了 liveData 协程 builder:

从 LiveData 迁移到 Kotlin 的 Flow

公开 一次性操作 的结果(LiveData)

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

由于状态持有者总是有一个值,所以最好将 UI状态 封装在某种支持 LoadingSuccessError 等状态的 Result 类中。

由于必须进行一些 配置,因此等效的 Flow 代码涉及的内容会更多:

从 LiveData 迁移到 Kotlin 的 Flow

公开 一次性操作 的结果 (StateFlow)

class MyViewModel(...) : ViewModel() {
    val result: StateFlow<Result<UiState>> = flow {
        emit(repository.fetchItem())
    }.stateIn(
        scope = viewModelScope, 
        started = WhileSubscribed(5000), // 或者 Lazily,因为它是一次性的
        initialValue = Result.Loading
    )
}

stateIn 是一个 Flow 运算符,它将 Flow 转换为 StateFlow。让我们暂时完全信任这些参数,因为我们需要更多的复杂性(知识),才能在以后正确解释它。

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

假设,你想加载一些依赖于用户 ID 的数据,并且你从公开 Flow 的 AuthManager 获得这些信息:

从 LiveData 迁移到 Kotlin 的 Flow

带参的 一次性数据 加载(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,那么更好的替代方案是将 streams 与 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()
}

使用 Flows 执行此操作,看起来非常相似:

从 LiveData 迁移到 Kotlin 的 Flow

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

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 并显式地 emit(发射) 条目:

val result = userId.transformLatest { newUserId ->
        emit(Result.LoadingData)
        emit(repository.fetchItem(newUserId))
    }.stateIn(
        scope = viewModelScope, 
        started = WhileSubscribed(5000), 
        initialValue = Result.LoadingUser // 注意不同的 Loading 状态
    )

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

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

继续我们的示例:我们不在数据源上调用 fetchItem,而是使用一个假设的 observeItem 运算符来返回 Flow。

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

从 LiveData 迁移到 Kotlin 的 Flow

观察带有参数的 stream(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 组合两个 flow,并仅将输出转换为 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 转换:

从 LiveData 迁移到 Kotlin 的 Flow

观察带有参数的 stream(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.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 将常规 flow 转换为 StateFlow,但它需要一些配置。如果你现在不想深入细节,只想复制粘贴,那么我推荐这种组合:

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

但是,如果您不确定这个看似随机的 5 秒 started  参数,请继续阅读。

stateIn 有 3 个参数(来自文档):

@param scope 
开启共享的 协程作用域。

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

@param initialValue 
state Flow 的初始值。
当使用带有 `replayExpirationMillis` 参数的 [SharingStarted.WhileSubscribed] 策略,重置 state flow 时,也会使用此值。

started 可以取 3 个值:

  • Lazily:当第一个订阅者出现时开始,当 scope 被取消时停止。
  • Eagerly:立即开始,并在 scope 被取消时停止
  • WhileSubscribed这就比较复杂了

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

WhileSubscribed 策略

WhileSubscribed 在没有收集者时,会取消 上游 flow。使用 stateIn 创建的 StateFlow 将数据公开给 view,但它也会观察来自 其他层app(上游) 的 flow。保持这些 flow 处于活跃的状态,可能会导致资源浪费,例如,假如它们持续从数据库连接、硬件传感器等其他来源读取数据(的话,就会导致资源浪费)。当您的 app 进入后台时,您应该做个良好市民,停止这些协程。

WhileSubscribed 有俩参数:

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

Stop 超时

From its documentation:

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

这用处可大了去了,因为如果 view 在几分之一秒内就停止监听的话,你肯定不想取消上游 flow。这种情况总是发生——例如,当用户旋转设备时,view 会被快速连续地销毁和重新创建。

liveData 协程 builder 中的解决方案,是 添加 5 秒的延迟,之后如果没有订阅者,那么协程将停止。 WhileSubscribed(5000) 就是这么干的:

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

这种方法包含下面这几条内容:

  • 当用户将您的 app 发送到后台时,来自其他层的更新,将在五秒后停止,以节省电池电量。
  • 最新的值仍将被缓存,以便当用户返回它时,view 立即就能有一些数据。
  • 订阅将重新启动,新值将出现,并在可用时刷新屏幕。

Replay 的到期时间

如果您不希望用户在离开太久时,看到过时的数据,并且您更喜欢显示 loading 画面,请查看 WhileSubscribed 中的 replayExpirationMillis 参数。在这种情况下它非常方便并且还节省了一些内存,因为缓存的值将被恢复为 stateIn 中定义的初始值。返回 app 不会那么快,但却不会显示旧的数据。

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

从 view 观察 StateFlow

正如我们到目前为止所看到的,让 ViewModel 中的 StateFlow 知道他们不再监听了,对 view 来说是非常重要的。然而,就像所有与生命周期相关的事情一样,事情并没有那么简单。

为了收集 flow,您需要一个协程。 Activities 和 fragments 提供了一堆协程 builder:

  • Activity.lifecycleScope.launch:立即启动协程,并在 activity 被销毁时取消它。
  • Fragment.lifecycleScope.launch:立即启动协程,并在 fragment 被销毁时取消它。
  • Fragment.viewLifecycleOwner.lifecycleScope.launch:立即启动协程,并在 fragment 的 view lifecycle 被销毁时取消协程。如果你正在修改 UI,你应该使用 view lifecycle

LaunchWhenStarted, launchWhenResumed…

名为 launchWhenX 的,即launch 的特殊版本,将一直等待,直到 lifecycleOwner 处于 X 状态,并在 lifecycleOwner 低于 X 状态时,挂起协程。需要注意的是,在它们的生命周期所有者被销毁之前,它们是不会取消协程的

从 LiveData 迁移到 Kotlin 的 Flow

使用 launch/launchWhenX 收集 Flow,是不安全的

在 app 处于后台时接收更新,可能会导致崩溃,可以通过在视图中挂起 collection,来解决这个问题。但是,当 app 处于后台时,上游 flow 仍处于活跃状态,这可能会浪费资源。

这意味着,到目前为止,我们为配置 StateFlow 所做的一切都将毫无用处;但是,眼下有一个新的 API 登场了。

lifecycle.repeatOnLifecycle 来救场

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

从 LiveData 迁移到 Kotlin 的 Flow

不同的 Flow 收集方法

例如,在 Fragment 中:

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

这将在 Fragment 的 view STARTED 的时候 ,开始收集,并在返回到 STOPPED 时停止。阅读以更安全的方式从 Android UI 收集 flow 的全部内容。

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

从 LiveData 迁移到 Kotlin 的 Flow

StateFlow 通过 WhileSubscribed(5000) 公开,并通过 repeatOnLifecycle(STARTED) 收集

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

对于 Data Binding 来说,你应该随处使用 Flow,并简单地添加 asLiveData(),将其公开给 view。当 lifecycle-runtime-ktx 2.4.0 变得稳定时,Data Binding 也会被更新。

总结

从 ViewModel 公开数据,并从 view 中收集数据的最佳方式是:

  • ✔️ 使用 WhileSubscribed 策略,公开一个带有超时的 StateFlow。 [例子]
  • ✔️ 使用 repeatOnLifecycle 收集。[例子]

任何其他组合,都将使上游 flow 保持活跃状态,从而浪费资源:

  • ❌ 使用 WhileSubscribed 公开,并在 lifecycleScope.launch/launchWhenX 中收集
  • ❌ 使用 Lazily/Eagerly 公开,并使用repeatOnLifecycle 收集

当然,如果您不需要 Flow 的全部功能的话……那就用 LiveData 就行了。:)

感谢 Manuel , Wojtek , Yigit , Alex Cook, Florina 还有 Chris!

本网站是一个以CSS、JavaScript、Vue、HTML为核心的前端开发技术网站。我们致力于为广大前端开发者提供专业、全面、实用的前端开发知识和技术支持。 在本网站中,您可以学习到最新的前端开发技术,了解前端开发的最新趋势和最佳实践。我们提供丰富的教程和案例,让您可以快速掌握前端开发的核心技术和流程。 本网站还提供一系列实用的工具和插件,帮助您更加高效地进行前端开发工作。我们提供的工具和插件都经过精心设计和优化,可以帮助您节省时间和精力,提升开发效率。 除此之外,本网站还拥有一个活跃的社区,您可以在社区中与其他前端开发者交流技术、分享经验、解决问题。我们相信,社区的力量可以帮助您更好地成长和进步。 在本网站中,您可以找到您需要的一切前端开发资源,让您成为一名更加优秀的前端开发者。欢迎您加入我们的大家庭,一起探索前端开发的无限可能!