iOS 基于MVVM + RAC + ViewModel-Based Navigation的微信開發(一)

前言
  • 由于最近兩個多月,筆者正和小伙伴們忙于對公司新項目的開發,筆者主要負責項目整體架構的搭建以及功能模塊的分工。首先,該項目采用MVVM + RAC + ViewModel-Based Navigation的設計模式,其次,嘗試利用ViewModel-Based來實現導航(push/poppresent/dismiss)操作。最后,該項目在經過兩個月的埋頭苦干,也于近期成功上架AppStore【輕空-母嬰二手用品寄售平臺】。考慮到公司項目文件的保密性,這里筆者絕不會共享源碼,而是采用筆者公司項目的同一套架構,來一步一步實現微信整體架構功能的開發。其目的就是讓大家更加深沉次的領會 MVVM設計模式,以及利用ViewModel-Based來實現導航(push/poppresent/dismiss)操作的優越性。
  • MVVM With ReactiveCocoa的架構設計以及ViewModel-Based Navigation導航方式,主要參照的是雷純鋒大神開源的MVVMReactiveCocoa的框架,在其架構的基礎上進行一系列改進和一些新特性的增加,不斷豐富該架構以此來滿足不同的開發場景,從而一步一步實現微信的基本架構,同時也側面驗證了雷純鋒大神的MVVM + RAC + ViewModel-Based Navigation的理論正確性和有效性,同時也希望能夠打消你對 MVVM + RAC + ViewModel-Based Navigation 模式的顧慮。
  • 本文將著重分析利用MVVM + RAC + ViewModel-Based Navigation的方式來設計和實踐微信(WeChat)大體功能的開發,希望大家能有所收獲,并將其運用到自己的實際項目中去,這才是此文的最大意義。筆者也將知無不言言無不盡的將其里面的核心分享給大家,同時在運用到實際開發中遇到問題以及解決辦法貢獻出來,希望大家在使用這套模式來開發的時候知其然知其所以然,為大家提供一點思路,少走一些彎路,填補一些細坑。文章僅供大家參考,若有不妥之處,還望不吝賜教,歡迎批評指正。
  • MVVM + ReactiveCocoa 的使用不了解的,請猛戳我iOS 關于MVVM With ReactiveCocoa設計模式的那些事
  • ViewModel-Based Navigation 的使用不了解的,請猛戳我 MVVM With ReactiveCocoa
  • 文章略長,先馬后看。
代碼結構
  1. 結構

    CodeStructure.png

  2. 說明

    • Model :存放數據-模型(data-model),例如:MHUser.
    • View:存放功能模塊自定義的View。例如:MHMainFrameTableViewCell.
    • ViewController:存放功能模塊的是視圖控制器。例如:MHMainFrameViewController.
    • ViewModel:存放功能模塊的是視圖對應的視圖模型。例如:MHMainFrameViewModel.
    • Utils:存放工具類和管理類。例如:分類Category,網絡服務層MHHTTPService,管理類MHFileManager...
    • Vendor:存放第三方框架。例如:MJRefresh...
    • Macros:存放常量。例如:宏(#define)定義常量,const常量,枚舉(NS_ENUM)常量,inline函數,URL路徑常量。
    • Resource:存放資源文件,例如:圖片,DataSQL文件。
  3. 細節

    • 代碼結構完全按照MVVM來設計命名,實際上MVVMV應該包括視圖控制器(ViewController)視圖(View),這里只是將其單獨分開,以便于更好的閱讀和開發。
    • 必須強調文件夾的命名,這里筆者是按照主功能模塊來命名,相信大家可以很清楚的看到 ViewViewControllerViewModel三個文件夾里面的子模塊文件夾都是一樣的。而后期若在設計子文件夾的時候,參照這種方式來創建文件夾,那么大家會發現,你的代碼目錄會非常非常的整齊漂亮,同時方便后期維護和其他開發人員閱讀代碼,何樂而不為呢。
    • 同時強調一下自定義的視圖控制器和視圖模型的命名,理論上,一個視圖控制器配備一個視圖模型,所以筆者這里只是將視圖控制的名字的ViewController替換成ViewModel即為配備的視圖模型的名字:例如:視圖控制器的名字為MHMainFrameViewController,則視圖模型的名字為MHMainFrameViewModel。這樣整個項目開發下來,你會發現ViewControllerViewModel文件下的文件都是對稱的。
    • 目錄層級不能超過三層。因為層級越深,越不易查找,且不易閱讀。這里就以我的(Profile)為例,我的(Profile)界面有一個用戶信息(UserInfo)子模塊,用戶信息(UserInfo)里面有一個更多(MoreInfo)子模塊,更多(MoreInfo)模塊當然也有子模塊等等。如果這樣劃分,必然會導致目錄結構很深,所以為了避免其發生,就盡量限制在三層即可,正所謂事不過三嘛,所謂三層目錄可想而知,就是ViewController - Profile - UserInfo這三層便是,那么我們就可將更多(MoreInfo)模塊與用戶信息(UserInfo)并列即可,當然你也可以將更多(MoreInfo)模塊的寫在用戶信息(UserInfo)里面,但是只創建文件,而不創建文件夾。只要保證不超過三層目錄即可。即如下圖所示:
ProfileCodeStructure.png
第三方框架

第三方框架想必對與小伙伴在熟悉不過了,其作用簡而言之就是:輔助。讓我們更專注于產品的業務邏輯開發,而不是某個功能點開發。這里簡單介紹一下此次搭建微信(WeChat)基本架構中主要用到的第三方框架。目的希望能夠讓大家學習更多更好用的輪子,以及結合自身項目的實際情況集成進去,減少不必要的開發。更多詳見Demo的Podfile文件。

  • AFNetworking :用于網絡數據請求。
  • SDWebImage:圖片異步加載和緩存。
  • ReactiveCocoa:函數響應式編程工具,主要用于MVVM設計模式的數據綁定。本項目使用的是 pod 'ReactiveCocoa' ,'2.5'的版本。
  • Masonry:是一個輕量級的布局框架,擁有自己的描述語法,采用更優雅的鏈式語法封裝自動布局,簡潔明了并具有高可讀性。
  • IQKeyboardManager:鍵盤管理工具,優雅的解決彈起鍵盤遮蓋輸入框的問題。
  • YYKit:一套比較齊全的iOS開發組件。以下是項目中常用到的幾個組件。
    • YYCategories:為Foundation and UIKit提供許多有用的分類。
    • YYText:強大的iOS富文本組件。
    • YYModel:高性能的字典轉模型的框架。
    • YYImage:功能強大的圖像框架。
    • YYWebImage:異步圖片加載框架。[注:本項目主要使用:YYWebImage來加載圖片,而SDWebImage主要兼容其他第三方框架]
    • YYCache:高性能 iOS 緩存框架,提供內存緩存磁盤緩存
  • UITableView+FDTemplateLayoutCell:自動計算cell高度并緩存cell高度。
  • FDFullscreenPopGesture:全屏左滑pop手勢。
  • FMDB:SQLite數據庫。
  • MJExtension:字典轉模型框架。[注:該項目使用YYModel來做字典轉模型,而MJExtension作為輔助.]。
  • MJRefresh:下拉刷新和上拉加載控件。
  • pop:動畫引擎,用于動畫過渡。若不會使用,請參照popping
  • DZNEmptyDataSet:UITableView/UICollectionView數據內容為空時展示的空白頁。
  • MBProgressHUD:加載loading以及顯示提示蒙版的HUD。
  • JPFPSStatus:通過FPS(Frames Per Second)每秒傳輸幀數的高低來檢查列表滾動的流暢度。
BaseClass

本項目中采用的是繼承的方式來設計的,所以BaseClass的存在在所難免,但是它在項目中的作用是舉足輕重的,簡直神一樣的存在。筆者這里主要詳述ModelViewControllerViewModel中的BaseClass,而View中的BaseClass無非是實際項目中開發者自定義的功能View,方便后期要使用只需繼承該功能View就可以了,減少了開發中的冗余代碼。比如:筆者項目中的MHButton是繼承于UIButton,而其作用只是去掉了按鈕的高亮狀態- (void)setHighlighted:(BOOL)highlighted {},以及MHImageView是繼承于UIImageView,而其作用只是增加了允許用戶的交互self.userInteractionEnabled = YES;。這里主要解析的各個是BaseClass的頭文件的屬性和方法,以及各自的使用場景和注意點。基類主要文件如下:

MHObject:所有數據模型的基類。
MHViewModel/MHViewController:所有自定義視圖控制器的基類,以及配備的視圖模型。
MHTableViewModel/MHTableViewController:所有需要顯示UITableView的自定義視圖控制器的基類,以及配備的視圖模型。
MHWebViewModel/MHWebViewController:所有需要顯示WKWebView的自定義視圖控制器的基類,以及配備的視圖模型。
MHTabBarViewModel/MHTabBarController:需要展示UITabBarController的自定義視圖控制器,以及配備的視圖模型。
  • Model -- BaseClass
    MHObject是整個項目的數據-模型(Data-Model)的基類,即:JSON轉成的模型的基類。MHObject遵守YYModel協議,MHObject.h文件的API也參照NSObject+YYModel.hAPI的實現,內部封裝了YYModel對應的字典轉模型的主要方法。所以使用前提你得會使用YYModel,這里筆者僅說明MHObjec.h的屬性和方法,具體的實現請移步筆者提供的Demo來閱讀和理解。MHObject.h內容如下:

  • ViewModel -- BaseClass
    MHViewModel是整個項目所有自定義的視圖模型的基類,主要提供數據給MHViewController,主要職責就是從 model 層獲取 view 所需的數據,并且將這些數據轉換成view能夠展示的形式。當然這里筆者為其配備了許多常用的屬性:是否允許左滑pop到上一層的interactivePopDisabled是否需要隱藏導航欄的prefersNavigationBarHidden是否需要隱藏導航欄底部細線的prefersNavigationBarBottomLineHidden是否啟用IQKeyboardManager來管理鍵盤的彈起和關閉的keyboardEnable等...大家可以根據項目中的實際情況來配置各個屬性的值,當然你也可以為其配備更多更好用的功能,以次來快速實現產品需求和避免冗余代碼的產生。MHViewModel的其他屬性或方法這里就不一一敘述了,大家可以根據筆者的屬性注釋設置其值,運行起來看看具體的效果即可。MHViewModel.h的內容如下:

    /// MVVM View
    /// The base map of 'params'
    /// The `params` parameter in `-initWithParams:` method.
    /// Key-Values's key
    /// 傳遞唯一ID的key:例如:商品id 用戶id...
    FOUNDATION_EXTERN NSString *const MHViewModelIDKey;
    /// 傳遞導航欄title的key:例如 導航欄的title...
    FOUNDATION_EXTERN NSString *const MHViewModelTitleKey;
    /// 傳遞數據模型的key:例如 商品模型的傳遞 用戶模型的傳遞...
    FOUNDATION_EXTERN NSString *const MHViewModelUtilKey;
    /// 傳遞webView Request的key:例如 webView request...
    FOUNDATION_EXTERN NSString *const MHViewModelRequestKey;
    
    @protocol MHViewModelServices;
    
    @interface MHViewModel : NSObject
    /// Initialization method. This is the preferred way to create a new view model.
    /// services - The service bus of the `Model` layer.
    /// params   - The parameters to be passed to view model.
    ///
    /// Returns a new view model.
    - (instancetype)initWithServices:(id<MHViewModelServices>)services params:(NSDictionary *)params;
    
    /// The `services` parameter in `-initWithServices:params:` method.
    @property (nonatomic, readonly, strong) id<MHViewModelServices> services;
    
    /// The `params` parameter in `-initWithParams:` method.
    /// The `params` Key's `kBaseViewModelParamsKey`
    @property (nonatomic, readonly, copy) NSDictionary *params;
    
    /// navItem.title
    @property (nonatomic, readwrite, copy) NSString *title;
    /// 返回按鈕的title,default is nil 。
    /// 如果設置了該值,那么當Push到一個新的控制器,則導航欄左側返回按鈕的title為backTitle
    @property (nonatomic, readwrite, copy) NSString *backTitle;
    
    /// The callback block. 當Push/Present時,通過block反向傳值
    @property (nonatomic, readwrite, copy) VoidBlock_id callback;
    
    /// A RACSubject object, which representing all errors occurred in view model.
    @property (nonatomic, readonly, strong) RACSubject *errors;
    
    /** should fetch local data when viewModel init  . default is YES */
    @property (nonatomic, readwrite, assign) BOOL shouldFetchLocalDataOnViewModelInitialize;
    /** should request data when viewController videwDidLoad . default is YES*/
    /** 是否需要在控制器viewDidLoad */
    @property (nonatomic, readwrite, assign) BOOL shouldRequestRemoteDataOnViewDidLoad;
    /// will disappear signal
    @property (nonatomic, strong, readonly) RACSubject *willDisappearSignal;
    
    /// FDFullscreenPopGesture
    /// Whether the interactive pop gesture is disabled when contained in a navigation
    /// stack. (是否取消掉左滑pop到上一層的功能(棧底控制器無效),默認為NO,不取消)
    @property (nonatomic, readwrite, assign) BOOL interactivePopDisabled;
    /// Indicate this view controller prefers its navigation bar hidden or not,
    /// checked when view controller based navigation bar's appearance is enabled.
    /// Default to NO, bars are more likely to show.
    /// 是否隱藏該控制器的導航欄 默認是不隱藏 (NO)
    @property (nonatomic, readwrite, assign) BOOL prefersNavigationBarHidden;
    
    /// 是否隱藏該控制器的導航欄底部的分割線 默認不隱藏 (NO)
    @property (nonatomic, readwrite, assign) BOOL prefersNavigationBarBottomLineHidden;
    
    /// IQKeyboardManager
    /// 是否讓IQKeyboardManager的管理鍵盤的事件 默認是YES(鍵盤管理)
    @property (nonatomic, readwrite, assign) BOOL keyboardEnable;
    /// 是否鍵盤彈起的時候,點擊其他局域鍵盤彈起 默認是 YES
    @property (nonatomic, readwrite, assign) BOOL shouldResignOnTouchOutside;
    
    /// An additional method, in which you can initialize data, RACCommand etc.
    ///
    /// This method will be execute after the execution of `-initWithParams:` method. But
    /// the premise is that you need to inherit `BaseViewModel`.
    - (void)initialize;
    @end
    

    MHWebViewModel主要是為要加載網頁(WKWebView)的視圖MHWebViewController提供數據的數據模型基類,繼承于MHViewModel。其頭文件暴露的屬性也比較簡單,都是平常開發中會遇到的,只要大家稍加利用,就能完成一些常用的功能。MHWebViewModel.h內容如下:

    @interface MHWebViewModel : MHViewModel
    /// web url quest
    @property (nonatomic, readwrite, copy) NSURLRequest *request;
    /// 下拉刷新 defalut is NO
    @property (nonatomic, readwrite, assign) BOOL shouldPullDownToRefresh;
    /// 是否取消導航欄的title等于webView的title。默認是不取消,default is NO
    @property (nonatomic, readwrite, assign) BOOL shouldDisableWebViewTitle;
    /// 是否取消關閉按鈕。默認是不取消,default is NO
    @property (nonatomic, readwrite, assign) BOOL shouldDisableWebViewClose;
    @end
    

    這里筆者講講shouldDisableWebViewTitleshouldDisableWebViewClose這兩個屬性的作用以及使用場景。
    shouldDisableWebViewTitle: 是否取消導航欄的title等于webViewtitle。默認做法是MHWebViewController及其子類的導航欄titleWebViewtitle,而不是MHViewModeltitle屬性。即控制器通過KVO的形式監聽WKWebViewtitle屬性,從而設置導航欄的titleself.navigationItem.title = self.webView.title。但是可能有幾個H5界面想要設置導航欄的titleMHViewModeltitle屬性,正所謂需求拉動生成,所以就產生了該屬性。
    shouldDisableWebViewClose:是否導航欄左側取消關閉按鈕,默認是不取消。這主要是為了解決點擊網頁里面的鏈接繼續加載另一個網頁,如果重復前面的步驟幾次,則網頁層次就會非常的深(A - B - C - D - E ...)。如果我們點擊MHWebViewController導航欄的左側的返回按鈕,其默認做法是返回到上一個網頁([self.webView goBack]),這樣由于前面的步驟,導致網頁層次過深,我們需要點擊多次返回按鈕,才能返回到最初的網頁,繼而才能返回上一個界面,這樣用戶操作過多,用戶體驗下降(PS:干著程序猿的活,抄著產品經理的心)。MHWebViewController的導航欄返回按鈕的事件處理代碼如下:

    - (void)_backItemDidClicked{ /// 返回按鈕事件處理
        /// 可以返回到上一個網頁,就返回到上一個網頁
        if (self.webView.canGoBack) {
            [self.webView goBack];
        }else{/// 不能返回上一個網頁,就返回到上一個界面
            /// 判斷 是Push還是Present進來的,
            if (self.presentingViewController) {
                [self.viewModel.services dismissViewModelAnimated:YES completion:NULL];
            } else {
                [self.viewModel.services popViewModelAnimated:YES];
            }
        }
    }
    

    所以,這時候為了解決此類問題,于是就出現了,當發現WKWebView能返回到上一個網頁(self.webView.canGoBack),那么就會讓導航欄左側(leftBarButtonItems)同時顯示返回和關閉按鈕,當我們點擊關閉按鈕,就直接返回到上一層頁面而不是返回上一個網頁。當然有些頁面是不要顯示關閉按鈕的,比如一些網頁點擊跳轉頂多兩三層。所以該屬性就是為了顯示和隱藏關閉按鈕而產生的。下面就是MHWebViewController中顯示關閉按鈕以及關閉按鈕的事件處理的代碼:

    /// 內容開始返回時調用
    - (void)webView:(WKWebView *)webView didCommitNavigation:(null_unspecified WKNavigation *)navigation {
        /// 不顯示關閉按鈕
        if(self.viewModel.shouldDisableWebViewClose) return;
    
        UIBarButtonItem *backItem = self.navigationItem.leftBarButtonItems.firstObject;
        if (backItem) {
            if ([self.webView canGoBack]) {
                [self.navigationItem setLeftBarButtonItems:@[backItem, self.closeItem]];
            } else {
                [self.navigationItem setLeftBarButtonItems:@[backItem]];
            }
        }
    }
    
    - (void)_closeItemDidClicked{
        /// 判斷 是Push還是Present進來的
        if (self.presentingViewController) {
            [self.viewModel.services dismissViewModelAnimated:YES completion:NULL];
        } else {
            [self.viewModel.services popViewModelAnimated:YES];
        }
    }
    

    MHTableViewModel主要是提供數據給MHTableViewController的視圖模型的基類,繼承于MHViewModel,且MHTableViewModel在本項目中使用最為廣泛。當然筆者也為其增添許多功能屬性,以此來加快了開發的便捷度以及減少了子類代碼的冗余度。具體的的使用請根據筆者提供的屬性注釋,根據自身項目來配置其屬性的值。MHTableViewModel.h具體內容如下:

    @interface MHTableViewModel : MHViewModel
    /// The data source of table view. 這里不能用NSMutableArray,因為NSMutableArray不支持KVO,不能被RACObserve
    @property (nonatomic, readwrite, copy) NSArray *dataSource;
    
    /// tableView‘s style defalut is UITableViewStylePlain , 只適合 UITableView 有效
    @property (nonatomic, readwrite, assign) UITableViewStyle style;
    
    /// 需要支持下來刷新 defalut is NO
    @property (nonatomic, readwrite, assign) BOOL shouldPullDownToRefresh;
    /// 需要支持上拉加載 defalut is NO
    @property (nonatomic, readwrite, assign) BOOL shouldPullUpToLoadMore;
    /// 是否數據是多段 (It's effect tableView's dataSource 'numberOfSectionsInTableView:') defalut is NO
    @property (nonatomic, readwrite, assign) BOOL shouldMultiSections;
    /// 是否在上拉加載后的數據,dataSource.count < pageSize 提示沒有更多的數據.default is NO 默認做法是數據不夠時,隱藏mj_footer
    @property (nonatomic, readwrite, assign) BOOL shouldEndRefreshingWithNoMoreData;
    
    /// 當前頁 defalut is 1
    @property (nonatomic, readwrite, assign) NSUInteger page;
    /// 每一頁的數據 defalut is 20
    @property (nonatomic, readwrite, assign) NSUInteger perPage;
    
    /// 選中命令 eg:  didSelectRowAtIndexPath:
    @property (nonatomic, readwrite, strong) RACCommand *didSelectCommand;
    /// 請求服務器數據的命令
    @property (nonatomic, readonly, strong) RACCommand *requestRemoteDataCommand;
    
    /// 占位empty類型
    //@property (nonatomic, readwrite, assign) SBDefaultEmptyBackgroundType emptyType;
    /// 網絡不可用 default is NO
    @property (nonatomic, readwrite, assign) BOOL disableNetwork;
    
    /** fetch the local data */
    - (id)fetchLocalData;
    
    /// 請求錯誤信息過濾
    - (BOOL (^)(NSError *error))requestRemoteDataErrorsFilter;
    
    /// 當前頁之前的所有數據
    - (NSUInteger)offsetForPage:(NSUInteger)page;
    
    /** request remote data or local data, sub class can override it
     *  page - 請求第幾頁的數據
     */
    - (RACSignal *)requestRemoteDataSignalWithPage:(NSUInteger)page;
    @end
    
  • ViewController -- BaseClass
    MHNavigationController :是整個項目所使用的導航欄控制器,用于替代系統的導航欄控制器(UINavigationController),當開發需要Push/Present一個導航欄控制器,我們應該Push/Present的是MHNavigationController,而不是UINavigationController。當然MHNavigationController不是單純只是簡單的繼承UINavigationController就完事了,筆者也是賦予了MHNavigationController一些使命的。MHNavigationController.h內容如下:

    @interface MHNavigationController : UINavigationController
    /// 顯示導航欄的細線
    - (void)showNavigationBottomLine;
    /// 隱藏導航欄的細線
    - (void)hideNavigationBottomLine;
    @end
    

    默認情況下,系統導航欄控制器的navigationBar底部有一根深灰色的細線(UIImageView),現實開發中,大家肯定遭遇到產品經理這樣的Diss

    " 該界面能否隱藏導航欄底部這根細線?"
    " 該界面為何要隱藏導航欄底部這根細線?"
    " 有沒有覺得導航欄底部這根細線顏色太深?"
    " 有沒有覺得導航欄底部這根細線過高?"
    ...
    

    理想很豐滿,現實很骨感,哎,說多了都是淚。于是乎,為了滿足產品的需求,便誕生了MHNavigationController.h中顯示和隱藏導航欄底部細線的方法,一般這兩個方法都是成對出現的,在ViewControllerviewWillAppear:viewWillDisappear:來控制導航欄底部細線的顯示和隱藏。
    其實網絡上有很多隱藏導航欄底部細線的方法,這里講講筆者的做法,其實很簡單,就是:找到它,隱藏它,自定義細線。代碼如下:

    // 查詢最后一條數據
    - (UIImageView *)_findHairlineImageViewUnder:(UIView *)view{
       if ([view isKindOfClass:UIImageView.class] && view.bounds.size.height <= 1.0) {
           return (UIImageView *)view;
       }
       for (UIView *subview in view.subviews){
           UIImageView *imageView = [self _findHairlineImageViewUnder:subview];
           if (imageView){ return imageView; }
       }
       return nil;
    }
    
    #pragma mark - 設置導航欄的分割線
    - (void)_setupNavigationBarBottomLine{
       //!!!:這里之前設置系統的 navigationBarBottomLine.image = xxx;無效 Why? 隱藏了系統的 自己添加了一個分割線
       // 隱藏系統的導航欄分割線
       UIImageView *navigationBarBottomLine = [self _findHairlineImageViewUnder:self.navigationBar];
       navigationBarBottomLine.hidden = YES;
       // 添加自己的分割線
       CGFloat navSystemLineH = .5f;
       UIImageView *navSystemLine = [[UIImageView alloc] initWithFrame:CGRectMake(0, self.navigationBar.mh_height - navSystemLineH, MH_SCREEN_WIDTH, navSystemLineH)];
       navSystemLine.backgroundColor = MHColor(223.0f, 223.0f, 221.0f);
       [self.navigationBar addSubview:navSystemLine];
       self.navigationBottomLine = navSystemLine;
    }
    

    其實,MHNavigationController最大的使命是:攔截系統的Push進來的所有子控制器,以便于統一處理:隱藏和顯示系統底部的UITabBar統一處理Push過來的子控制器的導航欄的左側按鈕(navigationItem.leftBarButtonItem)的返回樣式以及事件處理。當然返回按鈕(leftBarButtonItem)的樣式雖是多種多樣的,比如:直接顯示返回二字的 ,也有顯示一張<圖片的,也有顯示< xxx的。但事件是統一的,都是調用popViewControllerAnimated:來返回上一個界面。當然,你也可以在指定的ViewController里面,自定義設置導航欄左側的navigationItem.leftBarButtonItem的樣式,以及實現該leftBarButtonItem的事件即可。這里筆者以統一處理微信(WeChat)的返回按鈕樣式為例。說說筆者的思路,首先講講微信(WeChat)返回按鈕的樣式的需求偽代碼:假設有兩個控制器(A/B),且A.title = @"KKK"B.title = @"ZZZ",假設[A Push B],那么微信的默認做法,則B的導航欄返回按鈕是 < KKK,也就是B的導航欄返回按鈕的titleA.title 。當然如果考慮到A.title的文字很長,那么需要自定義B的導航欄返回按鈕的title< XXX。(大家沒繞暈吧...)。這種自定義的做法需要結合MHViewModelbackTitle屬性。詳見代碼如下:

      /// 能攔截所有push進來的子控制器
     - (void)pushViewController:(UIViewController *)viewController animated:(BOOL)animated{
         // 如果現在push的不是棧底控制器(最先push進來的那個控制器)
         if (self.viewControllers.count > 0){
             /// 隱藏底部tabbar
             viewController.hidesBottomBarWhenPushed = YES;
             NSString *title = @"返回";
             /// eg: [A push B]
             /// 1.取出當前的控制器的title , 也就是取出 A.title
             title = [[self topViewController] title]?:@"返回";
         
             /// 2.判斷要被Push的控制器(B)是否是 MHViewController ,
             if ([viewController isKindOfClass:[MHViewController class]]) {
             
             MHViewModel *viewModel = [(MHViewController *)viewController viewModel];
             
             /// 3. 查看backTitle 是否有值
             title = viewModel.backTitle?:title;
         }
         
         // 4.這里可以設置導航欄的左右按鈕 統一管理方法
         viewController.navigationItem.leftBarButtonItem = [UIBarButtonItem mh_backItemWithTitle:title imageName:@"barbuttonicon_back_15x30" target:self action:@selector(_back)];
     }
         // push
         [super pushViewController:viewController animated:animated];
     }
     /// 事件處理
     - (void)_back{
         [self popViewControllerAnimated:YES];
     }
    

    MHNavigationController當然還有一些其他使命,比如統一設置UINavigationBarUIBarButtonItem的主題。這里就不一一闡述了,詳見Demo里面的MHNavigationController.m文件。(PS:天青色等煙雨,而我在等你)。

    MHViewController 是整個項目中所有自定義的視圖控制器的基類。其主要使命是綁定MHViewModel提供的一系列屬性來完成一些初始化工作和基礎性的配置。MHViewController.h內容如下:

    @interface MHViewController : UIViewController
    /// The `viewModel` parameter in `-initWithViewModel:` method.
    @property (nonatomic, readonly, strong) MHViewModel *viewModel;
    
    /// 截圖(Push/Pop Present/Dismiss 過度過程中的縮略圖)
    @property (nonatomic, readwrite, strong) UIView *snapshot;
    /**
     統一使用該方法初始化,子類中直接聲明對于的'readonly' 的 'viewModel'屬性,
     并在@implementation內部加上關鍵詞 '@dynamic viewModel;'
     @dynamic A相當于告訴編譯器:“參數A的getter和setter方法并不在此處,
     而在其他地方實現了或者生成了,當你程序運行的時候你就知道了,
     所以別警告我了”這樣程序在運行的時候,
     對應參數的getter和setter方法就會在其他地方去尋找,比如父類。
     */
    /// Initialization method. This is the preferred way to create a new view.
    ///
    /// viewModel - corresponding view model
    ///
    /// Returns a new view.
    - (instancetype)initWithViewModel:(MHViewModel *)viewModel;
    
    /// Binds the corresponding view model to the view.(綁定數據模型)
    - (void)bindViewModel;
    @end
    

    通過API可見MHViewController的功能其實是比較單一的,只做了綁定視圖模型(MHViewModel及其子類)的一些基礎性配置。更多內容詳見Demo的MHViewController.m文件,筆者這里講講根據MHViewModeltitle的屬性設置導航欄title的細節,代碼和細節處理如下所述:

    /// set navgation title
    // CoderMikeHe Fixed: 這里只是單純設置導航欄的title。 不然以免self.title同時設置了navigatiItem.title, 同時又設置了tabBarItem.title
    RAC(self.navigationItem , title) = RACObserve(self, viewModel.title);
    

    MHWebViewController是整個項目中所有需要顯示WebView(WKWebView)的自定義的視圖控制器的基類。其內部添加了一個全屏的WKWebView作為視圖控制器View的子控件,主要目的是為了加載一些網頁鏈接以及本地H5,開發中只需要直接使用MHWebViewController即可,很少需要將其子類化。通過綁定MHWebViewModelrequest屬性來加載指定的網頁,只要你能熟練使用WkWebView即可,其他的細節問題比如下拉刷新網頁、WKWebView自適應屏幕、點擊網頁鏈接跳轉處理,以及多次跳轉網頁后的導航欄關閉按鈕的事件處理等... 請參考MHWebViewController.mMHWebViewController.h的頭文件內容如下:

    @interface MHWebViewController : MHViewController<WKNavigationDelegate,WKUIDelegate,WKScriptMessageHandler>
    /// webView
    @property (nonatomic, weak, readonly) WKWebView *webView;
    /// 內容縮進 (64,0,0,0)
    @property (nonatomic, readonly, assign) UIEdgeInsets contentInset;
    @end
    

    MHTabBarController在本項目繼承于MHViewController,主要作用是將UITabBarController作為自己的子控制器,并將tabBarController作為一個只讀(readonly)屬性暴露在頭文件中,以便子類能夠獲取并使用,即關鍵代碼如下:

     self.tabBarController = [[UITabBarController alloc] init];
     /// 添加子控制器
     [self.view addSubview:self.tabBarController.view];
     [self addChildViewController:self.tabBarController];
     [self.tabBarController didMoveToParentViewController:self];
    

    大家可能普遍會認為,MHTabBarController為何是繼承MHViewController,而不是直接繼承UITabBarController(PS:若為MVC模式,筆者定會直接繼承UITabBarController),這樣豈不更加清晰明了。筆者認為這主要是為了保證整個項目繼承的連續性,以便更好的使用到基類的屬性和方法,保證代碼的規范性。
    本項目主模塊的視圖控制器繼承關系為:
    MHHomePageViewController → MHTabBarController → MHViewController
    本項目主模塊的視圖模型的繼承關系為:
    MHHomePageViewModel → MHTabBarViewModel → MHViewModel
    如果直接單純的繼承UITabBarController,則繼承關系為:
    MHHomePageViewController → MHTabBarController → UITabBarController
    然而,UITabBarController是繼承于UIViewController的,這樣就使得與MHViewController失去了聯系,從而無法使用MHViewController中的屬性和方法。同理,視圖模型的繼承連續性也可以以此類比。
    當然,MHTabBarController內部還利用了KVC將其系統的tabBar替換成MHTabBar(PS:繼承UITabBar)。代碼如下:

     // kvc替換系統的tabBar
      MHTabBar *tabbar = [[MHTabBar alloc] init];
      //kvc實質是修改了系統的_tabBar
      [self.tabBarController setValue:tabbar forKeyPath:@"tabBar"];
    

    其目的就是便于更好的定制適合產品需求的UITabBar,比如:UITabBar頂部的細線顏色問題,高度問題 ,中間添加加號按鈕等...解決方案類似導航欄的navigationBar類似,即找到它,隱藏它,自定義細線。更多內容請參見Demo中的MHTabBarControllerMHTabBar即可。MHTabBarController.h內容如下

    @interface MHTabBarController : MHViewController<UITabBarControllerDelegate>
    /// The `tabBarController` instance
    @property (nonatomic, readonly, strong) UITabBarController *tabBarController;
    @end
    

    MHTableViewController是整個項目中所有需要顯示列表(UITableView)的自定義的視圖控制器的基類,也是項目中使用最多的基類。MHTableViewController內部添加了一個全屏的UITableView作為其子控件,通過配合綁定MHTableViewModel的屬性來實現 tableView的展示樣式tableView的數據展示tableView是否支持上拉加載和下拉刷新以及加載和刷新的邏輯tableView無數據或無網絡的展示tableView選中cell的事件處理。開發中我們絕大多數都是通過子類化MHTableViewController,然后重寫(Override)父類提供的方法來配置tableView的contentInsert提供tableView展示數據的cell綁定cell顯示的數據模型等等。關鍵是要學會根據項目需求來配置MHTableViewModel的屬性,依次來達到產品的需求。在此可見MVVMVM(視圖模型)的重要性。MHTableViewController.h的內容如下:

    @interface MHTableViewController : MHViewController<UITableViewDelegate , UITableViewDataSource>
    
    /// The table view for tableView controller.
    /// tableView
    @property (nonatomic, readonly, weak) UITableView *tableView;
    
    /// `tableView` 的內容縮進,default is UIEdgeInsetsMake(64,0,0,0),you can override it
    @property (nonatomic, readonly, assign) UIEdgeInsets contentInset;
    
    /// reload tableView data , sub class can override
    - (void)reloadData;
    
    /// dequeueReusableCell
     - (UITableViewCell *)tableView:(UITableView *)tableView dequeueReusableCellWithIdentifier:(NSString *)identifier forIndexPath:(NSIndexPath *)indexPath;
    
    /// configure cell data 
    - (void)configureCell:(UITableViewCell *)cell atIndexPath:(NSIndexPath *)indexPath withObject:(id)object;
    @end
    

    這里筆者講講在設計MHTableViewController時遇到的坑和填坑的辦法,以及部分關鍵代碼的解析,希望可以幫助大家在開發中更好的理解和避免被坑。
    內置tableView的尺寸布局的坑。由于項目中純代碼部分筆者都是利用Masonry來實現布局的,所以在MHTableViewController中布局tableView時,利用Masonry來布局,關鍵代碼如下:

    UITableView *tableView = [[UITableView alloc] initWithFrame:CGRectZero style:self.viewModel.style];
    [self.view addSubview:tableView];
    [tableView mas_makeConstraints:^(MASConstraintMaker *make) {
      make.edges.mas_equalTo(UIEdgeInsetsZero);
    }];
    

    其實,正常情況下完全沒問題,但是MHTableViewController子類化后,在子類中設置了tableViewcontentInset屬性,然而tableViewcontentOffset始終是(0,0),非常的神奇,到目前為止筆者也不知其原因(PS:若知道的大神, 請說一聲哦),這樣就導致了筆者一個需求上的Bug,就是筆者項目中首頁是個商品列表,當你向下滑動到一定距離,屏幕右下角處會出現一個能夠點擊滾動到頂部的按鈕,點擊向上按鈕就可以滾動到頂部即可。實現過程無非就是監聽按鈕的點擊方法,實現[self.tableView setContentOffset:CGPointMake(0, 0) animated:YES];即可(理論上)。但是如果采用Masonry布局,就會出現點擊向上按鈕,你怎么也滾動不到頂部去,感覺tableView抽風了。當然,大家可以利用筆者提供的MHDevelopExample_Objective_CMVVM那塊的內容進行復現或調試。
    筆者采取的解決辦法是:筆者首先覺得可能tableView還未布局好而導致的,所以在利用Masonry布局tableView時,在MHTableViewController中強制布局了子控件,即調用[self.view layoutIfNeeded];,結果也很神奇,就可以實現點擊向上按鈕,能滾動到頂部了。
    但是...BUG還是出現了。如果MHTableViewModeldataSource的數據不是通過- (RACSignal *)requestRemoteDataSignalWithPage:(NSUInteger)page來獲取的網絡數據,而是在- (void)initialize中就初始化的死數據,例如發現模塊頁面中cell的數據源。當我們的Cellxib創建,且一般開發中會在MHTableViewController的子類中的-(void)viewDidLoad里面注冊tableViewCell。切記:Bug復現條件必須是:TableViewModeldataSource是必須死(本地)數據,而非網絡數據,并且是Cell是用tableView注冊來獲取的,缺一不可。這樣會導致如下圖所示的Bug。

    UITableView崩潰.png

    如果開啟全局斷點,那么會崩潰定位到[self.view layoutIfNeeded]的位置,由于強制布局(layoutIfNeeded)視圖控制器的子控件,那么會導致tableView提前刷新(reloadData)其數據源的方法,而此時TableViewModeldataSource的數據又是本地數據,一開始是會有值,從而會調用tableView的數據源方法:- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath,而一般初始化cell的工作都是交個子類來重寫MHTableViewController- (UITableViewCell *)tableView:(UITableView *)tableView dequeueReusableCellWithIdentifier:(NSString *)identifier forIndexPath:(NSIndexPath *)indexPath的方法。所以當我們在子類的-(void)viewDidLoad中注冊TableViewCell,這樣就會因為代碼調用順序的原因,使得子類通過在重寫- (UITableViewCell *)tableView:(UITableView *)tableView dequeueReusableCellWithIdentifier:(NSString *)identifier forIndexPath:(NSIndexPath *)indexPath來返回一個cell,然而return [tableView dequeueReusableCellWithIdentifier:@"XXXXXX"];來獲取出來注冊(其實還未注冊)的cellnil而導致崩潰。子類的偽代碼調用順序如下:

      /// 子類代碼邏輯順序
      - (void)viewDidLoad {
          /// ①:子類調用父類的viewDidLoad方法,而父類主要是創建tableView以及強行布局子控件,從而導致tableView刷新,這樣就會去走tableView的數據源方法
          [super viewDidLoad];
    
          /// ③:注冊cell
          [self.tableView mh_registerNibCell:MHMainFrameTableViewCell.class];
      }
    
      /// 返回自定義的cell
      - (UITableViewCell *)tableView:(UITableView *)tableView dequeueReusableCellWithIdentifier:(NSString *)identifier forIndexPath:(NSIndexPath *)indexPath{
          // ②:父類的tableView的數據源方法的獲取cell是通過注冊cell的identifier來獲取cell,然而此時子類并未注冊cell,所以取出來的cell = nil而引發Crash
          return [tableView dequeueReusableCellWithIdentifier:@"MHMainFrameTableViewCell"];
      }
    

    當然,筆者平常開發都是通過純代碼來創建Cell的,極少使用到通過注冊Cell的方式(PS:個人編碼習慣問題而已)。一般筆者的做法都會在新建的Cell里面暴露一個獲取創建好的Cell的方法:+ (instancetype)cellWithTableView:(UITableView *)tableView。代碼實現如下:

    + (instancetype)cellWithTableView:(UITableView *)tableView{
        static NSString *ID = @"LiveRoomCell";
        MHMainFrameTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:ID];
        if (!cell) {
            cell = [self mh_viewFromXib];
            cell.selectionStyle = UITableViewCellSelectionStyleNone;
        }
        return cell;
     }
    

    所以起初筆者在調試這個BUG的時候,我也是一臉懵逼,因為我這里完美運行,而同事那里就蹦擦拉卡。后面才發現就是上面的偽代碼邏輯②處獲取的cellnil導致的,而如果②采用筆者的獲取cell的方法,是絕逼不會有問題的。但是考慮到同事是比較偏向于通過UITableView+FDTemplateLayoutCell來自動計算cell高度并緩存cell高度的方式開發,然而這框架的使用前提就是必須通過為Cell注冊一個identifier的方式。
    所以筆者為了兼容同事的開發習慣,最終的做法是在MHTableViewController中不使用Masonry來布局tableView,也不強制刷新(layoutIfNeeded)視圖控制器的子控件。而是直接指定tableViewframe,即:UITableView *tableView = [[UITableView alloc] initWithFrame:[UIScreen mainScreen].bounds style:self.viewModel.style];。如果子類想要修改tableView的尺寸,再使用Masonry來布局即可。所以,這就是最終的做法...
    當然還有MHTableViewController還有許多邏輯細節處理,這里就不在過多贅述,更多內容請參考Demo中的MHTableViewController設計。

Q&A

Q:項目中若同時集成 YYCategoriesReactiveCocoa,使用@weakify(self)@strongify(self);將會報Ambiguous expansion of macro weakifyAmbiguous expansion of macro strongify的警告。

weakify&strongify警告.png

A:由于 YYCategoriesReactiveCocoa都定義了weakifystrongify引起的。解決辦法如下:

weakify&strongify警告解決.png

知識點:怎樣去除Xcode中的警告?


Q:Xcode 9.0上,ReactiveCocoa(2.5)Unknown warning group '-Wreceiver-is-weak', ignored的警告。

Wreceiver-is-weak警告.png

A:RACObserve定義如下:

#define RACObserve(TARGET, KEYPATH) \
    ({ \
        _Pragma("clang diagnostic push") \
        _Pragma("clang diagnostic ignored \"-Wreceiver-is-weak\"") \
        __weak id target_ = (TARGET); \
        [target_ rac_valuesForKeyPath:@keypath(TARGET, KEYPATH) observer:self]; \
        _Pragma("clang diagnostic pop") \
    })

在之前的Xcode中如果消息接受者是一個weak對象,clang編譯器會報receiver-is-weak警告,所以加了這段push&pop,最新(iOS 11)的clang已經把這個警告給移除,所以沒必要加push&pop了。
解決辦法:修改Podfile文件,將 pod 'ReactiveCocoa' ,'2.5' 改成如下

pod 'ReactiveCocoa', :git => 'https://github.com/zhao0/ReactiveCocoa.git', :tag => '2.5.2'

該方法原文參照:簡書App適配iOS 11


Q:在Xcode 9.0上報 error: Illegal Configuration: Safe Area Layout Guide before iOS 9.0錯誤。

SafeAreaLayoutGuide.png

A:SafeArea的概念是在iOS 9.0以后才支持,所以只需要設置項目支持的版本:設置Deployment TargetiOS Deployment Target9.0以上即可。

SafeAreaLayoutGuide解決①.png

SafeAreaLayoutGuide解決②.png

總結

本篇主要介紹了筆者在使用MVVM + RAC + ViewModel-Based Navigation來搭建微信基本架構過程中的一點見解,其更深次的實踐還需要各位小伙伴去自行體會,建議結合筆者文末提供的Demo以及雷純鋒大神開源的MVVMReactiveCocoa來實踐。
當然實踐過程如人飲水,冷暖自知,多多重復,百煉成鋼。希望小伙伴通過閱讀這篇文章,能對MVVM + RAC + ViewModel-Based Navigation的使用有一定基本的了解和使用,不一定要求完全去掌握它,這僅僅是我們眾多開發模式的一個參考罷了,最主要的還是編程思想細節處理。顯然你也可以將其運用到MVC設計模式中去,比如代碼規范文件目錄BaseClass等等。使得MVVM真正做到從群眾(MVC)中來,到群眾(MVC)中去。
或許還有許多細小邏輯和細小Bug需要我們去優化和處理,當然這便是此篇文章的存在的意義:集眾人之智,成眾人之事

未完...待續...(PS:點關注,不迷路,筆者帶你上高速)

考慮到文章篇幅過長影響閱讀性,講述其中技術的拓展性和全面性。筆者在接下來的時間內,會陸續將在開發WeChat中的好用的技術以及細節處理分享出來,希望提供大家一個參考,并且可以運用到自己的實際的項目中去。主要是關于以下幾個問題的解釋和分析,還請小伙伴移步續篇??iOS 基于MVVM + RAC + ViewModel-Based Navigation的微信開發(二)

  • 項目中的整體服務(Service)層解析。
  • 項目中的網絡(Network)層解析。
  • 項目中如何快速搭建類似發現我的設置、...等界面解析。
  • 如何利用該設計模式搭建游客模式(PS: 微信是登錄模式的架構)的架構。
  • 搭建Debug調試工具。
期待
  1. 文章若對您有些許幫助,請給個喜歡??,畢竟碼字不易;若對您沒啥幫助,請給點建議??,切記學無止境。
  2. 針對文章所述內容,閱讀期間任何疑問;請在文章底部評論指出,我會火速解決和修正問題。
  3. GitHub地址:https://github.com/CoderMikeHe
  4. 源碼地址:WeChat
參考鏈接
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容