iOS的函数响应式编程(六)

643 阅读24分钟

本书译者为: kevinHM

翻译自 leanpub.com/iosfrp

iOS中的MVVM介绍

有一个禅宗佛教的概念叫做"初心"。禅宗法师铃木俊隆写道:"初学者的心中有很多可能性(潜意识的点子),但在专家心里(这种可能性/点子)就相对少很多"。在写作本书的过程中,我经常会回到这个概念里重新审视自己,提醒自己不要对那些看起来很新的或不习惯的事物过早下结论.

本着这种精神,我们回过头来看看你当初接触iOS应用开发的情形:与可能只知道使用Model-View-Controller(MVC)的架构来编写iOS应用的现在的你相比,那时候你一无所知。你的内心随时准备接纳外界无限的可能性(这里指的是任何可以编写iOS应用的方式)。而MVC社区的长老们指导你使用MVC架构来做,因为那就是他们所知道的苹果公司所倡导的方式。

如果你已经用这种方式开发iOS应用程序一段时间,你可能会熟悉MVC背后的另类意义:巨大的视图控制器.(因为MVC:恶搞成Massive View Controller的缩写)。很多时候,我们途方便把业务逻辑和其他代码都放在试图控制器中,即便从架构的角度上来说把它们放在这里不是最佳选择。

Model View View-Model 也称MVVM,是一种出自微软的替代MVC架构的新架构。我知道,我知道!iOS社区没有任何历史作为微软的铁杆粉丝而存在,但(微软)他们的软件工程小组确实做出了伟大的工作。MVVM不仅仅在.Net平台上使用---我们也可以在iOS平台上使用。就像我们在这一章将要看到的:与ReactiveCocoa结合使用,MVVM令人难以置信地适用于iOS。使用MVVM能够有效地减少ViewController中的业务逻辑,这会大大减少其臃肿的体积,也使得业务逻辑更容易测试。

什么是MVVM

传统的MVC架构应用中包含了三种组件,分别是:数据模型、视图以及视图控制器。其中用来保持数据的是数据模型,用来呈现数据的是视图,视图控制器帮忙调解前面两个组件之间进行的交互。

希望于Apple已经很好地测试过它的业务逻辑了。剩下的视图控制器它很少进行单元测试。

当新的数据到达时,model会通知ViewController(通常是通过键-值观察(KVO)的方式),然后ViewController会更新View。当View接收交互时,ViewController会更新Model。

Typical MVC Paradigm

正如你所看到的ViewController隐式地负责很多事情:验证输入、将模型数据映射到面向用户的信息、操作视图层次结构等等。

MVVM将大量的类似上面的业务逻辑从viewController中抽离出来了。

MVVM_high_level

在MVVM中,我们趋向于将view和view controller作为一个整体(这也解释了为什么不称它为MVVCVM),新的viewModel代替原来的viewController协调view与model之间的交互。

对这种MVVM架构中的"更新"机制,我们没有什么概念。实际上也没有什么关于MVVM的东西迫使你使用特定的机制来更新视图模型或视图。但在本书的范围内,我们将使用ReactiveCocoa来做处理这个。

ReactiveCocoa将会监控数据模型(model)的变化,并将这个变化映射到视图模型(viewModel)的属性上,执行任意必要的业务逻辑。

举一个具体的例子:假设我们的模型包含一个“日期”(用dateAdded表示),我们想要监控这个“日期”的变化,来更新我们视图模型(viewModel)的属性dateAdded.模型(model)的属性是一个NSDate的实例,但视图模型(viewModel)中对应的属性是从它转换过来的NSString。这种绑定看起来跟下面的代码类似(在viewModel的初始化方法中进行):

RAC(self, dateAdded) = [RACObserve(self.model,dateAdded) map:^(NSDate *date) {
    return [[ViewModel dateFormatter] stringFromDate:date];
}];

dateFormatter是ViewModel的一个类方法,它缓存了一个NSDateFormatter实例以便复用(创建NSDateFormatter代价昂贵)。 接下来,view controller 可以监控viewModel的dateAdded属性将它跟一个label进行绑定。

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

现在,我们已经将日期转换为字符串到视图模型的过程抽象出来了,在(viewModel)中我们可以为这个业务逻辑编写单元测试。这个例子看起来简单,但就像我们看到的,它显著地减少了你的视图控制器中的业务逻辑。

用MVVM重构FunctionalReactivePixels

在我们继续研究使用MVVM来重构我们的FunctionalReactivePixelsDemo之前,我们需要做一些准备工作。我们使用的500px_iOS_SDK的方式是不被他们的登录系统允许的。所以我们将从AppDelegate的头文件中移除apiHelper属性,然后用下面的代码替换实现文件里执行初始化的那行代码,填上你的消费者Key和Secret.

[PXRequest setConsumerKey:consumerKey consumerSecret:consumerSecret];

现在,任何调用AppDelegate.apiHelper来创建500px_API请求的地方,全部必须替换为[PXRequest apiHelper].

最后,记得将CocoaPods文件中500px_iOS_SDK的版本号更新到1.0.5

MVVM的具体实践

本章的其他部分将把Functional Reactive Pixels Demo的其他代码迁移到MVVM架构中。我们将添加一个新的库到Podfile文件里。Github上创作了ReactiveCocoa的黑客,也同时创建了一个ViewModel的基类:ReactiveViewModel.我们将要使用它的0.1.1版本。更新Podfile之后立即运行pod install以安装该库。

重构的第一个类是高清图片视图控制器。从这儿开始是因为它的业务逻辑比较少,抽象成viewModel时相对简单。我们循序渐进,慢慢来。

目前,我们的FRPFullSizePhotoViewController包含一个图片数组和当前图片(在数组中)的下标值。我们将把他们抽象到我们的视图模型中来。

从头文件中移除自定义初始化,追加FRPFullSizePhotoViewModel的预申明。然后在这个新类中追加一个属性。

@property (nonatomic ,strong ) FRPFullSizePhotoViewModel *viewModel;

在实现文件里,#import这个新的视图模型(别担心,我们很快就会创建它),

#import "FRPFullSizePhotoViewModel.h"

然后,移除photoModelArray私有属性的申明。重写我们的初始化方法以移除对photoModelArray实例的引用。代码看起来应该像下面这样:

- (instancetype)init {
    self = [super init];
    if(!self) return nil;

    //ViewControllers
    self.pageViewController = [UIPageViewController alloc]
                    initWithTransitionStyle:UIPageViewControllerTransitionStyleScroll
                    navigationOrientation:UIPageViewControllerNavigationOrientationHorizontal
                       options:@{ UIPageViewControllerOptionInterPageSpacingKey : @30 };

    self.pageViewController.dataSource = self;
    self.pageViewController.delegate      = self;
    [self addChildViewController:self.pageViewController];

    return self;
}

在你的ViewDidLoad:中添加如下代码:

//Configure child view controllers
[self.pageViewController \
        setViewControllers: @[ [self photoViewControllerForIndex:self.viewModel.initialPhotoIndex] ]
        direction:UIPageViewControllerNavigationDirectionForward
        animated:NO
        completion:nil ];

//Configure self
self.title = [self.viewModel.initialPhotoModel photoName];

我们将要写的这个我们提到的方法,对于veiwModel中发生的事情,给你一种XX感。最后,进到photoViewControllerForIndex方法中,它应用了已经解除分配的photoModelArray,用下面的实现替代它。

- (FRPPhotoViewController *)photoViewControllerForIndex:(NSInteger)index {
    if (index >= 0 && index < self.viewModel.photoArray.coung ) {
        FRPPhotoModel *photoModel = self.viewModel.model[index];

        FRPPhotoViewController *photoViewController = \
            [[FRPPhotoViewController alloc] initWithPhotoModel:photoModel index:index];

        return photoViewController;
    }

    // Index was out of bounds, return nil
    return nil;
}

好了!现在轮到我们的视图模型本身了。创建一个新的RVMViewModel的子类,并将其命名为FRPFullSizedPhotoViewModel.基于它将要封装的信息,以及我们在视图控制器中的需求,我们知道,我们的头文件看起来应该是下面这样:

@class FRPPhotoModel;

@interface FRPFullSizePhotoViewModel : RVMViewModel

- (instancetype)initWithPhotoArray:(NSArray *)photoArray initialPhotoIndex:(NSInteger)initialPhotoIndex;
- (FRPPhotoModel *)photoModelAtIndex:(NSInteger)index;

@property (nonatomic , readonly, strong) NSArray *model;
@property (nonatomic, readonly) NSInteger initialPhotoIndex;
@property (nonatomic, readonly) NSString *initialPhotoName;

@end

model属性在RVMViewModel中被定义为id类型,我们把它重定义为NSArray. 我们也勾住了(即使用全局变量记录)我们最初照片的索引(下标)并且给我们最初的照片名属性定义了只读属性。这种微不足道的逻辑我们可以放到我们的视图控制器中,但很快我们就会看到更为复杂的情况。

我们来完成实现文件里的东西。第一件事就是:我们需要#import FRPPhotoModel类的头文件。然后,我们将打开私有属性的读写访问权限。

//Model
#import "FRPPhotoModel.h"

@interface FRPFullSizePhotoViewModel ()
//private access
@property (nonatomic, assign) NSInteger initialPhotoIndex;

@end

好!下一步处理我们的初始化方法

- (instancetype)initWithPhotoArray:(NSArray *)photoArray initialPhotoIndex:(NSInteger)initialPhotoIndex {
    self = [super initWithModel:photoArray];
    if(!self) return nil;

    self.initialPhotoIndex = initialPhotoIndex;

    return self;
}

初始化方法中,先调用超类的initWithModel:实现,然后设置自己的initialPhotoIndex属性。剩下的两个只读属性的获取逻辑微不足道。

- (NSString *)initialPhotoName {
    return [[self photoModelAtIndex:self.initialPhotoIndex] photoName];
}

- (FRPPhotoModel *)photoModelAtIndex:(NSInteger)index {
    if(index < 0 || index > self.model.count - 1) {
        //Index was out of bounds, return nil
        return nil;
    }
    else {
        return self.model[ index ];
    }
}

这样做的另一个优点是:业务逻辑不需要重复书写,而且也使得业务逻辑非常好进行单元测试。

最后,我们需要在高清视图控制器中设置该视图模型,否则屏幕上将不会显示任何东西。导航到我们的画廊视图控制器(那个我们实例化并推出高清视图控制器的地方)。用下面的代码来替换这个业务逻辑:

[[self rac_signalForSelector:@selector(collectionView:didSelectItemAtIndexPath:)
    fromProtocol:@protocol(UIcollectionViewDelegate)] subscribeNext:^(RACTuple *arguments) {
        @strongify(self);

        NSIndexPath *indexPath = arguments.second;
        FRPFullSizePhotoViewModel *viewModel = [[FRPPhotoViewModel alloc]
            initWithPhotoArray:self.viewModel.model initialPhotoIndex:indexPath.item];

        FRPFullSizePhotoViewController *viewController = [[FRPFullSizePhotoViewController alloc] init];

        viewController.viewModel = viewModel;
        viewController.delegate = (id)self;

        [self.navigationController pushViewController:viewController animated:YES];
    }];

在下一节开始之前,我们没有计划为视图模型撰写单元测试。下一节我们看到在视图模型上如何运行测试驱动开发的概念。现在我们来完成FRPGalleryViewModel吧,很基础。我们想要从视图控制器中抽象出来的逻辑是通过API加载model的数据内容。我们来看一下应该怎么做:


@interface FRPGalleryViewModel : RVMViewModel

@property (nonatomic, readonly, strong) NSArray *model;

@end

基本的接口:将model申明为数组NSArray.接下来,我们简单实现它:


//Utilities

#import "FRPPhotoImporter.h"

@interface FRPGalleryViewModel ()

@end

@implementation FRPGalleryViewModel

- (instancetype)init {
    self = [super init];
    if(!self) return nil;

    RAC(self, model) = [[[FRPPhotoImporter importPhotos] logError] catchTo:[RACSignal empty]];

    return self;
}

@end

有争议的是,我们应该把从API加载数据的(RAC绑定的)逻辑放在初始化方法中,还是放在视图模型被激活的地方。接下来我们会讨论更多的关于激活的内容,但我想要展示给你们看这个视图模型到底能做到多简单。将直接在画廊视图控制器中加载数据内容的逻辑迁移到画廊的视图模型中是非常简单的:在视图控制器的初始化中初始化视图模型===》任何引用试图控制self.model属性的地方使用self.viewModel.model来代替即可。

我们可以进一步深挖视图模型的构造,甚至可以通过一系列的访问器把model的访问逻辑抽象出来,但在这个例子里就有点过多‘抽象’了。更重要的是你可以根据你的喜好将更多的或者更少的业务逻辑抽象到视图模型中。我发现,就我个人而言,这个架构使用的越多,业务逻辑抽象出来的越多,就意味着更轻量级的视图控制器以及高内聚和可测试的代码。

把注意力移到单元测试之前,我们来做多一次用视图模型来抽象业务逻辑的实践。

我们的最后一个例子是FRPPhotoViewController上的FRPPhotoViewModel:创建一个RVMViewModel的视图模型子类并放置在视图控制器中(很快我们会回到视图模型中)。

视图控制器的新的初始化方法如下:

- (instancetype)initWithViewModel:(FRPPhotoViewModel *)viewModel index:(NSInteger)photoIndex {

    self = [self init];//NS_DESIGNATED_INITIALIZER
    if(!self) return nil;

    self.viewModel = viewModel;
    self.photoIndex = photoIndex;

    return self;
}

确定导入必要的头文件并为视图模型申明私有属性。现在我们需要使用新的初始化方法初始化视图控制器。看一看视图控制器到页面视图控制器的方法photoViewControllerForIndex:.

- (FRPPhotoViewController *)photoViewControllerForIndex:(NSInteger)index {
    FRPPhotoModel *photoModel = [self.viewModel photoModelAtIndex:index];
    if(photoModel) {
        FRPPhotoViewModel *photoViewModel = [[FRPPhotoViewModel alloc] initWithModel:photoModel];
        FRPPhotoViewController *photoViewController = [[FRPPhotoViewController alloc] \
                             initWithViewModel:photoViewModel
                                          index:index];

        return photoViewController;
    }

    return nil;
}

新的初始化过程中我们创建了一个视图模型。

在我们的viewDidLoad:方法里,我们将使用这个新的视图模型为我们的图片视图提供数据,并且为用户显示图片的下载进度。这里有个貌似冲突的地方:图片的下载是视图的模型的业务逻辑之一,但视图什么时候显示开始加载数据(这个业务逻辑)视图模型中没有体现---记住一个好的视图模型不应该引用视图本身。那么我们如何来混合地使用这两个业务逻辑?

答案是我们借助视图模型的active状态来对付(上面的情况)。RVMViewModel提供了一个布尔属性active,当试图控制器变得"活跃"时(不管在语义的上下文里这是啥意思),在这里,我们可以在viewWillAppear:和viewDidDisappear:这些方法来设置这个属性。

- (void)viewWillAppear:(BOOL)animated {
    [super viewWillAppear:animated];

    self.viewModel.active = YES;
}

- (void)viewDidDisappear:(BOOL)animated {
    [super viewDidDisappear:animated];

    self.viewModel.active = NO;
}

相当简单吧,我们来看一下我们新的viewDidLoad方法:

- (void)viewDidLoad {
    [super viewDidLoad];

    //Configure self's view
    self.view.backgroundColor = [UIColor blackColor];

    //Configure subViews
    UIImageView *imageView = [[UIImageView alloc] initWithFrame:self.view.bounds];
    RAC(imageView, image) = RACObserve(self.viewModel,photoImage);
    imageView.contentModel = UIViewContentModelScaleAspectFit;
    [self.view addSubView:imageView];
    self.imageView = imageView;

    [RACObserve(self.viewModel, loading) subscribeNext:^(NSNumber *loading) {
        if(loading.boolValue) {
            [SVProgressHUD show];
        }
        else {
            [SVProgressHUD dismiss];
        }
    }];
}

该图片视图的图片属性的绑定是标准的ReactiveCocoa方式,有趣的是下面(我们要提到的)我们使用loading的时刻。当加载信号发送YES的时候我们展示进度HUD,发送NO的时候,让进度HUD消失。我们将看到该loading信号本身如何依赖于didBecomeActiveSignal。现在只是视图模型通过网络请求获取图像数据的序幕。

接口的申明如下:

@class FRPPhotoModel;

@interface FRPPhotoViewModel : RVMViewModel

@property (nonatomic, readonly) FRPPhotoModel *model;
@property (nonatomic, readonly) UIImage *photoImage;
@property (nonatomic, readonly, getter = isLoading) BOOL loading;

- (NSString *)photoName;

@end

该model和photoImage属性的用法已经解释过了。photoName事实上作为属性在代码库的其他地方被用来设置一些东西,类似于分页视图控制器的标题这样。你可以下载Github的代码库了解详情。我们来看一下实现:

#import "FRPPhotoViewModel.h"

//Utilities
#import "FRPPhotoImporter.h"
#import "FRPPhotoModel.h"

@interface FRPPhotoViewModel ()

@property (nonatomic, strong) UIImage *photoImage;
@property (nonatomic, assign, getter = isLoading) BOOL loading;

@end

@implementation FRPPhotoViewModel

- (instancetype)initWithModel:(FRPPhotoModel *)photoModel {
    self = [super initWithModel:photoModel];
    if(!self) return nil;

    @weakify(self);
    [self.didBeComeActiveSignal subscribeNext:^(id x) {
        @strongify(self);
        self.loading = YES;
        [[FRPPhotoImporter fetchPhotoDetails:self.model] subscribeError:^(NSError *error) {
            NSLog(@"Could not fetch photo details: %@",error);
        } completed:^{
            self.loading = NO;
            NSLog(@"Fetched photoDetails.");
        }];
    }];

    RAC(self, photoImage) = [RACObserve(self.model, fullsizedData) map:^id (id value) {
        return [UIImage imageWithData:value];
    }];

    return self;
}

- (NSString *)photoName {
    return self.model.photoName;
}

@end

该didBecomeActive信号订阅带有"函数副作用"的加载照片详情包括它的高清图片的数据。然后photoImage属性与模型的映射结果绑定。

使用didBecomeActiveSignal这种方法来启动一些像网络操作这样昂贵的任务,远远优于我们早前在初始化方法中启动他们的方法。

本书要介绍的全部内容已经讲完了,更多的内容请参考functionalreactivepixels,这个代码库包含了更多的在图片详情视图控制器和登陆视图控制器中使用视图模型的例子。这些Demo将向你展示如何有效地使用ReactiveCocoa执行网络操作和使用RACCommands响应用户界面交互。

测试ViewModels

在本书的最后,讲一下与测试相关的问题,其中单元测试尤为重要。测试这个话题相对于iOS开发社区来说还是颇具争议性的,在理想的情况下,我们在编写视图模型的时候就该为其编写单元测试了。之所以将测试这一章放在最后一节来讲解,就是考虑到大家在学习使用这种新的模式来进行编码的时候不是一件简单的事情了,再要试着测试一些没有吃透的东西是非常有难度的,而学到最后的话就大致上已经掌握了这种编码方式了,这样理解起来也相对容易。

当然我也注意到,并不是每个人都以相同的方式来测试,或者能够测试到相同的程度。我有.Net编程背景,在.net中使用mocks来测试系统的实现细节是最平常不过的了。其他平台背景的开发者较少使用mocks来做,甚至从来没有这样的经验。本节我只将我的单元测试方法分享给大家,如果你觉得合适就采用。

确保你的Podfile文件包含下面这些库:

target "FRPTests" do

pod 'ReactiveCocoa', '2.1.4'
pod 'ReactiveViewModel', '0.1.1'
pod 'libextobjc', '0.3'
pod '500px-iOS-api', '1.0.5'
pod 'Specta', '~> 0.2.1'
pod 'Expecta', '~> 0.2'
pod 'OCMock', '~> 2.2.2'

end

然后运行pod install.

首先我们来看看FRPFullSizePhotoViewModel,因为它最具Objective-C风范(没有太多ReactiveCocoa).

@interface FRPFullSizePhotoViewModel ()
//Private access
@property (nonatomic, assign) NSInteger initialPhotoIndex;

@end

@implementation FRPFullSizePhotoViewModel

- (instancetype)initWithPhotoArray:(NSArray *)photoArray initialPhotoIndex:(NSInteger)initialPhotoIndex {
    self = [self initWithModel:photoArray];
    if(!self) return nil;

    self.initialPhotoIndex = initialPhotoIndex;

    return self;
}

- (NSString *)initialPhotoName {
    return [self.model[self.initialPhotoIndex] photoName];
}

- (FRPPhotoModel *)photoModelAtIndex:(NSInteger)index {
    if(index < 0 || index > self.model.count - 1) {
        //Index was out of bounds, return nil
        return nil;
    }
    else {
        return self.model[index];
    }
}

@end

好了,我们先来测试这个初始化方法,然后在转移到其他两个方法上。

我们想印证初始化我们的视图模型时,它的两个属性model和initialPhotoIndex被正确地赋值了。

#import 
#define EXP_SHORTHAND
#import 
#import 
#import "FRPPhotoModel.h"

#import "FRPFullSizePhotoViewModel.h"

SpecBegin(FRPFullSizePhotoViewModel)

describe(@"FRPFullSizePhotoModel", ^{
    it (@"Should assign correct attributes when initialized", ^{
        NSArray *model = @[];
        NSInteger initialPhotoIndex = 1337;

        FRPFullSizePhotoViewModel *viewModel =\
         [[FRPFullSizePhotoViewModel alloc] initWithPhotoArray:model
                                                     initialPhotoIndex: initialPhotoIndex];

        expect(model).to.equal(viewModel.model);
        expect(initialPhotoIndex).to.equal(viewModel.initialPhotoIndex);

    });
});

SpecEnd

在该代码段顶部,我们导入了一些头文件,包括一个奇怪的预定义EXP_SHORTHAND,我们把他放在那里以便于可以使用类似expect()这样的shorthand matchers(速记匹配)的语法。然后我们引入我们的私有接口SpecBegin(...)/SpecEnd来为我们正在测试的视图模型屏蔽编译警告,最后的部分就是我们的单元测试本身。Specta的测试规范相当简单,你可以阅读更多的关于这方面的信息,但本书不会深入讲解它的一些细节。总之你的测试始于SpecBegin并终止于SpecEnd,测试例程用类似于@"应该。。。",^{ 预测正常的情况应该如何 }写在中间。

好了,停止模拟器中正在运行的应用,按下cmd+U快捷键,你就可以运行这段单元测试了。如果一切正常,你就能通过测试。

接下来我们来看看photoModelAtIndex:方法

- (FRPPhotoModel *)photoModelAtIndex:(NSInteger)index {
    if(index < 0 || index > self.model.count - 1 ) {
        // Index was out of bounds ,return nil
        return nil;
    }
    else {
        return self.model[ index ];
    }
}

这里面没有太多的业务逻辑,但是我们看到其他地方都要使用它,所以我们的测试应该是健壮的。

it(@"Should return nil for an out-of-bounds photo index", ^{
    NSArray *model = @[[NSobject new]];
    NSInteger initialPhotoIndex = 0;

    FRPFullSizePhotoViewModel *viewModel = \
        [[FRPFullSizePhotoViewModel alloc] initWithPhotoArray:model initialPhotoIndex:initialPhotoIndex];

    id subzeroModel = [viewModel photoModelAtIndex:-1];
    expect(subzeroModel).to.beNil();

    id aboveBoundsModel = [viewModel photoModelAtIndex:model.count];
    expect(aboveBoundsModel).to.beNil();
});

it(@"Should return the correct model for photoModelAtIndex:",^{
    id photoModel = [NSObject new];
    NSArray *model = @[photoModel];
    NSInteger initialPhotoIndex = 0;

    FRPFullSizePhotoViewModel *viewModel = \
        [[FRPFullSizePhotoViewModel alloc] initWithPhotoArray:model initialPhotoIndex:initialPhotoIndex];

    id returnModel = [viewModel photoModelAtIndex:0];
    expect(returnModel).to.equal(photoModel);

});

太棒了!我们这个新的测试保证了我们的代码具有完全的代码覆盖率。它检测了photoModelAtIndex:参数的三种可能的情况:少于0、在作用范围内以及越界。

最后,我们来看下initialPhotoName方法:

- (NSString *)initialPhotoName {
    return [self.model[self.initialPhotoIndex] photoName];
}

方法看起来很简单,但实际上这里面包含了更深层级的东西。恰当地重构一些代码并为它写一点不一样的更小的测试代码,来严格地测试这个方法。

- (NSString *)initialPhotoName {
    FRPPhotoModel *photoModel = [self initialPhotoModel];
    return [photoModel photoName];
}

- (FRPPhotoModel *)initialPhotoModel {
    return [self photoModelAtIndex:self.initialPhotoIndex];
}

这更清晰简单了,一个方法确切地只做一件事情,就像一棵树的树皮,层层叠叠相互依存。只要我们一路下来所有的代码都测试,那么最后我们就可以很确切地保证代码的健壮性。

initialPhotoModel是一个私有方法,所以测试它我们需要在测试文件中申明它。

@interface FRPFullSizePhotoViewModel ()

- (FRPPhotoModel *)initialPhotoModel;

@end

你看到的所有我们的测试代码都非常简单。

it (@"Should return the correct initial photo model", ^{
    NSArray *model = @[[NSobject new]];
    NSInteger initialPhotoIndex = 0;

    FRPFullSizePhotoViewModel *viewModel = \
        [[FRPFullSizePhotoViewModel alloc] initWithPhotoArray:model initialPhotoIndex:initialPhotoIndex];

    id mockViewModel = [OCMockObject partialMockForObject:viewModel];
    [[[mockViewModel expect] andReturn:model[0]] photoModelAtIndex:initialPhotoIndex];

    id returnedObject = [mockViewModel initialPhotoModel];

    expect(returnedObject).to.equal(model[0]);

    [mockViewModel verify];
});

这个测试是用来确认当initialPhotoModel被调用时,接下来它应该调用photoModelAtIndex:方法并将initialPhotoIndex作为参数传入。这个测试是否简单取决于我们测试photoModelAtIndex:是否充分。

接下来,就让我们一起来看看FRPGalleryViewModel,这看似非常简单:

- (instancetype)init {
    self = [super init];
    if(!self) return nil;

    RAC(self, model) = [[[FRPPhotoImporter importPhotos] logError] catchTo:[RACSignal empty]];

    return self;
}

然而,它可测性不高,需要重构。

我们简单地重构下视图模型。新的实现如下:

@implementation FRPGalleryViewModel

- (instancetype)init {
    self = [super init];
    if(!self) return nil;

    RAC(self, model) = [self importPhotosSignal];

    return self;
}

- (RACSignal *)importPhotosSignal {
    return [[[FRPPhotoImporter importPhotos] logError] catchTo:[RACSignal empty]];
}

@end

我们把importPhotos的调用抽出来,以方便测试这个方法是否被调用。我们不会测试FRPPhotoImporter,关于它的测试(即单例测试)已经超出了本书的范畴。

这部分的测试代码如下:

#import "Specta.h"
#import 

#import "FRPGalleryViewModel.h"

@interface FRPGalleryViewModel ()

- (RACSignal *)importPhotosSignal;

@end

SpecBegin(FRPGalleryViewModel)

describe(@"FRPGalleryViewModel",^{
    it(@"should be initialized and call importPhotos", ^{
        id mockObject = [OCMockObject mockForClass:[FRPGalleryViewModel class]];
        [[[mockObject expect] andReturn:[RACSignal empty]] importPhotosSignal];

        mockObject = [mockObject init];

        [mockObject verify];
        [mockObject stopMocking];
    });
});

为了测试一个方法,测试代码也太多了吧! 我知道,我知道~ 这是OCMock没落的原因之一,它竟然需要这么多的模板。但你不能责怪它,因为它要工作在令它不寒而栗的Objective-C平台上!

我们创建了一个FRPGalleryViewModel的mock版本,告诉它期望importPhotoSignal被调用。然后才进行对象的初始化。这里使用了一点点技巧,因为我们在mockObject上调用了init方法,但它(init)实际上是一个NSProxy的子类。然后,对OCMock来讲,它足够聪明,它了解这一切,有能力做出正确的选择。只是看起来有点诡异罢了。我们使用[mockObject init]给mockObject赋值,也是为了屏蔽编译警告。最后我们验证了所有预期可能被调用的方法。

这个例子中表现出来的测试很困难的情况也说明了另一个问题,你应该避免视图模型的初始化方法产生"副作用"(参见前面章节提到的“函数的副作用”),应该使用didBecomeActiveSignal来代理。

下面我们来测试FRPPhotoViewModel.再次突出引起函数副作用和使用didBecomeActiveSignal的区别。

快速浏览下实现:


@implementation FRPPhotoViewModel

- (intancetype)initWithModel:(FRPPhotoModel *)photoModel {
    self = [super initWithModel:photoModel];
    if(!self) return nil;

    @weakify(self);
    [self.didBecomeActiveSignal subscribeNext:^ (id x) {
        @strongify(self);
        self.loading = YES;
        [[FRPPhotoImporter fetchPhotoDetails:self.model]
            subscribeError: ^ (NSError *error) {
                NSLog(@"Could not fetch photo details: %@",error);
            }
            completed: ^ {
                self.loading = NO;
                NSLog(@"Fetched photo details");
            }];
    }];

    RAC(self, photoImage) = [RACObserve(self.model, fullsizedData) map:^id (id value) {
        return [UIImage imageWithData:value];
    }];

    return self;
}

- (NSString *)photoName {
    return self.model.photoName;
}

@end

首先我们来测试photoName方法:

#import 
#define EXP_SHORTHAND
#import 
#import 

#import "FRPPhotoViewModel.h"
#import "FRPPhotoModel.h"

SpecBegin(FRPPhotoViewModel)

describe (@"FRPPhotoViewModel", ^{
    it(@"should return the photo's name property when photoName is invoked", ^{
        NSString *name = @"Ash";

        id mockPhotoModel = [OCMockObject mockForClass:[FRPPhotoModel class]];
        [[[mockPhotoModel stub] andReturn:name] photoName];

        FRPPhotoViewModel *viewModel = [[FRPPhotoViewModel alloc] initWithModel:nil];
        id mockViewModel = [OCMockObject partialMockForObject:viewModel];
        [[[mockViewModel stub] andReturn:mockPhotoModel] model];

        id returnName = [mockViewModel photoName];

        expect(returnedName).to.equal(name);
        [mockPhotoModel stopMocking];
    });
});

我们为mock的视图模型的model属性添加了一个mockPhotoModel,它会mocks所有的途径。

现在来看这个复杂的初始化方法,这东西看起来真巨大!近20行纯粹的未经测试的代码。哎呀!让我们来一点点简化这个事情,并逐步加上我们的测试代码。

- (instancetype)initWithModel:(FRPPhotoModel *)photoModel {
    self = [super initWithModel:photoModel];
    if(!self) return nil;

    @weakify(self);
    [self.didBecomeActiveSignal subscribeNext:^(id x) {
        @strongify(self);
        [self downloadPhotoModelDetails];
    }];

    RAC(self, photoImage) = [RACObserve(self.model, fullsizedData) map:^id (id value) {
        return [UIImage imageWithData:value];
    }];

    return self;
}

- (void)downloadPhotoModelDetails {
    self.loading = YES;
    [[FRPPhotoImporter fetchPhotoDetails:self.model] subscribeError:^(NSError *error) {
        NSLog(@"Could not fetch photo details : %@",error);
    } completed:^ {
        self.loading = NO;
        NSLog(@"Fetched photo details.");
    }];
}

我们选择了不直接测试fetchPhotoDetails:,所以我们把它置于一个实例方法中,以便更容易对它进行测试。这个方法(即fetchPhotoDetails:)实现的细节在这里对我们不重要。

现在开始写关于它的测试代码吧:

it(@"should download photo model details when it becomes active", ^{
    FRPPhotoViewModel *viewModel = [[FRPPhotoViewModel alloc] initWithModel:nil];

    id mockViewModel = [OCMockObject partialMockForObject:viewModel];
    [[mockViewModel expect] downloadPhotoModelDetails];

    [mockViewModel setActive:YES];
    [mockViewModel verify];
});

注意看初始化方法中不产生(函数)副作用而是把这种副作用放在订阅didBecomeActiveSignal的Block块中时,测试视图模型的代码是多么简单!

现在我们需要测试剩下的那些视图模型,他们全部非常简单。我们使用更少的mock,因为很多的业务逻辑仅仅是视图模型的model值到他自己的属性的映射。

it (@"should return the photo's name property when photoName is invoked", ^{
    NSString *name = @"Ash";

    id mockPhotoModel = [OCMockObject mockForClass:[FRPPhotoModel class]];
    [[[mockPhotoModel stub] andReturn:name] photoName];

    FRPPhotoViewModel *viewModel = [[FRPPhotoViewModel alloc] initWithModel:nil];
    id mockViewModel = [OCMockObject partialMockForObject:viewModel];
    [[[mockViewModel stub] andReturn:mockPhotoModel] model];

    id returnedName = [mockViewModel photoName];

    expect(returnedName).to.equal(name);

    [mockPhotoModel stopMocking];
});

it (@"should correctly map image data to UIImage", ^{
    UIImage *image = [[UIImage alloc] init];
    NSData *imageData = [NSData data];

    id mockImage = [OCMockObject mockForClass:[UIImage class]];
    [[[mockImage stub] andReturn:image] imageWithData:imageData];

    FRPPhotoModel *photoModel = [[FRPPhotoModel alloc] init];

    photoModel.fullsizedData = imageData;

    __unused FRPPhotoViewModel *viewModel = [[FRPPhotoViewModel alloc] initWithModel:photoModel];

    [mockImage verify];
    [mockImage stopMocking];

});

it(@"should return the correct photo name", ^{
    NSString *name = @"Ash";

    FRPPhotoModel *photoModel = [[FRPPhotoModel alloc] init];
    photoModel.photoName = name;

    FRPPhotoViewModel *viewModel = [[FRPPhotoViewModel alloc] initWithModel:photoModel];

    NSString *returnedName = [viewModel photoName];

    expect(name).to.equal(returnedName);
});

这就是为视图模型撰写单元测试的全部内容了。

在理想的情况下,单元测试能帮助改进你的代码质量。小巧而高内聚的方法比随意的满是副作用的方法更招人待见,它简单而完美地诠释了函数响应型编程的精髓。

测试MVVM的好处是:我们不用触及UIKit。请记住,写得好的MVVM视图模型的特点是:该视图模型不会与用户交互的接口类有任何交互。

MVVM总结

MVVM这个架构使用起来非常的有趣。有关于MVVM架构思考的越多,它对于我的意义便越大。本章中的视图模型所呈现的业务逻辑都很轻量,我已经把它们上传到Github仓库里了,但是本章作为一个MVVM的示例为初学者的开始提供了参考。

我想提供一个具体的例子来说明它比MVC更有竞争力,更具意义。

最近我创建的一个App中,我们有一堆数据,支持下拉刷新,每一个元素点击之后会推出详情页面,视图控制器有很多业务逻辑,非常标准的东西。然而,这一堆数据彼此之间来路是不一样的,有的是主API入口的数据结果,有的是它们的搜索结果,有的是App在编译时就决定的静态元素。

使用MVC的话,我想到了两种方法来解决:

  1. 在臃肿的视图控制器中创建一个类处理显示,并管理所有的数据内容
  2. 毫无疑问,另一种方法就是子类化一个视图控制器的抽象基类来包含所有内容的通用逻辑。

这是过去我所采用的方式,但这方式很难重构,比方说:有些所有类型的通用内容变得只对部分类型有效时。这同样也能被称为是一种黑客攻击,因为Objective-C不支持抽象类。

我采用的方法是使用符合该视图控制器所依赖的协议的不同的视图模型。通过将定制的业务逻辑放置于视图模型中,我避免了视图控制器的臃肿化,视图控制器仅需要根据视图模型的协议来知道如何显示即可。 MVVM是子类化视图控制器的一个很好的选择。

另外,如果你有多平台需求(比如说:iOS & OSX),他们可以共用一套视图模型,因为他们不牵扯到视图本身的逻辑。你甚至可以走得更远,用另外的语言来生成视图模型,然后生成指定的语言的视图模型对象比如:Objective-C、C#、Ruby、Java或者其他你需要的任何语言。疯狂吧这玩意~

最后,我们并没有真正地涉及到RACCommand的使用。我将利用Justin Spahr-Summers的说法在MVVM的范畴来解释它。

  1. 控制(事件)与它交互
  2. 一个属于视图模型的命令被执行
  3. 视图模型的逻辑被运行(运行的是命令初始化时的signalBlock)
  4. 视图模型通过ReactiveCocoa来间接通知视图。在我们的例程中,视图会被更新。

再一次强调Github仓库包含了我们在本书中没有能够涉及的,关于RACCommand的,使用的详细信息。去看一看吧!

MVVM效果很好,与ReactiveCocoa结合起来使用更好。一下子掌握它太难了,你不妨从小处着手,先在一个视图控制器中使用,看看你到底能有多适合它,然后再在你的下一个项目中尝试使用它吧,你会看到它如何彻底简化你的视图控制器的复杂度。