前言
雖然 iOS 組件化與路由的話題在業界談了很久,但是貌似很多人都對其有所誤解,甚至沒搞明白“組件”、“模塊”、“路由”、“解耦”的含義。
相關的博文也蠻多,其實除了那幾個名家寫的,具有參考價值的很少,況且名家的觀點也并非都完全正確。架構往往需要權衡業務場景、學習成本、開發效率等,所以架構方案能客觀解釋卻又帶了些主觀色彩,加上些個人特色的修飾就特別容易讓人本末倒置。
所以要保持頭腦清晰,以辯證的態度看待問題,以下是業界比較有參考價值的文章:
iOS應用架構談 組件化方案
蘑菇街 App 的組件化之路
iOS 組件化 —— 路由設計思路分析
Category 特性在 iOS 組件化中的應用與管控
iOS 組件化方案探索
本文主要是筆者對 iOS 組件化和路由的理解,力求以更客觀與簡潔的方式來解釋各種方案的利弊,歡迎批評指正。
本文的 DEMO
一、組件與模塊的區別
image.png
“組件”強調的是復用,它被各個模塊或組件直接依賴,是基礎設施,它一般不包含業務或者包含弱業務,屬于縱向分層(比如網路請求組件、圖片下載組件)。
“模塊”強調的是封裝,它更多的是指功能獨立的業務模塊,屬于橫向分層(比如購物車模塊、個人中心模塊)。
所以從大家實施“組件化”的目的來看,叫做“模塊化”似乎更為合理。
但“組件”與“模塊”都是前人定義的意義,“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"]) { ... }}
形象一點:
image.png
拿到 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 決議的配接器就能解決問題:
image.png
路由方法寫在哪兒
統一路由呼叫類便于管理和使用,所以通常需要定義一個Mediator類。又考慮到不同模塊的維護者都需要修改Mediator來添加路由方法,可能存在作業流沖突。所以利用裝飾模式,為每一個模塊添加一個分類是不錯的實踐:
@interface Mediator (Detail)+ (void)gotoDetailWithName:(NSString *)name;@end
然后對應模塊的路由方法就寫到對應的分類中。
簡單路由的作用
這里的封裝,解除了業務模塊之間的直接耦合,然而它們還是間接耦合了(因為路由類需要匯入具體業務):
image.png
不過,一個簡單的路由不需關心耦合問題,就算是這樣一個簡單的處理也有如下好處:
清晰的引數串列,方便呼叫者使用。
解開業務模塊之間的耦合,業務更改時或許介面不需變動,外部呼叫就不用更改代碼。
就算是業務更改,路由方法必須得變動,得益于編譯器的檢查,也能直接定位呼叫位置進行更改。
(二) 支持動態呼叫的路由
動態呼叫,顧名思義就是呼叫路徑在不更新 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免去了對業務模塊的依賴:
image.png
而這些解耦技術,正是 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;#pragma clang diagnostic push#pragma clang diagnostic ignored "-Warc-performSelector-leaks" return [obj performSelector:sel withObject:params];#pragma clang diagnostic popfail: 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):
image.png
圖中“注意”處箭頭,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 - (void)gotoCAimControllerWithName:(NSString *)name callBack:(void (^)(void))callBack;@end
用一個類實作協議并且注冊協議:
@implementation CAimServiceProvider+ (void)load { [CMediator.share registerService:@protocol(CAimService) class:self];}#pragma mark - - (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 service = [CMediator.share getObject:@protocol(CAimService)];[service gotoCAimControllerWithName:@"From C" callBack:^{ NSLog(@"CAim CallBack");}];
看起來這種方案不需要硬編碼很舒服,但是它有個致命的問題 ——— 無法攔截所有路由方法。
這也就意味著這種方案做不了自動化動態呼叫。
阿里的 BeeHive 是目前的最佳實踐。注冊部分它可以將待注冊的類字串寫入 Data 段,然后在 Image 加載的時候讀取出來注冊。這個操作只是將注冊的執行放到了+load方法之前,仍然會拖慢啟動速度,所以這個優化筆者沒有看到價值。
為什么 Protocol -> Class 和 Key -> Block 需要注冊?
想象一下,解耦意味著呼叫方只有系統原生的標識,如何定位到目標業務?
必然有個映射。
而 runtime 可以直接呼叫目標業務,其它兩種方式只有建立映射表。
當然 Protocol 方式也可以不建立映射表,直接遍歷所有類,找出遵循這個協議的類也能找到,不過明顯這樣是低效且不安全的。
組件化總結
對于很多專案來說,并非一開始就需要實施組件化,為了避免在將來業務穩定需要實施的時候束手無策,在專案之初最好有一些前瞻性的設計,同時編碼程序中也要盡量降低各個業務模塊的耦合。
在設計路由時,盡量降低將來組件化時的遷移成本,所以理解各種方案的實施條件很重要。如果專案將來幾乎不可能做自動化動態路由,那么使用 Protocol -> Class 方案就能去除硬編碼;否則,還是使用 Runtime 或者 Key -> Block 方案,兩者都有不同程度的硬編碼但 Runtime 不需要注冊。
后語
設計一個方案時,最好的方式是窮舉所有方案,分別找出優勢和劣勢,然后根據業務需求,進行權衡和取舍。可能有的時候業界的方案并不完全適合自己的專案,這個時候就需要做一些創造性的改進。
不要總說“就應該是這樣”,而多想“為什么要這樣”。
作者:indulge_in
轉載請註明出處,本文鏈接:https://www.uj5u.com/yidong/148491.html
標籤:iOS
上一篇:Mac微信雙開如何實作?
