SwiftUI - 偶遇 MVI

上个月某天和往常一样、突然就被拉入了一个群, 新项目, 用 SwiftUI !
终于跟上潮流了啊, 但此时对 SwiftUI 的印象几乎剩下它的名字...
“立刻开发”、 “马上就要”
那时候, 我的内心只剩下:


阅读难度: [简单]
要求: 了解 SwiftUI、常用设计模式
本文是一次 SwiftUI 项目中对于 MVVM 架构适配实际业务场景的记录, 不构成任何开发建议


闲话少说、直接开干

又双叒叕快速刷了一遍苹果官方的 SwiftUI 和部分 WWDC 教程, 这次印象最深的是两个属性包装器@Observable@Environment, 这也开启了与 MVI 的偶遇之旅.
@Observable: iOS17 支持 , 为自定义类型添加观察, 使其支持 Observable 协议, 省去了老版本中的样板代码.
@Environment: 环境值, 自定义环境值需与 .environment() 配套使用. 使得我们在视图树中可以共享/访问特定的环境值.

原本 APP 上业务多而复杂、更新迭代非常快, APP 中从逻辑上独立了事件流, 这在一定程上减小多人合作中可能会出现的并行开发逻辑混乱的问题. 但依旧存在出现状态混乱的可能性. 在只求迭代速度的前提下, APP 出于“稳定性”、“成本”的考虑, 几乎不能从架构上来大刀阔斧的重构.

这次是全新的项目, 这俩属性包装器让我萌生了一个在 APP 侧想做却苦于没有机会的想法. 为了追求速度, 最初始的架构设计上依旧以 MVVM 为基础. 有了 @Observable 的助力, 使得 ViewModel 与 View 的双向绑定更加简化, 再通过 @State@Bindable@Observable 的关联、 SwiftUI 局部刷新也更加高效.

每个事件的处理都可能非常复杂, 涉及到的事件、交互成百上千. 一开始习惯性的通过拆分业务来避免 viewModel 过于臃肿, 可随着业务越来越多越来越复杂, 各类“子ViewModel” 越来越多, 关联逻辑越来越复杂冗余, 而这样的模块基本不会是1个人开发, 不可避免的, 又将滑向了屎山
(Oh ! holy...shit).

有了APP的前车之鉴, 这次一开始就隔离出了事件流, 为后续的改进省去了不少工作量. 我们用简单的订单列表页面为例子:

// VM
@Observable
class OrderDetailVM {
    var statusSectionModel:OrderDetailStatusSectionModel?
    var infoCardSectionModel:OrderDetailInfoCardSectionModel?
    var feeDetailSectionModel:OrderDetailFeeDetailSectionModel?
    var schedulingSectionModel:OrderDetailSchedulingSectionModel?
    // ...
}

extension OrderDetailVM {
    enum OrderDetailEvent {
        case statusButton(Int?)
        case refreshAllData
        //...
    }
    
    func runEvent(event: OrderListEvent) {
        switch event {
        case .statusButton(let actionID):
            solveStatusButton(buttonActionID: actionID)
            break
        case .refreshAllData:
            fetchAll()
            break
        //.....
        }
    }
}

// View
struct OrderDetailView: View {
    @State var viewModel = OrderDetailVM()
    
    var body: some View {
        VStack{
            OrderDetailStatusSectionView(viewModel: viewModel.statusSectionModel)
            //....
        }
        .environment(viewModel)
    }
}

struct OrderDetailStatusSectionView: View {
    @Binding var sectionModel: OrderDetailStatusSectionModel?
    @Environment(OrderDetailVM.self) private var viewModel
    var body: some View {
        //...
        HStack {
            if let buttons = sectionModel.actions {
                ForEach(buttons, id: \.actionId) { button in
                    Button {
                        viewModel?.runEvent.statusButton(button.actionId)
                    } label: {
                        Text("测试按钮")
                    }
                }
            }
        }
        // ...
    }
}

上面的代码我们可以发现:

  1. 结构上, View 层能够直接与 model 层通信, 不太严谨
  2. 事件流还是归属于 ViewModel, 并未将其从架构上独立出来

根源都在于“事件流仅仅在逻辑上进行了区分”,
视图层只需要通过事件流告知 ViewModel 变化, 针对这一点可以将事件流协议化, 使得 View 层强制遵循事件流的协议进行通信. 并将所有与 ViewModel 的通信都经过事件流处理, 从架构上限制业务.
优化优化:

// 事件流基础协议
protocol EventBusProtocol: AnyObject {
    associatedtype ViewModelType
    associatedtype EventBusType
    
    var eventBusVM: ViewModelType { get }
    func runEvent(_ event: EventBusType)
}

// 订单详情事件协议及实现
protocol OrderDetailVMEventProtocol: EventBusProtocol where ViewModelType == OrderDetailVM, EventBusType == OrderDetailEvent {
}

// 关联实现事件流对应的VM
extension OrderDetailVM: OrderDetailVMEventProtocol {
    var eventBusVM: OrderDetailVM {
        self
    }
}

// 在协议扩展中实现具体的处理
extension OrderDetailVMEventProtocol  {
    
    func runEvent(_ event: EventBusType) {
        switch event {
        case .statusButton(let actionID):
            solveStatusButton(buttonActionID: actionID)
            break
        case .refreshAllData:
            eventBusVM.fetchAll()
            break
        //...
        }
    }
}

这样改动后:

  1. 对事件流进行了协议式分离, View 层仅通过环境值来调用事件协议声明的方法. 杜绝了View直接与Model的通信.
  2. 完全由数据流驱动, 使应用状态管理更加明确可预测
  3. 面向协议的方式也使得事件流方式更加通用化,易理解, 也拥有较好的扩展性, 无论是严格按照事件流来处理交互响应、或是只有某一部分采用事件流的方式都能很好的适配.

相应的, View层上更新下对应环境值的获取与调用即可, 同样以上面的订单状态视图为例:

struct OrderDetailView: View {
    @State var viewModel = OrderDetailVM()
    
    var body: some View {
        VStack{
            OrderDetailStatusSectionView(viewModel: viewModel.statusSectionModel)
            //....
        }
        .environment(\.orderDetailEventBus, viewModel)
    }
}
struct OrderDetailStatusSectionView: View {
    @Binding var sectionModel: OrderDetailStatusSectionModel?
    @Environment(\.orderDetailEventBus) private var viewModel
    var body: some View {
        //...
        HStack {
            if let buttons = sectionModel.actions {
                ForEach(buttons, id: \.actionId) { button in
                    Button {
                        viewModel?.runEvent.statusButton(button.actionId)
                    } label: {
                        Text("测试按钮")
                    }
                }
            }
        }
        // ...
    }
}

这怎么有点像 MVI ? 我们的“事件流”(EventBus)实际上与MVI中的“意图”(Intent)一样, 都有着“单向数据流”的特点.

上述的改进细节上还需优化, 并且保留着 ViewModel . 虽然有了“MVI”中“单一状态流”的概念, 也只是协议化了事件流, 依旧在ViewModel 层上.

[ MVVM ]: ViewModel负责处理与UI无关的业务逻辑, 并提供数据供视图View显示. 使用数据绑定来保持ViewModel 与View之间的同步

MVVM.png

[ MVI ]: 侧重于通过Intent来驱动应用程序的状态变化. 通常包含一个单一的状态来作为数据流的核心, 通过处理Intent来更新状态, 并通过订阅来更新View

MVI.png

反过来想, 把我们现在的ViewModel层看作是Model层、而Intent层, 是ViewModel被抽象成了协议的事件流部分.从模块通信上看, Intent包含着 ViewModel 层, 那就假装它是Intent层吧.
或者, 把 MVI 当作是 ViewModel 带了个Intent 帽子的 MVVM. 应该也能凑合说的过去吧.

一个 View 可以搞定静态页面, 经久耐用的MVVM , 分工协作更加细化独立的VIPER......
有的架构模糊了部分模块之间的界限, 有的架构需要大量成本来维持其自身的通信规范. 这次与 MVI 的偶遇也再次加深了自己的看法:

架构是重构过程思想的重要体现, 而重构是需要一直进行的.
“没有最好的架构、只有目前最合适的”

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

推荐阅读更多精彩内容