來自公眾號:知識小集
連結:https://www.jianshu.com/p/40060fa2a564
前言
雖然 iOS 元件化與路由的話題在業界談了很久,但是貌似很多人都對其有所誤解,甚至沒搞明白“元件”、“模組”、“路由”、“解耦”的含義。
相關的博文也蠻多,其實除了那幾個名家寫的,具有參考價值的很少,況且名家的觀點也並非都完全正確。架構往往需要權衡業務場景、學習成本、開發效率等,所以架構方案能客觀解釋卻又帶了些主觀色彩,加上些個人特色的修飾就特別容易讓人本末倒置。
所以要保持頭腦清晰,以辯證的態度看待問題,以下是業界比較有參考價值的文章:
-
iOS應用架構談 元件化方案[1]
-
蘑菇街 App 的元件化之路[2]
-
iOS 元件化 —— 路由設計思路分析[3]
-
Category 特性在 iOS 元件化中的應用與管控[4]
-
iOS 元件化方案探索[5]
本文主要是筆者對 iOS 元件化和路由的理解,力求以更客觀與簡潔的方式來解釋各種方案的利弊,歡迎批評指正。
本文的 DEMO[6]
https://links.jianshu.com/go?to=https%3A%2F%2Fgithub.com%2FindulgeIn%2FYBRouterAndDecouplingDemo
一、元件與模組的區別
-
“元件”強調的是復用,它被各個模組或元件直接依賴,是基礎設施,它一般不包含業務或者包含弱業務,屬於縱向分層(比如網路請求元件、圖片下載元件)。
-
“模組”強調的是封裝,它更多的是指功能獨立的業務模組,屬於橫向分層(比如購物車模組、個人中心模組)。
所以從大家實施“元件化”的目的來看,叫做“模組化”似乎更為合理。
但“元件”與“模組”都是前人定義的意義,“iOS 元件化”的概念也已經先入為主,所以只需要明白“iOS 元件化”更多的是做業務模組之間的解耦就行了。
二、路由的意義
首先要明確的是,路由並非只是指的介面跳轉,還包括資料獲取等幾乎所有業務。
(一) 簡單的路由
內部呼叫的方式
效仿 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 方式呼叫
那麼對於外部呼叫,只需要為它們新增 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 {
1、動態呼叫邏輯(透過伺服器下發配置判斷)
2、透過 aim 和 params 動態呼叫具體業務
}
(關於如何動態呼叫具體業務的技術實現後文會將,這裡先不用管,只需要知道這裡透過這兩個引數就能動態定位到具體業務。)
然後,路由方法裡面就這麼寫了:
+ (void)gotoDetailWithName:(NSString *)name {
[self gotoAim:@”detail” params:@{@”name”:name}];
}
註意@"detail"
是約定好的 Aim,內部可以動態定位到具體業務。
由此可見,統一路由方法入口必然需要硬編碼,對於此方案來說自動化的動態呼叫必然需要硬編碼。
那麼,這裡使用一個分類方法+ (void)gotoDetailWithName:(NSString *)name;
將硬編碼包裝起來是個不錯的選擇,把這些 hard code 交給對應業務的工程師去維護吧。
Casa 的 CTMediator 分類就是如此做的,而這也正是蘑菇街元件化方案可以最佳化的地方。
路由總結
可以發現筆者用了大篇幅講了路由,卻未提及元件化,那是因為有路由不一定需要元件化。
路由的設計主要是考慮需不需要做全鏈路的自動化動態呼叫,列舉幾個場景:
-
原生頁面出現問題,需要切換到對應的 wap 頁面。
-
wap 訪問流量過大切換到原生頁面降低消耗。
可以發現,真正的全鏈路動態呼叫成本是非常高的。
三、元件化的意義
前面對路由的分析提到了使用標的和引數 (aim/params
) 動態定位到具體業務的技術點。實際上在 iOS Objective-C 中大概有反射和依賴註入兩種思路:
-
將
aim
轉化為具體的Class
和SEL
,利用 runtime 執行時呼叫到具體業務。 -
對於程式碼來說,行程空間是共享的,所以維護一個全域性的對映表,提前將
aim
對映到一段程式碼,呼叫時執行具體業務。
可以明確的是,這兩種方式都已經讓Mediator
免去了對業務模組的依賴:
而這些解耦技術,正是 iOS 元件化的核心。
元件化主要目的是為了讓各個業務模組獨立執行,互不幹擾,那麼業務模組之間的完全解耦是必然的,同時對於業務模組的拆分也非常考究,更應該追求功能獨立而不是最小粒度。
(一) Runtime 解耦
為 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;
return [obj performSelector:sel withObject:params];
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 交給對應的業務工程師。
為什麼要定義 Target “靶子”
-
避免同一模組路由邏輯散落各地,便於管理。
-
路由並非只有控制器跳轉,某些業務可能無法放程式碼(比如網路請求就需要額外建立類來接受路由呼叫)。
-
便於方案的接入和摒棄(靈活性)。
可能有些人對這些類的管理存在疑慮,下圖就表示它們的關係(一個塊表示一個 repo):
圖中“註意”處箭頭,B 模組是否需要引入它自己的分類 repo,取決於是否需要做所有介面跳轉的攔截,如果需要那麼 B 模組仍然要引入自己的 repo 使用。
完整的方案和程式碼可以檢視 Casa 的 CTMediator,設計得比較完備,筆者沒挑出什麼毛病。
(二) Block 解耦
下麵簡單實現了兩個方法:
– (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 解耦
這種方式仍然要註冊,使用一個全域性的字典 (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];
}
– (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
方法之前,仍然會拖慢啟動速度,所以這個最佳化筆者沒有看到價值。
元件化總結
對於很多專案來說,並非一開始就需要實施元件化,為了避免在將來業務穩定需要實施的時候束手無策,在專案之初最好有一些前瞻性的設計,同時編碼過程中也要儘量降低各個業務模組的耦合。
在設計路由時,儘量降低將來元件化時的遷移成本,所以理解各種方案的實施條件很重要。如果專案將來幾乎不可能做自動化動態路由,那麼使用 Protocol -> Class 方案就能去除硬編碼;否則,還是使用 Runtime 或者 Key -> Block 方案,兩者都有不同程度的硬編碼但是前者不需要註冊。
後語
設計一個方案時,最好的方式是窮舉所有方案,分別找出優勢和劣勢,然後根據業務需求,進行權衡和取捨。可能有的時候業界的方案並不完全適合自己的專案,這個時候就需要做一些創造性的改進。
不要總說“就應該是這樣”,而多想“為什麼要這樣”。
參考
[1]https://casatwy.com/iOS-Modulization.html
[2]https://limboy.me/tech/2016/03/10/mgj-components.html
[3]https://www.jianshu.com/p/76da56b3bd55
[4]https://tech.meituan.com/2018/11/08/ios-category-module-communicate.html
[5]http://blog.cnbang.net/tech/3080/
[6]https://github.com/indulgeIn/YBRouterAndDecouplingDemo
朋友會在“發現-看一看”看到你“在看”的內容