从LiveData迁移到Kotlin Flow

响应式的框架

RxJava:过于复杂、学习成本高

LiveData:针对Android定制、使用简单

针对Java开发者,初学者、简单场景可以考虑使用LiveData。除此以外,可以考虑使用Kotlin Flows。但是Kotlin Flows现在依然有陡峭的学习曲线,但它是Kotlin语言的一部分,由Jetbrains提供支持;另外即将到来的Jetpack Compose 非常适合响应式模式。

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

LiveData擅长于暴露最近获取的数据,并且能够结合Android的生命周期。后来我们了解到它也可以启动协程并创建复杂的转换,但这有点复杂。

现在让我们看看一些 LiveData 模式和它们的 Flow 等价写法:

1、使用可变数据持有者公开一次性操作的结果

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

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

我们可以使用StateFlow来达到相同的效果:

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

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

  • 总是有值
  • 只有一个值
  • 支持多个订阅者
  • 总是重播订阅的最新值,与活跃观察者的数量无关

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

2、公开一次性操作的结果

这与前面的代码片段等效,公开了没有可变后备属性的协程调用的结果。

对于 LiveData,我们为此使用了 liveData 协程构建器:


公开一次性操作的结果 (LiveData)
class MyViewModel(...) : ViewModel() {
    val result: LiveData<Result<UiState>> = liveData {
        emit(Result.Loading)
        emit(repository.fetchItem())
    }
}

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

Flow 等价写法涉及更多,因为您必须进行一些配置:

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

3、带参数的一次性数据加载

假设您想加载一些依赖于用户 ID 的数据,并且您从 AuthManager 的公开的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,那么更好的替代方法是将流与 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 执行此操作看起来非常相似:


带参数的一次性数据加载(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 并显式发出数据项:

    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
    )
5、观察带参数的数据流

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

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

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


观察带有参数的流 (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 转换:


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

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

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

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

@param scope 开始共享的协程范围。
@param 启动了控制何时开始和停止共享的策略。
@param initialValue 状态流的初始值。
当使用带有 replayExpirationMillis 参数的 [SharingStarted.WhileSubscribed] 策略重置状态流时,也会使用此值。

开始可以采用 3 个值

  • Lazily:在第一个订阅者出现时开始,在作用域取消时停止。
  • Eagerly:立即开始并在作用域取消时停止
  • WhileSubscribed这很复杂

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

WhileSubscribed 策略

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

WhileSubscribed 有两个参数:

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

Stop timeout

从它的文档:

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 秒后停止,从而节省电量。
  • 最新的值仍然会被缓存,这样当用户回到它时,视图会立即有一些数据。
  • 订阅重新启动,新值将出现,可用时刷新屏幕。

重播到期

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

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

从视图中观察 StateFlow

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

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

  • Activity.lifecycleScope.launch:立即启动协程,活动销毁时取消协程。

  • Fragment.lifecycleScope.launch:立即启动协程,并在片段销毁时取消协程。

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

LaunchWhenStarted, launchWhenResumed…

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

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

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

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

lifecycle.repeatOnLifecycle

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

不同的流量采集方式

例如,在一个Fragment中:

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

这将在 Fragment 的视图开始时开始收集,将继续通过 RESUMED,并在返回到 STOPPED 时停止。
点击阅读相关的全部介绍 A safer way to collect flows from Android UIs

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

StateFlow 使用 WhileSubscribed(5000) 公开并使用 repeatOnLifecycle(STARTED) 收集

警告:StateFlow support recently added to Data Binding 目前使用*launchWhenCreated*来收集更新,在达到稳定之后将会采用*repeatOnLifecycle*

对于数据绑定,您应该在任何地方使用 Flows 并简单地添加 asLiveData() 以将它们公开给视图。 数据绑定将在 Lifecycle-runtime-ktx 2.4.0 稳定后更新。

总结

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

  • 使用 WhileSubscribed 策略公开 StateFlow 并设置超时。[例子]
  • 使用 repeatOnLifecycle 收集。 [例子]

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

  • 使用 WhileSubscribed 公开并在生命周期范围内使用launch/launchWhenX收集
  • 使用Lazily/Eagerly公开并使用 repeatOnLifecycle 收集

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

引用自Migrating from LiveData to Kotlin’s Flow

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,968评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,601评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,220评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,416评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,425评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,144评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,432评论 3 401
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,088评论 0 261
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,586评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,028评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,137评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,783评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,343评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,333评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,559评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,595评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,901评论 2 345

推荐阅读更多精彩内容