【IOS开发高级系列】MVVM—ReactiveCocoa架构设计专题(一)

1 MVVM简介

1.1 MVC介绍

        MVC: Massive View Controller. Alot of the time, it’s convenient to put business logic and other code into viewcontrollers, even if that’s not architecturally the most sound place to put it.

        Use of MVVM helps reduce theamount of business logic in view controllers, which helps reduce their bloatedsize and also makes that business logic more testable.

1.2 MVVM要点

        In MVVM, we tend to consider the view andthe view controller as one entity (which explains why it’s not called MVVCVM).Instead of the view controller performing a lot of those mediations betweenmodel and view, the view model does, instead.

        ReactiveCocoa will monitor for changes inthe model and map those changes to properties on the view model, performing anynecessary business logic.

1.3 MVVM示例

        As a concrete example, imagine our modelcontains a date called dateAdded that we want to monitor for changes, andupdate our view model’s dateAdded property. The model’s property is an NSDate instance,while the view model’s is a transformed NSString. The binding would looksomething like the following (inside the view model’s init method).

RAC(self,dateAdded) = [RACObserve(self.model, dateAdded) map:^(NSDate *date) {

    return [[ViewModel dateFormatter] stringFromDate: date];

}];


        dateFormatter is a class method onViewModel that caches an NSDateFormatter instance so it can be reused (they’re expensive to create). Then, the view controller can monitor the view model’s

dateAdded property, binding it to a label.

RAC(self.label, text) = RACObserve(self.viewModel, dateAdded);


        We’ve now abstracted the logic of transforming the date to a string into our view model, where we might write unit tests for it. It seems contrived in this trivial example, but as we’ll see,it helps reduce the amount of logic in your view controllers significantly.


        此处有三个重点是我希望你看完本文能带走的:

• MVVM可以兼容你当下使用的MVC架构。

• MVVM增加你的应用的可测试性。

• MVVM配合一个绑定机制效果最好。

        如我们之前所见,MVVM基本上就是MVC的改进版,所以很容易就能看到它如何被整合到现有使用典型MVC架构的应用中。让我们看一个简单的PersonModel以及相应的View Controller:

@interface Person : NSObject

 @property (nonatomic, readonly) NSString*salutation;

@property (nonatomic, readonly) NSString*firstName;

@property (nonatomic, readonly) NSString*lastName;

@property (nonatomic, readonly) NSDate*birthdate;

 - (instancetype) initwithSalutation: (NSString *)salutation firstName:(NSString*)firstName lastName:(NSString *)lastName birthdate:(NSDate*)birthdate;

@end

        Cool!现在我们假设我们有一个 PersonViewController,在 viewDidLoad里,只需要基于它的 model属性设置一些Label即可。

- (void)viewDidLoad{

    [superviewDidLoad];

    if (self.model.salutation.length > 0) {

        self.nameLabel.text = [NSStringstringWithFormat:@"%@

%@ %@", self.model.salutation, self.model.firstName, self.model.lastName];

    }else{

        self.nameLabel.text = [NSStringstringWithFormat:@"%@

%@", self.model.firstName, self.model.lastName];

    }


    NSDateFormatter *dateFormatter =[[NSDateFormatter alloc] init];

    [dateFormatter setDateFormat:@"EEEE MMMM d, yyyy"];

    self.birthdateLabel.text = [dateFormatter stringFromDate: model.birthdate];

}

    这全都直截了当,标准的MVC。现在来看看我们如何用一个View Model来增强它。

@interface PersonViewModel : NSObject

@property (nonatomic, readonly) Person *person;

@property (nonatomic, readonly) NSString*nameText;

@property (nonatomic, readonly) NSString*birthdateText;

-(instancetype) initWithPerson: (Person *)person;

@end

    我们的View Model的实现大概如下:

@implementation PersonViewModel


- (instancetype) initWithPerson: (Person *)person {

    self = [super init];

    if (!self) return nil;


    _person = person;

    if (person.salutation.length > 0) {

        _nameText = [NSString stringWithFormat: @"%@

%@ %@", self.person.salutation, self.person.firstName, self.person.lastName];

    }else{

        _nameText = [NSString stringWithFormat: @"%@ %@", self.person.firstName, self.person.lastName];

    }


    NSDateFormatter *dateFormatter =[[NSDateFormatter alloc] init];

    [dateFormatter setDateFormat:@"EEEE MMMM d, yyyy"];

    _birthdateText = [dateFormatter stringFromDate: person.birthdate];


    return self;

}


@end

Cool!我们已经将 viewDidLoad中的表示逻辑放入我们的View Model里了。此时,我们新的 viewDidLoad就会非常轻量:

- (void)viewDidLoad{

    [superviewDidLoad];


    self.nameLabel.text = self.viewModel.nameText;

    self.birthdateLabel.text = self.viewModel.birthdateText;

}

        所以,如你所见,并没有对我们的MVC架构做太多改变。还是同样的代码,只不过移动了位置。它与MVC兼容,带来更轻量的 View Controllers

        可测试,嗯?是怎样?好吧,ViewController是出了名的难以测试,因为它们做了太多事情。在MVVM里,我们试着尽可能多的将代码移入View Model里。测试ViewController就变得容易多了,因为它们不再做一大堆事情,并且View Model也非常易于测试。让我们来看看:

SpecBegin(Person)

    NSString *salutation = @"Dr.";

    NSString *firstName = @"first";

    NSString *lastName = @"last";

    NSDate *birthdate = [NSDate dateWithTimeIntervalSince1970: 0];


    it (@"should use the salutation available. ", ^{

        Person *person = [[Person alloc] initWithSalutation: salutation firstName: firstName lastName: lastName birthdate: birthdate];

        PersonViewModel *viewModel =[[PersonViewModel alloc] initWithPerson:person];

        expect(viewModel.nameText).to.equal(@"Dr. first last");

    });


    it (@"should not use an unavailable salutation. ", ^{

        Person *person = [[Person alloc] initWithSalutation: nil firstName: firstName lastName: lastName birthdate: birthdate];

        PersonViewModel *viewModel =[[PersonViewModel alloc] initWithPerson: person];

        expect(viewModel.nameText).to.equal(@"first last");

    });


    it (@"should use the correct date format. ", ^{

        Person *person = [[Person alloc] initWithSalutation: nil firstName: firstName lastName: lastName birthdate: birthdate];

        PersonViewModel *viewModel =[[PersonViewModel alloc] initWithPerson: person];

        expect(viewModel.birthdateText).to.equal(@"Thursday January 1, 1970");

    });

SpecEnd

        注意到在这个简单的例子中,Model是不可变的,所以我们可以只在初始化的时候指定我们View Model的属性。对于可变Model,我们还需要使用一些绑定机制,这样View Model就能在背后的Model改变时更新自身的属性。此外,一旦View Model上的Model发生改变,那View的属性也需要更新。Model的改变应该级联向下通过ViewModel进入View。

        在OS X上,我们可以使用Cocoa绑定,但在iOS上我们并没有这样好的配置可用。我们想到了KVO(Key-Value Observation),而且它确实做了很伟大的工作。然而,对于一个简单的绑定都需要很大的样板代码,更不用说有许多属性需要绑定了。作为替代,我个人喜欢使用ReactiveCocoa,但MVVM并未强制我们使用ReactiveCocoa。MVVM是一个伟大的典范,它自身独立,只是在有一个良好的绑定框架时做得更好。

2 ReactiveCocoa

ReactiveCocoa指南一:信号

ReactiveCocoa指南二:Twitter搜索实例

MVVM指南一:Flickr搜索实例

MVVM指南二:Flickr搜索深入

2.1 响应式编程FRP

2.1.1 函数式编程 (FunctionalProgramming)

        函数式编程也可以写N篇,它是完全不同于OO的编程模式,这⾥里主要讲一下这个框架使⽤用到的函数式思想。

        (1) 高阶函数:在函数式编程中,把函数当参数来回传递,而这个,说成术语,我们把他叫做高阶函数。在oc中,blocks是被广泛使⽤用的参数传递,它实际上是匿名函数。

        高阶函数调用过程有点像linux命令⾥里的pipeline(管道),一个命令调用后的输出当作另一个命令输入,多个命令之间可以串起来操作。来个例子:

RACSequence *numbers = [@"1 2 3 4 5 6 7 8 9" componentsSeparatedByString:@" "].rac_sequence;

// Contains: 22 44 66 88

RACSequence *doubleNumber = [[numbers filter:^ BOOL(NSString *value) {

    return (value.intValue % 2) == 0;

}] map:^id(id value) {

    return [value stringByAppendingString: value];

}];

        上面的例子是数组里的值先进行过滤,得到偶数,然后再将每个值进行stringByAppendingString,最终输出22 44 66 88.

        (2) 惰性(或延迟)求值:Sequences对象等,只有当被使用到时,才会对其求值。

        关于函数编程,有兴趣的大家可以研究下haskell或者clojure,不过目前好多语⾔言都在借用函数式的思想。

2.1.2 响应式编程(Functional Reactive Programming:FRP)

        响应式编程是一种和事件流有关的编程模式,关注导致状态值改变的行为事件,一系列事件组成了事件流。一系列事件是导致属性值发生变化的原因。FRP非常类似于设计模式里的观察者模式。

        响应式编程是一种针对数据流和变化传递的编程模式,其执行引擎可以自动的在数据流之间传递数据的变化。比如说,在一种命令式编程语言中,a: = b + c 表示 a 是 b + c 表达式的值,但是在RP语言中,它可能意味着一个动态的数据流关系:当c或者b的值发生变化时,a的值自动的发生变化。

        RP已经被证实是一种最有效的处理交互式用户界面、实时模式下的动画的开发模式,但本质上是一种基本的编程模式。现在最为热门的JavaFX脚本语言中,引入的bind就是RP的一个概念实现。


响应式编程其关键点包括:

    (1) 输入被视为"行为",或者说一个随时间而变化的事件流

    (2) 连续的、随时间而变化的值

    (3) 按时间排序的离散事件序列

        FRP与普通的函数式编程相似,但是每个函数可以接收一个输入值的流,如果其中,一个新的输入值到达的话,这个函数将根据最新的输入值重新计算,并且产⽣生一个新的输出。这是一种”数据流"编程模式。

2.2 ReactiveCocoa试图解决什么问题

        经过一段时间的研究,我认为ReactiveCocoa试图解决以下3个问题:

    1、传统iOS开发过程中,状态以及状态之间依赖过多的问题;

    2、传统MVC架构的问题:Controller比较复杂,可测试性差;

    3、提供统一的消息传递机制;


        (1) 开发过程中,状态以及状态之间依赖过多,RAC更加有效率地处理事件流,而无需显式去管理状态。在OO或者过程式编程中,状态变化是最难跟踪,最头痛的事。这个也是最重要的一点。

        (2) 减少变量的使用,由于它跟踪状态和值的变化,因此不需要再申明变量不断地观察状态和更新值。

        (3) 提供统一的消息传递机制,将oc中的通知,action,KVO以及其它所有UIControl事件的变化都进行监控,当变化发生时,就会传递事件和值。

        (4) 当值随着事件变换时,可以使用map,filter,reduce等函数便利地对值进行变换操作。

2.3 试图解决MVC框架的问题

        我们在开发iOS应用时,一个界面元素的状态很可能受多个其它界面元素或后台状态的影响。

        RAC的信号机制很容易将某一个Model变量的变化与界面关联,所以非常容易应用Model-View-ViewModel框架。通过引入ViewModel层,然后用RAC将ViewModel与View关联,View层的变化可以直接响应ViewModel层的变化,这使得Controller变得更加简单,由于View不再与Model绑定,也增加了View的可重用性。

        因为引入了ViewModel层,所以单元测试可以在ViewModel层进行,iOS工程的可测试性也大大增强了。InfoQ也曾撰文介绍过MVVM:《MVVM启示录》 。

2.4 统一消息传递机制

        iOS开发中有着各种消息传递机制,包括KVO、Notification、delegation、block以及target-action方式。各种消息传递机制使得开发者在做具体选择时感到困惑,例如在objc.io上就有专门撰文(破船的翻译 ),介绍各种消息传递机制之间的差异性。

        RAC将传统的UI控件事件进行了封装,使得以上各种消息传递机制都可以用RAC来完成。

2.5 使用时机

2.5.1 (1)处理异步或者事件驱动的数据变化

static void *ObservationContext = &ObservationContext;

- (void)viewDidLoad {

    [super viewDidLoad];

    [LoginManager.sharedManager addObserver: self forKeyPath: @"loggingIn" options: NSKeyValueObservingOptionInitial context: &ObservationContext];

    [NSNotificationCenter.defaultCenter addObserver: self selector: @selector(loggedOut:) name: UserDidLogOutNotification object: LoginManager.sharedManager];

    [self.usernameTextField addTarget: self action: @selector(updateLogInButton) forControlEvents: UIControlEventEditingChanged];

    [self.passwordTextField addTarget: self action: @selector(updateLogInButton) forControlEvents: UIControlEventEditingChanged];

    [self.logInButton addTarget: self action: @selector(logInPressed:) forControlEvents: UIControlEventTouchUpInside];

}

- (void)dealloc {

    [LoginManager.sharedManager removeObserver: self forKeyPath: @"loggingIn" context: ObservationContext];

    [NSNotificationCenter.defaultCenter removeObserver: self];

}

- (void)updateLogInButton {

    BOOL textFieldsNonEmpty =self.usernameTextField.text.length > 0 && self.passwordTextField.text.length > 0;

    BOOL readyToLogIn =!LoginManager.sharedManager.isLoggingIn && !self.loggedIn;

    self.logInButton.enabled = textFieldsNonEmpty &&readyToLogIn;

}

- (IBAction)logInPressed:(UIButton *)sender {

    [[LoginManager sharedManager] logInWithUsername: self.usernameTextField.text password: self.passwordTextField.text success:^{

    self.loggedIn = YES;

    } failure:^(NSError *error) {

        [self presentError: error];

    }];

}

- (void)loggedOut:(NSNotification *)notification {

    self.loggedIn = NO;

}

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change: (NSDictionary *)change context:(void *)context {

    if (context == ObservationContext) {

        [self updateLogInButton];

    } else {

        [super observeValueForKeyPath: keyPath ofObject: object change: change context: context];

    }

}

// RAC实现:

- (void)viewDidLoad {

    [super viewDidLoad];

    @weakify(self);

    RAC(self.logInButton, enabled) = [RACSignal combineLatest: @[self.usernameTextField.rac_textSignal, self.passwordTextField.rac_textSignal, RACObserve(LoginManager.sharedManager, loggingIn), RACObserve(self, loggedIn)

] reduce:^(NSString *username, NSString *password,NSNumber *loggingIn, NSNumber *loggedIn) {

    return @(username.length > 0 &&password.length > 0 && !loggingIn.boolValue && !loggedIn.boolValue);

}];

    [[self.logInButton rac_signalForControlEvents: UIControlEventTouchUpInside] subscribeNext:^(UIButton *sender) {

    @strongify(self);

    RACSignal *loginSignal = [LoginManager.sharedManager logInWithUsername: self.usernameTextField.text password: self.passwordTextField.text];

    [loginSignal subscribeError:^(NSError *error) {

        @strongify(self);

        [self presentError: error];

    } completed:^{

        @strongify(self);

        self.loggedIn = YES;

    }];

}];

RAC(self, loggedIn) =[[NSNotificationCenter.defaultCenter rac_addObserverForName: UserDidLogOutNotificationobject: nil] mapReplace: @NO];

}


2.5.2 (2)链式的依赖操作

[client logInWithSuccess:^{

    [client loadCachedMessagesWithSuccess:^(NSArray*messages) {

        [client fetchMessagesAfterMessage: messages.lastObject success:^(NSArray *nextMessages) {

            NSLog(@"Fetched all messages.");

        } failure:^(NSError *error) {

            [self presentError: error];

        }];

    } failure:^(NSError *error) {

        [self presentError: error];

    }];

} failure:^(NSError *error) {

    [self presentError: error];

}];


// RAC实现:

[[[[client logIn] then:^{

    return [client loadCachedMessages];

}] flattenMap:^(NSArray *messages) {

    return [client fetchMessagesAfterMessage: messages.lastObject];

}] subscribeError:^(NSError *error) {

    [self presentError: error];

} completed:^{

    NSLog(@"Fetched all messages.");

}];


2.5.3 (3)并⾏行依赖操作:

__block NSArray *databaseObjects;

__block NSArray *fileContents;

NSOperationQueue *backgroundQueue = [[NSOperationQueuealloc] init];

NSBlockOperation *databaseOperation = [NSBlockOperationblockOperationWithBlock:^{

    databaseObjects = [databaseClient fetchObjectsMatchingPredicate: predicate];

}];

NSBlockOperation *filesOperation = [NSBlockOperationblockOperationWithBlock:^{

    NSMutableArray *filesInProgress = [NSMutableArray array];

    for (NSString *path in files) {

        [filesInProgress addObject:[NSData dataWithContentsOfFile: path]];

    }

    fileContents = [filesInProgress copy];

}];

NSBlockOperation *finishOperation = [NSBlockOperationblockOperationWithBlock:^{

    [self finishProcessingDatabaseObjects: databaseObjects fileContents: fileContents];

    NSLog(@"Done processing");

}];

[finishOperation addDependency: databaseOperation];

[finishOperation addDependency: filesOperation];

[backgroundQueue addOperation: databaseOperation];

[backgroundQueue addOperation: filesOperation];

[backgroundQueue addOperation: finishOperation];

//RAC

RACSignal *databaseSignal = [[databaseClient fetchObjectsMatchingPredicate: predicate] subscribeOn: [RACScheduler scheduler]];

RACSignal *fileSignal = [RACSignal startEagerlyWithScheduler: [RACScheduler scheduler] block:^(id subscriber) {

    NSMutableArray *filesInProgress = [NSMutableArray array];

    for (NSString *path in files) {

        [filesInProgress addObject: [NSData dataWithContentsOfFile: path]];

    }

    [subscriber sendNext: [filesInProgress copy]];

    [subscriber sendCompleted];

}];

[[RACSignal combineLatest: @[ databaseSignal, fileSignal ] reduce:^ id (NSArray *databaseObjects, NSArray*fileContents) {

    [self finishProcessingDatabaseObjects: databaseObjects fileContents: fileContents];

    return nil;

}] subscribeCompleted:^{

    NSLog(@"Done processing");

}];


2.5.4 (4)简化集合操作

NSMutableArray *results = [NSMutableArray array];

for (NSString *str in strings) {

    if (str.length < 2) {

        continue;

    }

    NSString *newString = [str stringByAppendingString: @"foobar"];

    [results addObject: newString];

}

RAC实现:

RACSequence *results = [[strings.rac_sequence filter:^ BOOL (NSString *str) {

    return str.length >= 2;

}] map:^(NSString *str) {

    return [str stringByAppendingString: @"foobar"];

}];


2.6 ReactiveCocoa的特点

        RAC在应用中大量使用了block,由于Objective-C语言的内存管理是基于引用计数的,为了避免循环引用问题,在block中如果要引用self,需要使用@weakify(self)和@strongify(self)来避免强引用。另外,在使用时应该注意block的嵌套层数,不恰当的滥用多层嵌套block可能给程序的可维护性带来灾难。

        RAC的编程方式和传统的MVC方式差异巨大,所以需要较长的学习时间。并且,业界内对于RAC并没有广泛应用,这造成可供参考的项目和教程比较欠缺。 另外,RAC项目本身也还在快速演进当中,1.x版本和2.x版本API改动了许多,3.0版本也正在快速开发中,对它的使用也需要考虑后期的升级维护问题。

        作为一个iOS开发领域的新开源框架,ReactiveCocoa带来了函数式编程和响应式编程的思想,值得大家关注并且学习。

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

推荐阅读更多精彩内容