repeatOnLifecycle + SharedFlow 隐藏坑排查与治理

10,762 阅读5分钟

前言

sharedFlow 是 kotlin 提供的热流框架,其自身不能根据 “页面生命周期” 决定消息去留,

例如 “弹 Dialog” 场景,需在 “页面已可见” 情况下实施,以确保生命周期安全,

对此官方提供 repeatOnLifecycle API 来补充这一能力,

不过 sharedFlow + repeatOnLifecycle 在 “页面初始化” 等高频场景存在 “错过收集时机” 乃至 “错过消费” 的隐患,

本文便是分享改善该隐患的心路历程,相信阅读后你会耳目一新。

SharedFlow 也 “丢” Result?

前不久,笔者肝了个框架 MVI-Dispatcher-KTX ,该框架中用到 sharedFlow + repeatOnLifecycle,

MVI-Dispatcher-KTX 在 sample module 提供一系列常规 + 暴力测试:

我们于 ComplexRequester 安排 4 组事件选项,事件 1 可轮询通知事件 4 回推 UI,事件 2 延迟 200 毫秒后可回推 UI,事件 3 可直接回推 UI,

class ComplexRequester : MviDispatcherKTX<ComplexEvent>() {
  override suspend fun onHandle(event: ComplexEvent) {
    when (event) {
      is ComplexEvent.ResultTest1 -> interval(100).collect { input(ComplexEvent.ResultTest4(it)) }
      is ComplexEvent.ResultTest2 -> timer(1000).collect { sendResult(event) }
      is ComplexEvent.ResultTest3 -> sendResult(event)
      is ComplexEvent.ResultTest4 -> sendResult(event)
    }
  }
  ...
}

与此同时,我们在 MainActivity 通过 output 函数注册观察 MVI-Dispatcher-KTX,并通过 input 函数向 MVI-Dispatcher-KTX 发起事件 1、2、3,

class MainActivity : BaseActivity() {
​
  ...
  
  override fun onOutput() {
    complexRequester.output(this) { complexEvent ->
      when (complexEvent) {
        is ComplexEvent.ResultTest1 -> Log.d("e", "---1")
        is ComplexEvent.ResultTest2 -> Log.d("e", "---2")
        is ComplexEvent.ResultTest3 -> Log.d("e", "---3")
        is ComplexEvent.ResultTest4 -> Log.d("e", "---4 " + complexEvent.count)
      }
    }
  }
  
  override fun onInput() {
    super.onInput()
    
    complexRequester.input(ComplexEvent.ResultTest1())
    
    complexRequester.input(ComplexEvent.ResultTest2())
    complexRequester.input(ComplexEvent.ResultTest2())
    complexRequester.input(ComplexEvent.ResultTest2())
    complexRequester.input(ComplexEvent.ResultTest2())
    
    complexRequester.input(ComplexEvent.ResultTest3())
    complexRequester.input(ComplexEvent.ResultTest3())
    complexRequester.input(ComplexEvent.ResultTest3())
  }
}

结果出人意料:

com.kunminx.purenote_ktx D/e: ---4 0
com.kunminx.purenote_ktx D/e: ---4 1
com.kunminx.purenote_ktx D/e: ---4 2
com.kunminx.purenote_ktx D/e: ---4 3
com.kunminx.purenote_ktx D/e: ---4 4
com.kunminx.purenote_ktx D/e: ---4 5
com.kunminx.purenote_ktx D/e: ---4 6
com.kunminx.purenote_ktx D/e: ---4 7
com.kunminx.purenote_ktx D/e: ---2
com.kunminx.purenote_ktx D/e: ---2
com.kunminx.purenote_ktx D/e: ---2
com.kunminx.purenote_ktx D/e: ---2
com.kunminx.purenote_ktx D/e: ---4 8
com.kunminx.purenote_ktx D/e: ---4 9
com.kunminx.purenote_ktx D/e: ---4 10
com.kunminx.purenote_ktx D/e: ---4 11
com.kunminx.purenote_ktx D/e: ---4 12

事件 3 回推结果呢?

MVI-Dispatcher 测试时无此问题,为何 KTX 版有?

于是继续打 Log 观察:

class ComplexRequester : MviDispatcherKTX<ComplexEvent>() {
  override suspend fun onHandle(event: ComplexEvent) {
    when (event) {
      ...
      is ComplexEvent.ResultTest3 -> {
        Log.d("---", "ResultTest3-sendResult")
        sendResult(event)
      }
    }
  }
}

KTX 版基类中观察 SharedFlow 收集时机:

open class MviDispatcherKTX<E> : ViewModel() {
  fun output(activity: AppCompatActivity?, observer: (E) -> Unit) {
    initQueue()
    activity?.lifecycleScope?.launch {
      Log.d("---", "activity.lifecycleScope.launch")
      activity.repeatOnLifecycle(Lifecycle.State.STARTED) {
        Log.d("---", "activity.repeatOnLifecycle")
        _sharedFlow?.collect { observer.invoke(it) }
      }
    }
  }
  ...
}

继续输出结果:

com.kunminx.purenote_ktx D/---: activity.lifecycleScope.launch
com.kunminx.purenote_ktx D/---: ResultTest3-sendResult
com.kunminx.purenote_ktx D/---: ResultTest3-sendResult
com.kunminx.purenote_ktx D/---: ResultTest3-sendResult
com.kunminx.purenote_ktx D/---: activity.repeatOnLifecycle
com.kunminx.purenote_ktx D/e: ---4 0
com.kunminx.purenote_ktx D/e: ---4 1
com.kunminx.purenote_ktx D/e: ---4 2
com.kunminx.purenote_ktx D/e: ---4 3
com.kunminx.purenote_ktx D/e: ---4 4

发现端倪 —— sharedFlow.emit 事件 3 时机早于 activity.repeatOnLifecycle 时机,错过 sharedFlow 收集时,

故此处将 sharedFlow replay 值改为 1 验证下:

open class MviDispatcherKTX<E> : ViewModel() {
  private var _sharedFlow: MutableSharedFlow<E>? = null
​
  private fun initQueue() {
    if (_sharedFlow == null) _sharedFlow = MutableSharedFlow(
      replay = 1,
      onBufferOverflow = BufferOverflow.DROP_OLDEST,
      extraBufferCapacity = initQueueMaxLength()
    )
  }
  ...
}

这下收到,确实是时机问题,也即 sharedFlow 并非人眼感知到的 “丢 Result”,而是其默认 replay = 0,不自动回推缓存数据给订阅者,该设计符合 “Result” 场景。

为何不用 StateFlow?

StateFlow 属于表现层组件,此处我们关注的是领域层 “消息分发”,

消息分发意味着 “一次性消息推送”,将 State 和 Event 串流传输,到达表现层后,再根据 State 或 Event 分别处理 —— State 交由 BehaviorSubject 例如 StateFlow、LiveData 来托管,Event 则作为一次性事件直接执行,

由此消息分发务必使用 PublishSubject 而非 BehaviorSubject。

并且就算使用 StateFlow,此场景如单纯指望 replay = 1,发射 3 次也仅能收到 1 次,不利于多类型消息响应,故修改 replay 方案暂且 pass。

com.kunminx.purenote_ktx D/---: activity.lifecycleScope.launch
com.kunminx.purenote_ktx D/---: ResultTest3-sendResult
com.kunminx.purenote_ktx D/---: ResultTest3-sendResult
com.kunminx.purenote_ktx D/---: ResultTest3-sendResult
com.kunminx.purenote_ktx D/---: activity.repeatOnLifecycle
com.kunminx.purenote_ktx D/e: ---3
com.kunminx.purenote_ktx D/e: ---4 0
com.kunminx.purenote_ktx D/e: ---4 1
com.kunminx.purenote_ktx D/e: ---4 2
com.kunminx.purenote_ktx D/e: ---4 3
com.kunminx.purenote_ktx D/e: ---4 4

那怎办,repeatOnLifecycle STARTED 回调相对 emit 存在时延,其实在 Activity 中易解决,即通过 View.post 时机,让 emit 处于 MessageQueue 中顺序执行,如此便能确保时机正确,

但发射一事件还要 View.post,显然易忘记、造成一致性问题,且 MVI-Dispatcher-KTX 采用内聚设计,故此处不妨往 input 方法注入 Activity,再于内部拿取 decorView 自动完成 post …

open class MviDispatcherKTX<E> : ViewModel() {
  ...
  fun input(event: E, activity: AppCompatActivity?) {
    activity?.window?.decorView?.post {
      viewModelScope.launch { onHandle(event) }
    }
  }
}

倒也行,不过每次 input 都额外注入个 Activity,这写法是不有点莫名其妙?

且如我想在 KTX 版子类内部 input “side effect” 怎办?故该方案暂且 pass。

… 还有无别的办法?

有,

考虑到 “错过事件” 情形较极端,常规 “从数据层取数据” 等操作,由于操作有其耗时,不易遇见;

如是页面 onCreate 环节末尾发送某 sealed.object 通知,由于毫不费时,则易先于 activity.repeatOnLifecycle(Lifecycle.State.STARTED),错过时机,

故此处可于每次 input 时自动延迟 1 毫秒 ——

默认设置为 1 毫秒,且通过维护一 delayMap 自动判断时机取消延迟:

open class MviDispatcherKTX<E> : ViewModel() {

  ...
  
  fun input(event: E) {
    viewModelScope.launch {
      if (needDelayForLifecycleState) delayForLifecycleState().collect { onHandle(event) }
      else onHandle(event)
    }
  }

  private val needDelayForLifecycleState
    get() = delayMap.isNotEmpty()
}

输出 Log 看看:

com.kunminx.purenote_ktx D/---: activity.lifecycleScope.launch
com.kunminx.purenote_ktx D/---: activity.repeatOnLifecycle
com.kunminx.purenote_ktx D/---: ResultTest3-sendResult
com.kunminx.purenote_ktx D/e: ---3
com.kunminx.purenote_ktx D/---: ResultTest3-sendResult
com.kunminx.purenote_ktx D/e: ---3
com.kunminx.purenote_ktx D/---: ResultTest3-sendResult
com.kunminx.purenote_ktx D/e: ---3
com.kunminx.purenote_ktx D/e: ---4 0
com.kunminx.purenote_ktx D/e: ---4 1
com.kunminx.purenote_ktx D/e: ---4 2
com.kunminx.purenote_ktx D/e: ---4 3
com.kunminx.purenote_ktx D/e: ---4 4

至此,3 个事件 3 皆收到。

加餐 2022.08.16:

实现 SharedFlow “消费且只消费一次”

有小伙伴反馈 “息屏亮屏” 场景下,亮屏后无法响应息屏时推送最新数据,

可见上述延迟方案仅仅只能解决初始化时机的 “错过收集”,不能完全覆盖其他场景,

为此考虑过 Channel,然 Channel 只能用于一对一场景,难实现一对多,

故综合考虑,采取 replay 次数与队列长度保持一致方式。

与此同时,通过 version 对比实现新订阅不接收推送;通过 observerCount 对比实现重回 Started 时只消费未消费过的消息。

提示:replay 次数可根据实际业务需求,在对应 MVI-Dispatcher-KTX 子类中重写 initQueueMaxLength( ) 以修改。

fun output(activity: AppCompatActivity?, observer: (E) -> Unit) {
  currentVersion = version
  observerCount++
  activity?.lifecycle?.addObserver(this)
  activity?.lifecycleScope?.launch {
    activity.repeatOnLifecycle(Lifecycle.State.STARTED) {
      _sharedFlow?.collect {
        if (version > currentVersion) {
          if (it.consumeCount >= observerCount) return@collect
          it.consumeCount++
          observer.invoke(it.value)
        }
      }
    }
  }
}

最后

sharedFlow 配合 repeatOnLifecycle 的使用,存在 “错过收集时机” 乃至 “错过消费” 的隐患,

对此不能单纯通过 “推迟” 初始化时机来规避,因为除了初始化场景,还包括 “息屏亮屏” 等页面生命周期离开 STARTED 的情况,

因此最终采取 “replay + version 比对 + 观察者监控” 方式,实现确保 “多观察者消费且各只消费一次” 前提下,消除页面初始化和息屏亮屏等场景 “Flow 错过收集”。

本文相关框架:

Github:MVI-Dispatcher

Github:MVI-Dispatcher-KTX