Android Jetpack系列之MVVM使用及封装(续)

西门骁
2023-12-01

前情提要

在前一篇 Android Jetpack系列之MVVM使用及封装 文章中,介绍了常用的MVCMVPMVVM架构及其对MVVM的封装使用,其中MVVM的主旨可以理解为数据驱动:Repository提供数据,ViewModel中发送数据,UI层使用的LiveData订阅数据,当有数据变化时会主动通知UI层进行刷新。接下来继续讨论LiveData的局限性以及google推荐的UI层订阅数据方式。

LiveData的缺点

在学习LiveData时,我们知道通过LiveData可以让数据被观察,且具备生命周期感知能力,但LiveData的缺点也很明显:

  • LiveData的接收只能在主线程;
  • LiveData发送数据是一次性买卖,不能多次发送;
  • LiveData发送数据的线程是固定的,不能切换线程,setValue/postValue本质上都是在主线程上发送的。当需要来回切换线程时,LiveData就显得无能为力了。

除了使用LiveData,还可以采用Flow替换,Flowgoogle官方提供的一套基于kotlin协程的响应式编程模型。常用的FlowStateFlowSharedFlow,详细使用参见:Android Kotlin之Flow数据流

Lifecycle.repeatOnLifecycle、Flow.flowWithLifecycle订阅数据

StateFlowLiveData 具有相似之处。两者都是可观察的数据容器类,并且在应用架构中使用时,两者都遵循相似模式。但两者还是有不同之处的:StateFlow 需要将初始状态传递给构造函数,而 LiveData 不需要。

View 进入 STOPPED 状态时,LiveData.observe() 会自动取消注册使用方,而从 StateFlow 或任何其他数据流收集数据的操作并不会自动停止,即App已经切到后台了,而UI层可能还会继续订阅数据,这样可能会存在隐患。

如需保证App只在前台时订阅数据,需要从 Lifecycle.repeatOnLifecycleFlow.flowWithLifecycle 块收集数据流。google使用更为安全的方式收集 Android UI 数据流中给的例子:

class LocationActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        lifecycleScope.launch {
            // 单次配置任务
            val expensiveObject = createExpensiveObject()
            lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
                // 在生命周期进入 STARTED 状态时开始重复任务,在 STOPED 状态时停止
                // 对 expensiveObject 进行操作
            }
            // 当协程恢复时,`lifecycle` 处于 DESTROY 状态。repeatOnLifecycle 会在
            // 进入 DESTROYED 状态前挂起协程的执行
        }
    }
}

或者

class LocationActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        locationProvider.locationFlow()
            .flowWithLifecycle(this, Lifecycle.State.STARTED)
            .onEach {
                // 新的位置!更新地图
            }
            .launchIn(lifecycleScope) 
    }
}

其中Flow.flowWithLifecycle内部也是通过Lifecycle.repeatOnLifecycle实现的,上述例子中会在生命周期进入 STARTED 状态时开始重复任务,在 STOPED 状态时停止操作,如果觉得使用起来写的重复代码太多,可以简单对Flow.flowWithLifecycle封装一下:

inline fun <T> Flow<T>.flowWithLifecycle2(
        lifecycleOwner: LifecycleOwner,
        minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
        crossinline block: suspend CoroutineScope.(T) -> Unit,
) = lifecycleOwner.lifecycleScope.launch {
    //前后台切换时可以重复订阅数据。如:Lifecycle.State是STARTED,那么在生命周期进入 STARTED 状态时开始任务,在 STOPED 状态时停止订阅
    flowWithLifecycle(lifecycleOwner.lifecycle, minActiveState).collect { block(it) }
}

UI层使用如下:

mViewModel.loadingFlow.flowWithLifecycle2(this, Lifecycle.State.STARTED) { isShow ->
    mStatusViewUtil.showLoadingView(isShow)
}

嗯,看上去简洁了一些。

事件分类导致的新问题

UI层订阅的事件通常分成两种:

  • 一种是同样的事件可以多次消费:比如UI的刷新,多次执行没有任何问题;
  • 另一种是同样的事件只能消费一次,多次执行可能会有问题:比如Loading弹窗、跳转、播放音乐等。

针对第二种情况,写一个简单的例子:

  //UI层
   mBtnQuest.setOnClickListener {
       mViewModel.getModelByFlow()
   }
        
   lifecycleScope.launch {
       mViewModel.mIntFlow
               .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED)
               .collect { value ->
                   log("collect here: $value")
                   //......其他......
               }
   }
 //ViewModel层
 private val _intFlow = MutableStateFlow<Int>(-1)
 val mIntFlow = _intFlow
    
 fun getModelByFlow() {
     viewModelScope.launch {
          intFlow.emit(1)
     }
 }

打开当前页面时,log如下:

2022-05-08 21:34:17.775 3482-3482/org.ninetripods.mq.study E/TTT: collect here: -1

StateFlow的默认值 -1 会先发送到UI层,点击Button之后:

2022-05-08 21:34:22.921 3482-3482/org.ninetripods.mq.study E/TTT: collect here: 1

ViewModel中发送了1并被UI层接收。一切都很正常,此时我们把App切到后台再切回来:

2022-05-08 21:38:01.597 3482-3482/org.ninetripods.mq.study E/TTT: collect here: 1

可以看到UI层又接收了一遍,这是因为不管是Lifecycle.repeatOnLifecycleFlow.flowWithLifecycle ,切换前后台时,当Lifecycle处于STOPED状态,会挂起调用它的协程;并会在进入STARTED状态时重新执行协程。如果此时UI层是播放语音且需求是只播放一次,那么这里就会有问题了,每次切换前后台都会再播一次,不符合需求了,那么怎么办呢?接着往下看。

避免UI层重复订阅

第一种方式:Channel

Flow底层使用的Channel机制实现,StateFlow、SharedFlow都是一对多的关系,如果上游发送者与下游UI层的订阅者是一对一的关系,可以使用Channel来实现,Channel默认是粘性的。

Channel使用场景:一次性消费场景,如上面说的播放背景音乐,需求是在UI层只播一次,即使App切到后台再切回来,也不会重复播放。Channel使用特点:

  • 每个消息只有一个订阅者可以收到,用于一对一的通信
  • 第一个订阅者可以收到collect之前的事件,即粘性事件

Channel使用举例:

//viewModel中
private val _loadingChannel = Channel<Boolean>()
val loadingFlow = _loadingChannel.receiveAsFlow()

private suspend fun loadStart() {
    _loadingChannel.send(true)
}

private suspend fun loadFinish() {
    _loadingChannel.send(false)
}
//UI层接收Loading信息
 lifecycleScope.launch {
      mViewModel.loadingFlow
             .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED)
             .collect { isShow ->
                 mStatusViewUtil.showLoadingView(isShow)
             }
 }

通过Channel.receiveAsFlow()可以将Channel转化为Flow使用,Channel是一对一的关系,且下游消费完之后事件就没了,切换前后台也不会再重复消费事件了,达到了我们的要求。

第二种方式:改造Flow.flowWithLifecycle

还有一种写法,是对Flow.flowWithLifecycle改造一下,系统默认的实现如下:

@OptIn(ExperimentalCoroutinesApi::class)
public fun <T> Flow<T>.flowWithLifecycle(
    lifecycle: Lifecycle,
    minActiveState: Lifecycle.State = Lifecycle.State.STARTED
): Flow<T> = callbackFlow {
    lifecycle.repeatOnLifecycle(minActiveState) {
        this@flowWithLifecycle.collect {
            send(it)
        }
    }
    close()
}

改为下面的方式:

/**
 * NOTE: 如果不想对UI层的Lifecycle.repeatOnLifecycle/Flow.flowWithLifecycle在前后台切换时重复订阅,可以使用此方法;
 * 效果类似于Channel,不过Channel是一对一的,而这里是一对多的
 */
fun <T> Flow<T>.flowOnSingleLifecycle(
        lifecycle: Lifecycle,
        minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
        isFirstCollect: Boolean = true,
): Flow<T> = callbackFlow {
    var lastValue: T? = null
    lifecycle.repeatOnLifecycle(minActiveState) {
        this@flowOnSingleLifecycle.collect {
            if ((lastValue != null || isFirstCollect) && (lastValue != it)) {
                send(it)
            }
            lastValue = it
        }
    }
    lastValue = null
    close()
}

本质上是保存了上次的值lastValue,如果再次订阅时会跟上次的值进行对比,只有值不一样时才会继续接收,从而达到跟Channel类似的效果,不过Channel是一对一的,而这里是一对多的。

第三种方式 :使用默认非粘性的SharedFlow(不推荐)

SharedFlow(共享Flow)默认是热流,发送器与收集器是一对多的关系。再来看下SharedFlow的构造参数:

public fun <T> MutableSharedFlow(
    replay: Int = 0,
    extraBufferCapacity: Int = 0,
    onBufferOverflow: BufferOverflow = BufferOverflow.SUSPEND
): MutableSharedFlow<T>
  • replay:重播给新订阅者时缓存数据的个数,默认是0。当新订阅者collect时,会先尝试获取上游replay个数据,为0时则不会获取之前的数据。replay缓存是针对后续所有的订阅者准备的
  • extraBufferCapacity:除了replay外,缓冲值的数量。当有剩余的缓冲区空间时,Emit不挂起(可选,不能为负,默认为零) 。extraBufferCapacity是为上游快速发射器及下游慢速收集器这种场景提供缓冲的,个人觉得有点类似于线程池中的存储队列。这里注意一点,replay保存的是最新值,而extraBufferCapacity保存的是最先发送的一个或多个值。
  • onBufferOverflow:配置缓冲区溢出的操作(可选,默认为SUSPEND,暂停尝试发出值),可选值有:SUSPEND-暂停发送、DROP_OLDEST-丢弃队列中最老的、DROP_LATEST-丢弃队列中最新的。

注意:缓冲区总值 = replay + extraBufferCapacity,SharedFlow禁止在缓冲区总值为零时使用onBufferOverflow = BufferOverflow.SUSPEND以外的任何东西。因为tryEmit(value: T)不会暂停,如果你用默认的replay和extraBufferCapacity值来使用它,它就不会工作。换句话说,用tryEmit(value: T)发射事件的唯一方法是,至少要有一个总缓冲区。

已知replay表示的重播给新订阅者时缓存数据个数。默认的 SharedFlow 构造方法中replay = 0,即默认设置的SharedFlow是非粘性的。已知Lifecycle.repeatOnLifecycle、Flow.flowWithLifecycle在前后台切换等场景下会重复订阅,如果ViewModel使用的是SharedFlow,那么应用回到前台产生新的订阅时,因为replay默认为0不会接收之前的老数据,只会接收订阅之后的新数据,所以正好符合我们的需求。示例:

//UI层
lifecycleScope.launch {
   mFlowModel.mSharedFlow
     .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED)
     .collect { str -> log("sharedFlow:$str") }
}

mFlowModel.fetchSharedFlowData() //请求数据
//ViewModel层
private val _sharedFlow = MutableSharedFlow<String>()
val mSharedFlow: SharedFlow<String> = _sharedFlow


fun fetchSharedFlowData() {
     viewModelScope.launch {
         _sharedFlow.emit("data")
     }
}

执行结果:

01:08:17.426  E  sharedFlow:data

此时按Home键切换到后台,再重新进入App,通过控制台可以看到没有日志输出,也验证了我们上面的结论。

使用SharedFlow需要注意一点,要保证我们的UI层中SharedFlow#collect()的时机必须早于SharedFlow#emit()的时机,否则就会接收不到数据。为什么呢?因为SharedFlow默认是非粘性的呀,下游新订阅的并不会获取上游之前发送的数据

因为非粘性,还有一个隐患,如果在应用切到后台等操作导致SharedFlow#collect()cancel掉, 而此时触发了SharedFlow#emit(),那么等应用再切回前台时,重新注册的SharedFlow#collect()再也不会接收到之前发送的数据了!

 类似资料: