在前一篇 Android Jetpack系列之MVVM使用及封装 文章中,介绍了常用的MVC
、MVP
、MVVM
架构及其对MVVM
的封装使用,其中MVVM
的主旨可以理解为数据驱动:Repository
提供数据,ViewModel
中发送数据,UI层
使用的LiveData
订阅数据,当有数据变化时会主动通知UI层
进行刷新。接下来继续讨论LiveData
的局限性以及google
推荐的UI
层订阅数据方式。
在学习LiveData
时,我们知道通过LiveData
可以让数据被观察,且具备生命周期感知能力,但LiveData
的缺点也很明显:
LiveData
的接收只能在主线程;LiveData
发送数据是一次性买卖,不能多次发送;LiveData
发送数据的线程是固定的,不能切换线程,setValue/postValue
本质上都是在主线程上发送的。当需要来回切换线程时,LiveData
就显得无能为力了。除了使用LiveData
,还可以采用Flow
替换,Flow
是google
官方提供的一套基于kotlin
协程的响应式编程模型。常用的Flow
有StateFlow
、SharedFlow
,详细使用参见:Android Kotlin之Flow数据流。
StateFlow
和 LiveData
具有相似之处。两者都是可观察的数据容器类,并且在应用架构中使用时,两者都遵循相似模式。但两者还是有不同之处的:StateFlow
需要将初始状态传递给构造函数,而 LiveData
不需要。
当 View
进入 STOPPED
状态时,LiveData.observe()
会自动取消注册使用方,而从 StateFlow
或任何其他数据流收集数据的操作并不会自动停止,即App
已经切到后台了,而UI层
可能还会继续订阅数据,这样可能会存在隐患。
如需保证App
只在前台时订阅数据,需要从 Lifecycle.repeatOnLifecycle
或Flow.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 Flow.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层
订阅的事件通常分成两种:
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(-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.repeatOnLifecycle
或Flow.flowWithLifecycle
,切换前后台时,当Lifecycle
处于STOPED
状态,会挂起调用它的协程;并会在进入STARTED
状态时重新执行协程。如果此时UI层
是播放语音且需求是只播放一次,那么这里就会有问题了,每次切换前后台都会再播一次,不符合需求了,那么怎么办呢?接着往下看。
Flow
底层使用的Channel
机制实现,StateFlow、SharedFlow
都是一对多的关系,如果上游发送者与下游UI层的订阅者是一对一的关系,可以使用Channel
来实现,Channel
默认是粘性的。
Channel
使用场景:一次性消费场景,如上面说的播放背景音乐,需求是在UI层
只播一次,即使App
切到后台再切回来,也不会重复播放。Channel
使用特点:
collect
之前的事件,即粘性事件Channel
使用举例:
//viewModel中
private val _loadingChannel = Channel()
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
改造一下,系统默认的实现如下:
@OptIn(ExperimentalCoroutinesApi::class)
public fun Flow.flowWithLifecycle(
lifecycle: Lifecycle,
minActiveState: Lifecycle.State = Lifecycle.State.STARTED
): Flow = callbackFlow {
lifecycle.repeatOnLifecycle(minActiveState) {
[email protected] {
send(it)
}
}
close()
}
改为下面的方式:
/**
* NOTE: 如果不想对UI层的Lifecycle.repeatOnLifecycle/Flow.flowWithLifecycle在前后台切换时重复订阅,可以使用此方法;
* 效果类似于Channel,不过Channel是一对一的,而这里是一对多的
*/
fun Flow.flowOnSingleLifecycle(
lifecycle: Lifecycle,
minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
isFirstCollect: Boolean = true,
): Flow = callbackFlow {
var lastValue: T? = null
lifecycle.repeatOnLifecycle(minActiveState) {
[email protected] {
if ((lastValue != null || isFirstCollect) && (lastValue != it)) {
send(it)
}
lastValue = it
}
}
lastValue = null
close()
}
本质上是保存了上次的值lastValue
,如果再次订阅时会跟上次的值进行对比,只有值不一样时才会继续接收,从而达到跟Channel
类似的效果,不过Channel
是一对一的,而这里是一对多的。
SharedFlow(共享Flow)
默认是热流,发送器与收集器是一对多的关系。再来看下SharedFlow
的构造参数:
public fun MutableSharedFlow(
replay: Int = 0,
extraBufferCapacity: Int = 0,
onBufferOverflow: BufferOverflow = BufferOverflow.SUSPEND
): MutableSharedFlow
replay
保存的是最新值,而extraBufferCapacity
保存的是最先发送的一个或多个值。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()
val mSharedFlow: SharedFlow = _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()
再也不会接收到之前发送的数据了!