本文做爲 QQ 閱讀 7.0 改版總結,從架構、頁面元素模塊化、UI 組件化、基於 iOS 系統響應鏈的事件處理、業務模板化等方面闡述了一套高效的列表類應用開發解決方案。git
本文同時發表於個人我的博客github
QQ 閱讀迎來了7.0版本,做爲慣例大版本須要大動做——『UI大改版』。 本文主要是對此次改版的一個總結並提煉出一套通用的『列表類業務』開發解決方案。 本文將從如下幾個方面展開討論:算法
本文部份內容來自列表類應用場景模板化和自定義 UI 組件庫網絡
列表類業務應該說是大多數 App 的主要業務場景,如朋友圈、新聞類 App 首頁、各種個性化推薦頁、微博首頁以及咱們的書城等等。架構
列表類業務其流程主要是:模塊化
UITableView
、UICollectionView
)形式展現出來;對於列表類業務每一個項目團隊可能都有一套架構,在 QQ 閱讀不斷迭代的過程當中也演化出一套架構。 組件化
上面分別是咱們這套架構的關鍵類圖和時序圖。總體上是由經典 MVC 模式演化而來:MVC 模式飽受詬病的一點就是:Controller 常常會變得過於臃腫(Massive View Controller)。 爲了解決這一問題,業界提出了多種解決方案,大部分都是經過添加中間層,將 Controller 的功能分解到中間層上,如 MVP (Model View Presenter) 模式。佈局
爲了解決 Controller 臃腫問題,在咱們的架構中將頁面元素抽象成一個個的 Module。 動畫
如上圖,紅色虛線分隔的就是不一樣的 Module。今後,頁面的生成過程就是拼接組裝 Module 的過程。在 TableView 中一個 Module 對應一個 section。 Module 的職責主要有:ui
UITableViewDataSource
協議);——即負責『模塊』的全部邏輯(與 React Component 相似)。
經過上述分析可知,Module 解析、存儲業務數據,Manager 存儲、管理 Module。
這種作法也存在弊端,因爲將解析業務數據、控制 UI 展現的邏輯(建立 cell 等)都放在了 Module 中。使得 Module 違反了『單一職責原則』。
『單一職責原則』(SRP)做爲面向對象設計的五大原則『SOLID』之一,很容易理解,也很難把握!『就好像生活中的各類"適量",適量放點鹽、適量加點水…』 Bob大叔在《敏捷軟件開發》中,將類的單一職責原則描述爲『應該僅有一個引發它變化的緣由』。
在 Module 中,業務數據解析、UI 展現就是兩個可變的因素——『一樣的 UI 用於展現不一樣的網絡協議返回的數據、同一協議返回的數據展現爲不用的 UI』。 在 QQ 閱讀中,書籍列表頁就屬於『一樣的 UI 展現不一樣協議返回的數據』:
針對這種狀況,無非就是將其中一個變化因子抽取出來,如將業務數據解析抽取爲一個單獨的類。 因爲 Module 中這兩個變化因子變更的機率並不大,爲了下降複雜度,只有在真正須要時纔將這二者分離開。『敏捷開發』的原則之一就是儘可能保持代碼簡單、並在必要時進行重構,防止代碼變壞。
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath
{
QRBaseModule *module = [self.manager moduleAtIndex:indexPath.section];
return [module heightForRow:indexPath.row];
}
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
{
QRBaseModule *module = [self.manager moduleAtIndex:section];
return [module numberOfRows];
}
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
QRBaseModule *module = [self.manager moduleAtIndex:indexPath.section];
return [module cellForRow:indexPath.row tableView:tableView];
}
- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView
{
return [self.manager moduleCount];
}
複製代碼
如上述代碼,模塊化後UITableViewDelegate
、UITableViewDataSource
的大部分方法都轉發給相應的 Module 去處理,大大簡化了 Controller 的複雜度。
另外,在頁面上增刪任何元素都只需在 Manager 中增刪相應的 Module 便可,Controller 無需任何改動——在 Controller 層面遵照了開放-封閉原則『OCP』。
模塊化不只簡化了 Controller,同時也提升了代碼的複用性。Module 能夠在不一樣頁面間複用。若是這些邏輯所有放在 Controller 裏,基本沒有複用性可言。
模塊化有沒有缺點? 答案是確定的😒:
UITableViewDelegate
、UITableViewDataSource
的部分方法);固然啦,我的認爲利大於弊😊
QQ 閱讀7.0改版,UI 修改的工做量佔大頭,涉及200多個頁面的修改。 此時,充分體現出 UI 複用的重要性。
雖然,咱們很早就提出經過 View-ViewModel 的方式實現 UI 組件化,提升複用性。 遺憾的是,因爲歷史緣由,在咱們的工程中依然存在大量重複的實現,即『同一 UI 樣式,N 份實現』。這對於 UI 大改版是災難性了!——「不只工做量成倍增長,還有漏改的可能性」
爲了不災難再次上演(8.0、9.0...),這次改版過程當中,咱們嚴格要求全部 UI 都必須以 View-ViewModel 模式作成 UI 組件。
在繼續以前,咱們簡單描述一下什麼是 UI 組件:
同時,咱們將 UI 組件分爲外部 UI 組件、內部 UI 組件:
QRExternalUIComponent
協議,使得業務方可靈活控制其邊距;@protocol QRExternalUIComponent <NSObject>
- (void)setEdgeInsets:(UIEdgeInsets)edgeInsets;
@end
複製代碼
複用沒把握好火候就變成耦合了。
例1.
咱們書城頂部 banner 有如上圖的推書樣式、通欄廣告圖樣式、還有柱狀圖動畫樣式。 在實現的時候,統統將這些樣式塞到一個類裏面,經過if...else...
區分,這就是嚴重的耦合,給後面的維護形成很大的困難。
例2.
三書與四書 UI 也是經過if...else...
區分,內部還要處理六書、八書的狀況,還要兼容 iPad,內部實現異常複雜,致使你們都不敢去碰這塊代碼。
爲此,咱們制定了以下規則:
Template Method: Define the skeleton of an algorithm in an operation, deferring some steps to subclasses. Template Method lets subclasses redefine certain steps of an algorithm without changing the algorithm's structure. 此例中 UI 佈局就是 Template Method 中的算法結構,而佈局的細節則能夠經過子類去控制。
咱們常常吐槽 QQ 閱讀 UI 的多樣性在業界能排 Top1。
如上圖單書組件,其中紅框框住的部分就有 1五、16種變幻。 爲此,咱們將這部分抽取出來,做爲單書組件的一個子組件由使用方負責構造該子組件並傳給單書組件去展現。好了,下面進入本節的正題,如何構造出複用性高的 UI 組件。
高可複用的 UI 組件,至少要知足如下兩點:
總之,UI 組件要與業務解耦。 此時,MVVM 模式進入咱們的視線,在該模式中 ViewModel 的存在是否是很好的解決了上面的問題。 在 MVVM 模式中,ViewModel 向上爲 View 提供展現數據(該數據已經在 ViewModel 中處理好了,View 無需任何處理,只要展現便可),向下接收來自業務層的數據,處理相關的業務展現邏輯。
能夠看出,ViewModel 做爲中間層很好地將業務與 UI 隔離開。 說到 MVVM,不少同窗並不喜歡,以爲其中的 Data-Binding 很麻煩,但咱們構建 UI 組件時用到的是 View-ViewModel 結構,並不要求必定是 MVVM,在 MVC 等模式下也可以使用。
同時,咱們採用的是面向接口的模式,View 對外依賴的是接口(protocol),而不是某個具體的 ViewModel。每一個 UI 組件其結構以下:
如上圖所示,若某個 UI 組件被多個業務所複用,能夠根據需求定義多個 ViewModel 以處理不一樣的業務邏輯,每一個 ViewModel 都實現ViewModelProtocol
協議爲 View 提供數據。
如上文提到的單書 UI,咱們抽取爲一個組件QRLeftPictureRightTextView
:
Module 與 UI 組件在兩個不一樣的層面實現複用。
現有的事件處理方案有兩大痛點,因而提出了基於響應鏈『Chain of Responsibility』的事件處理方案。
cell.delegate = controller;
view.delegate = cell;
…
複製代碼
尤爲是第一點一直困擾着我。直到前不久在《Design Patterns》一書中看到在介紹『Chain of Responsibility』模式時的一句話:『Using existing links works well when the links support the chain you need. It saves you from defining links explicitly, and it saves space』。 UIResponder
中的 nextResponder
不正是這個『existing links』嗎! 最上層 View 的事件經過nextResponder
鏈就能夠順利傳到 ViewController 中,從而也就省去了 delegate 的逐級傳遞了,痛點一、2隨之化解。 爲此,咱們爲 UIResponder
添加了傳遞、處理事件的分類:
@protocol ZSCEvent <NSObject>
@property (nonatomic, strong) __kindof UIResponder *sender;
@property (nonatomic, strong) NSIndexPath *indexPath;
@property (nonatomic, strong) NSMutableDictionary *userInfo;
@end
@interface UIResponder (ZSCEvent)
- (void)respondEvent:(NSObject<ZSCEvent> *)event;
@end
@implementation UIResponder (ZSCEvent)
- (void)respondEvent:(NSObject<ZSCEvent> *)event
{
[self.nextResponder respondEvent:event];
}
@end
複製代碼
UIResponder
的實現只是簡單地將事件傳遞給nextResponder
。 因爲 View 不包含業務數據,因此事件傳遞的過程當中須要不斷添加一些信息。
所以,咱們將
ZSCEvent#userInfo
定義爲 mutable。正常狀況下外露接口通常都是 immutable。
@implementation UITableViewCell (ZSCEvent)
- (void)respondEvent:(NSObject<ZSCEvent> *)event
{
event.sender = self;
[self.nextResponder respondEvent:event];
}
@end
複製代碼
如,在UITableViewCell
的respondEvent:
中須要將sender
設置爲self
,以便在UIViewController
中能夠經過cell
找到對應的 Module。
- (void)respondEvent:(NSObject<ZSCEvent> *)event
{
NSAssert([event.sender isKindOfClass:UITableViewCell.class], @"event sender must be UITableViewCell");
if (![event.sender isKindOfClass:UITableViewCell.class]) {
return;
}
NSIndexPath *indexPath = [_tableView indexPathForCell:event.sender];
id<ZSModule> module = [self.manager moduleAtIndex:indexPath.section];
event.sender = self;
event.indexPath = indexPath;
[event.userInfo setObject:_tableView
forKey:ZSCEventUserInfoKeys.tableView];
[module handleEvent:event];
}
複製代碼
在 View 中的事件處理代碼能夠這樣:
- (void)_clickedButton:(id)sender
{
ZSCEvent *event = [[ZSCEvent alloc] init];
event.sender = self;
[event.userInfo setObject:@(YES) forKey:@"clickedButton"];
[self respondEvent:event];
}
複製代碼
若是一個 cell 中有多個事件須要處理,就須要在
userInfo
中加以區分,如上面代碼第5
行。
總之,經過UIResponder
的nextResponder
響應鏈,沒必要再在 view 的層級間傳遞 delegate,減小了瑣碎的代碼,提升了開發效率。同時也統一規範了事件處理方案。
列表類應用場景模板化一文對此有詳細的描述,在此就不贅述了。 其效果仍是不錯的。 不少二級頁,因爲 Module 是徹底複用的,經過模板化腳本半小時就能作好一個二級頁✌️。
簡單、高效一直是軟件開發、工程管理追求的目標,本文從實際項目經驗出發,從架構、解耦、複用等角度總結出一套開發解決方案。