美文网首页
Android:解决 MVI 架构实战痛点

Android:解决 MVI 架构实战痛点

作者: 安安_660c | 来源:发表于2023-01-10 14:22 被阅读0次
    image.png

    说在前头:

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

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

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

    本文假设您已具备 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() }
      
      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))
      }
    }
    
    

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

    image.png

    改善版本 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 通知控件渲染,并为控件兜着最后一次状态。

    image.png

    对此下文的 “改善版本 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” 两处即可自动完成 “单向数据流” 开发,

    image

    改善版本 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
      }
    }
    
    

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

    [图片上传失败...(image-df55c7-1673417996791)]

    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

    本文转自 https://juejin.cn/post/7134594010642907149,如有侵权,请联系删除。

    相关文章

      网友评论

          本文标题:Android:解决 MVI 架构实战痛点

          本文链接:https://www.haomeiwen.com/subject/fkdvcdtx.html