前言
本文主要基于具体场景介绍StateFlow和ShareFlow的使用
场景实战
首页1s刷新一次
kotlin
// onresume
fun startRefreshUi() {
refreshJob = launch(Dispatchers.Main) {
while (_isRefreshUiRunning) {
_todayDataFlow.emitAll(
getTodayUseCase(
GetTodayUseCase.Params(uid, KevaUtil.getBoolean(NEED_REQUEST_KEY, true))
)
)
delay(REFRESH_DATA_DURATION)
}
}
}
// onstop
fun stopRefreshUi() {
Log.d(TAG, "stopRefreshUi")
_isRefreshUiRunning = false
}
- onResume开始刷新
- onStop 停止刷新
优化如下
markdown
/**
* [REFRESH_DATA_DURATION] 刷新一次today
*/
private val refreshTodayFlow = flow {
while (true) {
Log.i("yyyyyyyyyy", ": emitAll OnlineOrOffline")
emit(Unit)
delay(REFRESH_DATA_DURATION)
}
}
val todayDataFlow: SharedFlow<Res<TodayData>> = refreshTodayFlow.transform {
UserBean.getInstance()?.uniqid?.let { uid ->
emitAll(getTodayUseCase(
GetTodayUseCase.Params(uid, KevaUtil.getBoolean(NEED_REQUEST_KEY, true))
))
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed() , Res.Loading)
SharingStarted.WhileSubscribed()在onStop时 会立即停止上游更新。ShareFlow和StateFlow中的生命周期管理 - 文章 - ByteTech (bytedance.net)
分页处理
场景分析
使用jetpack paging3进行分页, paging3内部做了flow的适配
markdown
val pagingRankingData: MutableLiveData<Flow<PagingData<RankingsItem>>?> = MutableLiveData()
private val pagerChangedFlow = combineTransform(
rakingKeyWrapper,
rakingDuration,
updateLocationSignal,
reloadSignal
) { rankingKeyWrapper, duration, updateLocationSignal, reload ->
Log.i("yyyyy", "1231")
val rankingKey = rankingKeyWrapper?.data
nowRankingKey = rankingKey
nowRankingDuration = duration
// 1. 之前这样处理的原因是, 如果emitAll(pager.flow),只有第一次能执行,后续都不能进入combineTransform的 {}
pagingRankingData.value = Pager(PagingConfig(pageSize = pageSize)) {
val source = RankingListSource(
rankingKey?.key,
duration,
pageSize
) { rankingData, page ->
if (page == RankingListSource.FIRST_PAGE) {
//只有加载第一页的时候我们才刷新这个
viewModelScope.launch {
_rankingExtData.emit(rankingData)
}
}
}
return@Pager source
}.flow
emit(emptyFlow<Int>())
}.stateIn(viewModelScope, SharingStarted.Eagerly, 1) // 2. SharingStarted.Eagerly不太对
问题
- 只有第一次可以打印yyyyy,原理见combineTranform原理 - 文章 - ByteTech (bytedance.net)
- 退回后台时没有关闭流
简化模型
简化场景模型如下:
kotlin
private val pageFlowSignal = MutableSharedFlow<Int>(0)
private val pageFlow = MutableStateFlow(0)
private val testFlow1 = MutableStateFlow(1)
private val testFlow2 = MutableStateFlow("1")
val testFlow = combineTransform(testFlow1, testFlow2, pageFlowSignal) { a, b, _ ->
Log.i("yyyyyyy", ": combineTransform")
// emitAll 一个热流
emitAll(pageFlow)
}
// 每次点击button触发
fun test() {
viewModelScope.launch {
pageFlowSignal.emit((0..1100).random())
}
}
通过点击button按钮触发test函数,但只有第一次会打印yyyyyyy
- 在combineTransform里不要emitAll(热流)
改造
markdown
val pagerChangedFlow = combineTransform(
rakingKeyWrapper,
rakingDuration,
updateLocationSignal,
reloadSignal
) { rankingKeyWrapper, duration, updateLocationSignal, reload ->
val rankingKey = rankingKeyWrapper?.data
nowRankingKey = rankingKey
nowRankingDuration = duration
val rankingKeyJson = GsonUtils.toJson(rankingKeyWrapper?.data)
KevaUtil.putString(GetInitedRankingKeyUseCase.SAVED_RANKING_KEY, rankingKeyJson)
KevaUtil.putString(
GetInitedRankingDurationUseCase.SAVED_RANKING_DURATION,
duration.duration
)
emit(Pair(rankingKey, duration))
}.flatMapLatest {
XLog.tag(TAG).i(" enter page flow ")
val (rankingKey, duration) = it
if (rankingKey?.isNeedLocate == true && !fitLocationUtil.hasLocationPermission()) {
XLog.tag(TAG)
.d("pagingRankingData needLocate true and don't hasLocationPermission.")
flow {
emit(PagingData.empty<RankingsItem>())
}
} else {
val pageSize = 50
Pager(PagingConfig(pageSize = pageSize)) {
val source = RankingListSource(
rankingKey?.key,
duration,
pageSize
) { rankingData, page ->
if (page == RankingListSource.FIRST_PAGE) {
//只有加载第一页的时候我们才刷新这个
viewModelScope.launch {
_rankingExtData.emit(rankingData)
}
}
}
return@Pager source
}.flow
}
}.cachedIn(viewModelScope)
- 使用flatMapLatest平滑combineTransFrom。相当于把
pageFlow
的内层循环拿出来了
局部刷新
场景介绍
- 授权管理list
- 点击具体应用,弹出弹窗查看授权详情
- 在详情页面可分别对基础信息,运动数据授权
建立模型
整体使用了SDK原生的数据结构,其中有两个特点比较影响流模型构建
- 原生数据结构无法序列化,很难向DialoFragment传递序列化数据作为arguments,需要共用一个ViewModel
- 原生数据结构无法直接改值
scss
// 授权管理列表的uiState,SharingStarted.WhileSubscribed(5000, 1000)第二个参数表示重放的过期保留的时间,默认是MAX_VALUE
val authList = refreshAuthListSignal.transform {
// 得到授权列表
emitAll(getSportAuthListUseCase(Unit))
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000, 1000), Res.Loading)
// 详情弹窗的ui state
// sportAuthorizeInfo为sdk里的数据结构SportAuthorizeInfo
val detailScopes: StateFlow<List<ScopeInfo>> = sportAuthorizeInfo.transform { sportAuthInfo ->
if (sportAuthInfo != null) {
XLog.tag(TAG).i("sportAuthInfo = $sportAuthInfo")
emit(sportAuthInfo.sportAuthScopes.map { it })
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
- 局部刷新只会改detailScopes,而不会改sportAuthorizeInfo。所以sportAuthorizeInfo的状态会有一些问题。所以局部刷新尽量不要这么用即sportAuthorizeInfo作为一个stateFlow
- ScopeInfo 无法直接改值,所以需要自己的数据结构
改造
基于以上模型,做如下改造
kotlin
// 用于局部刷新
val partRefreshSignal = MutableSharedFlow<Unit>()
val detailScopes: StateFlow<List<Scope>> = _refreshInfoSignal.transform { action ->
val pos = if (action is Action.RefreshAllAction) action.pos else _curPos
val authInfo = curAuthList[pos]
val scopes = when (action) {
is Action.RemoveAllAction -> {
authInfo.sportAuthScopes.map {
it.newBuilder().name(it.name).isAuthorized(false).build()
}
}
else -> {
authInfo.sportAuthScopes
}
}
XLog.tag(TAG).i("sportAuthInfo = $authInfo")
emit(scopes.map { Scope(authInfo.client_key, it.name, it.isAuthorized) })
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
fun removeScope(scopeName: String) {
viewModelScope.launch {
val scope = curDetailScopes.first { it.name == scopeName }
val res = removeSportAuthUseCase(RemoveSportAuthUseCase.Params(scope.clientKey, scopeName))
if (res is Res.Success) {
XLog.tag(TAG).i("remove sport auth success")
scope.isAuthorized = false
XLog.tag(TAG).i(" removeScope scope = $scope ")
// 局部刷新
partRefreshSignal.emit(Unit)
AuthManagerTracker.change_app_authorize(trackerCurPos, curName, "data_detail", toTrackerDataStatus(userInfoStatus), toTrackerDataStatus(sportInfoStatus))
} else {
_actionErrorSignal.emit(Unit)
AuthManagerTracker.change_app_authorize(trackerCurPos, curName, "data_detail", toTrackerDataStatus(userInfoStatus), toTrackerDataStatus(sportInfoStatus))
}
}
}
// View层,新增一个collect
launchAndRepeatWithViewLifecycle {
model.partRefreshSignal.collect {
binding.bindChecked(model.curDetailScopes)
}
}
- google提出的MVI是一个理想模型,在局部刷新场景中很难实现,因为局部数据改动需要让整个数据变化是不科学的,浪费性能。
下载 & 上报
下载和上报这个场景,生命周期和View不太一致,当View退出的时候,两者应该不能被销毁,所以获取流的时候,在第二个参数应该为SharingStarted.Eagerly
如下
scss
stateIn(viewModelScope, SharingStarted.Eagerly, SyncState.Init)
这样就会在View退出的时候,上游(生产者)不会被关闭,下游(消费者)会被关闭。当重新进入View的时候,下游会恢复,上游会把状态emit给下游
我这里View是一个loading且不可关闭的view。如果是在可关闭的view场景,逻辑上是需要Service去处理的
在静态类中管理生命周期
我们在常规的开发中可以看到注册与反注册,比如在Activity的onCreate注册,在onDestory反注册,在ViewModel init时注册,在onClear时反注册。但是现在有了```lifecycelScope ``和
ViewModelScope`可以已sdk的方式提供出去了。接入方不用考虑生命周期了。比如考虑如下场景
- 调用方试图在KV组件的Key变化时,触发某些行为,比如Keva
kotlin
object UploadHistoryFlagger {
fun flagHadUploadHistory() {
KevaUtil.putBoolean(
Constant.HAD_UPLOAD_HISTORY_KEY + "_" + UserBean.getInstance()?.uniqid,
true
)
hasUploadListener?.invoke()
}
}
然后在ViewModel申明这样的Listener
kotlin
UploadHistoryFlagger.hasUploadListener = {
//在之前没有上传数据,现在上传数据之后,需要刷新
XLog.tag(TAG).d("hasUploadListener invoke")
}
override fun onCleared() {
// 在ViewModel置null
UploadHistoryFlagger.hasUploadListener = null
super.onCleared()
}
改造
我们利用viewModelScope
和Flow
(本质也是回调)来改造下,即在ViewModelScope的生命周期内用flow监听keva的key的变化,实现如下:
kotlin
fun CoroutineScope.kevaChannelFlow(): Flow<String> {
return getKevaKey()
}
/**
* 内部和viewScope的
*/
private fun CoroutineScope.getKevaKey(): Flow<String> {
val keyShareFlow = MutableSharedFlow<String>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
var listener: Keva.OnChangeListener? = null
// key回调
Keva.getRepo(KEVA_MAP_ID, MODE_SINGLE_PROCESS)
?.registerChangeListener(Keva.OnChangeListener { repo, key ->
launch {
keyShareFlow.tryEmit(key)
}
} .also {
listener = it
} )
// 我们在viewModelScope结束的时候反注册
return keyShareFlow.onCompletion {
XLog.tag(TAG).i(" onCompletion ")
Keva.getRepo(KEVA_MAP_ID, MODE_SINGLE_PROCESS)?.unRegisterChangeListener(listener)
}
}
在ViewModel
scss
val kevaDataFlow = viewModelScope.kevaChannelFlow()
.filter { it == Constant.HAD_UPLOAD_HISTORY_KEY + "_" + UserBean.getInstance()?.uniqid }
init {
kevaDataFlow.collect {
XLog.tag(TAG).d("hasUploadListener invoke")
reload()
}
}
利用combine的特性,进行复杂的埋点
无法复制加载中的内容
如上图所示,需要紫色流(todayDataFlow)和绿色流(badgeFlow),todayDataFlow是一个定时循环流,两个流都是异步任务。我们需要把埋点时机控制在紫色流的第一个节点和绿色流的节点都发射后触发埋点。难点在于都是异步流,无法确定时机,有可能变成如上图所示的错误流序列
下图说明了如何实现:
无法复制加载中的内容
即使用filter过滤掉2类型节点,然后和绿色流combine即可,如下代码为实现
kotlin
/**
* trackerBadgeFlow 纯粹是为了埋点
* 逻辑:等待badgeFlow,todayDataFlow一起返回,才会displayHomePage
*/
private val trackerBadgeFlow = _badgeFlow.transform { badge ->
Log.i(TAG, "badge: $badge")
if (badge is Res.Success) {
if (badge.data != null) emit(badge.data?.status ?: false)
} else {
emit(false)
}
}
val trackerDisplayHomeFlow = combineTransform(trackerBadgeFlow, todayDataFlow.filter { today ->
(today is Res.Success && today.data.weekInfo.second > 0)
} ) { newBadge, today ->
if (today is Res.Success) {
val weekInfo = today.data.weekInfo
Log.i(TAG, "weekInfo: ${weekInfo.second} today = $today")
if (weekInfo.second > 0) {
emit(Pair(weekInfo, newBadge))
}
}
} .shareIn(viewModelScope, SharingStarted.WhileSubscribed())
fun fetchBadge(showBadge: Boolean) {
viewModelScope.launch {
if (!showBadge) {
_badgeFlow.emit(Res.Error(IllegalArgumentException()))
} else {
val res = getBadgeInfoUseCase(Unit)
_badgeFlow.emit(res)
}
}
}
坑
生命周期生效逻辑
上游会以下游得生命周期Eagerly
为准,其实比较类似RxJava
总结
重新思考响应式 vs 命令式
无法复制加载中的内容
- 响应式面向流编程,监听流的变化,响应变化
- 用声明的方式响应未来发生的事件流
- 响应式面向结果,命令式面向过程
- Flutter在界面布局上是不能通过命令式去写的,只能在布局上描述结果。然后监听布局相关的数据变化,从而改变UI
- 响应式类似y=f(x),
- 所以基于shareFlow和stateFlow的ViewModel类一般长这样(类似Flutter,Compose的写法)
kotlin
val detailScopes: StateFlow<List<Scope>> = _refreshInfoSignal.transform { action ->
val pos = if (action is Action.RefreshAllAction) action.pos else _curPos
val authInfo = curAuthList.getOrNull(curPos) ?: return@transform
val scopes = when (action) {
is Action.RemoveAllAction -> {
authInfo.sportAuthScopes.map {
it.newBuilder().name(it.name).isAuthorized(false).build()
}
}
else -> {
authInfo.sportAuthScopes
}
}
XLog.tag(TAG).i("sportAuthInfo = $authInfo")
emit(scopes.map { Scope(authInfo.client_key, it.name, it.isAuthorized) } )
} .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
detailScopes
描述了结果_refreshInfoSignal
描述了输入transform
一般为转换函数
- LiveData的写法一般长这样
kotlin
val detailScopes = MutableLiveData<List<Scope>>()
fun removeAllAction() {
val scopes = authInfo.sportAuthScopes.map {
it.newBuilder().name(it.name).isAuthorized(false).build()
}
detailScopes.value = scopes
}
fun refreshAllAction() {
//
detailScopes.value = scopes
}
可以看到两种写法的不同点
- 第一种写法
detailScopes
是一个结果,可以直接定义。第二种写法需要命令式的写detailScopes.value = scopes
在普通的android开发中使用申明式的写法是非常理想化的。因为其不具备类似flutter和compose的树形结构(该结构会提高UI的刷新效率),所以一般是会有多个uiState
用来绑定每个部分的ui。