iOS 元件化/模組化架構設計實踐

xiangzhihong發表於2021-10-31

一、背景

業務元件化(或者叫模組化)作為移動端應用架構的主流方式之一,近年來一直是業界積極探索和實踐的方向。有贊移動團隊自16年起也在不斷嘗試各種元件化方案,在有贊微信商城,有贊零售,有讚美業等多個應用中進行了實踐。我們踩過一些坑,也收穫了很多寶貴的經驗,並沉澱出 iOS 相關框架 Bifrost (雷神裡的彩虹橋)。在過程中我們深刻體會到“沒有絕對正確的架構,只有最合適的架構”這句話的意義。

iOS 元件化/模組化的方案有很多,我們只提供一種實現思路,對遇到類似問題的同學能有所啟發,並不準備對元件化架構設計方案給出一份標準答案。區別於功能模組/元件(比如圖片庫,網路庫),本文討論的是業務模組/元件(比如訂單模組,商品模組)相關的架構設計。

二、 業務模組化/元件化

傳統的 App 架構設計更多強調的是分層,基於設計模式六大原則之一的單一職責原則,將系統劃分為基礎層,網路層,UI層等等,以便於維護和擴充套件。但隨著業務的發展,系統變得越來越複雜,只做分層就不夠了。App 內各子系統之間耦合嚴重, 邊界越來越模糊,經常發生你中有我我中有你的情況(圖一)。這對程式碼質量,功能擴充套件,以及開發效率都會造成很大的影響。此時,一般會將各個子系統劃分為相對獨立的模組,通過中介者模式收斂互動程式碼,把模組間互動部分進行集中封裝, 所有模組間呼叫均通過中介者來做(圖二)。這時架構邏輯會清晰很多,但因為中介者仍然需要反向依賴業務模組,這並沒有從根本上解除循壞依賴等問題。時不時發生一個模組進行改動,多個模組受影響編譯不過的情況。進一步的,通過技術手段,消除中介者對業務模組依賴,即形成了業務模組化架構設計(圖三)。

在這裡插入圖片描述
總的來說,通過業務模組化架構,一般可以達到明確模組職責及邊界,提升程式碼質量,減少複雜依賴,優化編譯速度,提升開發效率等效果。

三、常見模組化方案

業務模組化設計通過對各業務模組的解耦改造,避免迴圈雙向依賴,達到提升開發效率和質量的目的。但業務需求的依賴是無法消除的,所以模組化方案首先要解決的是如何在無程式碼依賴的情況下實現跨模組通訊的問題。

iOS 因為其強大的執行時特性,無論是基於 NSInvocation 還是基於 peformSelector 方法, 都可以很很容易做到這一點。但不能為了解耦而解耦,提升質量與效率才是我們的目的。直接基於 hardcode 字串 + 反射的程式碼明顯會極大損害開發質量與效率,與目標背道而馳。所以,模組化解耦需求的更準確的描述應該是“如何在保證開發質量和效率的前提下做到無程式碼依賴的跨模組通訊”。

目前業界常見的模組間通訊方案大致如下幾種:

  • 基於路由 URL 的 UI 頁面統跳管理。
  • 基於反射的遠端介面呼叫封裝。
  • 基於面向協議思想的服務註冊方案。
  • 基於通知的廣播方案。

3.1 路由 URL 統跳方案

統跳路由是頁面解耦的最常見方式,大量應用於前端頁面。通過把一個 URL 與一個頁面繫結,需要時通過 URL 可以方便的開啟相應頁面,如下所示。

//kRouteGoodsList = @"//goods/goods_list"
UIViewController *vc = [Router handleURL:kRouteGoodsList]; 
if(vc) {
    [self.navigationController pushViewController:vc animated:YES];
}

當然有些場景會比這個複雜,比如有些頁面需要更多引數。 不過,基本型別的引數,URL 協議天然支援。

//kRouteGoodsDetails = @“//goods/goods_detail?goods_id=%d”
NSString *urlStr = [NSString stringWithFormat:@"kRouteGoodsDetails", 123];
UIViewController *vc = [Router handleURL:urlStr];
if(vc) {
   [self.navigationController pushViewController:vc animated:YES];
}

而對於複雜型別的引數,則可以通過提供一個額外的字典引數 complexParams,然後將複雜引數放到字典中即可。

+ (nullable id)handleURL:(nonnull NSString *)urlStr
           complexParams:(nullable NSDictionary*)complexParams
              completion:(nullable RouteCompletion)completion;

上面方法裡的 completion 引數,是一個回撥 block, 處理開啟某個頁面需要有回撥功能的場景。比如開啟會員選擇頁面,搜尋會員,搜到之後點選確定,回傳會員資料等。

//kRouteMemberSearch = @“//member/member_search”
UIViewController *vc = [Router handleURL:urlStr complexParams:nil completion:^(id  _Nullable result) {
    //code to handle the result
    ...
}];
if(vc) {
    [self.navigationController pushViewController:vc animated:YES];
}

考慮實際的情況,需要將提供路由服務的頁面的URL 與一個 block 相繫結。block 中放入所需的初始化程式碼,然後在合適的地方將初始化 block 與路由 URL 繫結,比如在 +load 方法裡。

+ (void)load {
    [Router bindURL:kRouteGoodsList
           toHandler:^id _Nullable(NSDictionary * _Nullable parameters) {
        return [[GoodsListViewController alloc] init];
    }];
}

URL 本身是一種跨多端的通用協議。使用路由URL統跳方案的優勢是動態性及多端統一 (H5, iOS,Android,Weex/RN); 缺點是能處理的互動場景偏簡單。所以一般更適用於簡單 UI 頁面跳轉。一些複雜操作和資料傳輸,雖然也可以通過此方式實現,但都不是很效率。

3.2 基於反射的遠端呼叫封裝

在平時的開發中,當無法直接 import 某個類的標頭檔案但仍需呼叫其方法時,最常用的方法就是反射了。反射是面嚮物件語言的基本特徵,Java和oC都有這一特徵。

Class manager = NSClassFromString(@"YZGoodsManager");
NSArray *list = [manager performSelector:@selector(getGoodsList)];
//code to handle the list
...

但這種方式存在大量的 hardcode 字串。無法觸發程式碼自動補全,容易出現拼寫錯誤,而且這類錯誤只能在執行時觸發相關方法後才能正常工作,無論是開發效率還是開發質量都有較大的影響。

如何進行優化呢?這其實是各端遠端呼叫都需要解決的問題。移動端最常見的遠端呼叫就是向後端介面髮網路請求。針對這類問題,我們很容易想到建立一個網路層,將這類“危險程式碼”封裝到裡面。上層業務呼叫時網路層介面時,不需要 hardcode 字串,也不需要理解內部麻煩的邏輯。

類似的,我可以將模組間通訊也封裝到一個“網路層”中(或者叫訊息轉發層)。這樣危險程式碼只存在某幾個檔案裡,可以特別地進行 code review 和聯調測試。後期還可以通過單元測試來保障質量。模組化方案中,我們可以稱這類“轉發層”為 Mediator (當然你也可以起個別的名字)。同時因為 performSelector 方法附帶引數數量有限,也沒有返回值,所以更適合使用 NSInvocation 來實現。

//Mediator提供基於NSInvocation的遠端介面呼叫方法的統一封裝
- (id)performTarget:(NSString *)targetName
             action:(NSString *)actionName
             params:(NSDictionary *)params;

//Goods模組所有對外提供的方法封裝在一個Category中
@interface Mediator(Goods)
- (NSArray*)goods_getGoodsList;
- (NSInteger)goods_getGoodsCount;
...
@end
@impletation Mediator(Goods)
- (NSArray*)goods_getGoodsList {
    return [self performTarget:@“GoodsModule” action:@"getGoodsList" params:nil];
}
- (NSInteger)goods_getGoodsCount {
    return [self performTarget:@“GoodsModule” action:@"getGoodsCount" params:nil];
}
...
@end

然後,各個業務模組再依賴Mediator, 就可以直接呼叫這些方法了。

//業務方依賴Mediator模組,可以直接呼叫相關方法
...
NSArray *list = [[Mediator sharedInstance] goods_getGoodsList];
...

這種方案的優勢是,呼叫簡單方便,程式碼自動補全和編譯時檢查都仍然有效,劣勢是 category 存在重名覆蓋的風險,需要通過開發規範以及一些檢查機制來規避。同時 Mediator 只是收斂了 HardCode, 並未消除 HardCode,仍然對開發效率有一定影響。而業界著名的CTMediator 元件化方案,以及美團都是採用類似方案進行的實現。

3.3 服務註冊方案

有沒有辦法絕對的避免 hardcode 呢?如果接觸過後端的服務化改造,會發現和移動端的業務模組化很相似。Dubbo 就是服務化的經典框架之一。它是通過服務註冊的方式來實現遠端介面呼叫的。即每個模組提供自己對外服務的協議宣告,然後將此宣告註冊到中間層。呼叫方能從中間層看到存在哪些服務介面,然後直接進行呼叫即可。

//Goods模組提供的所有對外服務都放在GoodsModuleService中
@protocol GoodsModuleService
- (NSArray*)getGoodsList;
- (NSInteger)getGoodsCount;
...
@end
//Goods模組提供實現GoodsModuleService的物件, 
//並在+load方法中註冊
@interface GoodsModule : NSObject<GoodsModuleService>
@end
@implementation GoodsModule
+ (void)load {
    //註冊服務
    [ServiceManager registerService:@protocol(service_protocol) 
                  withModule:self.class]
}
//提供具體實現
- (NSArray*)getGoodsList {...}
- (NSInteger)getGoodsCount {...}
@end

//將GoodsModuleService放在某個公共模組中,對所有業務模組可見
//業務模組可以直接呼叫相關介面
...
id<GoodsModuleService> module = [ServiceManager objByService:@protocol(GoodsModuleService)];
NSArray *list = [module getGoodsList];
...

可以看到,這種方案實現起來也比較簡單,協議的所有實現仍然在模組內部,所以不需要寫反射程式碼。同時對外暴露的只有協議,符合團隊協作的“面向協議程式設計”的思想。劣勢是如果服務提供方和使用方依賴的是公共模組中的同一份協議(protocol), 當協議內容改變時,會存在所有服務依賴模組編譯失敗的風險。同時需要一個註冊過程,將 Protocol 協議與具體實現繫結起來。

3.4 通知廣播方案

基於通知的模組間通訊方案,實現思路非常簡單, 直接基於系統的 NSNotificationCenter 即可。
優勢是實現簡單,非常適合處理一對多的通訊場景。
劣勢是僅適用於簡單通訊場景。複雜資料傳輸,同步呼叫等方式都不太方便。
模組化通訊方案中,更多的是把通知方案作為以上幾種方案的補充。

3.5 其它

除了模組間通訊的實現,業務模組化架構還需要考慮每個模組內部的設計,比如其生命週期控制,複雜物件傳輸,重複資源的處理等。可能因為每個公司都有自己的實際場景,業界方案裡對這些問題描述的並不是很多。但實際上他們非常重要,有贊在模組化過程中做了很多相關思考和嘗試,會在後面環節進行介紹。

四、模組化實踐

4.1 摸索與嘗試

16 年,有贊微信商城、有贊收銀等 App 經歷了初期的功能快速迭代,內部依賴混亂,耦合嚴重,急需優化重構。傳統的 MVVM、MVP 等優化方式無法從全域性層面解決這些問題。後來在 InfoQ 的"移動開發前線"微信群裡聽了蘑菇街的元件化方案分享,非常受啟發。不過當時還是有一些顧慮,比如微信商城和收銀當時都屬於中小型專案,每端開發人員都只有 4-6 人。業務模組化改造後會形成一定的開發門檻,帶來一定的開發效率下降。小專案適合模組化改造嗎?其收益是否能匹配付出呢?但考慮到當時 App 各模組邊界已經穩定,即使模組化改造出現問題,也可以用很小的代價將其降級到傳統的中介者模式,所以改造開始了。

4.1.1 模組間通訊方式設計

首先,是梳理我們的模組間通訊需求,主要包括以下三種場景:

  • UI 頁面跳轉:比如IM模組點選使用者頭像開啟會員模組的使用者詳情頁。
  • 動作執行及複雜資料傳輸:比如商品模組向開單模組傳遞商品資料模型並進行價格計算。
  • 一對多的通知廣播:比如 logout 時賬號模組發出廣播,各業務模組進行 cache 清理及其它相應操作。

在分析了具體的業務後,我們最終選擇了路由 URL + 遠端介面呼叫封裝 + 廣播相結合的方式。對於遠端介面呼叫的封裝方式,我們沒有完全照抄 Mediator 方案。當時非常期望保留模組化的編譯隔離屬性。比如當 A 模組對外提供的某個介面發生變化時,不會引發依賴這個介面的模組的編譯錯誤。這樣可以避免依賴模組被迫中斷手頭的工作先去解決編譯問題。當時也沒有采用Beehive的服務註冊方式,也是因為同樣的原因。 經過討論,當時選擇參考網路層封裝方式,在每個模組中設計一個對外的“網路層” ModuleService。將對其它模組的介面的反射呼叫,放入各個模組的 ModuleService 中。

同時,我們希望各業務模組不需要去理解所依賴模組的內部複雜實現。比如 A 模組依賴 D 模組的 class D1 的介面 method1, class D2 的介面method2, class D3 的介面 method3. A 需要了解 D 模組的這些內部資訊才能完成反射功能的實現。如果 D 模組中這些命名有所變化,還會出現呼叫失敗。所以我們對各個模組使用外觀(Facade)模式進行重構。D 模組建立一個外觀層 FacadeD. 通過 FacadeD 物件對外提供所有服務,同時隱藏內部複雜實現。呼叫方也只需要理解 FacadeD 的標頭檔案 包含哪些介面即可。

外觀(Facade)模式: 為子系統中的一組介面提供一個一致的介面, Facade 模式定義了一個高層介面,這個介面使得這一子系統更加容易使用。引入外觀角色之後,使用者只需要直接與外觀角色互動,使用者與子系統之間的複雜關係由外觀角色來實現,從而降低了系統的耦合度。

在這裡插入圖片描述

另外,為什麼還需要路由 URL 呢?
其實從功能角度,遠端介面的網路層,完全可以取代路由 URL 實現頁面跳轉,而且沒有路由 URL 的一些 hardcode 的問題。而且路由 URL 和
遠端介面存在一定的功能重合,還會造成後續實現新功能時,分不清應選擇路由 URL 還是選擇遠端介面的困惑。這裡選擇支援路由 URL 的主要原因是我們存在動態化且多端統一的需求。比如訊息模組下發的各種訊息資料模型完全是動態的。後端配好展示內容以及跳轉需求後,客戶端不需要理解具體需求,只需要通過統一的路由跳轉協議執行跳轉動作即可。

4.1.2 模組內設計及 App 結構調整

個模組除了 Facade 模式改造之外,還需要考慮以下問題:

  • 合適的註冊及初始化方式。
  • 接收並處理全域性事件。
  • App 層和 Common 層設計。
  • 模組編譯產出以及整合到 App 中的方式。

因為考慮到每個 App 中業務模組數量不會很多,所以我們為每個模組建立了一個 Module 物件並令其為單例。在 +load() 方法中將自身註冊給模組化 SDK Bifrost. 經測試,這裡因為單例造成的記憶體佔用以及 +load 方法引起的啟動速度影響都微乎其微。模組需要監聽的全域性事件主要為 UIApplicationDelegate 中的那些方法。所以我們定義了一個繼承 UIApplicationDelegate 的協議 BifrostModuleProtocol,令每個模組的 Module 物件都服從這個協議。App 的 AppDelegate物件,會輪詢所有註冊了的業務模組並進行必要的呼叫。

@protocol BifrostModuleProtocol <UIApplicationDelegate, NSObject>
@required
+ (instancetype)sharedInstance;
- (void)setup;
...
@optional
+ (BOOL)setupModuleSynchronously;
...
@end

所有業務程式碼挪入各業務模組的 Module 物件後,工程的AppDelegate是非常乾淨的。

@implementation YZAppDelegate
- (BOOL)application:(UIApplication *)application willFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    [Bifrost setupAllModules];
    [Bifrost checkAllModulesWithSelector:_cmd arguments:@[Safe(application), Safe(launchOptions)]];
    return YES;
}
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    [Bifrost checkAllModulesWithSelector:_cmd arguments:@[Safe(application), Safe(launchOptions)]];
    return YES;
}
- (void)applicationWillEnterForeground:(UIApplication *)application {
    [Bifrost checkAllModulesWithSelector:_cmd arguments:@[Safe(application)]];
}
...
@end

每個業務模組都作為一個子 Project 整合入 App Project. 同時建立一個特殊的模組 Common,用於放置一些通用業務和全域性的基類。App 層只保留 AppDelegate 等全域性類和 plist 等特殊配置,基本沒有任何業務程式碼。Common 層因為沒有明確的業務來負責,所以也應該儘量輕薄。各業務模組之間互不可見,但可以直接依賴 Common 模組。通過search path來設定模組依賴關係。

每個業務模組的產出包括可執行檔案和資原始檔兩部分。有2種選擇:生成 framework 和生成靜態庫 + 資源 bundle。

使用framework的優點是輸出在同一個物件內,方便管理。缺點是作為動態庫載入,影響載入速度。所以當時選擇了靜態庫 + bundle 的形式。不過個人感覺這塊還是需要具體測一下會慢做少再做決定更合適。但因為二者差別不大,所以後續我們也一直沒作調整。

另外如果使用framework,需要注意資源讀取的問題。因為傳統的資源讀取方式無法定位到framework內資源,需要通過 bundleForClass: 才行。

//傳統方式只能定位到指定bundle,比如main bundle中資源
NSURL *path = [[NSBundle mainBundle] URLForResource:@"file_name" withExtension:@"txt"]; 

// framework bundle需要通過bundleForClass獲取
NSBundle *bundle = [NSBundle bundleForClass:classA]; //classA為framework中的某各類
// 讀UIStoryboard
UIStoryboard *storyboard = [UIStoryboard storyboardWithName:@“sb_name” bundle:bundle];
// 讀UIImage
UIImage *image = [UIImage imageNamed:@"icon_name" inBundle:bundle compatibleWithTraitCollection:nil];
...

4.1.3 複雜物件傳輸

當時最糾結的點就是複雜物件的傳輸。例如商品模型,它包含幾十個欄位。如果是傳字典或傳 json, 那麼資料提供方(商品模組)和使用方(開單模組)都需要專門理解並實現一下這種模型的各種欄位,對開發效率影響很大.
有沒有辦法直接傳遞模型物件呢?這裡涉及到模型的類檔案放在哪裡。最容易想到的方案是沉入 Common 模組。但一旦這個口子放開,後續會有越來越多的模型放入 Common,和前面提到的簡化 Common 層的目標是相悖的。而且因為 Common 模組沒有明確業務組歸屬,所有小組都能編輯, 其質量和穩定性難以保障。最終我們採用了一個 tricky 的方案,把要傳遞的複雜模型的程式碼複製一份放在使用方模組中,同時通過修改類名字首加以區分,這樣就可以避免打包時的連結衝突錯誤。

比如商品模組內叫 YZGGoodsModel, 開單模組內叫 YZSGoodsModel. 商品模組的介面返回的是 YZGGoodsModel,開單模組將其強轉為 YZSGoodsModel 即可。

//YZSaleModuleService.m內
#import "YZSGoodsModel.h"

- (YZSGoodsModel*)goodsById:(NSString*)goodsId {
    //Sale Module遠端呼叫Goods Module的介面
    id obj = [Bifrost performTarget:@"YZGoodsModule"
                           action:@"goodsById:"
                             params:@[goodsId]];
    //做一次強轉
    YZSGoodsModel *goods = (YZSGoodsModel*)obj;
    return goods;
}

這種方式雖然比較粗暴,但考慮到兩個模組間互動的複雜物件應該不會很多,同時拷貝貼上操作起來成本可控,所以可以接受。同時這種方法也能達到預期的編譯隔離的效果。但兩邊模型定義及實現還是有不一致的風險。為了解決一致性問題,我們做了個檢查指令碼工具,在編譯時觸發。會根據命名規則查詢這類“同名” model 的程式碼,並做一個比較。如果發現不一致,則報 warning. 注意不是報error, 因為我們希望一個模組做了介面修改,另一個模組可以存在一種選擇,是馬上更新介面,還是先完成手頭的工作將來再更新。

4.1.4 重複資源處理

類資源主要包括圖片、音視訊,資料模型等等。首先,我們排除了無腦放入 Common 的方案。因為下沉入 Common 會破壞各業務模組的完整性,同時也會影響 Common 的質量。經過討論後,決定把資源分為三類:

  • 通用功能所用資源,將相關程式碼整理為功能元件後一起放入 Common.
  • 業務功能的大部分資源可以通過無失真壓縮控制體積,體積不大的資源允許一定程度上的重複。
  • 較大體積的資源放到服務端,App 端動態拉取放在本地快取中。

同時,工程打包前通過自動化工具檢測無用資源,以及重複資源的大小,以便及時優化包體積。

4.1.5 成果展示

基於以上設計,我們大概花了 3 的個月的時間對已有專案進行了業務模組化改造(邊做業務邊改造)。因為方案細節考慮的比較多,大家對一些可能存在的問題也都有預期,所以當時改造後大家多持肯定態度,不過最終的結果還是可觀的。

1.0 版本改造後,App 整體架構關係如下圖。

在這裡插入圖片描述
而整體工程結構如下圖所示。
在這裡插入圖片描述

4.2 優化

上面介紹的模組化設計方案雖然可行,但還存在兩個嚴重的問題:

  • 模組間網路層的封裝基於反射程式碼,寫起來仍然有些麻煩。而且需要額外寫單測保證質量。
  • 複雜物件的處理方式帶來了額外的問題,比如拷貝貼上的方式比較醜陋,重複程式碼會帶來包體積的增加。

針對上面的問題,我們著手從如下幾個方面進行優化。

4.2.1 遠端介面封裝優化

首先,是如何避免反射及 hardcode. 阿里 Beehive 的基於服務註冊的方式 是不需要 hardcode 程式碼的。但它有額外的服務註冊過程,可能會影響啟動速度,效能弱於基於反射的介面封裝方案。

使用服務註冊對啟動速度的影響究竟有多少呢?我們做了個測試,在 +load 方法中註冊了 1000 個 Sevice Protocol,啟動時間影響大概是 2-4 ms,所以影響是非常少的。

在這裡插入圖片描述
因為我們每個模組都是基於外觀模式設計的。所以每個模組只需要對外暴露一個 Service Protocol 即可。我們 App 的實際模組數量大概是 20 個,所以對啟動速度的影響可以忽略不計。而且前文提到,每個模組本來也需要註冊自己的外觀類(Module 物件)以處理生命週期和接受 AppDelegate 訊息。這裡 Service Protocl 的實現者就是這個 Module 物件,所以其實沒有額外的效能消耗。

4.2.2 複雜物件傳輸優化

之前的業務模組化方案沒有使用 Beehive 還有個原因,就是服務提供方和使用方共同依賴同一個 Protocol,不符合我們編譯隔離的需求。但既然我們可以拷貝貼上複雜物件程式碼,是否也可以拷貝貼上 Protocol 宣告呢?

答案是可行的。而且即使工程中同時存在多個同名的 Protocol 也不會引起編譯問題,連改名這一步都省去了。以商品模型為例,為它定義一個 GoodModelProtocol, 服務使用方開單模組可以直接將這個 Protocol 的宣告 copy 到自己模組中,也不需要改名,操作成本非常低。然後商品模組內就可以使用這個 Protocol 了。同時因為用的是同一個協議物件,所以 v1.0 中的型別強轉風險也沒有了。

NSString *goodsID = @"123123123";
id<YZGoodsModelProtocol> goods = [BFModule(YZGoodsModuleService) goodsById:goodsID];
self.goodsCell.name = goods.name;
self.goodsCell.price = goods.price;
...

並且,為了儘量減少拷貝貼上頻率,我們將每個模組對外提供的介面服務,路由定義,通知定義,以及複雜物件 Protocol 定義都放在 ModuleService.h 中。管理非常方便規範,別的模組 copy 起來也簡單,只需要把這個 ModuleService.h 檔案copy 到自己模組內部,就可以直接依賴並呼叫介面了。而且如果將來需要從伺服器拉取相關配置,一個檔案會方便很多。但是也需要考慮如果以上內容都放入同一個標頭檔案,會不會導致檔案過大的問題。當時分析模組間互動是有限的,否則就需要考慮模組劃分是否合適。所以問題應該不大。從結果來看,目前我們最大的 ModuleService.h, 加上註釋大概是 300 多行。

4.2.3 其它優化

另外,我們發現每個模組對初始化順序也有需求。比如賬號模組的初始化可能要優先於別的模組,以便別的模組在初始化時使用其服務。所以我們也對 ModuleProtocol 增加了優先順序介面。每個模組可以定義自己的初始化優先順序。

/**
 The priority of the module to be setup. 0 is the lowest priority;
 If not provided, the default priority is BifrostModuleDefaultPriority;

 @return the priority
 */
+ (NSUInteger)priority;

經過以上優化改造,基本解決了之前模組化的所有質量及效率方面的隱患,業務模組化方案趨近成熟。

沉澱與完善

在解決了複雜物件的傳遞等問題後,模組化的方案基本走向了成熟。不過,架構對於一些新人還是不太友好,於是我們繼續思考。

4.3.1 編譯隔離的思考

Copy 標頭檔案的方式仍然有一些理解成本。移動團隊規模快速發展,一些新來的小夥伴還是會提出疑問。18 年年中我們做了幾次檢查,發現模組間 ModuleService 版本不一致的情況時有發生。當時零售移動團隊雖然達到 30 多人,但仍然是一個協作緊密的整體,發版節奏基本一致。各業務模組程式碼都在同一個 git 工程中,基本每次發版用的都是各個模組的最新版本。而且實際做了幾次調查,發現 ModuleService 中介面改變導致的依賴模組的修改,其實成本很低,改起來很快。此時我們開始思考之前追求的編譯隔離是否適合當前階段,是否有實際價值。

最終我們決定節省每一份精力,效率最大化。將各業務的 ModuleService進行下沉到 Commom 模組,各業務模組直接依賴 Common 中的這些 ModuleServie 標頭檔案,不再需要 copy 操作。這樣改造的代價是形成了更多的依賴。本來一個業務模組是可以不依賴 Common 的,但現在就必須依賴了。但考慮到實際情況,還沒有不依賴 Common 的業務模組存在,這種追求沒有價值,所以應該問題不大。同時因為下沉的都是一些標頭檔案,沒有具體實現,將來如果需要模組間的進一步隔離,比如模組單獨打包等,只需要將這些 Moduleservie 做到服務端可配置 + 自動化下載生成即可,改造成本非常小。

但這樣改造後又發生了一件事。某個新來的同學,直接在 Common 模組中寫程式碼通過這些 ModuleService 呼叫了上層業務模組的功能,形成了底層 Commmon 模組對上層業務模組的反向依賴。於是我們進一步拆分出了一個新模組 Mediator, 將 Bifrost SDK 和這些 ModuleSevice 放入其中。Common 模組和 Mediator 互不可見,此時最終形成的 App 架構為:
在這裡插入圖片描述

業界有些方案是把 ModuleServie 分開存放的,相當於把以上方案裡的 Mediator 部分進行分拆,每個業務模組都有一個。這種方式的優點是職責明確,大家不用同時對一個公共模組進行修改,同時可以做到依賴關係很清晰;劣勢是模組的數量增加了一倍,維護成本增加很多。考慮到我們目前的情況,Mediator 模組是很薄的一層,共同修改維護這個模組也可以接受,所以目前沒有將其拆開。將來如果需要,再將其做分拆改造即可,改造工作量很小。

4.3.2 程式碼隔離的思考

除了不在不合適的階段追求編譯隔離,我們還發現程式碼隔離並不適合我們。

業務模組化的效果之一就是個業務模組可以單獨打包,放入殼工程執行。很容易想到的一個改造就是把各個模組拆到不同的 git 中。好處很多,比如單獨的許可權控制,獨立的版本號,萬一發版時發現問題可以及時 rollback 用老版本打包。我們的微信商城 App 就做了這種嘗試。將程式碼遷到了很多 git 中,通過 pod 的方式進行管理。但後續開發中體驗並不是很好。當時微信商城 App 的模組數量比開發同學數量多很多,每個同學都同時維護著多個模組。有時一個專案,一個人需要同時在多個 git 中修改多個模組的程式碼。修改完成後,要多次執行提交、打版本號以及整合測試等操作,效率很低。同時因為涉及到多個 git,程式碼提交的 Merge Request 和相關的編譯檢查也複雜了很多。同樣的,因為微信商城 App 中不同模組的開發發版節奏也基本一致,所以多 git 多 pod 的不同版本管理及回退的優勢也沒有體現出來。最終還是將各模組程式碼遷回了主 git 中。

但編譯隔離和程式碼隔離真的沒有價值嗎?當然不是,主要是我們當前階段並不需要。過早的調整增加了成本卻沒有價值產出,所以並不合適。實際上我們還有一些業務模組是跨 App 使用的,比如IM模組,資產模組等等。他們都是獨立 git 獨立發版的。編譯隔離和程式碼隔離屬性對他們很有效。

另外,每個模組單獨git可以有更細粒度的許可權管理。我們因為在一個git中,曾發生過好幾次小夥伴改別人的模組改出問題的例子(雖然有MR, 但人難免有遺漏)。後來我們是通過 git commit hook + 修改檔案路徑來控制修改許可權才解決了這個問題。後續介紹有贊移動基礎設施建設的文章中會有更多相關細節。

4.3.3 業務模組化的一些建議

我們建議所有進入業務領域劃分穩定期(業務模組基本確定,不會發生較大變動)的團隊採用業務模組化架構設計。即使模組劃分還沒完全明確,也可以考慮對部分明確了模組進行模組化改造。因為遲早要用,晚用不如早用。目前基於路由 URL + 協議註冊的模組間通訊方式,對開發效率基本無損。

五、總結

移動應用的業務模組化架構設計,其真正的目標是提升開發質量和效率。單從實現角度來看並沒有什麼黑魔法或技術難點,更多的是結合團隊實際開發協作方式和業務場景的具體考量——“適合自己的才是最好的”。有贊移動技術團隊通過4年的技術實踐,發現一味的追求效能,絕對的追求模組間編譯隔離,過早的追求模組程式碼管理隔離等方式都偏離了模組化設計的真正目的,是得不償失的。

更合適的方式是在可控的改造代價下,一定程度考慮未來的優化方式,更多的考慮當前的實際場景,來設計適合自己的模組化方式。

相關文章