KMM 求生日记二:跨端的 MVI 框架 —— MVIKotlin

·  阅读 3821
KMM 求生日记二:跨端的 MVI 框架 —— MVIKotlin

Android 开发的架构模式最流行的莫过于 Jetpack 架构组件提供的强大易用的 MVVM 实现。去年公司要重构一块老旧的重要业务,原先的 Java + 无架构实现被我们全面切换到 Kotlin + Coroutines + Jetpack AAC。总体效果令我们颇为满意,也没有发现什么明显的缺陷与短板。

Jetpack AAC 虽然很赞,但它不能用于 KMM,于是我们在开源社区找到了一个“替代品”——MVIKotlin。

MVIKotlin 是一款实现 MVI 模式的框架,它不仅能用于 KMM,还能用于 JavaScript、JVM、LinusX64、MacX64 等多个 Kotlin Target。

那 MVI 是一种怎样的模式?简单来说是改进版本的 MVVM。在 MVVM 中,View 通过监听 ViewModel 内的数据变化(LiveData/StateFlow 等)来完成更新,而用户对 View 的操作则通过对 ViewModel 的直接调用来触发数据状态的变更。而在 MVI 中则是把 View 触发数据状态的变更改进为发送“意图(Intent)”,从而进一步解耦。

借用一张 MVIKotlin 官网的说明图:

image.png

看起来还挺简单的,但实际上这张图太简略了。Model 事实上应该是静态的,那么持有 State 就需要另外一个组件(或者叫概念、容器),类似于 MVVM 中的 ViewModel,在 MVIKotlin 中,这个类似 ViewModel 的概念叫做 Store,于是官方又给出了下面一张图来详细说明数据流转的流程:

image.png

Store 负责一切动态的东西,包括数据拉取、通知 UI、保存状态等等,Binder 用于将 Store 和 View 绑定并控制整个流程运转的开始与停止。

这么看整个架构的实现就清晰多了。但 MVIKotlin 库实现的核心在于 Store,Store 内部还拥有多种概念,并且它们也以一种较为复杂的方式组织在一起,库的使用者需要严格遵守这些概念的组织形式来编程:

image.png

Executor 是 Store 的引擎,它接收 View 发出的意图并根据意图(Intent)来生产数据,生产出的数据结果(Result)传递给 Reducer,Reducer 将其加工成 View 可显示的状态(State)后发布出去,Reducer 从概念上来说有点像 Jetpack 中的 LiveData。Bootstrapper 是一个启动器,用于在初始化的时候首次发出加载数据的动作(Action),接收 Action 的仍然是 Executor。

在 MVIKotlin 的设计中,Bootstrapper 与 Action 是可省缺的,其他每个概念都要严格定义。我们来看一个官方完整的 Store Demo:

internal interface CalculatorStore : Store<Intent, State, Nothing> {
    sealed class Intent {        
        object Increment : Intent()        
        object Decrement : Intent()
        data class Sum(val n: Int): Intent()
    }
    
    data class State(val value: Long = 0L)
}

internal class CalculatorStoreFactory(private val storeFactory: StoreFactory) {
    private sealed class Result {        
        class Value(val value: Long) : Result()    
    }
    
    private object ReducerImpl : Reducer<State, Result> {        
        override fun State.reduce(result: Result): State = 
            when (result) {                
                is Result.Value -> copy(value = result.value)            
            }    
        }        
         
        fun create(): CalculatorStore =        
            object : CalculatorStore, Store<Intent, State, Nothing> by storeFactory.create(            
                name = "CounterStore",            
                initialState = State(),            
                bootstrapper = BootstrapperImpl,            
                executorFactory = ::ExecutorImpl,            
                reducer = ReducerImpl) {}
             
         private sealed class Action {        
             class SetValue(val value: Long): Action()    
         }        
         
         private class BootstrapperImpl : CoroutineBootstrapper<Action>() {       
             override fun invoke() {            
                 scope.launch {                
                     val sum = withContext(Dispatchers.Default) {
                         (1L..1000000.toLong()).sum() 
                     }                
                     dispatch(Action.SetValue(sum))            
                 }        
             }    
         }
         
        private class ExecutorImpl : CoroutineExecutor<Intent, Action, State, Result, Nothing>() {        
            override fun executeAction(action: Action, getState: () -> State) =         
                when (action) {                
                    is Action.SetValue -> dispatch(Result.Value(action.value))         
                }
                    
            override fun executeIntent(intent: Intent, getState: () -> State) =         
                when (intent) {                
                    is Intent.Increment -> dispatch(Result.Value(getState().value + 1))                     is Intent.Decrement -> dispatch(Result.Value(getState().value - 1))                     is Intent.Sum -> sum(intent.n)            
                }
                
            private fun sum(n: Int) {            
                scope.launch {                
                    val sum = withContext(Dispatchers.Default) { 
                        (1L..n.toLong()).sum() 
                    }                
                    dispatch(Result.Value(sum))            
                }        
           }    
       }
复制代码

在 Store 的设计中大量使用了模版方法设计模式,用户在使用该库的时候要严格继承库中提供的超类,并严格按照规则实现其每一个抽象函数。严格的定义带来了灵活度的下降以及学习成本的提升,从而导致其推广速度的下降,但它强制提升了代码规范性,算是有利有弊。此外,MVIKotlin 提供针对 Coroutines 与 Reaktive 的扩展,这类似于 Jetpack 中提供了 viewModelScope 与 lifecycleScope,可以自动帮助我们依靠生命周期来停止异步任务。

但这个库个人感觉也有一定的缺点——数据在每一个组件之间流动的时候都要以不同的类型来表示,例如 Action、Intent、Result、State 等等。并且它们都依赖密封类(sealed class)实现,而每个 sealed class 或多或少又拥有许多子类,这样每编写一个独立的业务模块都要定义大量的 class,这无疑理论上对 size 有一定的挑战,并且一个很简单的业务也需要编写大量的样板代码。

那如何优化掉大量的类型定义?个人有以下三条建议:

  1. 能用 value class 的就用 value class,内联掉一个是一个。

  2. 统一 Action 和 Intent;Action 只在初始化的时候用一次,为它单独定义一个类型不划算,也没有特别的意义,与 Intent 统一是可行的。

  3. 统一整个工程的 Result,Result 通常的功能是:表示成功或失败,若成功则携带数据,若失败则携带异常信息,我们可以轻易的在每个业务模块中使用诸如标准库中的 Result 类来表示所有的 Result。

再来看看 View 层的 demo:

interface CalculatorView : MviView<Model, Event> {
    data class Model(val value: String)
    
    sealed class Event {        
        object IncrementClicked: Event()        
        object DecrementClicked: Event()    
    }
}

class CalculatorViewImpl(root: View) : BaseMviView<Model, Event>(), CalculatorView {

    private val textView = root.requireViewById<TextView>(R.id.text)
    
    init {        
        root.requireViewById<View>(R.id.button_increment).setOnClickListener {                   
            dispatch(Event.IncrementClicked)        
        }        
        root.requireViewById<View>(R.id.button_decrement).setOnClickListener {                     
            dispatch(Event.DecrementClicked)        
        }    
    }
    
    override fun render(model: Model) {        
        super.render(model)
        textView.text = model.value    
    }
}
复制代码

同样需要实现库接口 MviView,上面定义的 Model 与 Event 实际上就是 State 与 Intent,这里又进行了新的类型定义,实在没必要。

那 iOS 上用起来是什么样的?

class CalculatorViewProxy: BaseMviView<CalculatorViewModel, CalculatorViewEvent>, CalculatorView, ObservableObject {
    @Published var model: CalculatorViewModel?
    
    override func render(model: CalculatorViewModel) {        
        self.model = model    
    }
}

struct CalculatorView: View {    
    @ObservedObject var proxy = CalculatorViewProxy()
    var body: some View {        
        VStack {            
            Text(proxy.model?.value ?? "")
            Button(action: { self.proxy.dispatch(event: CalculatorViewEvent.IncrementClicked()) }) {                
                Text("Increment")            
            }
            Button(action: { self.proxy.dispatch(event: CalculatorViewEvent.DecrementClicked()) }) {                
                Text("Decrement")            
            }        
        }    
    }
}
复制代码

最后看一下 Binder 的 demo:

class CalculatorController {    
    private val store = CalculatorStoreFactory(DefaultStoreFactory).create()    
    private var binder: Binder? = null
    
    fun onViewCreated(view: CalculatorView) {        
        binder = bind {            
            store.states.map(stateToModel) bindTo view            // Use store.labels to bind Labels to a consumer            
            view.events.map(eventToIntent) bindTo store        
        }    
    }
    
    fun onStart() {        
        binder?.start()    
    }
    
    fun onStop() {        
        binder?.stop()    
    }
    
    fun onViewDestroyed() {        
        binder = null    
    }        
    
    fun onDestroy() {        
        store.dispose()    
    }
}
复制代码

Binder 流转数据状态依赖对外暴露的 API 调用,在示例中,我们可以在平台相关的 UI 组件(Activity、Fragment、UIViewController 等)中,依靠生命周期来调用 binder 的 start、stop,以及 store 的 dispose 函数。

官方也提供了针对 Jetpack Lifecycle 的扩展,可以让 Binder 与 Lifecycle 绑定。

个人看法

如果仅 Android 来说,Jetpack AAC 的开发体验远高于 MVIKotlin,并且稍加改进 Jetpack AAC 也能流畅的实现 MVI 模式。MVIKotlin 没有 Lifecycle,也不能通过相同的 owner 来获取相同的 Store 从而实现共享数据。那么 MVIKotlin 目前唯一的优势就是可以跨端,解决了我们当前 KMM 项目的燃眉之急。但 MVIKotlin 的实际稳定性如何还有待我们经过一段时间的生产环境观察。

在当前 Model 层已无太大障碍的前提下,攻克 ViewModel 层是我们的主要目标之一,MVIKotlin 是暂时唯一的选择,但不是永远唯一的选择。如何将 Jetpack AAC 按照一定的方式 porting 到 iOS 是一个值得探索的方向,StateFlow 本身就是 Coroutines Flow 的一部分,在多平台方面已经就位,可以作为 LiveData 的替代品,那么需要 porting 到 iOS 平台主要的工作在于 ViewModel、Lifecycle,以及针对 Lifecycle 自定义我们自己的 UIViewController。

《KMM 求生日记》 系列已经有一段时间没更新了,在这几个月里 KMM 的 UI 跨平台仍然没有太大进展,不过好消息是 Kotlin/Native 的新 GC 快要搞定了,在理想状态下,等到 Kotlin 1.6.20 或 1.6.30 发布的时候,KMM 并发编程就不再有对象子图机制限制。那除了继续研究架构组件以外我们还有哪些事可以做?KMM 项目的单元测试完善,以及 Kotlin/Native 的代码覆盖率如何统计都是值得探索的课题。

分类:
Android
标签:
收藏成功!
已添加到「」, 点击更改