解讀 iOS 組件化與路由的本質

前言

雖然 iOS 組件化與路由的話題在業界談了好久,可是貌似不少人都對其有所誤解,甚至沒搞明白「組件」、「模塊」、「路由」、「解耦」的含義。html

相關的博文也蠻多,其實除了那幾個名家寫的,具備參考價值的不多,何況名家的觀點也並不是都徹底正確。架構每每須要權衡業務場景、學習成本、開發效率等,因此架構方案能客觀解釋卻又帶了些主觀色彩,加上些我的特點的修飾就特別容易讓人本末倒置。ios

因此要保持頭腦清晰,以辯證的態度看待問題,如下是業界比較有參考價值的文章:
iOS應用架構談 組件化方案
蘑菇街 App 的組件化之路
iOS 組件化 —— 路由設計思路分析
Category 特性在 iOS 組件化中的應用與管控
iOS 組件化方案探索git

本文主要是筆者對 iOS 組件化和路由的理解,力求以更客觀與簡潔的方式來解釋各類方案的利弊,歡迎批評指正。github

本文的 DEMOweb

1、組件與模塊的區別

圖1
圖1

  • 「組件」強調的是複用,它被各個模塊或組件直接依賴,是基礎設施,它通常不包含業務或者包含弱業務,屬於縱向分層(好比網絡請求組件、圖片下載組件)。
  • 「模塊」強調的是封裝,它更多的是指功能獨立的業務模塊,屬於橫向分層(好比購物車模塊、我的中心模塊)。

因此從你們實施「組件化」的目的來看,叫作「模塊化」彷佛更爲合理。安全

但「組件」與「模塊」都是前人定義的意義,「iOS 組件化」的概念也已經先入爲主,因此只須要明白「iOS 組件化」更多的是作業務模塊之間的解耦就好了。bash

2、路由的意義

首先要明確的是,路由並不是只是指的界面跳轉,還包括數據獲取等幾乎全部業務。服務器

(一) 簡單的路由

內部調用的方式

效仿 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"]) {
        ...
    }
}
複製代碼

形象一點:

圖2
圖2

拿到 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 解析的適配器就能解決問題:

圖3
圖3

路由方法寫在哪兒

統一路由調用類便於管理和使用,因此一般須要定義一個Mediator類。又考慮到不一樣模塊的維護者都須要修改Mediator來添加路由方法,可能存在工做流衝突。因此利用裝飾模式,爲每個模塊添加一個分類是不錯的實踐:

@interface Mediator (Detail)
+ (void)gotoDetailWithName:(NSString *)name;
@end
複製代碼

而後對應模塊的路由方法就寫到對應的分類中。

簡單路由的做用

這裏的封裝,解除了業務模塊之間的直接耦合,然而它們仍是間接耦合了(由於路由類須要導入具體業務):

圖4
圖4

不過,一個簡單的路由不需關心耦合問題,就算是這樣一個簡單的處理也有以下好處:

  • 清晰的參數列表,方便調用者使用。
  • 解開業務模塊之間的耦合,業務更改時或許接口不需變更,外部調用就不用更改代碼。
  • 就算是業務更改,路由方法必須得變更,得益於編譯器的檢查,也能直接定位調用位置進行更改。

(二) 支持動態調用的路由

動態調用,顧名思義就是調用路徑在不更新 App 的狀況下發生變化。好比點擊 A 觸發跳轉到 B 界面,某一時刻又須要點擊 A 跳轉到 C 界面。

要保證最小粒度的動態調用,就須要目標業務的完整信息,好比上面說的aimparams,即目標和參數。

而後須要一套規則,這個規則有兩個來源:

  • 來自服務器的配置。
  • 本地的一些判斷邏輯。

預知的動態調用

+ (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,內部能夠動態定位到具體業務。

圖解以下:

圖3 - 演變 - 解耦1
圖3 - 演變 - 解耦1

固然,外部調用能夠不通過內部調用,那麼就能夠作到具體業務無感知的動態定位本地資源:

圖3 - 演變 - 解耦2
圖3 - 演變 - 解耦2

因而可知,統一路由方法入口必然須要硬編碼,對於此方案來講自動化的動態調用必然須要硬編碼

那麼,這裏使用一個分類方法+ (void)gotoDetailWithName:(NSString *)name;將硬編碼包裝起來是個不錯的選擇,把這些 hard code 交給對應業務的工程師去維護吧。

Casa 的 CTMediator 分類就是如此作的,而這也正是蘑菇街組件化方案能夠優化的地方。

(三) 關於內部調用是否須要通過 URI 的疑問

前面筆者表達了內部調用不須要走 URI 的觀點(請查看 圖3 以及其演變)。

可能有些朋友以爲內部調用只須要在 URI 那一套上再封裝一層編譯器可檢查的語法糖(好比一個分類),就變成下圖這個樣子:

圖3 - 演變 - URI.
圖3 - 演變 - URI

Q1:能夠理解的是,這樣處理看起來有一個能說服人的理由:全部的路由調用都統一通過了 URI 解析。那麼,這個解析 URL 的方法就至關於一個攔截器了,彷佛能作到上面提到的動態調用

A1:然而,這樣只能支持預知的動態調用,也就是說,你須要明確某一個具體的業務,而後寫上一些「死」代碼,只能讓觸發點是動態的。那麼這樣的預知的動態調用代碼都寫在「內部調用(語法糖)」裏面就能夠了,經不通過統一的 URI 解析根本不重要了,這樣的代碼集中在一處與散落各地沒有區別。

下面是解耦方式:

圖3 - 演變 - URI解耦
圖3 - 演變 - URI解耦

Q2:可能有人會說,「圖3 - 演變 - URI解耦」作法,不須要導入具體的業務代碼,不就實現了自動化的動態調用了?

A2:然而,這樣作後不就至關於有兩個攔截器了?解耦方式調用業務,自己就擁有了一個統一的入口了。因此,內部調用通過這個統一的 URI 解析方法攔截器就沒有意義了。

Q3:能夠又有人說,他只是想經過統一的 URI 解析攔截入口作一些事情,並非作自動化的動態調用

A3:這也就是意味着,他不會在這個攔截入口中作對具體業務的徹底解耦,且攔截作的這些事情與具體業務無關(若與具體業務有關又回到了 Q1 的問題),那麼就是「圖3 - 演變 - URI」作法。這種場景彷佛能成爲一個理由,好比記錄全部路由調用卻又不涉及具體業務模塊?可是內部調用不通過 URI 解析也能作到:

圖3 - 演變 - 不解耦.png
圖3 - 演變 - 不解耦

不要說這樣會產生硬編碼,由於內部調用通過 URL 解析仍然有硬編碼。

筆者的觀點是:內部調用走 URI 方式是沒必要要的。若是你非要這麼作,筆者說一下缺點:

  • 若是路由不須要和具體業務解耦,內部調用走 URI 方式增長了無心義的硬編碼。
  • URI 解析這個規則,三端都須要統一。若不統一,外部調用仍然須要額外的轉換適配器,多出了無心義的轉換工做,且「WebView 調用」和「外部 App 調用」的規則也要統一;若全部的規則都統一,那麼三端就須要大量的溝通成本,且任意一端不能輕易的更改規則,內部調用的路由受到了外部調用的「徹底制約」。
  • 字符串不支持不少系統原生類型,URI 解析爲 aim / params 時可能須要轉換爲原生參數(好比字符串轉 NSData)的工做,那麼內部調用 (須要將 NSData 轉換爲字符串) -> URI 解析 (再將字符串轉換爲 NSData) -> aim / pamras,明顯轉換過程多餘了。(casa 在博客中也大概說了一下這個問題)

在軟件開發中,「統一」彷佛成爲了一個強迫症思惟,其實應該結合具體業務深刻場景,分析真正的意義才能更好的實施架構。

可能這部分表述有些抽象,若有疑問歡迎在文末留言(留言仍是在簡書好一點,方便筆者回復,其它轉發平臺的回覆不必定能及時看到)。

(四) 路由總結

能夠發現筆者用了大篇幅講了路由,卻未說起組件化,那是由於有路由不必定須要組件化。

路由的設計主要是考慮需不須要作全鏈路的自動化動態調用,列舉幾個場景:

  • 原生頁面出現問題,須要切換到對應的 wap 頁面。
  • wap 訪問流量過大切換到原生頁面下降消耗。

能夠發現,真正的全鏈路動態調用成本是很是高的。

3、組件化的意義

前面對路由的分析提到了使用目標和參數 (aim/params) 動態定位到具體業務的技術點。實際上在 iOS Objective-C 中大概有反射依賴注入兩種思路:

  • aim轉化爲具體的ClassSEL,利用 runtime 運行時調用到具體業務。
  • 對於代碼來講,進程空間是共享的,因此維護一個全局的映射表,提早將aim映射到一段代碼,調用時執行具體業務。

能夠明確的是,這兩種方式都已經讓Mediator免去了對業務模塊的依賴:

圖5

而這些解耦技術,正是 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 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 交給對應的業務工程師。

爲何要定義 Target 「靶子」

  • 避免同一模塊路由邏輯散落各地,便於管理。
  • 路由並不是只有控制器跳轉,某些業務可能沒法放代碼(好比網絡請求就須要額外建立類來接受路由調用)。
  • 便於方案的接入和摒棄(靈活性)。

可能有些人對這些類的管理存在疑慮,下圖就表示它們的關係(一個塊表示一個 repo):

圖6

圖中「注意」處箭頭,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];
}
#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方法以前,仍然會拖慢啓動速度,因此這個優化筆者沒有看到價值。

爲何 Protocol -> Class 和 Key -> Block 須要註冊?

想象一下,解耦意味着調用方只有系統原生的標識,如何定位到目標業務? 必然有個映射。 而 runtime 能夠直接調用目標業務,其它兩種方式只有創建映射表。 固然 Protocol 方式也能夠不創建映射表,直接遍歷全部類,找出遵循這個協議的類也能找到,不過明顯這樣是低效且不安全的。

組件化總結

對於不少項目來講,並不是一開始就須要實施組件化,爲了不在未來業務穩定須要實施的時候一籌莫展,在項目之初最好有一些前瞻性的設計,同時編碼過程當中也要儘可能下降各個業務模塊的耦合。

在設計路由時,儘可能下降未來組件化時的遷移成本,因此理解各類方案的實施條件很重要。若是項目未來幾乎不可能作自動化動態路由,那麼使用 Protocol -> Class 方案就能去除硬編碼;不然,仍是使用 Runtime 或者 Key -> Block 方案,二者都有不一樣程度的硬編碼但 Runtime 不須要註冊。

後語

設計一個方案時,最好的方式是窮舉全部方案,分別找出優點和劣勢,而後根據業務需求,進行權衡和取捨。可能有的時候業界的方案並不徹底適合本身的項目,這個時候就須要作一些創造性的改進。

不要總說「就應該是這樣」,而多想「爲何要這樣」。

相關文章
相關標籤/搜索