Oct 29

面向状态机编程:重新理解APP中的MVVM架构设计

Lrdcq , 2017/10/29 19:03 , 雜談 , 閱讀(6569) , Via 本站原創
从两年前开始,我们app开发就在逐步引入并尝试使用MVVM这种理念的架构设计了,并且往往是通过函数式编程来实现的(ReactiveCocoa/RxJava)。当然,要说MVVM,web前端当然是走得遥遥领先,而我们App中实践MVVM多多少少遇到了不少问题。
不过经过两年来的迭代,我们的App中的MVVM架构的实现上,已经有了很大的变化,可以说是更向前端靠拢吧,也可以说是踩过无数坑之后自然而然形成的,面向状态机的设计。

理解状态机

在MVVM中,其实很重要一个概念就是状态机。想想MVVM的定义中,viewmodel的职责就是view的数据绑定的抽象,viewmodel和view的变化一一对应,再看看各大web前端框架的实现,react和vue中的state,摆明了就是说,viewmodel,也就是view和model层的联系,就应该是通过状态机来实现。

这样,我们viewmodel到底应该怎么写,就很清晰了:

1. viewmodel暴露的所有数据出口,都应该是状态机。那么状态机的实现,除了属性+监听,在响应式编程里,状态机就是——BehaviorSubject了。

2. 当然,我们还是坚持一开始定义的上下行数据分离,因此viewmodel暴露到view的应该还是普通的Signal,上行数据不通过同一个状态机,而是通过其他的单点方法来实现。(web前端普遍也是这么做的)

最后,我们的viewmodel类应该就只有两部分:状态机和事件触发接口:
//比如这是一个购物车数量显示小圆点控件的viewmodel
@interface KLMCartTotalViewModel : KLMBaseViewModel

@property (strong, nonatomic, readonly) RACSignal *vo; // KLMCartCountViewVO

- (void)getCount;

@end

//这是购物车页面的viewmodel的一部分
@interface KLMCartViewModel : KLMBaseViewModel

//状态机
@property(strong, nonatomic, readonly) RACSignal *list;//KLMCartListVO
@property(strong, nonatomic, readonly) RACSignal *price;//KLMCartPriceVO
@property(strong, nonatomic, readonly) RACSignal *allCheck;//NSNumber BOOL
@property(strong, nonatomic, readonly) RACSignal *hasCheck;//NSNumber BOOL
@property(strong, nonatomic, readonly) RACSignal *editing;//NSNumber BOOL

//触发
- (void)updateListNoSelect;
- (void)updateListAndSelect:(NSArray<NSNumber *> *)csuId;
- (void)checkItem:(NSInteger)csuId checked:(BOOL)checked;
- (void)checkAll:(BOOL)checked;
- (void)deleteItem:(NSInteger)csuId;
- (void)deleteItems;

- (void)enterEditMode;
- (void)leftEditMode;

@end

简化viewmodel

之前的viewmodel设计中还提出了一种viewmodel是单例的,但是总觉得哪儿不对。现在看来,单例的viewmodel的存在原因是:

1. 这部分操作只有触发上行数据,没有状态机,另外viewmodel的内部实现也是无状态的——这样自然就得到了可以单例的viewmodel。

2. 或者是把上行和状态机拆成两个viewmodel的,其中上行的自然可以单例,但是这需要保证下行的数据和上行的没啥关系,也就是说数据和界面无关的情况——这样的情况也就我们遇到的购物车模块——无论在何处只有一个中心数据源的情况,可能会遇到了。

另外,还有一个简化viewmodel的方向是单纯界面状态机黑盒化。实际上难道离开viewmodel我们的界面就真的纹丝不动了么,当然是不可能的,比如我们的各种系统控件就会在用户响应下直接动起来,如果我们不关系那些动相关的事件的话,直接无视就可以了。

这么说起来,从组件化的角度来看,一个组件是否能动,如果不需要上层数据关系的话,只需要封装到组件内部就可以了,至于组件内到底是自己配制了viewmodel来驱动改变,还是自己咋实现的,我们并不关心。举个例子的话,我们app中封装的可展开收起列表组件,的展开收起功能,就是不用外部关心的界面状态,组件内消化即可。

状态机解决不了的问题

然而,无论是app还是web中,还是有很多解决不了的问题。

比如很常见的是执行(真)路由,除了web前端执行组件化的假路由,无论是app还是web,执行路由并不是维护一个状态,而更像是调用一个方法或者一个命令来执行的页面跳转。另外比如类似toast的信息队列,每一次在界面send一条消息,但是并不是由我们维护它的显示与消失,而是它自己解决的。

所以,我们遇到的问题是,界面部分有些状态改变并不能受我们通过状态机完全控制。

因此,解决方案也就是对应的,viewmodel中出现了第三种暴露类型,我成为——命令。它暴露的还是Signal,不过它本身也就是Signal了而不是啥BehaviorSubject了。

距离的话,比如购物车也暴露了一个toast的RACSignal,虽然在viewmodel声明上,和状态机并没有什么区别(只有通过注释区分),但是在使用上,状态机和命令的区别就很明显了:
- (void)bindViewModel {
    _viewmodel = [[KLMCartViewModel alloc] init];
    
    @weakify(self)
    //状态机
    //在ios的reactivecocoa中,状态机和组件属性绑定是非常自然的
    RAC(_checkAll, selected) = _viewmodel.allCheck;
    RAC(_delete, enabled) = _viewmodel.hasCheck;
    RAC(_table, list) = [_viewmodel.list filter:^BOOL(id value) {
        @strongify(self)
        return self.view.userInteractionEnabled && [self isThisTop];
    }];
    RAC(_moneyBox, hidden) = _viewmodel.editing;
    RAC(_edit, editing) = _viewmodel.editing;
    RAC(_delete, hidden) = _viewmodel.editing.not;
    
    //命令
    [_viewmodel.toast subscribeNext:^(id x) {
        @strongify(self)
        if ([self isThisTop]) {
            [SVProgressHUD showInfoWithStatus:x];
        }
    }];
}

另外还有一个问题是一些受限于界面api设计的情况。比如我们的jsbridge,每一次回调回在一个block中处理并且调用完成block,比如:
[bridge register:@"getCartInfo" callback:^(NSString *data, KLBClientResponseCallback responseCallback) {
    NSMutableDictionary *ans = //lalalalalalala
    responseCallback(YES, ans);
}];

显然,这种情况下,数据的传输也无法通过状态机或者命令来控制。严格来说,真的按照mvvm来做的话,因为每一次回调会话就是一次上下文,那应该每一次回调就new一个对应的viewmodel并且处理这个事件返回结果。不过这样也太麻烦了。

解决方案首先想到的是:

1. 放弃上下行数据分离和状态机的方案,直接在block中解决数据。但是还是觉得这样太混乱了完全打破了mvvm规则不宜采用。

2. 最后采用的方法是让viewmodel来做这件事,而涉及到需要界面做一些事的时候才通过命令的方式甩到view层去解决。界面看到的只有界面层琐碎的工作,

面向状态设计

实体设计

我们面向对象的语言中会建立很多实体,比如网络数据对象到app中我们会把json转换为实体对象再往下传输到viewmodel层再到界面层并使用。这也是我们一开始的设计方法。

很快,我们就发现了一些问题。比如我有加减号控件,由三个属性来控制,它们分别是数量quantity,最大值max,最小值min。那么从原理上讲,应该有3个状态机来控制它,并且界面进行3次绑定。
@interface KLMXViewModel : KLMBaseViewModel

@property(strong, nonatomic, readonly) RACSignal *quantity;//NSNumber
@property(strong, nonatomic, readonly) RACSignal *max;//NSNumber
@property(strong, nonatomic, readonly) RACSignal *min;//NSNumber

@end

//vc
    RAC(_view, quantity) = _viewmodel.quantity;
    RAC(_view, max) = _viewmodel.max;
    RAC(_view, min) = _viewmodel.min;
//

但是在viewmodel的实现中,我们发现,这三个量其实是我们在一次获取数据的过程中同时修改的,如:
- (void)getInfo {
    [[Loader load] subscribeNext:^id(id value) {
        [_quantity sendNext:value['quantity']];
        [_max sendNext:value['max']];
        [_min sendNext:value['min']];
    }];
}

其实,我们是一次改动来修改这这个组件的3个属性,也就是说这三个属性是同一个状态,但是我们确通过3个状态机来控制了。现在不但这样设计明显不合理,还有可能产生奇怪的异步问题,因此显然不能这么做。传统的粗暴的做法,应该是把整个数据对象甩给view,让view去读,但是这明显不符合MVVM分层中的原则了。

因此,对于复杂的界面,我们的数据实体往往会有一个vo层和界面或者组件一一匹配,同时也实现了界面数据和服务数据的解耦,中间的转换怎么做都好。

组件设计

从组件和状态机的角度,我们应该是在这个拥有quantity,max,min这三个属性的界面组件上,再封装一个业务组件,它拥有的是一个针对这个业务组件在业务中的状态的vo,即:
@interface XViewVO : NSObject

@property(strong, nonatomic, readonly) NSInteger quantity;//NSNumber
@property(strong, nonatomic, readonly) NSInteger max;//NSNumber
@property(strong, nonatomic, readonly) NSInteger min;//NSNumber

@end

那么我们的绑定应该是通过这个状态的vo绑定,才是实现真正的一个状态一个数据。这样的结构在复杂的界面数据绑定,才能实现逻辑的单一性和一致性,代码也会干净整洁易于阅读,发挥出MVVM的优势。

那么综上来看,我们设计组件的时候,除了纯粹的界面组件,有必要的话还需要封装针对状态的业务组件,来让界面层和逻辑层进行符合界面状态逻辑的数据绑定。而且就算是不可绑定的界面操作,也可以通过各种warpper把操作封装。比如页面中的加载模态窗,本来是一个第三方库的操作,我们通过一个warpper把它的操作封装为一个组件和组件的属性以便于状态绑定。
@interface LoadingWarpper : NSObject

@property(assign, nonatomic) BOOL loading;

@end

@implementation LoadingWarpper

- (void)setLoading:(BOOL)loading {
    _loading = loading;
    if (_loading) {
        [SVProgressHUD showProgress:YES];
    } else {
        [SVProgressHUD showProgress:NO];
    }
}

@end
//

用这样的形式封装,界面中绝大部分操作,除了上文所说的无法用状态解决的东西,ios中比如tabbar和navitem的调整,单点第三方界面控件的调整,都可以用过以状态为目标封装组件来实现MVVM的耦合。

总结

其实在面向状态的设计MVVM过程中,我们涉及到了很多其他的设计原则,最后总结起来这么几步:

1. 开发前
    a. 整理需编写界面中状态变化(数据下行)
    b. 整理需编写界面中的用户事件(数据上行)
    c. 整理还需要编写的东西(命令)

2. 界面开发
    a. 高度组件化,包括界面组件和业务组件
    b. 尽量把不可组件化的东西封装为组件
    c. 整理每一个组件的状态变化需要的数据,并设计vo

3. viewmodel开发
    a. 按照界面设计设计暴露状态属性
    b. 保证一个状态可以不加以业务逻辑变换的对应到组件
    c. 保证viewmodel的状态和界面实际状态对应,不做循环绑定
    d. 可按界面照逻辑拆分viewmodel到最小组件单位
    
4. 绑定
    a. viewmodel和界面状态一一对应,不复用,按情况拆分
    b. viewmodel和view最小距离绑定,不做无意义的数据传递
    c. 无需数据操作的状态在组件内绑定或者自由实现
    d. 命令的绑定和状态绑定区分
    
疑问

最后,还有一些疑问是一些不置可否的情况:

1. 如果设计的高度封装的组件包含数据逻辑怎么处理:比如分页加载组件,通过viewmodel的接口?通过多层viewmodel数据传递?

2. 如果viewmodel有逻辑需要存取其他状态,即需要持有:通过界面绑定逻辑解决(一定可以实现但是复杂)?从另一个状态维度来重新设计?

3. 初试状态怎么处理:在viewmodel初始化中定义?沿用界面初始状态?
关键词:mvvm
logo