我的iOS开发小屋iOS 响应式编程iOS开发

iOS 关于MVVM With ReactiveCocoa设计模

2017-06-26  本文已影响4264人  CoderMikeHe
一、概述
二、MVVM Without RAC的瑕疵

金无足赤,人无完人。虽然利用 MVVM + KVO这种方式,完全是可以很好的玩弄MVVM的,但是在使用过程中我们又不得吐槽它的瑕疵和局限性,这可能主要体现在以下几个方面:

 /// 是否正在执行
 @property (nonatomic, readonly, assign) BOOL executing;
 /// 请求失败的信息
 @property (nonatomic, readonly, strong) NSError *error;
 /// 请求成功的数据
 @property (nonatomic, readonly, strong) id responseObject;
 /// 调起登录
 - (void) login;

要清楚MVVM中的 viewModel 仍然只是一个对象,主要是负责视图的逻辑处理和数据转换,而不是去维护一堆状态(否则视图模型将成为状态数的重灾区)。但我们仍该努力将尽可能多的逻辑移到无状态的函数值中,这样我们将viewModel数据转成给用户在屏幕上看到的东西,避免了视图控制器的复杂性。

三、ReactiveCocoa

综上所述👆,使用MVVM Without RAC开发难免会存在一点瑕疵,ReactiveCocoa(RAC) 就是来拯救我们的。MVVM 在使用当中,通常还会利用双向绑定技术,使得Model 变化时,ViewModel会自动更新,而ViewModel变化时,View 也会自动变化。MVVM开发中可以使用RAC来在viewviewModel之间充当 binder的角色,优雅地实现两者之间数据同步,同时可以在viewModel中暴露RACSignal对象来替代像字符串和图像这样的属性,这能在viewModel上消除更多的状态以及一定程度上精简了ViewController上的代码。

/// 冷信号
RACSignal *signal = [RACSignal createSignal:^ RACDisposable * (id<RACSubscriber> subscriber) { 
    [subscriber sendNext:@"foobar"]; 
    [subscriber sendCompleted]; 
    return nil; 
}]; 

只有订阅了这个信号,这个信号才会变为热信号,值改变了才会触发。

[signal subscribeCompleted:^{ 
    NSLog(@"subscription %u", subscriptions); 
}]; 
四、MVVM With RAC 代码实践

本文的实践内容与 上一篇 的需求一致,目的就是提供一个使用RAC来实现MVVM不使用RAC来实现MVVM的异同以及各自的优缺点,更好为大家在现实开发中是使用MVVM With RAC还是MVVM Without RAC提供一个不错的参考, 不了解的产品需求的读者,请事先阅读 上一篇 的UI设计和需求分析。这里就不在赘述了,还望见谅。这里笔者将会尽可能地回避具体的业务逻辑,重点关注MVVM With RAC 的实践思路。

分析:根据我们前面对MVVM的探讨,viewModel事先需要提供view所需的数据和命令。因此,SULoginViewModel2.h/m 头文件的内容大致如下:

/// 登录界面的视图模型
@interface SULoginViewModel2 : SUViewModel2
  /// 手机号
@property (nonatomic, readwrite, copy) NSString *mobilePhone;
/// 验证码
@property (nonatomic, readwrite, copy) NSString *verifyCode;
/// 用户头像
@property (nonatomic, readonly, copy) NSString *avatarUrlString;
/// 按钮能否点击
@property (nonatomic, readonly, strong) RACSignal *validLoginSignal;
/// 登录按钮点击执行的命令
@property (nonatomic, readonly, strong) RACCommand *loginCommand;
 @end
 - (void)initialize
{
    [super initialize];
    @weakify(self);
    
    /// 数据绑定
    RAC(self, avatarUrlString) = [[RACObserve(self, mobilePhone)
                             map:^NSString *(NSString * mobilePhone) {
                                /// 模拟从数据库获取用户头像的数据
                                /// 假数据 别在意
                                return ![NSString mh_isValidMobile:mobilePhone]?nil:[AppDelegate sharedDelegate].account.avatarUrl;
                                 
                             }]
                            distinctUntilChanged];
  
    /// 按钮有效性
    self.validLoginSignal = [[RACSignal
                              combineLatest:@[ RACObserve(self, mobilePhone), RACObserve(self, verifyCode) ]
                              reduce:^(NSString *mobilePhone, NSString *verifyCode) {
                                  return @(mobilePhone.length > 0 && verifyCode.length > 0);
                              }]
                             distinctUntilChanged];
    
    /// 登录命令
    self.loginCommand = [[RACCommand alloc] initWithSignalBlock:^RACSignal *(id input) {
        @strongify(self);
        // 这里手机号以及验证码在控制器那里也可以在视图控制器筛选,但同时也可以在viewModel中处理
        // 最好的写法:button.rac_command = viewmodel.loginCommand...把位数判断移到这里
        if (![NSString mh_isValidMobile:self.mobilePhone]) {
           
            return [RACSignal error:[NSError errorWithDomain:SUCommandErrorDomain code:SUCommandErrorCode userInfo:@{SUCommandErrorUserInfoKey:@"请输入正确的手机号码"}]];
        }
        if (![NSString mh_isPureDigitCharacters:self.verifyCode] || self.verifyCode.length != 4 ) {
            
            return [RACSignal error:[NSError errorWithDomain:SUCommandErrorDomain code:SUCommandErrorCode userInfo:@{SUCommandErrorUserInfoKey:@"验证码错误"}]];
        }
        @weakify(self);
        return [RACSignal createSignal:^RACDisposable *(id<RACSubscriber> subscriber) {
            @strongify(self);
            @weakify(self);
            /// 发起请求 模拟网络请求
            dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0f * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
                @strongify(self);
                /// 登录成功 保存数据 简单起见 随便存了哈
                [[NSUserDefaults standardUserDefaults] setValue:self.mobilePhone forKey:SULoginPhoneKey2];
                [[NSUserDefaults standardUserDefaults] setValue:self.verifyCode forKey:SULoginVerifyCodeKey2];
                [[NSUserDefaults standardUserDefaults] synchronize];
                /// 保存用户数据 这个逻辑就不要我来实现了吧 假数据参照 [AppDelegate sharedDelegate].account
                /// 模拟成功或者失败
#if 1
                [subscriber sendNext:nil];
                /// 必须sendCompleted 否则command.executing一直为1 导致HUD 一直 loading
                [subscriber sendCompleted];
#else
                /// 失败的回调 我就不处理 现实中开发绝逼不是这样的
                [subscriber sendError:[NSError errorWithDomain:SUCommandErrorDomain code:SUCommandErrorCode userInfo:@{SUCommandErrorUserInfoKey:@"呜呜,服务器不给力呀..."}]];
#endif
            });
            
            return nil;
        }];
    }];
}

代码梳理如下:

接下来看看,SULoginController2中的关键代码:

- (void)bindViewModel
{   
   [super bindViewModel];
   
   @weakify(self);
   
   /// 判定数据
   [RACObserve(self.viewModel, avatarUrlString) subscribeNext:^(NSString *avatarUrlString) {
       @strongify(self);
       [MHWebImageTool setImageWithURL:avatarUrlString placeholderImage:placeholderUserIcon() imageView:self.userAvatar];
   }];
    RAC(self.viewModel , mobilePhone) = [RACSignal merge:@[RACObserve(self.inputView.phoneTextField, text),self.inputView.phoneTextField.rac_textSignal]];
    RAC(self.viewModel , verifyCode) = [RACSignal merge:@[RACObserve(self.inputView.verifyTextField, text),self.inputView.verifyTextField.rac_textSignal]];
    RAC(self.loginBtn , enabled) = self.viewModel.validLoginSignal;
   [[[self.loginBtn rac_signalForControlEvents:UIControlEventTouchUpInside]
    doNext:^(id x) {
        @strongify(self);
        [self.view endEditing:YES];
        [MBProgressHUD mh_showProgressHUD:@"Loading..."];
    }]
    subscribeNext:^(UIButton *sender) {
        @strongify(self);
        [self.viewModel.loginCommand execute:nil];
    }];

   /// 数据成功
   [self.viewModel.loginCommand.executionSignals.switchToLatest
    subscribeNext:^(id x) {
        @strongify(self);
        [MBProgressHUD mh_hideHUD];
        /// 跳转
        SUGoodsViewModel2 *viewModel = [[SUGoodsViewModel2 alloc] initWithParams:@{}];
        SUGoodsController2 *goodsVc = [[SUGoodsController2 alloc] initWithViewModel:viewModel];
        [self.navigationController pushViewController:goodsVc animated:YES];
   }];
   
   /// 错误信息
   [self.viewModel.loginCommand.errors subscribeNext:^(NSError *error) {
       /// 处理验证错误的error
       if ([error.domain isEqualToString:SUCommandErrorDomain]) {
           [MBProgressHUD mh_showTips:error.userInfo[SUCommandErrorUserInfoKey]];
           return ;
       }
       [MBProgressHUD mh_showErrorTips:error];
   }];
}

代码梳理如下:

综上所述,我们将 SULoginController2 中的展示逻辑抽取到 SULoginViewModel2 中后,使得 SULoginController2 中的代码更加简洁和清晰。实践MVVM的关键点在于,我们要能够分析清楚 viewModel 需要暴露给view的数据和命令,这些数据和命令能够代表view当前的状态。换句话来说:使用MVC开发我们是 敲太多 ,而使用 MVVM 我们是 想太多

五、 填补细坑

使用RAC来实现ViewViewModel之间的数据绑定非常优雅的同时也会使得Bug很难被调试。你看到界面异常了,有可能是你 View 的代码有 Bug,也可能是 Model 的代码有问题。数据绑定使得一个位置的 Bug 被快速传递到别的位置,要定位原始出问题的地方就变得不那么容易了。笔者通过使用RAC来实战这个Demo也遇到了许多问题,特此分享出来,目的是少走一点弯路,填补一些细坑。

  1. 利用RACCommand来处理网络请求的坑
    /// 登录命令
    self.loginCommand = [[RACCommand alloc] initWithSignalBlock:^RACSignal *(id input) {
        @strongify(self);
        @weakify(self);
        return [RACSignal createSignal:^RACDisposable *(id<RACSubscriber> subscriber) {
            @strongify(self);
            @weakify(self);
            /// 发起请求 模拟网络请求
            dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0f * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
                @strongify(self);
                /// 登录成功 保存数据 简单起见 随便存了哈
                [[NSUserDefaults standardUserDefaults] setValue:self.mobilePhone forKey:SULoginPhoneKey2];
                [[NSUserDefaults standardUserDefaults] setValue:self.verifyCode forKey:SULoginVerifyCodeKey2];
                [[NSUserDefaults standardUserDefaults] synchronize];
                /// 保存用户数据 这个逻辑就不要我来实现了吧 假数据参照 [AppDelegate sharedDelegate].account
                [subscriber sendNext:nil];
                [subscriber sendCompleted];
            });
            return nil;
        }];
    }];

切记在实践过程中,如果成功请求到网络数据,调用[subscriber sendNext:nil];的同时必须调用[subscriber sendCompleted],这样才能保证命令已经执行完毕。否则 command.executing 一直传递的是1,从而导致HUD一直处在 loading 的状态。

  1. 通过程序赋值phoneTextField.text = @"xxx",不会触发phoneTextField.rac_textSignal的事件的坑👉 请戳我
/***
    /// Fixed:rac_textSignal只有用户输入才有效,如果只是直接赋值 eg:self.inputView.phoneTextField.text = @"xxxx"  这样self.inputView.phoneTextField.rac_textSignal就不会触发的。
    /// 解决办法:利用 RACObserve 来观察self.inputView.phoneTextField.text的赋值办法即可
    /// 用户输入的情况 触发rac_textSignal
    /// 用户非输入而是直接赋值的情况 触发RACObserve
 
    RAC(self.viewModel , mobilePhone) = self.inputView.phoneTextField.rac_textSignal;
    RAC(self.viewModel , verifyCode) = self.inputView.verifyTextField.rac_textSignal;
**/
    RAC(self.viewModel , mobilePhone) = [RACSignal merge:@[RACObserve(self.inputView.phoneTextField, text),self.inputView.phoneTextField.rac_textSignal]];
    RAC(self.viewModel , verifyCode) = [RACSignal merge:@[RACObserve(self.inputView.verifyTextField, text),self.inputView.verifyTextField.rac_textSignal]];
  1. 一个对象同时绑定多个RACDynamicSignalCrash ,👉 请戳我
/// 登录按钮点击
    /** 切记:如果按照下面👇这样写会崩溃:原因是 一个对象只能绑定一个RACDynamicSignal的信号
        RAC(self.loginBtn , enabled) = self.viewModel.validLoginSignal;
        self.loginBtn.rac_command = self.viewModel.loginCommand;
        reason:'Signal <RACDynamicSignal: 0x60800023d3e0> name:  is already bound to key path "enabled" on object <UIButton: 0x7f8448c57690; frame = (12 362; 351 49); opaque = NO; autoresize = RM+BM; layer = <CALayer: 0x60800023dae0>>, adding signal <RACReplaySubject: 0x60000027ce00> name:  is undefined behavior'
    */
 
    /// 👇为正确的打开方式
    RAC(self.loginBtn , enabled) = self.viewModel.validLoginSignal;
    [[[self.loginBtn rac_signalForControlEvents:UIControlEventTouchUpInside]
     doNext:^(id x) {
         @strongify(self);
         [self.view endEditing:YES];
         [MBProgressHUD mh_showProgressHUD:@"Loading..."];
     }]
     subscribeNext:^(UIButton *sender) {
         @strongify(self);
         [self.viewModel.loginCommand execute:nil];
     }];

解决办法 👉 请戳我

  1. 可变数组(字典/...)不能被RACObserve 👉 请戳我
/// The data source of table view. 这里不能用NSMutableArray,因为NSMutableArray不支持KVO,不能被RACObserve
@property (nonatomic, readwrite, copy) NSArray *dataSource;

注意:RACObserve使用了KVO来监听property的变化,只要property被自己或外部改变,block就会被执行。但不是所有的property都可以被RACObserve,该property必须支持KVO,比如NSURLCachecurrentDiskUsage就不能被RACObserve。因为RAC是基于KVO的,NSMutableArray并不会在调用addObjectremoveObject时发送通知( willChangeValueForKey:didChangevlueForKey:),所以不可行。在使用RAC开发时,若要监听数组的变化,请将数组设计为不可变的数组(NSArray *dataSource),但是NSMutableArray也是可以添加KVO的 👉 详情请戳我

  1. 关于Cell复用时清理数据绑定或者事件监听的问题
@implementation SUGoodsCell
 - (void) awakeFromNib {
    [super awakeFromNib];
    RAC(self.usernameLabel,  text) = RACObserve(self,  viewModel. username);
    RAC(self.userIdLabel,  text) = RACObserve(self,  viewModel. userId);
}

注意viewModel出现在RACObserve宏中逗号右边。 这些 cell 终将被重用,新的viewModels将会被赋值,如果我们不将 viewModel放在逗号右边,那就会监听viewModel属性的变化然后每次都要重新设置绑定;如果放在逗号右边, RACObserve 将会为我们负责这些事儿, 因此我们只需要设定一次绑定并让Reactive Cocoa做剩余的部分。
当然,RACUITableViewCell提供了一个方法:rac_prepareForReuseSignal,它的作用是当Cell即将要被重用时,告诉Cell。想象Cell上有多个buttonCell在初始化时给每个buttonaddTarget:action:forControlEvents,被重用时需要先移除这些target,下面这段代码就可以很方便地解决这个问题:

[[[self.cancelButton
    rac_signalForControlEvents:UIControlEventTouchUpInside]
    takeUntil:self.rac_prepareForReuseSignal]
    subscribeNext:^(UIButton *x) {
    // do other things
}];
六、代码阅读

由于这个功能笔者分别采用 MVCMVVM Without ReactiveCococa以及MVVM Without ReactiveCococa来开发实践,毕竟萝卜白菜,各有所爱,目的就是便于大家更深层次的了解MVCMVVM的异同,以及提供一个利用MVVM真实开发的样例,希望能够打消大家对MVVM模式的顾虑。为了方便我们从宏观上了解功能的的整体结构,我们可以分别看看MVCMVVM Without ReactiveCococa 以及MVVM WithReactiveCococa 的类图。大家可以跟着类图,顺藤摸瓜,秉承该看的看,不该看的偷偷看的原则,赶快行动起来吧。

七、期待

文章若对您有点帮助,请给个喜欢❤️,毕竟码字不易;若对您没啥帮助,请给点建议💗,切记学无止境。
针对文章所述内容,阅读期间任何疑问;请在文章底部批评指正,我会火速解决和修正问题。
GitHub地址:https://github.com/CoderMikeHe

八、参考链接
上一篇 下一篇

猜你喜欢

热点阅读