前言
- 由于最近兩個多月,筆者正和小伙伴們忙于對公司新項目的開發,筆者主要負責項目整體架構的搭建以及功能模塊的分工。首先,該項目采用
MVVM + RAC + ViewModel-Based Navigation
的設計模式,其次,嘗試利用ViewModel-Based
來實現導航(push/pop
和present/dismiss
)操作。最后,該項目在經過兩個月的埋頭苦干,也于近期成功上架AppStore【輕空-母嬰二手用品寄售平臺】。考慮到公司項目文件的保密性,這里筆者絕不會共享源碼,而是采用筆者公司項目的同一套架構,來一步一步實現微信整體架構功能的開發。其目的就是讓大家更加深沉次的領會MVVM
設計模式,以及利用ViewModel-Based
來實現導航(push/pop
和present/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 - 文章略長,先馬后看。
代碼結構
-
結構
CodeStructure.png -
說明
-
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:存放資源文件,例如:圖片,
Data
,SQL
文件。
-
Model :存放數據-模型(
-
細節
- 代碼結構完全按照
MVVM
來設計命名,實際上MVVM
的V
應該包括視圖控制器(ViewController)
和視圖(View)
,這里只是將其單獨分開,以便于更好的閱讀和開發。 - 必須強調
文件夾
的命名,這里筆者是按照主功能模塊
來命名,相信大家可以很清楚的看到View
、ViewController
、ViewModel
三個文件夾里面的子模塊文件夾都是一樣的。而后期若在設計子文件夾的時候,參照這種方式來創建文件夾,那么大家會發現,你的代碼目錄會非常非常的整齊漂亮,同時方便后期維護和其他開發人員閱讀代碼,何樂而不為呢。 - 同時強調一下自定義的視圖控制器和視圖模型的命名,理論上,一個視圖控制器配備一個視圖模型,所以筆者這里只是將視圖控制的名字的
ViewController
替換成ViewModel
即為配備的視圖模型的名字:例如:視圖控制器的名字為MHMainFrameViewController
,則視圖模型的名字為MHMainFrameViewModel
。這樣整個項目開發下來,你會發現ViewController
和ViewModel
文件下的文件都是對稱的。 - 目錄層級不能超過
三層
。因為層級越深,越不易查找,且不易閱讀。這里就以我的(Profile)
為例,我的(Profile)
界面有一個用戶信息(UserInfo)
子模塊,用戶信息(UserInfo)
里面有一個更多(MoreInfo)
子模塊,更多(MoreInfo)
模塊當然也有子模塊等等。如果這樣劃分,必然會導致目錄結構很深,所以為了避免其發生,就盡量限制在三層即可,正所謂事不過三嘛,所謂三層目錄可想而知,就是ViewController - Profile - UserInfo
這三層便是,那么我們就可將更多(MoreInfo)
模塊與用戶信息(UserInfo)
并列即可,當然你也可以將更多(MoreInfo)
模塊的寫在用戶信息(UserInfo)
里面,但是只創建文件,而不創建文件夾。只要保證不超過三層目錄即可。即如下圖所示:
- 代碼結構完全按照
第三方框架
第三方框架想必對與小伙伴在熟悉不過了,其作用簡而言之就是:輔助。讓我們更專注于產品的業務邏輯開發,而不是某個功能點開發。這里簡單介紹一下此次搭建微信(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 緩存框架,提供
內存緩存
和磁盤緩存
。
-
YYCategories:為
- 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
的存在在所難免,但是它在項目中的作用是舉足輕重的,簡直神一樣的存在。筆者這里主要詳述Model
、ViewController
、ViewModel
中的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.h
的API
的實現,內部封裝了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
這里筆者講講
shouldDisableWebViewTitle
和shouldDisableWebViewClose
這兩個屬性的作用以及使用場景。
shouldDisableWebViewTitle
: 是否取消導航欄的title
等于webView
的title
。默認做法是MHWebViewController
及其子類的導航欄title
為WebView
的title
,而不是MHViewModel
的title
屬性。即控制器通過KVO
的形式監聽WKWebView
的title
屬性,從而設置導航欄的title
,self.navigationItem.title = self.webView.title
。但是可能有幾個H5
界面想要設置導航欄的title
為MHViewModel
的title
屬性,正所謂需求拉動生成,所以就產生了該屬性。
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
中顯示和隱藏導航欄底部細線的方法,一般這兩個方法都是成對出現的,在ViewController
的viewWillAppear:
和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
的導航欄返回按鈕的title
是A.title
。當然如果考慮到A.title
的文字很長,那么需要自定義B
的導航欄返回按鈕的title
是< XXX
。(大家沒繞暈吧...)。這種自定義的做法需要結合MHViewModel
的backTitle
屬性。詳見代碼如下:/// 能攔截所有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
當然還有一些其他使命,比如統一設置UINavigationBar
和UIBarButtonItem
的主題。這里就不一一闡述了,詳見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
文件,筆者這里講講根據MHViewModel
的title
的屬性設置導航欄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
即可,很少需要將其子類化。通過綁定MHWebViewModel
的request
屬性來加載指定的網頁,只要你能熟練使用WkWebView
即可,其他的細節問題比如下拉刷新網頁、WKWebView
自適應屏幕、點擊網頁鏈接跳轉處理,以及多次跳轉網頁后的導航欄關閉按鈕的事件處理等... 請參考MHWebViewController.m
。MHWebViewController.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中的MHTabBarController
和MHTabBar
即可。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
的屬性,依次來達到產品的需求。在此可見MVVM
中VM(視圖模型)
的重要性。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
子類化后,在子類中設置了tableView
的contentInset
屬性,然而tableView
的contentOffset
始終是(0,0)
,非常的神奇,到目前為止筆者也不知其原因(PS:若知道的大神, 請說一聲哦),這樣就導致了筆者一個需求上的Bug,就是筆者項目中首頁是個商品列表,當你向下滑動到一定距離,屏幕右下角處會出現一個能夠點擊滾動到頂部的按鈕,點擊向上按鈕就可以滾動到頂部即可。實現過程無非就是監聽按鈕的點擊方法,實現[self.tableView setContentOffset:CGPointMake(0, 0) animated:YES];
即可(理論上)。但是如果采用Masonry
布局,就會出現點擊向上按鈕,你怎么也滾動不到頂部去,感覺tableView
抽風了。當然,大家可以利用筆者提供的MHDevelopExample_Objective_C的MVVM
那塊的內容進行復現或調試。
筆者采取的解決辦法是:筆者首先覺得可能tableView
還未布局好而導致的,所以在利用Masonry
布局tableView
時,在MHTableViewController
中強制布局了子控件,即調用[self.view layoutIfNeeded];
,結果也很神奇,就可以實現點擊向上按鈕,能滾動到頂部了。
但是...BUG還是出現了。如果MHTableViewModel
的dataSource
的數據不是通過- (RACSignal *)requestRemoteDataSignalWithPage:(NSUInteger)page
來獲取的網絡數據,而是在- (void)initialize
中就初始化的死數據,例如發現模塊
頁面中cell
的數據源。當我們的Cell
是xib
創建,且一般開發中會在MHTableViewController
的子類中的-(void)viewDidLoad
里面注冊tableViewCell
。切記:Bug復現條件必須是:TableViewModel
的dataSource
是必須死(本地)數據,而非網絡數據,并且是Cell
是用tableView
注冊來獲取的,缺一不可。這樣會導致如下圖所示的Bug。
UITableView崩潰.png如果開啟全局斷點,那么會崩潰定位到[self.view layoutIfNeeded]的位置,由于
強制布局(layoutIfNeeded)
視圖控制器的子控件,那么會導致tableView
提前刷新(reloadData)
其數據源的方法,而此時TableViewModel
的dataSource
的數據又是本地數據,一開始是會有值,從而會調用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"];
來獲取出來注冊(其實還未注冊)的cell
為nil
而導致崩潰。子類的偽代碼調用順序如下:/// 子類代碼邏輯順序 - (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
的時候,我也是一臉懵逼,因為我這里完美運行,而同事那里就蹦擦拉卡。后面才發現就是上面的偽代碼邏輯②處獲取的cell
為nil
導致的,而如果②采用筆者的獲取cell
的方法,是絕逼不會有問題的。但是考慮到同事是比較偏向于通過UITableView+FDTemplateLayoutCell來自動計算cell
高度并緩存cell
高度的方式開發,然而這框架的使用前提就是必須通過為Cell
注冊一個identifier
的方式。
所以筆者為了兼容同事的開發習慣,最終的做法是在MHTableViewController
中不使用Masonry
來布局tableView
,也不強制刷新(layoutIfNeeded)
視圖控制器的子控件。而是直接指定tableView
的frame
,即:UITableView *tableView = [[UITableView alloc] initWithFrame:[UIScreen mainScreen].bounds style:self.viewModel.style];
。如果子類想要修改tableView
的尺寸,再使用Masonry
來布局即可。所以,這就是最終的做法...
當然還有MHTableViewController
還有許多邏輯細節處理,這里就不在過多贅述,更多內容請參考Demo中的MHTableViewController
設計。
Q&A
Q:項目中若同時集成 YYCategories
和 ReactiveCocoa
,使用@weakify(self)
和@strongify(self);
將會報Ambiguous expansion of macro weakify
和Ambiguous expansion of macro strongify
的警告。
A:由于 YYCategories
和 ReactiveCocoa
都定義了weakify
和strongify
引起的。解決辦法如下:
知識點:怎樣去除Xcode中的警告?
Q: 在Xcode 9.0上,ReactiveCocoa(2.5)
報Unknown warning group '-Wreceiver-is-weak', ignored
的警告。
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
錯誤。
A:SafeArea的概念是在iOS 9.0
以后才支持,所以只需要設置項目支持的版本:設置Deployment Target
和iOS Deployment Target
為9.0以上即可。
總結
本篇主要介紹了筆者在使用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
調試工具。
期待
- 文章若對您有些許幫助,請給個喜歡??,畢竟碼字不易;若對您沒啥幫助,請給點建議??,切記學無止境。
- 針對文章所述內容,閱讀期間任何疑問;請在文章底部評論指出,我會火速解決和修正問題。
- GitHub地址:https://github.com/CoderMikeHe
- 源碼地址:WeChat