有贊移動 iOS 組件化(模塊化)架構設計實踐

1、背景

業務組件化(或者叫模塊化)做爲移動端應用架構的主流方式之一,近年來一直是業界積極探索和實踐的方向。有贊移動團隊自16年起也在不斷嘗試各類組件化方案,在有贊微商城,有贊零售,有讚美業等多個應用中進行了實踐。咱們踩過一些坑,也收穫了不少寶貴的經驗,並沉澱出 iOS 相關框架 Bifrost (雷神裏的彩虹橋)。在過程當中咱們深入體會到「沒有絕對正確的架構,只有最合適的架構」這句話的意義。不少通用方案只是組件化的冰山一角,實際落地過程當中還有至關多的東西須要考量。
本文並不許備對組件化架構設計方案給出一份標準答案,而是但願經過咱們的實踐經驗和思考分析,提供一種思路,對遇到相似問題的同窗能有所啓發。前端

git

  1. 區別於功能模塊/組件(好比圖片庫,網絡庫),本文討論的是業務模塊/組件(好比訂單模塊,商品模塊)相關的架構設計。
  2. 相比組件(Component),我的感受稱之爲模塊(Module)更爲合適。組件強調物理拆分,以便複用;模塊強調邏輯拆分,以便解耦。並且若是用過 Android Studio, 會發現它建立的子系統都叫 Module. 但介於業界習慣稱之爲組件化,因此咱們繼續使用這個術語。本文下面所用名詞,「模塊」等同於「組件」。

2、什麼是業務模塊化(組件化)

傳統的 App 架構設計更多強調的是分層,基於設計模式六大原則之一的單一職責原則,將系統劃分爲基礎層,網絡層,UI層等等,以便於維護和擴展。但隨着業務的發展,系統變得愈來愈複雜,只作分層就不夠了。App 內各子系統之間耦合嚴重, 邊界愈來愈模糊,常常發生你中有我我中有你的狀況(圖一)。這對代碼質量,功能擴展,以及開發效率都會形成很大的影響。此時,通常會將各個子系統劃分爲相對獨立的模塊,經過中介者模式收斂交互代碼,把模塊間交互部分進行集中封裝, 全部模塊間調用均經過中介者來作(圖二)。這時架構邏輯會清晰不少,但由於中介者仍然須要反向依賴業務模塊,這並無從根本上解除循壞依賴等問題。時不時發生一個模塊進行改動,多個模塊受影響編譯不過的狀況。進一步的,經過技術手段,消除中介者對業務模塊依賴,即造成了業務模塊化架構設計(圖三)。
<center></center>
經過業務模塊化架構,通常能夠達到明確模塊職責及邊界,提高代碼質量,減小複雜依賴,優化編譯速度,提高開發效率等效果。不少文章都有相關分析,在此再也不累述。github

3、業界常見模塊化方案

業務模塊化設計經過對各業務模塊的解耦改造,避免循環雙向依賴,達到提高開發效率和質量的目的。但業務需求的依賴是沒法消除的,因此模塊化方案首先要解決的是如何在無代碼依賴的狀況下實現跨模塊通訊的問題。iOS 由於其強大的運行時特性,不管是基於 NSInvocation 仍是基於 peformSelector 方法, 均可以很很容易作到這一點。但不能爲了解耦而解耦,提高質量與效率纔是咱們的目的。直接基於 hardcode 字符串 + 反射的代碼明顯會極大損害開發質量與效率,與目標背道而馳。因此,模塊化解耦需求的更準確的描述應該是「如何在保證開發質量和效率的前提下作到無代碼依賴的跨模塊通訊」。
目前業界常見的模塊間通信方案大體以下幾種:objective-c

  1. 基於路由 URL 的 UI 頁面統跳管理。
  2. 基於反射的遠程接口調用封裝。
  3. 基於面向協議思想的服務註冊方案。
  4. 基於通知的廣播方案。

根據具體業務和需求的不一樣,大部分公司會採用以上一種或者某幾種的組合。編程

3.1 路由 URL 統跳方案

統跳路由是頁面解耦的最多見方式,大量應用於前端頁面。經過把一個 URL 與一個頁面綁定,須要時經過 URL 能夠方便的打開相應頁面。json

//經過路由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 相關例子,能夠參考 Bifrost 項目中的 Demo.

URL 自己是一種跨多端的通用協議。使用路由URL統跳方案的優點是動態性及多端統一 (H5, iOS,Android,Weex/RN); 缺點是能處理的交互場景偏簡單。因此通常更適用於簡單 UI 頁面跳轉。一些複雜操做和數據傳輸,雖然也能夠經過此方式實現,但都不是很效率。
目前天貓和蘑菇街都有使用路由 URL 做爲本身的頁面統跳方案,達到解耦的目的。

3.2 基於反射的遠程調用封裝

當沒法 import 某個類的頭文件但仍需調用其方法時,最常想到的就是基於反射來實現了。例:

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 協議與具體實現綁定起來。

業界裏,蘑菇街的 ServiceManager 和阿里的 BeeHive 都是採用的這個方案。

3.4 通知廣播方案

基於通知的模塊間通信方案,實現思路很是簡單, 直接基於系統的 NSNotificationCenter 便可。
優點是實現簡單,很是適合處理一對多的通信場景。
劣勢是僅適用於簡單通信場景。複雜數據傳輸,同步調用等方式都不太方便。
模塊化通信方案中,更多的是把通知方案做爲以上幾種方案的補充。

3.5 其它

除了模塊間通信的實現,業務模塊化架構還須要考慮每一個模塊內部的設計,好比其生命週期控制,複雜對象傳輸,重複資源的處理等。可能由於每一個公司都有本身的實際場景,業界方案裏對這些問題描述的並非不少。但實際上他們很是重要,有贊在模塊化過程當中作了不少相關思考和嘗試,會在後面環節進行介紹。

4、有讚的模塊化實踐

有贊移動自 16 年起開始實踐業務模塊化架構方式,大體經歷了 2016 年的嘗試+摸索,2017 年的思考+優化以及 2018 年的成熟+沉澱幾個階段。期間有過對已有 App 的模塊化改造,也試過直接應用於新起項目。模塊化方案經歷過幾回改版,踩過一些坑,也收穫了不少寶貴的經驗。

4.1 v1.0: 嘗試+摸索

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

4.1.1 模塊間通訊方式設計

首先是梳理咱們的模塊間通訊需求,主要包括如下三種:

  1. UI 頁面跳轉。好比IM模塊點擊用戶頭像打開會員模塊的用戶詳情頁。
  2. 動做執行及複雜數據傳輸。好比商品模塊向開單模塊傳遞商品數據模型並進行價格計算。
  3. 一對多的通知廣播。好比 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 模式定義了一個高層接口,這個接口使得這一子系統更加容易使用。引入外觀角色以後,用戶只須要直接與外觀角色交互,用戶與子系統之間的複雜關係由外觀角色來實現,從而下降了系統的耦合度。
<center>Facade</center>

另外,爲何還須要路由 URL 呢?
其實從功能角度,遠程接口的網絡層,徹底能夠取代路由 URL 實現頁面跳轉,並且沒有路由 URL 的一些 hardcode 的問題。並且路由 URL 和
遠程接口存在必定的功能重合,還會形成後續實現新功能時,分不清應選擇路由 URL 仍是選擇遠程接口的困惑。這裏選擇支持路由 URL 的主要緣由是咱們存在動態化且多端統一的需求。好比消息模塊下發的各類消息數據模型徹底是動態的。後端配好展現內容以及跳轉需求後,客戶端不須要理解具體需求,只須要經過統一的路由跳轉協議執行跳轉動做便可。

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

每一個模塊除了 Facade 模式改造以外,還須要考慮如下問題:

  1. 合適的註冊及初始化方式。
  2. 接收並處理全局事件。
  3. App 層和 Common 層設計。
  4. 模塊編譯產出以及集成到 App 中的方式。

由於考慮到每一個 App 中業務模塊數量不會不少(咱們幾個 App 內大可能是20個左右),因此咱們爲每一個模塊建立了一個 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 的質量。通過討論後,決定把資源分爲三類:

  1. 通用功能所用資源,將相關代碼整理爲功能組件後一塊兒放入 Common.
  2. 業務功能的大部分資源能夠經過無損壓縮控制體積,體積不大的資源容許必定程度上的重複。
  3. 較大致積的資源放到服務端,App 端動態拉取放在本地緩存中。

同時平時按期經過自動化工具檢測無用資源,以及重複資源的大小,以便及時優化包體積。

4.1.5 體驗與成果

基於以上設計,咱們大概花了 3 的個月的時間對已有項目進行了業務模塊化改造(邊作業務邊改造)。由於方案細節考慮的比較多,你們對一些可能存在的問題也都有預期,因此當時改造後你們多持確定態度,成本 vs 收益仍是可觀的。

v1.0 版本改造後,App 架構關係如圖:
<center>Arch1.0</center>
App 項目結構如圖:

<img src="https://tech.youzan.com/conte...; width = 180 />

4.2 v2.0: 思考+優化

16 年的初版模塊化設計方案雖然可行,但還存在兩個痛點:

  1. 模塊間網絡層的封裝基於反射代碼,寫起來仍然有些麻煩。並且須要額外寫單測保證質量。
  2. 複雜對象的處理方式也存在一些問題,好比拷貝粘貼的方式比較醜陋,重複代碼會帶來包體積的增長。

上述問題在團隊規模擴大,新同窗到來時格外明顯,常常須要答疑講解。甚至有一次業務項目時間特別緊張時,有些小夥伴私下更改模塊間頭文件 search path,直接依賴的了別的模塊,以便重用複雜模型類的狀況。

這些問題的根本緣由仍是存在效率損失,"不方便",怎麼優化呢?

4.2.1 遠程接口封裝優化

首先是如何避免反射及 hardcode. 阿里 Beehive 的基於服務註冊的方式 是不須要 hardcode 代碼的。但它有額外的服務註冊過程,可能會影響啓動速度,性能弱於基於反射的接口封裝方案。這裏對啓動速度的影響究竟有多少呢?咱們作了個測試,在 +load 方法中註冊了 1000 個 Sevice Protocol, 啓動時間影響大概是 2-4 ms, 很是少。
<center></center>
由於咱們每一個模塊都是基於外觀模式設計的。因此每一個模塊只須要對外暴露一個 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;

通過以上優化改造,基本解決了 v1.0 的全部質量及效率方面的隱患,業務模塊化方案趨近成熟。

4.3 v3.0: 成熟+沉澱

17 年優化後的模塊化方案,基本算是具備有贊特點的相對成熟的方案了,支撐了包括零售在內的多個大型app的開發。

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 架構爲:
<center>App_Arch</center>

:業界有些方案是把 ModuleServie 分開存放的,至關於把以上方案裏的 Mediator 部分進行分拆,每一個業務模塊都有一個。這種方式的優勢是職責明確,你們不用同時對一個公共模塊進行修改,同時能夠作到依賴關係很清晰;劣勢是模塊的數量增長了一倍,維護成本增長不少。考慮到咱們目前的狀況,Mediator 模塊是很薄的一層,共同修改維護這個模塊也能夠接受,因此目前沒有將其拆開。未來若是須要,再將其作分拆改造便可,改造工做量很小。

4.3.2 代碼隔離的思考

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

業務模塊化的效果之一就是個業務模塊能夠單獨打包,放入殼工程運行。很容易想到的一個改造就是把各個模塊拆到不一樣的 git 中。好處不少,好比單獨的權限控制,獨立的版本號,萬一發版時發現問題能夠及時 rollback 用老版本打包。咱們的微商城 App 就作了這種嘗試。將代碼遷到了不少 git 中,經過 pod 的方式進行管理。但後續開發中體驗並非很好。當時微商城 App 的模塊數量比開發同窗數量多不少,每一個同窗都同時維護着多個模塊。有時一個項目,一我的須要同時在多個 git 中修改多個模塊的代碼。修改完成後,要屢次執行提交、打版本號以及集成測試等操做,很不效率。同時由於涉及到多個 git,代碼提交的 Merge Request 和相關的編譯檢查也複雜了不少。一樣的,由於微商城 App 中不一樣模塊的開發發版節奏也基本一致,因此多 git 多 pod 的不一樣版本管理及回退的優點也沒有體現出來。最終仍是將各模塊代碼遷回了主 git 中。

4.3.3 沒價值的隔離?

但編譯隔離和代碼隔離真的沒有價值嗎?固然不是,主要是咱們當前階段並不須要。過早的調整增長了成本卻沒有價值產出,因此並不合適。實際上咱們還有一些業務模塊是跨 App 使用的,好比IM模塊,資產模塊等等。他們都是獨立 git 獨立發版的。編譯隔離和代碼隔離屬性對他們頗有效。

另外,每一個模塊單獨git能夠有更細粒度的權限管理。咱們由於在一個git中,曾發生過好幾回小夥伴改別人的模塊改出問題的例子(雖然有MR, 但人不免有遺漏)。後來咱們是經過 git commit hook + 修改文件路徑來控制修改權限才解決了這個問題。後續介紹有贊移動基礎設施建設的文章中會有更多相關細節。

4.3.4 Bifrost (雷神裏的彩虹橋)

最終,咱們總結了全部咱們須要的業務模塊化需求,沉澱出了輕量級的模塊化 SDK Bifrost.

爲何不直接使用業界的 CTMediator 或者 Beehive 或者 MGJRouter, 要再造個輪子呢?主要有三個緣由:一是咱們開始嘗試模塊化改造時,業界尚未相關框架開源出來,因此須要本身實現。二是咱們的需求和業界的開源庫不徹底相符。MGJRouter 缺乏服務管理,CTMediator 和設計不符,Beehive 沒有路由管理同時不夠輕量(不少接口仍是基於阿里的需求提供的,咱們用不到,會造成理解成本)。緣由三實際上是最關鍵的,就是模塊化 SDK 的實現其實不難。經過前面的介紹,能夠發現其中並無什麼黑魔法,代碼量也很少,實現成本很低。模塊化過程更多精力花在了全局架構設計,與之配合的開發規範,以及結合本身團隊狀況的一些取捨。模塊化 SDK 只是模塊化總體設計的冰山一角。咱們也推薦讀者所在團隊,若是有時間能夠嘗試本身實現模塊化工具,Bifrost 只用作參考便可。

4.3.5 業務模塊化時機

咱們建議全部進入業務領域劃分穩按期(業務模塊基本肯定,不會發生較大變更)的團隊採用業務模塊化架構設計。即便模塊劃分還沒徹底明確,也能夠考慮對部分明確了模塊進行模塊化改造。由於早晚要用,晚用不如早用。目前基於路由 URL + 協議註冊的模塊間通信方式,對開發效率基本無損。

5、總結

移動應用的業務模塊化架構設計,其真正的目標是提高開發質量和效率。單從實現角度來看並無什麼黑魔法或技術難點,更多的是結合團隊實際開發協做方式和業務場景的具體考量——「適合本身的纔是最好的」。有贊移動團隊經過過往3年的實踐,發現一味的追求性能,絕對的追求模塊間編譯隔離,過早的追求模塊代碼管理隔離等方式都偏離了模塊化設計的真正目的,是得不償失的。更合適的方式是在可控的改造代價下,必定程度考慮將來的優化方式,更多的考慮當前的實際場景,來設計適合本身的模塊化方式。但願經過本文提供的具體案例和思考方式,你們都能找到適合本身應用的業務模塊化之路。

相關文章
相關標籤/搜索