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

·  阅读 277

前情提要

在前一篇 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是一对一的,而这里是一对多的。

分类:
Android
标签:
分类:
Android
标签:
收藏成功!
已添加到「」, 点击更改