雖然 iOS 組件化與路由的話題在業界談了好久,可是貌似不少人都對其有所誤解,甚至沒搞明白「組件」、「模塊」、「路由」、「解耦」的含義。html
相關的博文也蠻多,其實除了那幾個名家寫的,具備參考價值的不多,何況名家的觀點也並不是都徹底正確。架構每每須要權衡業務場景、學習成本、開發效率等,因此架構方案能客觀解釋卻又帶了些主觀色彩,加上些我的特點的修飾就特別容易讓人本末倒置。ios
因此要保持頭腦清晰,以辯證的態度看待問題,如下是業界比較有參考價值的文章:
iOS應用架構談 組件化方案
蘑菇街 App 的組件化之路
iOS 組件化 —— 路由設計思路分析
Category 特性在 iOS 組件化中的應用與管控
iOS 組件化方案探索git
本文主要是筆者對 iOS 組件化和路由的理解,力求以更客觀與簡潔的方式來解釋各類方案的利弊,歡迎批評指正。github
本文的 DEMOweb
因此從你們實施「組件化」的目的來看,叫作「模塊化」彷佛更爲合理。安全
但「組件」與「模塊」都是前人定義的意義,「iOS 組件化」的概念也已經先入爲主,因此只須要明白「iOS 組件化」更多的是作業務模塊之間的解耦就好了。bash
首先要明確的是,路由並不是只是指的界面跳轉,還包括數據獲取等幾乎全部業務。服務器
效仿 web 路由,最初的 iOS 原生路由看起來是這樣的:網絡
[Mediator gotoURI:@"protocol://detail?name=xx"];
複製代碼
缺點很明顯:字符串 URI 並不能表徵 iOS 系統原生類型,要閱讀對應模塊的使用文檔,大量的硬編碼。閉包
代碼實現大概就是:
+ (void)gotoURI:(NSString *)URI {
解析 URI 獲得目標和參數
NSString *aim = ...;
NSDictionary *parmas = ...;
if ([aim isEqualToString:@"Detail"]) {
DetailController *vc = [DetailController new];
vc.name = parmas[@"name"];
[... pushViewController:vc animated:YES];
} else if ([aim isEqualToString:@"list"]) {
...
}
}
複製代碼
形象一點:
拿到 URI 事後,始終有轉換爲目標和參數 (aim/params
) 的邏輯,而後再真正的調用原生模塊。顯而易見,對於內部調用來講,解析 URI 這一步就是多此一舉 (casa 在博客中說過這個問題)。
路由方法簡化以下:
+ (void)gotoDetailWithName:(NSString *)name {
DetailController *vc = [DetailController new];
vc.name = name;
[... pushViewController:vc animated:YES];
}
複製代碼
使用起來就很簡單了:
[Mediator gotoDetailWithName:@"xx"];
複製代碼
如此,方法的參數列表便能替代額外的文檔,而且通過編譯器檢查。
那麼對於外部調用,只須要爲它們添加 URI 解析的適配器就能解決問題:
統一路由調用類便於管理和使用,因此一般須要定義一個Mediator
類。又考慮到不一樣模塊的維護者都須要修改Mediator
來添加路由方法,可能存在工做流衝突。因此利用裝飾模式,爲每個模塊添加一個分類是不錯的實踐:
@interface Mediator (Detail)
+ (void)gotoDetailWithName:(NSString *)name;
@end
複製代碼
而後對應模塊的路由方法就寫到對應的分類中。
這裏的封裝,解除了業務模塊之間的直接耦合,然而它們仍是間接耦合了(由於路由類須要導入具體業務):
不過,一個簡單的路由不需關心耦合問題,就算是這樣一個簡單的處理也有以下好處:
動態調用,顧名思義就是調用路徑在不更新 App 的狀況下發生變化。好比點擊 A 觸發跳轉到 B 界面,某一時刻又須要點擊 A 跳轉到 C 界面。
要保證最小粒度的動態調用,就須要目標業務的完整信息,好比上面說的aim
和params
,即目標和參數。
而後須要一套規則,這個規則有兩個來源:
+ (void)gotoDetailWithName:(NSString *)name {
if (本地防禦邏輯判斷 DetailController 出現異常) {
跳轉到 DetailOldController
return;
}
DetailController *vc = [DetailController new];
vc.name = name;
[... pushViewController:vc animated:YES];
}
複製代碼
開發者須要明確的知道「某個業務」支持動態調用而且動態調用的目標是「某個業務」。也就是說,這是一種「僞」動態調用,代碼邏輯是寫死的,只是觸發點是動態的而已。
試想,上面那種方法+ (void)gotoDetailWithName:(NSString *)name;
能支持自動的動態調用麼?
答案是否認的,要實現真正的「自動化」,必需要知足一個條件:須要全部路由方法的一個切面。
這個切面的目的就是攔截路由目標和參數,而後作動態調度。一提到 AOP 你們可能會想到 Hook 技術,可是對於下面兩個路由方法:
+ (void)gotoDetailWithName:(NSString *)name;
+ (void)pushOldDetail;
複製代碼
你沒法找到它們之間的相同點,難以命中。
因此,拿到一個切面的方法筆者能想到的只有一個:統一路由方法入口。
定義這樣一個方法:
- (void)gotoAim:(NSString *)aim params:(NSDictionary *)params {
一、動態調用邏輯(經過服務器下發配置判斷)
二、經過 aim 和 params 動態調用具體業務
}
複製代碼
(關於如何動態調用具體業務的技術實現後文會講,這裏先不用管,只須要知道這裏經過這兩個參數就能動態定位到具體業務。)
而後,路由方法裏面就這麼寫了:
+ (void)gotoDetailWithName:(NSString *)name {
[self gotoAim:@"detail" params:@{@"name":name}];
}
複製代碼
注意@"detail"
是約定好的 Aim,內部能夠動態定位到具體業務。
圖解以下:
固然,外部調用能夠不通過內部調用,那麼就能夠作到具體業務無感知的動態定位本地資源:
因而可知,統一路由方法入口必然須要硬編碼,對於此方案來講自動化的動態調用必然須要硬編碼。
那麼,這裏使用一個分類方法+ (void)gotoDetailWithName:(NSString *)name;
將硬編碼包裝起來是個不錯的選擇,把這些 hard code 交給對應業務的工程師去維護吧。
Casa 的 CTMediator 分類就是如此作的,而這也正是蘑菇街組件化方案能夠優化的地方。
前面筆者表達了內部調用不須要走 URI 的觀點(請查看 圖3 以及其演變)。
可能有些朋友以爲內部調用只須要在 URI 那一套上再封裝一層編譯器可檢查的語法糖(好比一個分類),就變成下圖這個樣子:
Q1:能夠理解的是,這樣處理看起來有一個能說服人的理由:全部的路由調用都統一通過了 URI 解析。那麼,這個解析 URL 的方法就至關於一個攔截器了,彷佛能作到上面提到的動態調用?
A1:然而,這樣只能支持預知的動態調用,也就是說,你須要明確某一個具體的業務,而後寫上一些「死」代碼,只能讓觸發點是動態的。那麼這樣的預知的動態調用代碼都寫在「內部調用(語法糖)」裏面就能夠了,經不通過統一的 URI 解析根本不重要了,這樣的代碼集中在一處與散落各地沒有區別。
下面是解耦方式:
Q2:可能有人會說,「圖3 - 演變 - URI解耦」作法,不須要導入具體的業務代碼,不就實現了自動化的動態調用了?
A2:然而,這樣作後不就至關於有兩個攔截器了?解耦方式調用業務,自己就擁有了一個統一的入口了。因此,內部調用通過這個統一的 URI 解析方法攔截器就沒有意義了。
Q3:能夠又有人說,他只是想經過統一的 URI 解析攔截入口作一些事情,並非作自動化的動態調用。
A3:這也就是意味着,他不會在這個攔截入口中作對具體業務的徹底解耦,且攔截作的這些事情與具體業務無關(若與具體業務有關又回到了 Q1 的問題),那麼就是「圖3 - 演變 - URI」作法。這種場景彷佛能成爲一個理由,好比記錄全部路由調用卻又不涉及具體業務模塊?可是內部調用不通過 URI 解析也能作到:
不要說這樣會產生硬編碼,由於內部調用通過 URL 解析仍然有硬編碼。
筆者的觀點是:內部調用走 URI 方式是沒必要要的。若是你非要這麼作,筆者說一下缺點:
aim / params
時可能須要轉換爲原生參數(好比字符串轉 NSData)的工做,那麼內部調用 (須要將 NSData 轉換爲字符串) -> URI 解析 (再將字符串轉換爲 NSData) -> aim / pamras
,明顯轉換過程多餘了。(casa 在博客中也大概說了一下這個問題)在軟件開發中,「統一」彷佛成爲了一個強迫症思惟,其實應該結合具體業務深刻場景,分析真正的意義才能更好的實施架構。
可能這部分表述有些抽象,若有疑問歡迎在文末留言(留言仍是在簡書好一點,方便筆者回復,其它轉發平臺的回覆不必定能及時看到)。
能夠發現筆者用了大篇幅講了路由,卻未說起組件化,那是由於有路由不必定須要組件化。
路由的設計主要是考慮需不須要作全鏈路的自動化動態調用,列舉幾個場景:
能夠發現,真正的全鏈路動態調用成本是很是高的。
前面對路由的分析提到了使用目標和參數 (aim/params
) 動態定位到具體業務的技術點。實際上在 iOS Objective-C 中大概有反射和依賴注入兩種思路:
aim
轉化爲具體的Class
和SEL
,利用 runtime 運行時調用到具體業務。aim
映射到一段代碼,調用時執行具體業務。能夠明確的是,這兩種方式都已經讓Mediator
免去了對業務模塊的依賴:
而這些解耦技術,正是 iOS 組件化的核心。
組件化主要目的是爲了讓各個業務模塊獨立運行,互不干擾,那麼業務模塊之間的徹底解耦是必然的,同時對於業務模塊的拆分也很是考究,更應該追求功能獨立而不是最小粒度。
爲 Mediator 定義了一個統一入口方法:
/// 此方法就是一個攔截器,可作容錯以及動態調度
- (id)performTarget:(NSString *)target action:(NSString *)action params:(NSDictionary *)params {
Class cls; id obj; SEL sel;
cls = NSClassFromString(target);
if (!cls) goto fail;
sel = NSSelectorFromString(action);
if (!sel) goto fail;
obj = [cls new];
if (![obj respondsToSelector:sel]) goto fail;
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
return [obj performSelector:sel withObject:params];
#pragma clang diagnostic pop
fail:
NSLog(@"找不到目標,寫容錯邏輯");
return nil;
}
複製代碼
簡單寫了下代碼,原理很簡單,可用 Demo 測試。對於內部調用,爲每個模塊寫一個分類:
@implementation BMediator (BAim)
- (void)gotoBAimControllerWithName:(NSString *)name callBack:(void (^)(void))callBack {
[self performTarget:@"BTarget" action:@"gotoBAimController:" params:@{@"name":name, @"callBack":callBack}];
}
@end
複製代碼
能夠看到這裏是給BTarget
發送消息:
@interface BTarget : NSObject
- (void)gotoBAimController:(NSDictionary *)params;
@end
@implementation BTarget
- (void)gotoBAimController:(NSDictionary *)params {
BAimController *vc = [BAimController new];
vc.name = params[@"name"];
vc.callBack = params[@"callBack"];
[UIViewController.yb_top.navigationController pushViewController:vc animated:YES];
}
@end
複製代碼
定義分類的目的前面也說了,至關於一個語法糖,讓調用者輕鬆使用,讓 hard code 交給對應的業務工程師。
可能有些人對這些類的管理存在疑慮,下圖就表示它們的關係(一個塊表示一個 repo):
圖中「注意」處箭頭,B 模塊是否須要引入它本身的分類 repo,取決因而否須要作全部界面跳轉的攔截,若是須要那麼 B 模塊仍然要引入本身的 repo 使用。
完整的方案和代碼能夠查看 Casa 的 CTMediator,設計得比較完備,筆者沒挑出什麼毛病。
下面簡單實現了兩個方法:
- (void)registerKey:(NSString *)key block:(nonnull id _Nullable (^)(NSDictionary * _Nullable))block {
if (!key || !block) return;
self.map[key] = block;
}
/// 此方法就是一個攔截器,可作容錯以及動態調度
- (id)excuteBlockWithKey:(NSString *)key params:(NSDictionary *)params {
if (!key) return nil;
id(^block)(NSDictionary *) = self.map[key];
if (!block) return nil;
return block(params);
}
複製代碼
維護一個全局的字典 (Key -> Block),只須要保證閉包的註冊在業務代碼跑起來以前,很容易想到在+load
中寫:
@implementation DRegister
+ (void)load {
[DMediator.share registerKey:@"gotoDAimKey" block:^id _Nullable(NSDictionary * _Nullable params) {
DAimController *vc = [DAimController new];
vc.name = params[@"name"];
vc.callBack = params[@"callBack"];
[UIViewController.yb_top.navigationController pushViewController:vc animated:YES];
return nil;
}];
}
@end
複製代碼
至於爲何要使用一個單獨的DRegister
類,和前面「Runtime 解耦」爲何要定義一個Target
是一個道理。一樣的,使用一個分類來簡化內部調用(這是蘑菇街方案能夠優化的地方):
@implementation DMediator (DAim)
- (void)gotoDAimControllerWithName:(NSString *)name callBack:(void (^)(void))callBack {
[self excuteBlockWithKey:@"gotoDAimKey" params:@{@"name":name, @"callBack":callBack}];
}
@end
複製代碼
能夠看到,Block 方案和 Runtime 方案 repo 架構上能夠基本一致(見圖6),只是 Block 多了註冊這一步。
爲了靈活性,Demo 中讓 Key -> Block,這就讓 Block 裏面要寫不少代碼,若是縮小範圍將 Key -> UIViewController.class 能夠減小注冊的代碼量,但這樣又難以覆蓋全部場景。
註冊所產生的內存佔用並非負擔,主要是大量的註冊可能會明顯拖慢啓動速度。
這種方式仍然要註冊,使用一個全局的字典 (Protocol -> Class) 存儲起來。
- (void)registerService:(Protocol *)service class:(Class)cls {
if (!service || !cls) return;
self.map[NSStringFromProtocol(service)] = cls;
}
- (id)getObject:(Protocol *)service {
if (!service) return nil;
Class cls = self.map[NSStringFromProtocol(service)];
id obj = [cls new];
if ([obj conformsToProtocol:service]) {
return obj;
}
return nil;
}
複製代碼
定義一個協議服務:
@protocol CAimService <NSObject>
- (void)gotoCAimControllerWithName:(NSString *)name callBack:(void (^)(void))callBack;
@end
複製代碼
用一個類實現協議而且註冊協議:
@implementation CAimServiceProvider
+ (void)load {
[CMediator.share registerService:@protocol(CAimService) class:self];
}
#pragma mark - <CAimService>
- (void)gotoCAimControllerWithName:(NSString *)name callBack:(void (^)(void))callBack {
CAimController *vc = [CAimController new];
vc.name = name;
vc.callBack = callBack;
[UIViewController.yb_top.navigationController pushViewController:vc animated:YES];
}
@end
複製代碼
至於爲何要使用一個單獨的ServiceProvider
類,和前面「Runtime 解耦」爲何要定義一個Target
是一個道理。
使用起來很優雅:
id<CAimService> service = [CMediator.share getObject:@protocol(CAimService)];
[service gotoCAimControllerWithName:@"From C" callBack:^{
NSLog(@"CAim CallBack");
}];
複製代碼
看起來這種方案不須要硬編碼很舒服,可是它有個致命的問題 ——— 沒法攔截全部路由方法。
這也就意味着這種方案作不了自動化動態調用。
阿里的 BeeHive 是目前的最佳實踐。註冊部分它能夠將待註冊的類字符串寫入 Data 段,而後在 Image 加載的時候讀取出來註冊。這個操做只是將註冊的執行放到了+load
方法以前,仍然會拖慢啓動速度,因此這個優化筆者沒有看到價值。
想象一下,解耦意味着調用方只有系統原生的標識,如何定位到目標業務? 必然有個映射。 而 runtime 能夠直接調用目標業務,其它兩種方式只有創建映射表。 固然 Protocol 方式也能夠不創建映射表,直接遍歷全部類,找出遵循這個協議的類也能找到,不過明顯這樣是低效且不安全的。
對於不少項目來講,並不是一開始就須要實施組件化,爲了不在未來業務穩定須要實施的時候一籌莫展,在項目之初最好有一些前瞻性的設計,同時編碼過程當中也要儘可能下降各個業務模塊的耦合。
在設計路由時,儘可能下降未來組件化時的遷移成本,因此理解各類方案的實施條件很重要。若是項目未來幾乎不可能作自動化動態路由,那麼使用 Protocol -> Class 方案就能去除硬編碼;不然,仍是使用 Runtime 或者 Key -> Block 方案,二者都有不一樣程度的硬編碼但 Runtime 不須要註冊。
設計一個方案時,最好的方式是窮舉全部方案,分別找出優點和劣勢,而後根據業務需求,進行權衡和取捨。可能有的時候業界的方案並不徹底適合本身的項目,這個時候就須要作一些創造性的改進。
不要總說「就應該是這樣」,而多想「爲何要這樣」。