Don't let your UIViewController think for itself(译)

翻译自Don't let your UIViewController think for itself。是作者的这一系列三篇文章中的第二篇。

上篇文章,我展示了如何将协议(protocol)从view controller分离出来,以达到为胖controller瘦身的目的。在本系列三篇文章的第二篇,我将使用MVVM架构来精简我们的View controllerView controller通常会包含非常多的业务逻辑,这会导致非常多的问题,比如:

  • 代码将会难以阅读。让View controller负责外观,而将业务逻辑封装在单独对象里。
  • View controller将会变得非常臃肿。然而业务逻辑本不应该放在View controller里。

MVVM?

MVVMModel View ViewModel 的缩写,是一种将界面、业务逻辑与数据分离的方法,并以一种相互独立的方式结合在一起。
这种方法的工作方式就像这张图展示的一样:

MVVM-1.png

我认识的一个聪明家伙是这样描述它的:

“...数据从Model传递给ViewModel,经过ViewModel处理之后,再发送给View。交互事件与数据修改从View传递给ViewModel,经过ViewModel处理之后,再发送给Model。”

我本来可以说出一大堆使用MVVM的好处(比如测试、代码的可读性等等),但这已经超过了本文的探讨范畴。当然,如果你对此非常感兴趣,objc.io上的这篇非常棒的文章可以供你阅读。

特征蠕动

假设我们现在有了一个非常棒的view controller,而且在一个完美的世界里我们不再需要修改它了。如果真这样的话,我们程序员可就都要失业了。

回归现实,产品经理刚刚给你提了一个新的需求。Joe Public非常想在点击列表或数据加载完成的时候修改导航栏的标题。

非常简单对不对?我们只需要把这段代码写在...view controller里...?不!这是坏码农的做法!

一个好的码农,今天要做的就是把业务逻辑从view controller中分离出来。那么业务逻辑应该放哪呢?我很高兴地告诉你:放在ViewModel里。

ViewModel

如果你已经知道MVVM或者看了之前提到的那篇文章,那么你应该已经知道什么是ViewModel了。在MVVMViewModel是传递给view controller的一个对象,内部实现了所有有关view controler的业务逻辑和事件处理,是数据操作的大脑。

迁移到ViewModel里

OK,回到我们的工程里,首先我们需要创建一个名为VCTableModelViewModel类,继承于NSObject。在开始实现新功能之前,我们先把view controller已经存在的业务逻辑放进我们新创建的ViewModel中,看见没?就是MyAPI!代码如下:

//VCTableModel.h
@class MyAPI;

typedef void(^didReloadDataBlock)(NSArray *data);  
typedef void(^didErrorBlock)(NSString *error);

@interface VCTableModel : NSObject
-(instancetype)initWithManager:(MyAPI *)api;
-(void)reloadData;
@property (nonatomic, copy) didReloadDataBlock didReloadData;
@property (nonatomic, copy) didErrorBlock didError;
@end

在这段代码中,你会发现我们是通过构造方法将MyAPI对象传给VCTableModel。这项技术被称为 依赖注入,非常适合在 MVVM中使用。如果你对依赖注入,可以阅读这篇文章

使用MVVM的一个最大的好处就是,如果运用得当的话,可以使view controller难以置信地随着数据的变化而自动变化。而这一神奇的特性是通过在view controllerViewModel之间创建一种绑定关系实现的。

有很多工具可以实现这种绑定关系,比如ReactiveCocoaikevents,而在这里我仅仅通过block来实现。可以发现我已经在** VCTableModel**中定义了两个事件, 一个用来处理出错,另一个用来处理数据更新。最后我们还有一个方法用来在view controller中加载数据的。

接口已经定义好了,接下来该实现它们了,打开VCTableModel.m,为我们的MyAPI创建一个私有属性:

@property (nonatomic, strong) MyAPI *api;

添加构造方法:

-(instancetype)initWithManager:(MyAPI *)api {
    if (!(self = [super init])) { return nil; }
    self.api = api;
    return self;
}

Next up let's add the methods that will 'push' our events and a small helper to change an NSError into an NSString

接下来添加一个传递事件的方法,而且在传递事件之前,我们会把NSError转化为NSString的形式:

-(NSString *)userMessageForError:(NSError *)error {
    return [NSString stringWithFormat:@"An Error Occurred\n\n%@", error.localizedDescription];
}
-(void)sendError:(NSError *)error {
  if (self.didError == nil) { return; }

  dispatch_async(dispatch_get_main_queue(), ^{
    self.didError([self userMessageForError:error]);
  });
}
-(void)sendData:(NSArray *)data {
    if (self.didReloadData == nil) { return; }

    dispatch_async(dispatch_get_main_queue(), ^{
        self.didReloadData(data);
    });
}

干得漂亮!现在只剩下一件事要做了:将调用MyAPI的代码移植过来,因为MyAPI已经不再与界面直接藕合了。

-(void)reloadData {
    [self.api getPhotos:^(NSArray *json) {
        //Convert JSON items into our model objects
        NSMutableArray *items = [NSMutableArray array];
        for (NSDictionary *jsonItem in json) {
            VCTableCellData *item = [[VCTableCellData alloc] initWithJSON:jsonItem];
            [items addObject:item];
        }  

        //Raise didReloadData
        [self sendData:items];

    } error:^(NSError *error) {
        //Something went wrong, raise didError
       [self sendError:error];
    }];
}

好了,就是这样!现在功能都已经放在ViewModel里了,随时可以传递给view controller

迁移View Controller

移除老的代码

既然我们已经创建了ViewModel,接下来应该使用它来更新我们的view controller了。首先我们在view controller中把移入ViewModel的代码删除。

执行以下步骤:

  • 修改title的更新方式并且删除self.api = [MyAPI new];

  • 删除#import "MyAPI.h"

  • 删除私有属性 MyAPI *api

  • 使用以下代码替换现有的reloadData方法:

    -(void)reloadData {
      [self.refresh beginRefreshing];
      [self.model reloadData];
    }
    

目前先暂时忽略编译错误,我们马上就会修复它。

引入新的代码

First thing we need to do is provide an instance of VCTableModel to our view controller via the constructor:
我们需要做的第一件事就是为我们的view controller 的构造方法添加一个VCTableModel参数:

//VCTable.h  
@class VCTableModel;

@interface VCTable : UIViewController
    -(instancetype)initWithModel:(VCTableModel *)model;
@end

接下来为view controller 添加一个私有的ViewModel属性:

#import "VCTableModel.h"

@property (nonatomic, strong) VCTableModel *model;

新的构造方法实现如下:

-(instancetype)initWithModel:(VCTableModel *)model {
    if (!(self = [super initWithNibName:NSStringFromClass([self class]) bundle:nil])) 
    { 
        return nil; 
    }
    self.model = model;
    return self;
}

差不多了!下一步我们添加一个方法用来将ViewModel中的事件与view controller绑定在一起:

-(void)bindToModel {
    self.model.didError = [self modelDidError];
    self.model.didReloadData = [self modelDidReloadData];
}
-(didErrorBlock)modelDidError {
    return ^(NSString *error) {
        [self endRefreshing];

        [[[UIAlertView alloc]
          initWithTitle:@"Oops..." message:error 
          delegate:nil cancelButtonTitle:@"OK" otherButtonTitles:nil]
         show];
    };
}
-(didReloadDataBlock)modelDidReloadData {
    return ^(NSArray *data) {
        [self endRefreshing];
        [self.tableViewCoordinator reloadData:data];
    };
}

注意:你可能不太熟悉这种处理事件的语法,但是block可以作为方法的返回值。这样做仅仅是为了提高代码的可阅读性。

最后务必在方法viewDidLoad中调用 [self bindToModel]

祝贺你! 你现在已经把你的 view controller 转换为MVVM模式!

实现新功能!

现在可以开始添加我们新的功能了!第一步,我们应该把获取view controller标题的方法放进我们的ViewModel里,这样做可以让修改标题变得非常容易。比如,你有一个个人资料的界面,该界面与一个User对象关联,你就可以非常方便地将标题修改为用户(User)的名字,而不需要修改view controller( 这是MVVM的一个关键的原则)。

所以让我们再次更新ViewModel

//VCTableModel.h
@property (readonly) NSString *title;


//VCTableModel.m
-(NSString *)title {
    return @"MVVM Table View Example";
}

并且修改view controller的** viewDidLoad**方法:self.title = self.model.title;

我们知道有两种情况需要更新导航栏的标题,我们可以通过以下步骤来实现:

  1. 定义一个事件回调来:

     //VCTableModel.h
     typedef void(^didUpdateNavigationTitleBlock)(NSString *title);
    
    @property (nonatomic, copy) didUpdateNavigationTitleBlock didUpdateNavigationTitle;
    
  2. 定义一个方法来触发这个事件回调:

     //VCTableModel.m
     -(void)sendNavigationTitle:(NSString *)title {
         if (self.didUpdateNavigationTitle == nil) { return; }
    
         dispatch_async(dispatch_get_main_queue(), ^{
             self.didUpdateNavigationTitle(title);
         });
     }
    
  3. 我们还需要一个方法来处理列表的点击事件:

     //VCTableModel.h
     @class VCTableCellData;
     -(void)userSelectedCell:(VCTableCellData *)cellData;
    
     //VCTableModel.m
      -(void)userSelectedCell:(VCTableCellData *)cellData {
         [self sendNavigationTitle:cellData.title];
     }
    

ViewModel最后需要修改的就是更新方法reloadData:在调用api之前添加[self sendNavigationTitle:self.title];

  1. 在我们的view controller中,添加一个处理事件回调的方法:

     -(didUpdateNavigationTitleBlock)modelDidUpdateNavigationTitle {
         return ^(NSString *title) {
               self.title = title;
         };
     }
    

然后在方法bindToModel里添加self.model.didUpdateNavigationTitle = [self modelDidUpdateNavigationTitle];

  1. 最后在方法itemPressed添加:Lastly add [self.model userSelectedCell:data];, 这样我们的model就可以发送这些事件了,并且我们的新功能也完成了!

扫尾工作

最后,我喜欢写一个便利方法,可以非常方便地将view controllerViewModel与依赖关系结合在一起。

创建** VCTable的一个category/extension,取名为VCTable+Factory**:

 //VCTable+Factory.h
@interface VCTable (Factory)
 +(instancetype)factoryInstance;
@end


//VCTable+Factory.m
#import "VCTableModel.h"
#import "MyAPI.h"

@implementation VCTable (Factory)
+(instancetype)factoryInstance {
    VCTableModel *model = [[VCTableModel alloc] initWithManager:[MyAPI new]];
    return [[VCTable alloc] initWithModel:model];
}
@end

AppDelegate使用#import "VCTable+Factory.h"替换原来的#import "VCTable.h"并且更新方法**application:didFinishLaunchingWithOptions: **:

-(BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];

    VCTable *rootViewController = [VCTable factoryInstance];
    self.navigationController = [[UINavigationController alloc] initWithRootViewController:rootViewController];
    self.window.rootViewController = self.navigationController;
    [self.window makeKeyAndVisible];

    return YES;
}

现在你可以开始运行它了。

瘦而简单的view controller!

干得好!现在我们的view controller只负责展示界面而不用关心其他事情了。

在这篇文章里,我向你展示了将业务逻辑从 view controller中剥离出来是如此的简单,并且我希望这篇MVVM入门的文章对我来说会非常简单。

Grab the source for our MVVM view controller here.
点此获取本篇相关源代码

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

推荐阅读更多精彩内容