Model View View-Model on iOS

翻译自:Functional Reactive Programming on iOS – Ash Furrow. 版权所有, 转载请著明出处,保留链接。

##Model View View-Model on iOS
有一个Zen Buddhist概念,被称为入门者思维,是这样说的:在入门者思维中,有很多种可能,而在专家思想中,却很少。在这本书的写作中,我经常思考这个概念,并反省自己,不要轻易地对新的事物下结论。
在这个精神的指导下,让我们回到刚开始写iOS应用程序的时候。看起来,对象-视图-控制器模型是写iOS应用程序的唯一方式。社区中的先行者们指导你使用MVC, 因为这是他们知道并被Apple推荐的方式。
假如你开发iOS应用程序有段时间了,你一定熟悉MVC的含义:大量的视图控制器。大多数情况下,把业务逻辑和其他一些代码放在视图控制器中是非常方便的,尽管这里不是放置他们最合理的地方。
模型 视图 视图-模型(MVVM), 是来自微软的对MVC的一种替代。我知道,iOS社区历史上对微软不是很感冒,但是他们的软件工程做了一件了不起的工作。MVVM不必拘泥于.Net平台,我们在iOS平台上也能使用。正如我们在这章看到的那样,MVVM在iOS中非常实用,并且能与ReactiveCocoa很好的衔接。使用MVVM能够减少视图控制器中的业务逻辑,这使得代码更具有可测试性。

What is MVVM?

在传统的MVC应用程序中,你有三个组件:模型,视图,控制器。模型是存放数据的地方,而视图是呈现数据的,而控制器协调两者的交互。
这种协调时重要的。模型和视图间是不会注意到各自的存在的,所有的事情都要通过视图控制器。在典型的iOS应用程序中,模型是精练的,意味着他们不包含业务逻辑。视图属于UIKit,它的业务逻辑已经被Apple测试过。剩下视图控制器,是很少被单元测试的。
当新的数据到达时,模型通过键-值观察通知视图控制器,然后视图控制器更新视图。当视图被交互,视图控制器更新模型。

chapter_5_typical_mvc_paradigm

正如你看到的,视图控制器负责很多事情,验证输入,映射模型数据到用户界面,操作视图关系等等。MVVM移除了视图控制器中的很多逻辑。

chapter_5_mvvm_higer_level

在MVVM中,我们趋向于把视图和视图控制器当做一个整体。视图模型来代替视图控制器协调模型和视图。
我们对MVVM做了些修改,没有特殊的机制更新视图模型和视图,而是要使用ReactiveCocoa来完成这些功能。
ReactiveCocoa将会监视模型中的变化,并把这些变化映射到视图模型的属性中,执行相应的业务逻辑。
举一个具体的例子,假如我们的模型包含一个日期的属性:dateAdded, 我们要求监视它的变化,然后更新视图模型中dateAdded属性。模型中的属性石一个NSDate实例,而视图模型把它转换成NSString类型,他们间的绑定如下。

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

dateFormatter方法是ViewModel上的类方法,它缓存了一个NSDateFormatter实例,所以能够被重用。然后,视图控制器能够监视视图买模型的dateAdded属性,绑定到一个标签上。

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

在视图模型中,我们抽象了把日期类型转换成字符串的逻辑,在视图模型中,我们会写一些单元测试代码。这个例子看起来有些做作,但是你看到了,它帮组我们在视图控制器中减少了很多逻辑。

###Revisiting Functional Reactive Pixels
在把MVVM应用到Functional Reactive Pixels的例子前,我们来做些改进。例子中验证登陆与500px iOS SDK不太和谐。
在app 代理的头文件中移除apiHelper属性,在实现文件中更换成如下初始化代码,并且使用你自己的密匙秘钥。

[PXRequestsetConsumerKey:consumerKeyconsumerSecret:consumerSecret];

现在,在任何地方调用AppDelegate.apiHelper创建对500px API的请求,需要替换成[PXRequest apiHelper]
最后,请更新CocoaPods文件中500px iOS SDK中的版本为1.0.5

###MVVM in Practice
这章将讨论在Functional Reactive Pixels的例子怎么使用MVVM。我么开始于在podfile文件中增加新库。ReactiveCocoa的作者在GitHub上也写了个视图模型的基类。它叫ReactiveViewModel,我们将使用其0.1.1版本,并使用pod安装它。
第一个我们要改造的类是全幅照片视图控制器。我们从这里开始,因为它没有很复杂的业务逻辑,比较好抽象成一个视图模型。
在FRPFullSizePhotoViewController中,包含一个照片模型的数组,我一个当前照片的索引。我们把这两者都抽象到一个视图模型中。
移除头文件中的初始化代码,并增加对FRPFullSizePhotoViewModel的前向声明,并增加一个属性。

@property(nonatomic,strong) FRPFullSizePhotoViewModel *viewModel;

在你的实现文件中,#import新的视图模型。

#import "FRPFullSizePhotoViewModel.h"

下一步,移除photoModelArray私有属性的声明,重写初始化方法,移除对photoModelArray的引用。如下。

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

    // View controllers
    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];

我们展现了在视图模型中将要写的方法。最后,定位到photoViewControllerForIndex方法。它引用了已删除的photoModelArray。用如下代码代替它的实现。

-(FRPPhotoViewController*)photoViewControllerForIndex:(NSInteger)index{ 
    if (index >= 0 && index < self.viewModel.photoArray.count) { 
        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;
}

很好,现在轮到我们的视图模型自己了。创建一个文件命名为FRPFullSizedPhotoViewModel,是RVMViewModel的子类。基于它封装的信息,以及它需要实现的方法,它的头文件如下。

@classFRPPhotoModel;

@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

模型属性在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 = [self initWithModel:photoArray];
    if (!self) return nil;
    self.initialPhotoIndex = initialPhotoIndex; 
    return self;
}

初始化者把照片模型传递给了父级的initWithModel:的实现,并且设置initalPhotoIndex。我们只剩下两项业务逻辑。如下。

-(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 = [[FRPFullSizePhotoViewModel alloc]
                                initWithPhotoArray:self.viewModel.model initialPhotoIndex:indexPath.item];

            FRPFullSizePhotoViewController *viewController = [[FRPFullSizePhotoViewController alloc] init];
            viewController.viewModel = viewModel;
            viewController.delegate = (id<FRPFullSizePhotoViewControllerDelegate>)self;
            [self.navigationController pushViewController:viewController animated:YES];
}];

在下一节,我们将为这个视图模型写测试代码,在哪你会看到怎样应用测试驱动的开发在视图模型上。
现在检查下FRPGAlleryViewModel类,它是非常基本的,我们从视图控制器中抽象的逻辑是模型数据的加载。

@interface FRPGalleryViewModel:RVMViewModel

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

@end

基本的接口,声明model为一个数组,有如下简单的实现。

//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

这是有疑义的,我们把加载的逻辑放在初始化方法中,以及什么时候视图模型该激活,我们将会更多讨论视图模型的激活,但是现在我们向你展示了视图模型的简单性。在这个例子中,避免直接从视图控制器中加载数据,而是把这个逻辑迁移至相册视图模型中是非常直接的,在视图控制器的初始化方法中初始化视图模型。然后,任何引用self.model的地方改成引用self.viewModel.model。
在视图模型中,我们可以进一步抽象,抽象对模型的访问。重点的是你可以控制视图模型中的逻辑,可多可少。个人认为,你越多的使用这个编程样式,你会越多的抽象走逻辑。这意味着,更小的视图控制器,更紧密的、可测试的代码。
让我们再做个转换成视图模型的数据,在写测试代码前。
最后的例子是伴随着FRPPhohtoViewController的FRPPhotoViewModel。创建RVMViewModel的子类,再到视图控制器中。
我们的新的视图控制器的初始化方法看起来如下:

-(instancetype)initWithViewModel:(FRPPhotoViewModel*)viewModel index:(NSInteger)photoIndex{
    self = [self init]; if (!self) return nil;
    self.viewModel = viewModel;
    self.photoIndex = photoIndex;
    return self
}

确保#import必要的头文件,并且声明一个私有属性。
现在,我们需要用新的init方法初始化视图控制器。看一下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方法中,将使用视图模型来为图像视图提供数据,同使用HUB progress来指示用户图像正在下载一样。问题是图像的加载,是业务逻辑,属于视图模型但是当视图出现的时初始化下载逻辑不出现在视图模型中。记住,好的视图模型不引用视图,所以我们怎么样混合这两段逻辑。
答案是我们使用视图模型的激活状态。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.contentMode = UIViewContentModeScaleAspectFit;
     [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会显示progress HUD,当加载信号发送NO,删除progress HUD。我们将看到加载信号怎么依赖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 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;
}

didBecomeActiveSignal订阅为下载照片的详细信息,包括图像的全大小数据。然后,photoImage属性绑定到映射后的模型。
相比于在初始化方法中触发网络请求,didBecomeActiveSignal来吃触发网络请求,更好。
这就是我们全书要讨论的内容,funtional reactive pixels repository有很多例子,介绍在视图控制器中怎么使用视图模型。这些例子展示了怎么高效的使用ReactiveCocoa来执行网络请求,和使用RACCommand来回应用户的交互事件。

Testing View Models

剩下的不翻译了。