歡迎光臨
每天分享高質量文章

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

來自公眾號:知識小集

連結: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 介面。

要保證最小粒度的動態呼叫,就需要標的業務的完整資訊,比如上面說的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 {
    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轉化為具體的ClassSEL,利用 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;

#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):

圖中“註意”處箭頭,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(CAimServiceclass: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<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

已同步到看一看
贊(0)

分享創造快樂