1. 程式人生 > >介紹一個使用 MVVM 和 RAC 開發的開源專案 MVVMReactiveCocoa

介紹一個使用 MVVM 和 RAC 開發的開源專案 MVVMReactiveCocoa

前言

MVVM + KVO ,適用於現有的 MVC 專案,想轉換成 MVVM 但是不打算引入 RAC 作為 binder 的團隊;
MVVM + RAC ,適用於現有的 MVC 專案,想轉換成 MVVM 並且打算引入 RAC 作為 binder 的團隊;
MVVM + RAC + ViewModel-Based Navigation ,適用於全新的專案,想實踐 MVVM 並且打算引入 RAC 作為 binder ,然後也想實踐 ViewModel-Based Navigation 的團隊。

架構和設計思路

類圖

這裡寫圖片描述

在 MVVMReactiveCocoa 中主要有兩大繼承體系:

1)用藍色標識出來的 viewModel 的繼承體系,基類為 MRCViewModel ;
2)用紅色標識出來的 view 的繼承體系,基類為 MRCViewController 。

除了提供與系統基類 UIViewController 相對應的基類 MRCViewModel/MRCViewController 外,還提供了與系統基類 UITableViewController 和 UITabBarController 相對應的基類 MRCTableViewModel/MRCTableViewController 和 MRCTabBarViewModel/MRCTabBarController ,其中基類 MRCTableViewModel/MRCTableViewController 的使用最為普遍。

通過基類的方式來組織 MVVMReactiveCocoa ,是因為通過基類的方式可以儘可能簡單地實現程式碼重用(繼承的特點),提高開發效率,便於統一app整體風格主題。

服務匯流排

這裡寫圖片描述
為了方便 viewModel 層呼叫 model 層中的所有服務,並且統一管理這些服務的建立,我使用抽象工廠模式(java中可以集合反射機制,效能會有所降低,但有利於擴張維護)將 model 層的所有服務集中管理了起來,結構圖如下:

在服務匯流排類 MRCViewModelServices/MRCViewModelServicesImpl 中,主要包括以下三個方面的內容:

1、應用自有的服務類,用柚黃色進行了標識,包括 MRCAppStoreService/MRCAppStoreServiceImpl 和 MRCRepositoryService/MRCRepositoryServiceImpl 兩個服務類;
2、第三方 GitHub 提供的 API 框架,用天藍色進行了標識,主要包括 OCTClient 服務類;
3、應用的導航服務,用藻綠色進行了標識,包括 MRCNavigationProtocol 協議和實現類 MRCViewModelServicesImpl 等。

前兩者都是以訊號的形式對 viewModel 層提供服務,代表非同步的網路請求等資料獲取操作,而我們在 viewModel 層則可以通過訂閱訊號的形式獲取到所需的資料。
此外,服務匯流排還實現了 MRCNavigationProtocol 協議,它的內容如下:

@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

ViewModel-Based Navigation

從理論上來說,MVVM 模式的應用應該是以 viewModel 為驅動來運轉的;
根據我們前面對 MVVM 的探討,viewModel 提供了 view 所需的資料和命令。因此,我們往往可以直接在命令執行成功後使用 doNext 順帶就把導航操作給做了,一氣呵成;
這樣可以使 view 更加輕量級,只需要繫結 viewModel 提供的資料和命令即可。

iOS 中的導航操作無外乎兩種,push/pop 和 present/dismiss ,前者是 UINavigationController 特有的功能,而後者是所有 UIViewController 都具備的功能。

這裡寫圖片描述

viewModel 層是不能引入 view 層的任何東西的,更嚴格的說,是不能引入任何 UIKit 中的東西的,並且也會散失 viewModel 的可測試性。

如何讓VM,V這兩者產生關聯?
viewModel 通過呼叫 MRCViewModelServicesImpl 中的空操作來表明需要執行相應的導航操作,而 MRCNavigationControllerStack 則通過 Hook 來捕獲這些空操作,然後使用棧頂的 NavigationController 來執行真正的導航操作。

- (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 的方式,我們最終實現了 ViewModel-Based 的導航操作,並且在 viewModel 層中也沒有引入 view 層的任意東西,實現瞭解耦合。

Router

在 viewModel 中呼叫導航操作的時候,只傳入了 viewModel 的例項作為引數,那麼我們在 MRCNavigationControllerStack 中執行真正的導航操作時,怎麼才能知道要跳轉到哪個介面呢?

方案:
配置了一個從 viewModel 到 view 的對映,並且約定了一個統一的初始化 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",
        ...
    };
}

實踐 MVVM 的關鍵點在於,我們要能夠分析清楚 viewModel 需要暴露給 view 的資料和命令,這些資料和命令能夠代表 view 當前的狀態。

MRCLoginViewModel.h 標頭檔案的內容

@interface MRCLoginViewModel : MRCViewModel

/// The avatar URL of the user.當用戶輸入的使用者名稱發生變化時,呼叫 model 層的方法查詢本地資料庫中快取的使用者資料,並返回 avatarURL 屬性;
@property (nonatomic, copy, readonly) NSURL *avatarURL;

/// The username entered by the user.
@property (nonatomic, copy) NSString *username;

/// The password entered by the user.
@property (nonatomic, copy) NSString *password;

/**
 屬性代表的是登入按鈕是否可用,它將會與 view 中登入按鈕的 enabled 屬性進行繫結
 */
@property (nonatomic, strong, readonly) RACSignal *validLoginSignal;

/// The command of login button.
@property (nonatomic, strong, readonly) RACCommand *loginCommand;

/// The command of uses browser to login button.
@property (nonatomic, strong, readonly) RACCommand *browserLoginCommand;
@property (nonatomic, strong, readonly) RACCommand *exchangeTokenCommand;