【Android MVIKotlin技术】跨端的 MVI 框架原理分析

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 官网的说明图:

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

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

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

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 的代码覆盖率如何统计都是值得探索的课题。

作者:Kotlin上海用户组
链接:https://juejin.cn/post/7011379586030108703
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
更多Android技术分享可以关注@我,也可以加入QQ群号:Android进阶学习群:345659112,一起学习交流。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,222评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,455评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,720评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,568评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,696评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,879评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,028评论 3 409
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,773评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,220评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,550评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,697评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,360评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,002评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,782评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,010评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,433评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,587评论 2 350

推荐阅读更多精彩内容