MVVM + RAC 模式詳解

MVVM 是一種軟件架構(gòu)模式歉眷,它是 Martin FowlerPresentation Model 的一種變體,最先由微軟的架構(gòu)師 John Gossman 在 2005 年提出,并應(yīng)用在微軟的 WPFSilverlight 軟件開發(fā)中中狂。MVVM衍生于 MVC 圈匆,是對(duì) MVC 的一種演進(jìn)晤斩,它促進(jìn)了 UI 代碼與業(yè)務(wù)邏輯的分離。

說明:本文將采用理論與實(shí)踐相結(jié)合的方式坚俗,重點(diǎn)介紹一個(gè)使用 MVVMRAC 開發(fā)的 iOS 開源項(xiàng)目 MVVMReactiveCocoa 镜盯,目的是希望能為你實(shí)踐 MVVM 提供幫助。不過猖败,在正式開始介紹正文之前速缆,請(qǐng)你先思考以下三個(gè)問題:

  • MVCMVVM 有什么異同點(diǎn),MVCMVVM 是怎樣演進(jìn)的恩闻;
  • RACMVVM 中扮演什么樣的角色艺糜,MVVM 是否一定要結(jié)合 RAC 使用;
  • 如何將一個(gè)現(xiàn)有的 MVC 應(yīng)用轉(zhuǎn)變成一個(gè) MVVM 應(yīng)用幢尚,有哪些需要注意的地方破停。

帶著以上問題,我們一起進(jìn)入正文尉剩。

名詞解釋:本文中的 RACReactiveCocoa 的縮寫真慢。

MVC

MVCiOS 開發(fā)中使用最普遍的架構(gòu)模式,同時(shí)也是蘋果官方推薦的架構(gòu)模式理茎。MVC 代表的是 Model–view–controller 黑界,它們之間的關(guān)系如下:

image

是的管嬉,MVC 看上去棒極了,model 代表數(shù)據(jù)朗鸠,view 代表 UI 蚯撩,而 controller 則負(fù)責(zé)協(xié)調(diào)它們兩者之間的關(guān)系。然而烛占,盡管從技術(shù)上看 viewcontroller 是相互獨(dú)立的胎挎,但事實(shí)上它們幾乎總是結(jié)對(duì)出現(xiàn),一個(gè) view 只能與一個(gè) controller 進(jìn)行匹配忆家,反之亦然犹菇。既然如此,那我們?yōu)楹尾粚⑺鼈兛醋饕粋€(gè)整體呢:

image

因此弦赖,M-VC 可能是對(duì) iOS 中的 MVC 模式更為準(zhǔn)確的解讀项栏。在一個(gè)典型的 MVC 應(yīng)用中浦辨,controller 由于承載了過多的邏輯蹬竖,往往會(huì)變得臃腫不堪,所以 MVC 也經(jīng)常被人調(diào)侃成 Massive View Controller

iOS architecture, where MVC stands for Massive View Controller.

坦白說流酬,有一部分邏輯確實(shí)是屬于 controller 的币厕,但是也有一部分邏輯是不應(yīng)該被放置在 controller 中的。比如芽腾,將 model 中的 NSDate 轉(zhuǎn)換成 view 可以展示的 NSString 等旦装。在 MVVM 中,我們將這些邏輯統(tǒng)稱為展示邏輯摊滔。

MVVM

因此阴绢,一種可以很好地解決 Massive View Controller 問題的辦法就是將 controller 中的展示邏輯抽取出來,放置到一個(gè)專門的地方艰躺,而這個(gè)地方就是 viewModel 呻袭。其實(shí),我們只要在上圖中的 M-VC 之間放入 VM 腺兴,就可以得到 MVVM 模式的結(jié)構(gòu)圖:

image

從上圖中左电,我們可以非常清楚地看到 MVVM 中四個(gè)組件之間的關(guān)系。:除了 view 页响、viewModelmodel 之外篓足,MVVM 中還有一個(gè)非常重要的隱含組件 binder

  • view :由 MVC 中的 viewcontroller 組成,負(fù)責(zé) UI 的展示闰蚕,綁定 viewModel 中的屬性栈拖,觸發(fā) viewModel 中的命令;
  • viewModel :從 MVCcontroller 中抽取出來的展示邏輯没陡,負(fù)責(zé)從 model 中獲取 view 所需的數(shù)據(jù)涩哟,轉(zhuǎn)換成 view 可以展示的數(shù)據(jù)烟瞧,并暴露公開的屬性和命令供 view 進(jìn)行綁定;
  • model :與 MVC 中的 model 一致染簇,包括數(shù)據(jù)模型参滴、訪問數(shù)據(jù)庫(kù)的操作和網(wǎng)絡(luò)請(qǐng)求等;
  • binder :在 MVVM 中锻弓,聲明式的數(shù)據(jù)和命令綁定是一個(gè)隱含的約定砾赔,它可以讓開發(fā)者非常方便地實(shí)現(xiàn) viewviewModel 的同步,避免編寫大量繁雜的樣板化代碼青灼。在微軟的 MVVM 實(shí)現(xiàn)中暴心,使用的是一種被稱為 XAML 的標(biāo)記語(yǔ)言。

ReactiveCocoa

盡管杂拨,在 iOS 開發(fā)中专普,系統(tǒng)并沒有提供類似的框架可以讓我們方便地實(shí)現(xiàn) binder 功能,不過弹沽,值得慶幸的是檀夹,GitHub 開源的 RAC ,給了我們一個(gè)非常不錯(cuò)的選擇策橘。

RAC 是一個(gè) iOS 中的函數(shù)式響應(yīng)式編程框架炸渡,它受 Functional Reactive Programming 的啟發(fā),是 Justin Spahr-SummersJosh Abernathy 在開發(fā) GitHub for Mac 過程中的一個(gè)副產(chǎn)品丽已,它提供了一系列用來組合和轉(zhuǎn)換值流的 API 蚌堵。如需了解更多關(guān)于 RAC 的信息,可以閱讀我的上一篇文章《ReactiveCocoa v2.5 源碼解析之架構(gòu)總覽》沛婴。

iOSMVVM 實(shí)現(xiàn)中吼畏,我們可以使用 RAC 來在 viewviewModel 之間充當(dāng) binder 的角色,優(yōu)雅地實(shí)現(xiàn)兩者之間的同步嘁灯。此外泻蚊,我們還可以把 RAC 用在 model 層,使用 Signal 來代表異步的數(shù)據(jù)獲取操作旁仿,比如讀取文件藕夫、訪問數(shù)據(jù)庫(kù)和網(wǎng)絡(luò)請(qǐng)求等。說明枯冈,RAC 的后一個(gè)應(yīng)用場(chǎng)景是與 MVVM 無關(guān)的毅贮,也就是說,我們同樣可以在 MVCmodel 層這么用尘奏。

小結(jié)

綜上所述滩褥,我們只要將 MVC 中的 controller 中的展示邏輯抽取出來,放置到 viewModel 中炫加,然后通過一定的技術(shù)手段瑰煎,比如 RAC 來同步 viewviewModel 铺然,就完成了 MVCMVVM 的轉(zhuǎn)變。

Talk is cheap. Show me the code.

下面酒甸,我們直接上代碼魄健,一起來看一個(gè) MVC 模式轉(zhuǎn)換成 MVVM 模式的示例。首先是 model 層的代碼 Person

@interface Person : NSObject

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

@property (nonatomic, copy, readonly) NSString *salutation;
@property (nonatomic, copy, readonly) NSString *firstName;
@property (nonatomic, copy, readonly) NSString *lastName;
@property (nonatomic, copy, readonly) NSDate *birthdate;

@end

然后是 view 層的代碼 PersonViewController 插勤,在 viewDidLoad 方法中沽瘦,我們將 Person 中的屬性進(jìn)行一定的轉(zhuǎn)換后,賦值給相應(yīng)的 view 進(jìn)行展示:

- (void)viewDidLoad {
    [super viewDidLoad];

    if (self.model.salutation.length > 0) {
        self.nameLabel.text = [NSString stringWithFormat:@"%@ %@ %@", self.model.salutation, self.model.firstName, self.model.lastName];
    } else {
        self.nameLabel.text = [NSString stringWithFormat:@"%@ %@", self.model.firstName, self.model.lastName];
    }

    NSDateFormatter *dateFormatter = [[NSDateFormatter alloc] init];
    [dateFormatter setDateFormat:@"EEEE MMMM d, yyyy"];
    self.birthdateLabel.text = [dateFormatter stringFromDate:model.birthdate];
}

接下來农尖,我們引入一個(gè) viewModel 析恋,將 PersonViewController 中的展示邏輯抽取到這個(gè) PersonViewModel 中:

@interface PersonViewModel : NSObject

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

@property (nonatomic, strong, readonly) Person *person;
@property (nonatomic, copy, readonly) NSString *nameText;
@property (nonatomic, copy, readonly) NSString *birthdateText;

@end

@implementation PersonViewModel

- (instancetype)initWithPerson:(Person *)person {
    self = [super init];
    if (self) {
       _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

最終,PersonViewController 將會(huì)變得非常輕量級(jí):

- (void)viewDidLoad {
    [super viewDidLoad];

    self.nameLabel.text = self.viewModel.nameText;
    self.birthdateLabel.text = self.viewModel.birthdateText;
}

怎么樣盛卡?其實(shí) MVVM 并沒有想像中的那么難吧助隧,而且更重要的是它也沒有破壞 MVC 的現(xiàn)有結(jié)構(gòu),只不過是移動(dòng)了一些代碼滑沧,僅此而已并村。好了,說了這么多嚎货,那 MVVM 相比 MVC 到底有哪些好處呢橘霎?我想,主要可以歸納為以下三點(diǎn):

  • 由于展示邏輯被抽取到了 viewModel 中殖属,所以 view 中的代碼將會(huì)變得非常輕量級(jí);
  • 由于 viewModel 中的代碼是與 UI 無關(guān)的瓦盛,所以它具有良好的可測(cè)試性洗显;
  • 對(duì)于一個(gè)封裝了大量業(yè)務(wù)邏輯的 model 來說,改變它可能會(huì)比較困難原环,并且存在一定的風(fēng)險(xiǎn)挠唆。在這種場(chǎng)景下,viewModel 可以作為 model 的適配器使用嘱吗,從而避免對(duì) model 進(jìn)行較大的改動(dòng)玄组。

通過前面的示例,我們對(duì)第一點(diǎn)已經(jīng)有了一定的感觸谒麦;至于第三點(diǎn)俄讹,可能對(duì)于一個(gè)復(fù)雜的大型應(yīng)用來說,才會(huì)比較明顯绕德;下面患膛,我們還是使用前面的示例,來直觀地感受下第二點(diǎn)好處:

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

對(duì)于 MVVM 來說耻蛇,我們可以把 view 看作是 viewModel 的可視化形式踪蹬,viewModel 提供了 view所需的數(shù)據(jù)和命令胞此。因此,viewModel 的可測(cè)試性可以幫助我們極大地提高應(yīng)用的質(zhì)量跃捣。

MVVMReactiveCocoa

接下來漱牵,我們進(jìn)入本文的第二部分,重點(diǎn)介紹一個(gè)使用 MVVMRAC 開發(fā)的開源項(xiàng)目 MVVMReactiveCocoa 疚漆。說明布疙,本文將主要介紹這個(gè)應(yīng)用的架構(gòu)和設(shè)計(jì)思路,希望可以為你實(shí)踐 MVVM 提供一個(gè)真實(shí)的參考案例愿卸,有些架構(gòu)并非是 MVVM 所必須的灵临,而是我們?yōu)榱烁槙车厥褂?MVVM 而引入的,特別是 ViewModel-Based Navigation 趴荸。所以儒溉,請(qǐng)你在實(shí)踐的過程中能夠結(jié)合自身應(yīng)用的實(shí)際情況做出相應(yīng)的取舍,靈活處理发钝。最后顿涣,我們將以登錄界面為例,一起探討下 MVVM的實(shí)踐思路酝豪。

說明横侦,以下內(nèi)容均基于 MVVMReactiveCocoav2.1.1 標(biāo)簽進(jìn)行展開,并且對(duì)部分無關(guān)代碼做了刪減基跑。

類圖

為了方便我們從宏觀上了解 MVVMReactiveCocoa 的整體結(jié)構(gòu)闷沥,我們先來看看它的類圖:

MVVMReactiveCocoa-v2.1.1

從上圖中,我們可以看到瘫证,在 MVVMReactiveCocoa 中主要有兩大繼承體系:

  • 用藍(lán)色標(biāo)識(shí)出來的 viewModel 的繼承體系揉阎,基類為 MRCViewModel
  • 用紅色標(biāo)識(shí)出來的 view 的繼承體系背捌,基類為 MRCViewController 毙籽。

除了提供與系統(tǒng)基類 UIViewController 相對(duì)應(yīng)的基類 MRCViewModel/MRCViewController 外,還提供了與系統(tǒng)基類 UITableViewControllerUITabBarController 相對(duì)應(yīng)的基類 MRCTableViewModel/MRCTableViewControllerMRCTabBarViewModel/MRCTabBarController 毡庆,其中基類 MRCTableViewModel/MRCTableViewController 的使用最為普遍坑赡。

說明,之所以通過基類的方式來組織 MVVMReactiveCocoa 么抗,一方面是因?yàn)橹饕_發(fā)者只有我一個(gè)人毅否,這個(gè)方案非常容易實(shí)施;另一方面是因?yàn)橥ㄟ^基類的方式可以盡可能簡(jiǎn)單地實(shí)現(xiàn)代碼重用乖坠,提高開發(fā)效率搀突。

服務(wù)總線

經(jīng)過前面的探討,我們已經(jīng)知道了 MVVM 中的 viewModel 的主要職責(zé)就是從 model 層獲取 view所需的數(shù)據(jù),并且將這些數(shù)據(jù)轉(zhuǎn)換成 view 能夠展示的形式仰迁。因此甸昏,為了方便 viewModel 層調(diào)用 model 層中的所有服務(wù),并且統(tǒng)一管理這些服務(wù)的創(chuàng)建徐许,我使用抽象工廠模式將 model 層的所有服務(wù)集中管理了起來施蜜,結(jié)構(gòu)圖如下:

image

從上圖中,我們可以看出雌隅,在服務(wù)總線類 MRCViewModelServices/MRCViewModelServicesImpl 中翻默,主要包括以下三個(gè)方面的內(nèi)容:

  • 應(yīng)用自有的服務(wù)類,用柚黃色進(jìn)行了標(biāo)識(shí)恰起,包括 MRCAppStoreService/MRCAppStoreServiceImplMRCRepositoryService/MRCRepositoryServiceImpl 兩個(gè)服務(wù)類修械;
  • 第三方 GitHub 提供的 API 框架,用天藍(lán)色進(jìn)行了標(biāo)識(shí)检盼,主要包括 OCTClient 服務(wù)類肯污;
  • 應(yīng)用的導(dǎo)航服務(wù),用藻綠色進(jìn)行了標(biāo)識(shí)吨枉,包括 MRCNavigationProtocol 協(xié)議和實(shí)現(xiàn)類 MRCViewModelServicesImpl 等蹦渣。

其中,前兩者都是以信號(hào)的形式對(duì) viewModel 層提供服務(wù)貌亭,代表異步的網(wǎng)絡(luò)請(qǐng)求等數(shù)據(jù)獲取操作柬唯,而我們?cè)?viewModel 層則可以通過訂閱信號(hào)的形式獲取到所需的數(shù)據(jù)。此外圃庭,服務(wù)總線還實(shí)現(xiàn)了 MRCNavigationProtocol 協(xié)議锄奢,它的內(nèi)容如下:

@protocol MRCNavigationProtocol <NSObject>

- (void)pushViewModel:(MRCViewModel *)viewModel animated:(BOOL)animated;

- (void)popViewModelAnimated:(BOOL)animated;

- (void)popToRootViewModelAnimated:(BOOL)animated;

- (void)presentViewModel:(MRCViewModel *)viewModel animated:(BOOL)animated completion:(VoidBlock)completion;

- (void)dismissViewModelAnimated:(BOOL)animated completion:(VoidBlock)completion;

- (void)resetRootViewModel:(MRCViewModel *)viewModel;

@end

看上去是不是有點(diǎn)眼熟?是的冤议,MRCNavigationProtocol 協(xié)議其實(shí)就是參照系統(tǒng)的導(dǎo)航操作定義出來的斟薇,用來實(shí)現(xiàn) ViewModel-Based 的導(dǎo)航服務(wù)。注意恕酸,服務(wù)總線類 MRCViewModelServicesImpl 其實(shí)并沒有真正實(shí)現(xiàn) MRCNavigationProtocol 協(xié)議中聲明的操作,只不過是實(shí)現(xiàn)了一些空操作而已:

- (void)pushViewModel:(MRCViewModel *)viewModel animated:(BOOL)animated {}

- (void)popViewModelAnimated:(BOOL)animated {}

- (void)popToRootViewModelAnimated:(BOOL)animated {}

- (void)presentViewModel:(MRCViewModel *)viewModel animated:(BOOL)animated completion:(VoidBlock)completion {}

- (void)dismissViewModelAnimated:(BOOL)animated completion:(VoidBlock)completion {}

- (void)resetRootViewModel:(MRCViewModel *)viewModel {}

那么胯陋,我們是怎么實(shí)現(xiàn) ViewModel-Based 的導(dǎo)航操作的呢蕊温?用 MRCViewModelServicesImpl 來實(shí)現(xiàn)這些空操作到底有什么用意?為什么要這么做遏乔,目的是為了什么义矛?兄臺(tái),莫急盟萨,請(qǐng)接著看下一小節(jié)的內(nèi)容凉翻。

ViewModel-Based Navigation

我們先來思考一個(gè)問題,就是我們?yōu)槭裁匆獙?shí)現(xiàn) ViewModel-Based 的導(dǎo)航操作呢捻激?直接在 view 層使用系統(tǒng)的 push/present 等操作來完成導(dǎo)航不就好了么制轰?我總結(jié)了一下這么做的理由前计,主要有以下三點(diǎn):

  • 從理論上來說,MVVM 模式的應(yīng)用應(yīng)該是以 viewModel 為驅(qū)動(dòng)來運(yùn)轉(zhuǎn)的垃杖;
  • 根據(jù)我們前面對(duì) MVVM 的探討男杈,viewModel 提供了 view 所需的數(shù)據(jù)和命令。因此调俘,我們往往可以直接在命令執(zhí)行成功后使用 doNext 順帶就把導(dǎo)航操作給做了伶棒,一氣呵成;
  • 這樣可以使 view 更加輕量級(jí)彩库,只需要綁定 viewModel 提供的數(shù)據(jù)和命令即可肤无。

既然如此,那我們究竟要如何實(shí)現(xiàn) ViewModel-Based 的導(dǎo)航操作呢骇钦?我們都知道 iOS 中的導(dǎo)航操作無外乎兩種宛渐,push/poppresent/dismiss ,前者是 UINavigationController 特有的功能司忱,而后者是所有 UIViewController 都具備的功能皇忿。注意UINavigationController 也是 UIViewController 的子類坦仍,所以它也同樣具備 present/dismiss 的功能鳍烁。因此,從本質(zhì)上來說繁扎,不管我們要實(shí)現(xiàn)什么樣的導(dǎo)航操作幔荒,最終都是離不開 push/poppresent/dismiss 的。

目前梳玫,MVVMReactiveCocoa 的做法是在 view 層維護(hù)一個(gè) NavigationController 的堆棧 MRCNavigationControllerStack 爹梁,不管是 push/pop 還是 present/dismiss ,都使用棧頂?shù)?NavigationController 來執(zhí)行導(dǎo)航操作提澎,并且保證 present 出來的是一個(gè) NavigationController 姚垃。

接下來,我們一起來看看 MVVMReactiveCocoa 在執(zhí)行了 push/poppresent/dismiss 操作后視圖層次結(jié)構(gòu)的變化過程盼忌。首先积糯,我們來看看用戶在登錄成功后進(jìn)入到首頁(yè)時(shí)應(yīng)用的視圖層次結(jié)構(gòu)圖:

image

此時(shí),應(yīng)用展示的界面是 NewsViewController 谦纱。在 MRCNavigationControllerStack 堆棧中只有 NavigationController0 一個(gè)元素看成;而 NavigationController1 并沒有在 MRCNavigationControllerStack 堆棧中,這是因?yàn)樾枰С?TabBarController 的滑動(dòng)切換而設(shè)計(jì)的視圖層次結(jié)構(gòu)跨嘉,是首頁(yè)比較特殊的一個(gè)地方川慌。更多信息可以查看 GitHub 開源庫(kù) WXTabBarController ,在這里,我們不用太過于關(guān)心這個(gè)問題梦重,只需要理解原理就好了兑燥。

接下來,當(dāng)用戶在 NewsViewController 界面忍饰,點(diǎn)擊了某一個(gè) cell 贪嫂,通過 push 的方式,進(jìn)入到倉(cāng)庫(kù)詳情界面時(shí)艾蓝,應(yīng)用的視圖層次結(jié)構(gòu)圖如下:

image

應(yīng)用通過 MRCNavigationControllerStack 棧頂?shù)脑?NavigationController0 力崇,將倉(cāng)庫(kù)詳情界面 push 到了自身的堆棧中。此時(shí)赢织,應(yīng)用展示的界面是被 push 進(jìn)來的倉(cāng)庫(kù)詳情界面 RepoDetailViewController 亮靴。最后,當(dāng)用戶在倉(cāng)庫(kù)詳情界面于置,點(diǎn)擊左下角的切換分支按鈕茧吊,通過 present 的方式,彈出分支選擇界面時(shí)八毯,應(yīng)用的視圖層次結(jié)構(gòu)圖如下:

image

應(yīng)用通過 MRCNavigationControllerStack 棧頂?shù)脑?NavigationController0 搓侄,將 NavigationController5present 的方式彈出來。此時(shí)话速,應(yīng)用展示的是 NavigationController5 的根視圖 SelectBranchOrTagViewController 讶踪。說明,由于 popdismisspushpresent 互為逆操作泊交,所以只要按照從下到上的順序看上面的視圖層次結(jié)構(gòu)圖即可乳讥,這里不再贅述。

等等廓俭,如果我沒有記錯(cuò)的話云石,MRCNavigationControllerStack 堆棧是在 view 層,而服務(wù)總線類 MRCViewModelServicesImpl 是在 viewModel 層的研乒。據(jù)我所知汹忠,viewModel 層是不能引入 view 層的任何東西的,更嚴(yán)格的說雹熬,是不能引入任何 UIKit 中的東西的错维,否則就違背了 MVVM 的基本原則,并且也會(huì)散失 viewModel 的可測(cè)試性橄唬。在這個(gè)前提下,你要如何讓這兩者產(chǎn)生關(guān)聯(lián)呢参歹?

沒錯(cuò)仰楚,這就是 MRCViewModelServicesImpl 中之所以實(shí)現(xiàn)那些空操作的目的所在了。viewModel 通過調(diào)用 MRCViewModelServicesImpl 中的空操作來表明需要執(zhí)行相應(yīng)的導(dǎo)航操作,而 MRCNavigationControllerStack 則通過 Hook 來捕獲這些空操作僧界,然后使用棧頂?shù)?NavigationController 來執(zhí)行真正的導(dǎo)航操作:

- (void)registerNavigationHooks {
    @weakify(self)
    [[(NSObject *)self.services
        rac_signalForSelector:@selector(pushViewModel:animated:)]
        subscribeNext:^(RACTuple *tuple) {
            @strongify(self)
            UIViewController *viewController = (UIViewController *)[MRCRouter.sharedInstance viewControllerForViewModel:tuple.first];
            [self.navigationControllers.lastObject pushViewController:viewController animated:[tuple.second boolValue]];
        }];

    [[(NSObject *)self.services
        rac_signalForSelector:@selector(popViewModelAnimated:)]
        subscribeNext:^(RACTuple *tuple) {
          @strongify(self)
            [self.navigationControllers.lastObject popViewControllerAnimated:[tuple.first boolValue]];
        }];

    [[(NSObject *)self.services
        rac_signalForSelector:@selector(popToRootViewModelAnimated:)]
        subscribeNext:^(RACTuple *tuple) {
            @strongify(self)
            [self.navigationControllers.lastObject popToRootViewControllerAnimated:[tuple.first boolValue]];
        }];

    [[(NSObject *)self.services
        rac_signalForSelector:@selector(presentViewModel:animated:completion:)]
        subscribeNext:^(RACTuple *tuple) {
          @strongify(self)
            UIViewController *viewController = (UIViewController *)[MRCRouter.sharedInstance viewControllerForViewModel:tuple.first];

            UINavigationController *presentingViewController = self.navigationControllers.lastObject;
            if (![viewController isKindOfClass:UINavigationController.class]) {
                viewController = [[MRCNavigationController alloc] initWithRootViewController:viewController];
            }
            [self pushNavigationController:(UINavigationController *)viewController];

            [presentingViewController presentViewController:viewController animated:[tuple.second boolValue] completion:tuple.third];
        }];

    [[(NSObject *)self.services
        rac_signalForSelector:@selector(dismissViewModelAnimated:completion:)]
        subscribeNext:^(RACTuple *tuple) {
            @strongify(self)
            [self popNavigationController];
            [self.navigationControllers.lastObject dismissViewControllerAnimated:[tuple.first boolValue] completion:tuple.second];
        }];

    [[(NSObject *)self.services
        rac_signalForSelector:@selector(resetRootViewModel:)]
        subscribeNext:^(RACTuple *tuple) {
            @strongify(self)
            [self.navigationControllers removeAllObjects];

            UIViewController *viewController = (UIViewController *)[MRCRouter.sharedInstance viewControllerForViewModel:tuple.first];

            if (![viewController isKindOfClass:[UINavigationController class]]) {
                viewController = [[MRCNavigationController alloc] initWithRootViewController:viewController];
                ((UINavigationController *)viewController).delegate = self;
                [self pushNavigationController:(UINavigationController *)viewController];
            }

            MRCSharedAppDelegate.window.rootViewController = viewController;
        }];
}

通過 Hook 的方式侨嘀,我們最終實(shí)現(xiàn)了 ViewModel-Based 的導(dǎo)航操作,并且在 viewModel 層中也沒有引入 view 層的任意東西捂襟,實(shí)現(xiàn)了解耦合咬腕。

Router

還有一點(diǎn)值得一提的是,我們?cè)?viewModel 中調(diào)用導(dǎo)航操作的時(shí)候葬荷,只傳入了 viewModel 的實(shí)例作為參數(shù)涨共,那么我們?cè)?MRCNavigationControllerStack 中執(zhí)行真正的導(dǎo)航操作時(shí),怎么才能知道要跳轉(zhuǎn)到哪個(gè)界面呢宠漩?為此举反,我們配置了一個(gè)從 viewModelview 的映射,并且約定了一個(gè)統(tǒng)一的初始化 view 的方法 initWithViewModel:

- (MRCViewController *)viewControllerForViewModel:(MRCViewModel *)viewModel {
    NSString *viewController = self.viewModelViewMappings[NSStringFromClass(viewModel.class)];

    NSParameterAssert([NSClassFromString(viewController) isSubclassOfClass:[MRCViewController class]]);
    NSParameterAssert([NSClassFromString(viewController) instancesRespondToSelector:@selector(initWithViewModel:)]);

    return [[NSClassFromString(viewController) alloc] initWithViewModel:viewModel];
}

- (NSDictionary *)viewModelViewMappings {
    return @{
      @"MRCLoginViewModel": @"MRCLoginViewController",
        @"MRCHomepageViewModel": @"MRCHomepageViewController",
        @"MRCRepoDetailViewModel": @"MRCRepoDetailViewController",
        ...
    };
}

登錄界面

最后扒吁,我們一起來看看登錄界面中 viewModelview 的部分關(guān)鍵代碼火鼻,探討一下 MVVM 的具體實(shí)踐過程。說明雕崩,我們將會(huì)盡可能地回避具體的業(yè)務(wù)邏輯魁索,重點(diǎn)關(guān)注 MVVM 的實(shí)踐思路。下面是登錄界面的截圖:

image

其中盼铁,主要的界面元素有:

  • 一個(gè)用于展示用戶頭像的按鈕 avatarButton 粗蔚;
  • 用于輸入賬號(hào)和密碼的輸入框 usernameTextFieldpasswordTextField
  • 一個(gè)直接登錄的按鈕 loginButton 和一個(gè)跳轉(zhuǎn)到瀏覽器授權(quán)登錄的按鈕 browserLoginButton 捉貌。

分析:根據(jù)我們前面對(duì) MVVM 的探討支鸡,viewModel 需要提供 view 所需的數(shù)據(jù)和命令。因此趁窃,MRCLoginViewModel.h 頭文件的內(nèi)容大致如下:

@interface MRCLoginViewModel : MRCViewModel

@property (nonatomic, copy, readonly) NSURL *avatarURL;
@property (nonatomic, copy) NSString *username;
@property (nonatomic, copy) NSString *password;

@property (nonatomic, strong, readonly) RACSignal *validLoginSignal;
@property (nonatomic, strong, readonly) RACCommand *loginCommand;
@property (nonatomic, strong, readonly) RACCommand *browserLoginCommand;

@end

非常直觀牧挣,其中需要特別說明的是 validLoginSignal 屬性代表的是登錄按鈕是否可用,它將會(huì)與 view 中登錄按鈕的 enabled 屬性進(jìn)行綁定醒陆。接著瀑构,我們來看看 MRCLoginViewModel.m 的實(shí)現(xiàn)文件中的部分關(guān)鍵代碼:

@implementation MRCLoginViewModel

- (void)initialize {
    [super initialize];

    RAC(self, avatarURL) = [[RACObserve(self, username)
        map:^(NSString *username) {
            return [[OCTUser mrc_fetchUserWithRawLogin:username] avatarURL];
        }]
        distinctUntilChanged];

    self.validLoginSignal = [[RACSignal
      combineLatest:@[ RACObserve(self, username), RACObserve(self, password) ]
        reduce:^(NSString *username, NSString *password) {
          return @(username.length > 0 && password.length > 0);
        }]
        distinctUntilChanged];

    @weakify(self)
    void (^doNext)(OCTClient *) = ^(OCTClient *authenticatedClient) {
        @strongify(self)
        MRCHomepageViewModel *viewModel = [[MRCHomepageViewModel alloc] initWithServices:self.services params:nil];
        dispatch_async(dispatch_get_main_queue(), ^{
            [self.services resetRootViewModel:viewModel];
        });
    };

    [OCTClient setClientID:MRC_CLIENT_ID clientSecret:MRC_CLIENT_SECRET];

    self.loginCommand = [[RACCommand alloc] initWithSignalBlock:^(NSString *oneTimePassword) {
      @strongify(self)
        OCTUser *user = [OCTUser userWithRawLogin:self.username server:OCTServer.dotComServer];
        return [[OCTClient
          signInAsUser:user password:self.password oneTimePassword:oneTimePassword scopes:OCTClientAuthorizationScopesUser | OCTClientAuthorizationScopesRepository note:nil noteURL:nil fingerprint:nil]
            doNext:doNext];
    }];

    self.browserLoginCommand = [[RACCommand alloc] initWithSignalBlock:^(id input) {
        return [[OCTClient
          signInToServerUsingWebBrowser:OCTServer.dotComServer scopes:OCTClientAuthorizationScopesUser | OCTClientAuthorizationScopesRepository]
            doNext:doNext];
    }];
}

@end

  • 當(dāng)用戶輸入的用戶名發(fā)生變化時(shí),調(diào)用 model 層的方法查詢本地?cái)?shù)據(jù)庫(kù)中緩存的用戶數(shù)據(jù)刨摩,并返回 avatarURL 屬性;
  • 當(dāng)用戶輸入的用戶名或密碼發(fā)生變化時(shí)寺晌,判斷用戶名和密碼的長(zhǎng)度是否均大于 0 ,如果是則登錄按鈕可用澡刹,否則不可用;
  • 當(dāng) loginCommandbrowserLoginCommand 命令執(zhí)行成功時(shí)呻征,調(diào)用 doNext 代碼塊,使用服務(wù)總線中的方法 resetRootViewModel: 進(jìn)入首頁(yè)罢浇。

接下來陆赋,我們來看看 MRCLoginViewController 中的部分關(guān)鍵代碼:

@implementation MRCLoginViewController

- (void)bindViewModel {
    [super bindViewModel];

    @weakify(self)
    [RACObserve(self.viewModel, avatarURL) subscribeNext:^(NSURL *avatarURL) {
      @strongify(self)
        [self.avatarButton sd_setImageWithURL:avatarURL forState:UIControlStateNormal placeholderImage:[UIImage imageNamed:@"default-avatar"]];
    }];

    RAC(self.viewModel, username)  = self.usernameTextField.rac_textSignal;
    RAC(self.viewModel, password)  = self.passwordTextField.rac_textSignal;
    RAC(self.loginButton, enabled) = self.viewModel.validLoginSignal;

    [[self.loginButton
        rac_signalForControlEvents:UIControlEventTouchUpInside]
        subscribeNext:^(id x) {
            @strongify(self)
            [self.viewModel.loginCommand execute:nil];
        }];

    [[self.browserLoginButton
        rac_signalForControlEvents:UIControlEventTouchUpInside]
        subscribeNext:^(id x) {
            @strongify(self)
            NSString *message = [NSString stringWithFormat:@"“%@” wants to open “Safari”", MRC_APP_NAME];

            UIAlertController *alertController = [UIAlertController alertControllerWithTitle:nil
                                                                                     message:message
                                                                              preferredStyle:UIAlertControllerStyleAlert];

            [alertController addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:NULL]];
            [alertController addAction:[UIAlertAction actionWithTitle:@"Open" style:UIAlertActionStyleDefault handler:^(UIAlertAction *action) {
                @strongify(self)
                [self.viewModel.browserLoginCommand execute:nil];
            }]];

            [self presentViewController:alertController animated:YES completion:NULL];
        }];
}

@end

  • 觀察 viewModelavatarURL 屬性的變化沐祷,然后設(shè)置 avatarButton 中的圖片;
  • viewModel 中的 usernamepassword 屬性分別與 usernameTextFieldpasswordTextField 輸入框中的內(nèi)容進(jìn)行綁定攒岛;
  • loginButtonenabled 屬性與 viewModelvalidLoginSignal 屬性進(jìn)行綁定赖临;
  • loginButtonbrowserLoginButton 按鈕被點(diǎn)擊時(shí)分別執(zhí)行 loginCommandbrowserLoginCommand 命令。

綜上所述灾锯,我們將 MRCLoginViewController 中的展示邏輯抽取到 MRCLoginViewModel 中后兢榨,使得 MRCLoginViewController 中的代碼更加簡(jiǎn)潔和清晰。實(shí)踐 MVVM 的關(guān)鍵點(diǎn)在于顺饮,我們要能夠分析清楚 viewModel 需要暴露給 view 的數(shù)據(jù)和命令吵聪,這些數(shù)據(jù)和命令能夠代表 view 當(dāng)前的狀態(tài)。

總結(jié)

首先领突,我們從理論出發(fā)介紹了 MVCMVVM 各自的概念以及從 MVCMVVM 的演進(jìn)過程暖璧;接著,介紹了 RACMVVM 中的兩個(gè)使用場(chǎng)景君旦;最后澎办,我們從實(shí)踐的角度,重點(diǎn)介紹了一個(gè)使用 MVVMRAC 開發(fā)的開源項(xiàng)目 MVVMReactiveCocoa 金砍【质矗總的來說,我認(rèn)為 iOS 中的 MVVM 可以分為以下三種不同的實(shí)踐程度恕稠,它們分別對(duì)應(yīng)不同的適用場(chǎng)景:

  • MVVM + KVO 琅绅,適用于現(xiàn)有的 MVC 項(xiàng)目,想轉(zhuǎn)換成 MVVM 但是不打算引入 RAC 作為 binder 的團(tuán)隊(duì)鹅巍;
  • MVVM + RAC 千扶,適用于現(xiàn)有的 MVC 項(xiàng)目,想轉(zhuǎn)換成 MVVM 并且打算引入 RAC 作為 binder 的團(tuán)隊(duì)骆捧;
  • MVVM + RAC + ViewModel-Based Navigation 澎羞,適用于全新的項(xiàng)目,想實(shí)踐 MVVM 并且打算引入 RAC 作為 binder 敛苇,然后也想實(shí)踐 ViewModel-Based Navigation 的團(tuán)隊(duì)妆绞。

寫在最后,希望這篇文章能夠打消你對(duì) MVVM 模式的顧慮枫攀,趕快行動(dòng)起來吧括饶。

原文鏈接:http://blog.leichunfeng.com/blog/2016/02/27/mvvm-with-reactivecocoa/

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市来涨,隨后出現(xiàn)的幾起案子图焰,更是在濱河造成了極大的恐慌,老刑警劉巖蹦掐,帶你破解...
    沈念sama閱讀 223,207評(píng)論 6 521
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件楞泼,死亡現(xiàn)場(chǎng)離奇詭異驰徊,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)堕阔,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 95,455評(píng)論 3 400
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來颗味,“玉大人超陆,你說我怎么就攤上這事∑致恚” “怎么了时呀?”我有些...
    開封第一講書人閱讀 170,031評(píng)論 0 366
  • 文/不壞的土叔 我叫張陵,是天一觀的道長(zhǎng)晶默。 經(jīng)常有香客問我谨娜,道長(zhǎng),這世上最難降的妖魔是什么磺陡? 我笑而不...
    開封第一講書人閱讀 60,334評(píng)論 1 300
  • 正文 為了忘掉前任趴梢,我火速辦了婚禮,結(jié)果婚禮上币他,老公的妹妹穿的比我還像新娘坞靶。我一直安慰自己,他們只是感情好蝴悉,可當(dāng)我...
    茶點(diǎn)故事閱讀 69,322評(píng)論 6 398
  • 文/花漫 我一把揭開白布彰阴。 她就那樣靜靜地躺著,像睡著了一般拍冠。 火紅的嫁衣襯著肌膚如雪尿这。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 52,895評(píng)論 1 314
  • 那天庆杜,我揣著相機(jī)與錄音射众,去河邊找鬼。 笑死欣福,一個(gè)胖子當(dāng)著我的面吹牛责球,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播拓劝,決...
    沈念sama閱讀 41,300評(píng)論 3 424
  • 文/蒼蘭香墨 我猛地睜開眼雏逾,長(zhǎng)吁一口氣:“原來是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來了郑临?” 一聲冷哼從身側(cè)響起栖博,我...
    開封第一講書人閱讀 40,264評(píng)論 0 277
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎厢洞,沒想到半個(gè)月后仇让,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體典奉,經(jīng)...
    沈念sama閱讀 46,784評(píng)論 1 321
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 38,870評(píng)論 3 343
  • 正文 我和宋清朗相戀三年丧叽,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了卫玖。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 40,989評(píng)論 1 354
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡踊淳,死狀恐怖假瞬,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情迂尝,我是刑警寧澤脱茉,帶...
    沈念sama閱讀 36,649評(píng)論 5 351
  • 正文 年R本政府宣布,位于F島的核電站垄开,受9級(jí)特大地震影響琴许,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜溉躲,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 42,331評(píng)論 3 336
  • 文/蒙蒙 一榜田、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧签财,春花似錦串慰、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,814評(píng)論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)。三九已至神汹,卻和暖如春庆捺,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背屁魏。 一陣腳步聲響...
    開封第一講書人閱讀 33,940評(píng)論 1 275
  • 我被黑心中介騙來泰國(guó)打工滔以, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人氓拼。 一個(gè)月前我還...
    沈念sama閱讀 49,452評(píng)論 3 379
  • 正文 我出身青樓你画,卻偏偏與公主長(zhǎng)得像,于是被迫代替她去往敵國(guó)和親桃漾。 傳聞我的和親對(duì)象是個(gè)殘疾皇子坏匪,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,995評(píng)論 2 361

推薦閱讀更多精彩內(nèi)容

  • MVVM[https://en.wikipedia.org/wiki/Model%E2%80%93view%E2%...
    追風(fēng)者366閱讀 5,494評(píng)論 8 20
  • 前言 由于最近兩個(gè)多月,筆者正和小伙伴們忙于對(duì)公司新項(xiàng)目的開發(fā)撬统,筆者主要負(fù)責(zé)項(xiàng)目整體架構(gòu)的搭建以及功能模塊的分工适滓。...
    CoderMikeHe閱讀 27,048評(píng)論 74 271
  • 一、前言 對(duì)于設(shè)計(jì)模式這個(gè)讓人又愛又恨的玩意恋追,說來其實(shí)簡(jiǎn)單凭迹,但一千個(gè)人眼中就有一千種哈姆雷特罚屋,說他千變?nèi)f化確實(shí)是事...
    北極點(diǎn)閱讀 946評(píng)論 0 0
  • 一、概述 通過上一篇文章的學(xué)習(xí)嗅绸,我們對(duì)關(guān)于MVC的弊端的產(chǎn)生和MVVM中viewModel的職責(zé)及其使用注意事項(xiàng)脾猛,...
    CoderMikeHe閱讀 28,413評(píng)論 110 353
  • 久違的晴天,家長(zhǎng)會(huì)朽砰。 家長(zhǎng)大會(huì)開好到教室時(shí)尖滚,離放學(xué)已經(jīng)沒多少時(shí)間了。班主任說已經(jīng)安排了三個(gè)家長(zhǎng)分享經(jīng)驗(yàn)瞧柔。 放學(xué)鈴聲...
    飄雪兒5閱讀 7,528評(píng)論 16 22