Android:解决 MVI 架构实战痛点

18,838 阅读11分钟

说在前头:

纪晓岚问和珅,为何他们往灾民粥里掺沙子,和珅道:“你是有所不知啊,如不掺沙子,灾民怕是一口粥也喝不上啊”。

同理,架构的存在是为 “在实际开发过程中消除不可预期问题”,而非为架构而架构。

为使架构组件真正能在团队中普及,乃至最终有效达成 “消除大部分不可预期问题” 目的,本文采取 “淡化理论概念 + 设计简明易懂” 方式,让团队新手老手都能因为 “这框架好懂、简便、用着舒服”,而自然而然效仿和使用。

本文假设您已具备 State、Event、响应式编程、BehaviorSubject、PublishSubject、函数式编程、纯函数、副作用、MVI、软件工程、设计模式原则、一致性问题、单一职责原则、过度设计 等前置知识,且在团队中推行 MVI 遭遇不利,想就近找到平替方案。

通过本文可快速了解:

1.为何使用 MVI,是否非用不可,

2.为何最终考虑 SharedFlow 实现,

3.repeatOnLifecycle + SharedFlow 实现思路

文章目录一览

  • 前置知识
  • 为何使用 MVI,是否非用不可
  • MVI 经典模型
  • 改善版本 1:添加防抖处理
    • 使用 DataBinding ObservableField
    • 使用 distinctUntilChanged
    • 使用 RecyclerView DiffUtils
  • 改善版本 2:简化 Action 和 Reduce
    • 使用 Sealed Class 分流
  • 改善版本 3:改用 PublishSubject 回推结果
    • 使用 SharedFlow 回推结果
  • 改善版本 4:通过计数防止重复回推
  • 改善版本 5:封装和屏蔽样板代码
  • 改善版本 6:State 和 Event 合与分
    • State 和 Event 什么时候该分,什么时候该合
    • 添加 version 防止订阅回推
    • 三层架构 vs 二层架构
  • 综上

前置知识

上一期《MVI 的存在意义》已铺垫信息如下:

1.响应式编程暗示人们 应当总是向数据源请求数据,并在指定观察者中响应数据的变化

2.响应式编程的好处是 便于测试,有输入必有回响

3.响应式编程 存在 “多个粘性观察者回推不符预期数据” 的漏洞

4.MVI 即是 通过 “聚合页面状态” 消除该漏洞

5.鉴于 “响应式编程” 便于测试,官方出于完备性考虑,也是以响应式编程作为架构示例。

6.由于 Kotlin 抹平语法复杂度,便于响应式编程,且 Kotlin 开发者更容易跟着官方文档走,接受这套开发模式,乃至 有机会踩坑,且有动力通过 MVI 改善

7.Android 开发者 70% 仍是纯 Java,响应式编程在 Android Java 开发者中的推行不太理想。

为何使用 MVI,是否非用不可

所以至此,第一个问题的答案呼之欲出,

因为对一部分开发者来说,响应式编程很香,但又存在漏洞,即部分 BehaviorSubject 框架存在过度设计,导致存在 “多个粘性观察者不符预期回推” 的漏洞,所以需要 MVI 出马解决。

注:什么是过度设计,如何避免?具体见上期解析,本文不再累述。

那有人可能会问,既然部分 BehaviorSubject 框架过度设计,那替换成没有过度设计的 BehaviorSubject,比如 ObservableField 不就可以了,

可以是可以,不过也看情况,MVI 天然适合与 Jetpack Compose 搭配,

如果是使用 Jetpack Compose,就用不上 ObservableField,只能使用 LiveData/StateFlow 来回推 UiStates,也即只能通过 MVI 来消除漏洞,难有别的平替方案。

所以如果暂不使用 Jetpack Compose,根据上期的分析易知,只要消除过度设计,就能从源头上把问题解决,无所谓开发者用不用 MVI。

鉴于上期文末已分享 MVI 最小成本平替方案,本文直接从 “设计模式原则” 出发,探索一种更加普适的方案,相信阅读后你会耳目一新。

MVI 经典模型

1.创建一个 UiStates,反映当前页面的所有状态。

data class UiStates {
  val weather : Weather,
  val isLoading : Boolean,
  val error : List<UiEvent>,
}

2.创建一个 Intent,用于发送请求时携带参数,和指明当前想执行的业务。

sealed class MainPageIntent {
  data class GetWeather(val cityCode) : MainPageIntent()
}

3.创建一个 Actions,用于 reduce 当前业务的 partialChange 并生成新的 UiStates。

sealed class MainPageActions {
  fun reduce(oldStates : UiStates) : UiStates {
    return when(this){
      Loading -> oldStates.copy(isLoading = true)
      is Success -> oldStates.copy(isLoading = false, weather = this.weather)
      is Error -> oldStates.copy(isLoading = false, error = listOf(UiEvent(msg)))
    }
  }
  
  object Loading : MainPageActions()
  data class Success(val weather : Weather) : MainPageActions()
  data class Error(val msg : String) : MainPageActions()
}

4.创建当前页面使用的 MVI-Model。

class MainPageModel : MVI_Model<UiStates>() {
  private val _stateFlow = MutableStateFlow(UiStates())
  val stateFlow = _stateFlow.asStateFlow
  
  private fun sendResult(uiStates: S) = _stateFlow.emit(uiStates)
  
  fun input(intent: Intent) = viewModelScope.launch{ onHandle(intent) }
  
  private suspend fun onHandle(intent: Intent) {
    when(intent){
      is GetWeather -> {
        sendResult(MainPageActions.Loading.reduce(oldStates)
        val response = api.post()
        if(response.isSuccess) sendResult(
         MainPageActions.Success(response.data).reduce(oldStates)
        else sendResult(
         MainPageActions.Error(response.message).reduce(oldStates)
      }
    }
  }
}

5.创建 MVI-View,并在 stateFlow 中响应 MVI-Model 数据。

class MainPageActivity : Android_Activity(){
  private val model : MainPageModel
  fun onCreate(){
    lifecycleScope.launch {
    repeatOnLifecycle(Lifecycle.State.STARTED) {
      model.stateFlow.collect {uiStates ->
        progressView.setProgress(uiStates.isLoading)
        tvWeatherInfo.setText(uiStates.weather.info)
        ...
      }
    }
    model.input(Intent.GetWeather(BEI_JING))
  }
}

整个流程用一张图来表示即:

改善版本 1:添加防抖处理

使用 DataBinding ObservableField

考虑到 DataBinding ObservableField 存在防抖特性,故页面可通过 ObservableField 完成末端状态改变,尽可能消除 “控件刷新” 性能开销。

class MainPageActivity : Android_Activity(){
  private val model : MainPageModel
  private val views : MainPageViews
  fun onCreate(){
    lifecycleScope.launch {
    repeatOnLifecycle(Lifecycle.State.STARTED) {
      model.stateFlow.collect {uiStates ->
        views.progress.set(uiStates.isLoading)
        views.weatherInfo.set(uiStates.weather.info)
        ...
      }
    }
    model.input(Intent.GetWeather(BEI_JING))
  }
  class MainPageViews : Jetpack_ViewModel() {
    val progress = ObservableBoolean(false)
    val weatherInfo = ObservableField<String>("")
    ...
  }
}

不过这要求开发者具备 DataBinding 使用经验、额外书写 DataBinding 样板代码和 XML 绑定。

使用 distinctUntilChanged

除了 DataBinding,网上还提到有 2 类方案:

一类是通过 distinctUntilChanged 来为 ViewStates 的属性提供防抖,

但如此后续便难屏蔽 diff,只能暴露给开发者手动 map distinct 分流,增加手写代码量和认知成本,

class View-Controller : Android-Activity() {
  fun onCreate() {
    lifecycleScope.launch {
      repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.uiState
            .map { it.isDownload }
            .distinctUntilChanged()
            .collect { progress = it }
        viewModel.uiState
            .map { it.Setting }
            .distinctUntilChanged()
            .collect { btnChecked = it }
        ...
      }
    }
  }
}

使用 RecyclerView DiffUtils

另一类是通过 RecyclerView 编写页面。

如此便难支持复杂交互效果、容易引入其他不可预期问题,也难在多数开发者中普及开(有点为 MVI 而 MVI),且 DiffUtils 需手动配置,equals 列表密密麻麻易漏写或写错,

val diff = object : DiffUtil.ItemCallback<ViewStates>() {
  override fun areItemsTheSame(oldItem: ViewStates, newItem: ViewStates): Boolean {
    return oldItem.equals(newItem)
  }
​
  override fun areContentsTheSame(oldItem: ViewStates, newItem: ViewStates): Boolean {
    return oldItem.progress().equals(newItem.progress())
      && ... equals ...
      && ... equals ...
      && ... equals ...
      ...
  }
}

易得 diff 方式皆存在学习成本和使用成本,当同事写多了感觉厌烦,便自动回归原始,架构目的前功尽弃。

故我们只好另辟蹊径,探索少有人走的路,

改善版本 2:简化 Action 和 Reduce

如上文所述,响应式编程漏洞是由多观察者引起,只要使用单一观察者,便无该漏洞需修补。

不过单一观察者并不意味着只能通过 data class 聚合 UiStates,我们也可将其限定为,每次只从同一个出口回推当前业务的数据,如此便也无线程安全问题,乃至无需 Actions 和 reduce,

使用 Sealed Class 分流

为此每个页面可以简单通过 Intent 来包含入参和结果的传递,loading、error 等 Action 可以通过单独的 Intent 来反映,如此将 MVI 中最繁琐的 Action 设计拍平:

sealed class MainIntent {
  data class Loading(var progress: Boolean) : MainIntent()
  data class Info(var title: String) : MainIntent()
  ...
}
​
class Model : Jetpack-ViewModel() {
  private val _states = MutableLiveData<MainIntent>()
  val states = _states.asLiveData()
  fun request(intent: Intent){
    when(intent){
      is Intent.XXX -> {
        _states.setValue(MainIntent.Loading(true))
        _states.setValue(MainIntent.Info(DataRepository.getInfo()))
        _states.setValue(MainIntent.Loading(false))
      }
    }
  }
}
​
class View-Controller : Android-Activity() {
  private val model : Model
  private val holder : StateHolder
  fun onCreate(){
    model.states.observe(this){
      when(it){
        is MainIntent.Loading -> holder.progress = it.progress
        is MainIntent.Info -> holder.tvTitle = it.title
        ...
      }
    }
  }
}

然而 BehaviorSubject 天然不适合连续发送消息的场景,

例如息屏(页面生命周期离开 STARTED)期间所获消息,BehaviorSubject 仅存留最后一个,那么分流设计下,亮屏后(页面生命周期重回 STARTED)多种类消息只会推送最后一个,其余皆丢失(比如 Loading、Success、Error 等数据,最终只响应 Error),

故改用 PublishSubject,比如 SharedFlow 来处理。

那么有人可能会问,改用 PublishSubject,那 State 如何保留和自动回推?

笔者认为会有这样的疑问,本身是由于搞混组件的职责所致。网上流行的写法,领域层和表现层混杂一处,BehaviorSubject 同时承担业务消息回推和 State 容器,这也是造成响应式编程漏洞的祸根。

根据单一职责原则,组件宜职责单一,各司其职。在领域层消息分发环节,可以使用 PublishSubject 专职业务消息的回推,在表现层渲染环节,可以使用 BehaviorSubject 通知控件渲染,并为控件兜着最后一次状态。

对此下文的 “改善版本 6” 一节,通过图文详细介绍 State 和 Event 合与分的时机和设计。

改善版本 3:改用 PublishSubject 回推结果

使用 SharedFlow 回推结果

SharedFlow 内有一队列,如欲亮屏后自动推送多种类消息,则可将 replay 次数设置为与队列长度一致,例如 10,

class Model : Jetpack-ViewModel() {
  private val _sharedFlow: MutableSharedFlow<ViewStates>? by lazy {
    MutableSharedFlow(
      onBufferOverflow = BufferOverflow.DROP_OLDEST,
      extraBufferCapacity = DEFAULT_QUEUE_LENGTH,
      replay = DEFAULT_QUEUE_LENGTH
    )
  }
  val sharedFlow = _sharedFlow.asSharedFlow()
  companion object {
    private const val DEFAULT_QUEUE_LENGTH = 10
  }
}

由于 replay 会重走设定次数中队列的元素,故重走 STARTED 时会重走所有,包括已消费和未消费过,视觉上给人感觉即,控件上旧数据 “一闪而过”,

这体验并不好,

改善版本 4:通过计数防止重复回推

故此处可加个判断 —— 如已消费,则下次 replay 时不消费。

class Model : class Model : Jetpack-ViewModel() {
  private var observerCount = 0
  private val _sharedFlow: MutableSharedFlow<ViewStates>? by lazy {
    MutableSharedFlow(
      onBufferOverflow = BufferOverflow.DROP_OLDEST,
      extraBufferCapacity = DEFAULT_QUEUE_LENGTH,
      replay = DEFAULT_QUEUE_LENGTH
    )
  }
  val sharedFlow = _sharedFlow.asSharedFlow()
  companion object {
    private const val DEFAULT_QUEUE_LENGTH = 10
  }
}
​
data class ConsumeOnceValue<E>(
  var consumeCount: Int = 0,
  val value: E
)
​
class View-Controller : Android-Activity() {
  private val model : Model
  private val holder : StateHolder
  fun onCreate(){
    lifecycleScope?.launch {
      repeatOnLifecycle(Lifecycle.State.STARTED) {
        model.states.collect {
          if (version > currentVersion) {
            if (model.consumeCount >= observerCount) return@collect
            model.consumeCount++
            when(it){
              is MainIntent.Download -> holder.progress = it.progress
              is MainIntent.Setting -> holder.btnChecked = it.btnChecked
              is MainIntent.Info -> holder.tvTitle = it.title
              is MainIntent.List -> holder.list = it.list
            }
          }
        }
      }
    }
  }
}

但每次创建一页面都需如此写一番,繁琐且易出错,

故可将其内聚,统一抽取至单独框架维护,

MVI-Dispatcher-KTX 应运而生,

改善版本 5:封装和屏蔽样板代码

如下,通过将 repeatOnLifecycle、计数比对、mutable/immutable 等样板逻辑内聚,

open class MviDispatcherKTX<E> : ViewModel(), DefaultLifecycleObserver {
  private var observerCount = 0
  private val _sharedFlow: MutableSharedFlow<ConsumeOnceValue<E>>? by lazy {
    MutableSharedFlow(
      onBufferOverflow = BufferOverflow.DROP_OLDEST,
      extraBufferCapacity = initQueueMaxLength(),
      replay = initQueueMaxLength()
    )
  }
​
  protected open fun initQueueMaxLength(): Int {
    return DEFAULT_QUEUE_LENGTH
  }
​
  fun output(activity: AppCompatActivity?, observer: (E) -> Unit) {
    observerCount++
    activity?.lifecycle?.addObserver(this)
    activity?.lifecycleScope?.launch {
      activity.repeatOnLifecycle(Lifecycle.State.STARTED) {
        _sharedFlow?.collect {
          if (it.consumeCount >= observerCount) return@collect
          it.consumeCount++
          observer.invoke(it.value)
        }
      }
    }
  }
​
  override fun onDestroy(owner: LifecycleOwner) {
    super.onDestroy(owner)
    observerCount--
  }
​
  protected suspend fun sendResult(event: E) {
    _sharedFlow?.emit(ConsumeOnceValue(value = event))
  }
​
  fun input(event: E) {
    viewModelScope.launch { onHandle(event) }
  }
​
  protected open suspend fun onHandle(event: E) {}
​
  data class ConsumeOnceValue<E>(
    var consumeCount: Int = 0,
    val value: E
  )
​
  companion object {
    private const val DEFAULT_QUEUE_LENGTH = 10
  }
}

如此开发者哪怕不熟 MVI、mutable,只需关注 “input-output” 两处即可自动完成 “单向数据流” 开发,

改善版本 6:State 和 Event 合与分

State 和 Event 什么时候该分,什么时候该合

为了改善 “副作用”(关于 “副作用” 见上期解析),通常是 传输过程中合并 UiStates 和 UiEvents,并在响应时分开处理,这也和 “响应式编程” 串流设计不谋而合。

对此官方做法是,将 UiEvents 整合到 UiStates,界面事件 | Android Developers

笔者认为,此做法相较于 UiStates 和 UiEvents 分开发送的优点在于,使 UiEvents 同处于 STATRED 环节响应,避免手写遗漏乃至引发 “弹窗无法获取 token” 等情况,

缺点是,需要手动 filterNot 屏蔽已消费事件,增加学习成本且埋下手写的一致性隐患。

故笔者采取的是另一种办法 —— 将 UiState 整合到 UiEvent,响应时再将 UiState 和 UiEvent 解离。也即我们可以采用 PublishSubject 来做观察者,并在观察者回调中,单独对 UiState 采取 BehaviorSubject(比如 ObservableField)的方式来通知控件响应和渲染。

添加 version 防止订阅回推

故此处可再加个 verison 比对,

open class MviDispatcherKTX<E> : ViewModel(), DefaultLifecycleObserver {
  private var version = START_VERSION
  private var currentVersion = START_VERSION
  private var observerCount = 0
​
  ...
  
  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)
          }
        }
      }
    }
  }
​
  protected suspend fun sendResult(event: E) {
    version++
    _sharedFlow?.emit(ConsumeOnceValue(value = event))
  }
​
  companion object {
    private const val DEFAULT_QUEUE_LENGTH = 10
    private const val START_VERSION = -1
  }
}

如此即可从根源上消除 “响应式编程” 的漏洞,且无论团队成员是否理解 “响应式编程”,都可快速稳定迭代,不滋生不可预期问题。

class MainPageActivity : Android_Activity(){
  private val model : MainPageModel
  private val views : MainPageViews
  fun onOutput(){
    model.output(this){ intent ->
      when(intent){
        MainIntent.Progress -> views.progress.set(intent.progress)
        MainIntent.Weather -> views.weatherInfo.set(intent.weather)
        MainIntent.Error -> showErrorDialog()
      }
    }
    model.input(Intent.GetWeather(BEI_JING))
  }
  class MainPageViews : Jetpack_ViewModel() {
    val progress = ObservableBoolean(false)
    val weatherInfo = ObservableField<String>("")
    ...
  }
}

三层架构 vs 二层架构

换言之,上述设计属于三层架构(表现层、领域层、数据层),也即表现层使用 Jetpack ViewModel 做 StateHolder,其中安排各式 ObservableField 作为 BehaviorSubject,用于为 State 兜着状态、自动通知控件渲染,以及旋屏重建时一对一自动回推。领域层使用 MVI-Dispatcher 做业务处理,其中安排一个 PublishSubject 用作唯一出口 output 消息回推。

三层架构由于各司其职,消息回推环节不使用 BehaviorSubject,从而从根源上消除 “响应式编程” 漏洞。且三层架构复用性更佳,同一业务的页面皆可共用同一套业务逻辑,避免业务逻辑的冗余,

反之,网上流行的二层架构(表现层,数据层。其中 ViewModel 属于表现层)意味着每个页面都要在配套的 ViewModel 书写业务逻辑,对于冗余的业务逻辑,后续容易发生修改其中一个页面的,忘记另一页面的,造成代码更新的不一致。

注:SharedFlow 仅限于 Kotlin 项目,如 Java 项目也想用,可参考 MVI-Dispatcher 设计,其内部维护一队列,通过基于 LiveData 改造的 Mutable-Result 亦圆满实现上述功能。

综上

理论模型皆旨在特定环境下解决特定问题,直用于生产环境或存在不可预期问题,故我们不断尝试、交流和更新。

感谢实事求是测试反馈交流的小伙伴,让 MVI-Dispatcher 系框架得以演化至今。

Github:MVI-Dispatcher

Github:MVI-Dispatcher-KTX