摘要:一背景業務組件化或者叫模塊化作為移動端應用架構的主流方式之一,近年來一直是業界積極探索和實踐的方向。有贊移動團隊自年起也在不斷嘗試各種組件化方案,在有贊微商城,有贊零售,有贊美業等多個應用中進行了實踐。相比組件,個人感覺稱之為模塊更為合適。
一、背景
業務組件化(或者叫模塊化)作為移動端應用架構的主流方式之一,近年來一直是業界積極探索和實踐的方向。有贊移動團隊自16年起也在不斷嘗試各種組件化方案,在有贊微商城,有贊零售,有贊美業等多個應用中進行了實踐。我們踩過一些坑,也收獲了很多寶貴的經驗,并沉淀出 iOS 相關框架 Bifrost (雷神里的彩虹橋)。在過程中我們深刻體會到“沒有絕對正確的架構,只有最合適的架構”這句話的意義。很多通用方案只是組件化的冰山一角,實際落地過程中還有相當多的東西需要考量。
本文并不準備對組件化架構設計方案給出一份標準答案,而是希望通過我們的實踐經驗和思考分析,提供一種思路,對遇到類似問題的同學能有所啟發。
注:
區別于功能模塊/組件(比如圖片庫,網絡庫),本文討論的是業務模塊/組件(比如訂單模塊,商品模塊)相關的架構設計。
相比組件(Component),個人感覺稱之為模塊(Module)更為合適。組件強調物理拆分,以便復用;模塊強調邏輯拆分,以便解耦。而且如果用過 Android Studio, 會發現它創建的子系統都叫 Module. 但介于業界習慣稱之為組件化,所以我們繼續使用這個術語。本文下面所用名詞,“模塊”等同于“組件”。
二、什么是業務模塊化(組件化)傳統的 App 架構設計更多強調的是分層,基于設計模式六大原則之一的單一職責原則,將系統劃分為基礎層,網絡層,UI層等等,以便于維護和擴展。但隨著業務的發展,系統變得越來越復雜,只做分層就不夠了。App 內各子系統之間耦合嚴重, 邊界越來越模糊,經常發生你中有我我中有你的情況(圖一)。這對代碼質量,功能擴展,以及開發效率都會造成很大的影響。此時,一般會將各個子系統劃分為相對獨立的模塊,通過中介者模式收斂交互代碼,把模塊間交互部分進行集中封裝, 所有模塊間調用均通過中介者來做(圖二)。這時架構邏輯會清晰很多,但因為中介者仍然需要反向依賴業務模塊,這并沒有從根本上解除循壞依賴等問題。時不時發生一個模塊進行改動,多個模塊受影響編譯不過的情況。進一步的,通過技術手段,消除中介者對業務模塊依賴,即形成了業務模塊化架構設計(圖三)。
業務模塊化設計通過對各業務模塊的解耦改造,避免循環雙向依賴,達到提升開發效率和質量的目的。但業務需求的依賴是無法消除的,所以模塊化方案首先要解決的是如何在無代碼依賴的情況下實現跨模塊通信的問題。iOS 因為其強大的運行時特性,無論是基于 NSInvocation 還是基于 peformSelector 方法, 都可以很很容易做到這一點。但不能為了解耦而解耦,提升質量與效率才是我們的目的。直接基于 hardcode 字符串 + 反射的代碼明顯會極大損害開發質量與效率,與目標背道而馳。所以,模塊化解耦需求的更準確的描述應該是“如何在保證開發質量和效率的前提下做到無代碼依賴的跨模塊通信”。
目前業界常見的模塊間通訊方案大致如下幾種:
基于路由 URL 的 UI 頁面統跳管理。
基于反射的遠程接口調用封裝。
基于面向協議思想的服務注冊方案。
基于通知的廣播方案。
根據具體業務和需求的不同,大部分公司會采用以上一種或者某幾種的組合。
3.1 路由 URL 統跳方案統跳路由是頁面解耦的最常見方式,大量應用于前端頁面。通過把一個 URL 與一個頁面綁定,需要時通過 URL 可以方便的打開相應頁面。
//通過路由URL跳轉到商品列表頁面 //kRouteGoodsList = @"http://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 作為自己的頁面統跳方案,達到解耦的目的。
當無法 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@end @implementation GoodsModule + (void)load { //注冊服務 [ServiceManager registerService:@protocol(service_protocol) withModule:self.class] } //提供具體實現 - (NSArray*)getGoodsList {...} - (NSInteger)getGoodsCount {...} @end //將GoodsModuleService放在某個公共模塊中,對所有業務模塊可見 //業務模塊可以直接調用相關接口 ... id module = [ServiceManager objByService:@protocol(GoodsModuleService)]; NSArray *list = [module getGoodsList]; ...
這種方式的優勢也包括調用簡單方便。代碼自動補全和編譯時檢查都有效。實現起來也簡單,協議的所有實現仍然在模塊內部,所以不需要寫反射代碼了。同時對外暴露的只有協議,符合團隊協作的“面向協議編程”的思想。劣勢是如果服務提供方和使用方依賴的是公共模塊中的同一份協議(protocol), 當協議內容改變時,會存在所有服務依賴模塊編譯失敗的風險。同時需要一個注冊過程,將 Protocol 協議與具體實現綁定起來。
業界里,蘑菇街的 ServiceManager 和阿里的 BeeHive 都是采用的這個方案。
3.4 通知廣播方案基于通知的模塊間通訊方案,實現思路非常簡單, 直接基于系統的 NSNotificationCenter 即可。
優勢是實現簡單,非常適合處理一對多的通訊場景。
劣勢是僅適用于簡單通訊場景。復雜數據傳輸,同步調用等方式都不太方便。
模塊化通訊方案中,更多的是把通知方案作為以上幾種方案的補充。
除了模塊間通訊的實現,業務模塊化架構還需要考慮每個模塊內部的設計,比如其生命周期控制,復雜對象傳輸,重復資源的處理等。可能因為每個公司都有自己的實際場景,業界方案里對這些問題描述的并不是很多。但實際上他們非常重要,有贊在模塊化過程中做了很多相關思考和嘗試,會在后面環節進行介紹。
四、有贊的模塊化實踐有贊移動自 16 年起開始實踐業務模塊化架構方式,大致經歷了 2016 年的嘗試+摸索,2017 年的思考+優化以及 2018 年的成熟+沉淀幾個階段。期間有過對已有 App 的模塊化改造,也試過直接應用于新起項目。模塊化方案經歷過幾次改版,踩過一些坑,也收獲了很多寶貴的經驗。
4.1 v1.0: 嘗試+摸索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 的主要原因是我們存在動態化且多端統一的需求。比如消息模塊下發的各種消息數據模型完全是動態的。后端配好展示內容以及跳轉需求后,客戶端不需要理解具體需求,只需要通過統一的路由跳轉協議執行跳轉動作即可。
每個模塊除了 Facade 模式改造之外,還需要考慮以下問題:
合適的注冊及初始化方式。
接收并處理全局事件。
App 層和 Common 層設計。
模塊編譯產出以及集成到 App 中的方式。
因為考慮到每個 App 中業務模塊數量不會很多(我們幾個 App 內大多是20個左右),所以我們為每個模塊創建了一個 Module 對象并令其為單例。在 +load 方法中將自身注冊給模塊化 SDK Bifrost. 經測試,這里因為單例造成的內存占用以及 +load 方法引起的啟動速度影響都微乎其微。模塊需要監聽的全局事件主要為 UIApplicationDelegate 中的那些方法。所以我們定義了一個繼承 UIApplicationDelegate 的協議 BifrostModuleProtocol,令每個模塊的 Module 對象都服從這個協議。App 的 AppDelegate對象,會輪詢所有注冊了的業務模塊并進行必要的調用。
@protocol BifrostModuleProtocol@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 的個月的時間對已有項目進行了業務模塊化改造(邊做業務邊改造)。因為方案細節考慮的比較多,大家對一些可能存在的問題也都有預期,所以當時改造后大家多持肯定態度,成本 vs 收益還是可觀的。
v1.0 版本改造后,App 架構關系如圖:
16 年的第一版模塊化設計方案雖然可行,但還存在兩個痛點:
模塊間網絡層的封裝基于反射代碼,寫起來仍然有些麻煩。而且需要額外寫單測保證質量。
復雜對象的處理方式也存在一些問題,比如拷貝粘貼的方式比較丑陋,重復代碼會帶來包體積的增加。
上述問題在團隊規模擴大,新同學到來時格外明顯,經常需要答疑講解。甚至有一次業務項目時間特別緊張時,有些小伙伴私下更改模塊間頭文件 search path,直接依賴的了別的模塊,以便重用復雜模型類的情況。
這些問題的根本原因還是存在效率損失,"不方便",怎么優化呢?
4.2.1 遠程接口封裝優化首先是如何避免反射及 hardcode. 阿里 Beehive 的基于服務注冊的方式 是不需要 hardcode 代碼的。但它有額外的服務注冊過程,可能會影響啟動速度,性能弱于基于反射的接口封裝方案。這里對啟動速度的影響究竟有多少呢?我們做了個測試,在 +load 方法中注冊了 1000 個 Sevice Protocol, 啟動時間影響大概是 2-4 ms, 非常少。
之前的業務模塊化方案沒有使用 Beehive 還有個原因,就是服務提供方和使用方共同依賴同一個 Protocol,不符合我們編譯隔離的需求。但既然我們可以拷貝粘貼復雜對象代碼,是否也可以拷貝粘貼 Protocol 聲明呢?答案是可行的。而且即使工程中同時存在多個同名的 Protocol 也不會引起編譯問題,連改名這一步都省去了。以商品模型為例,為它定義一個 GoodModelProtocol, 服務使用方開單模塊可以直接將這個 Protocol 的聲明 copy 到自己模塊中,也不需要改名,操作成本非常低。然后商品模塊內就可以使用這個 Protocol 了。同時因為用的是同一個協議對象,所以 v1.0 中的類型強轉風險也沒有了。
跨模塊進行方法調用和數據讀取非常便捷:
NSString *goodsID = @"123123123"; idgoods = [BFModule(YZGoodsModuleService) goodsById:goodsID]; self.goodsCell.name = goods.name; self.goodsCell.price = goods.price; ...
為盡量減少拷貝粘貼頻率,我們將每個模塊對外提供的接口服務,路由定義,通知定義,以及復雜對象 Protocol 定義都放在 ModuleService.h 中。管理非常方便規范,別的模塊 copy 起來也簡單,只需要把這個 ModuleService.h 文件
copy 到自己模塊內部,就可以直接依賴并調用接口了。而且如果將來需要從服務器拉取相關配置,一個文件會方便很多。但是也需要考慮如果以上內容都放入同一個頭文件,會不會導致文件過大的問題。當時分析模塊間交互是有限的,否則就需要考慮模塊劃分是否合適。所以問題應該不大。從結果來看,目前我們最大的 ModuleService.h, 加上注釋大概是 300 多行。
另外,我們發現每個模塊對初始化順序也有需求。比如賬號模塊的初始化可能要優先于別的模塊,以便別的模塊在初始化時使用其服務。所以我們也對 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 架構為:
注:業界有些方案是把 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 + 協議注冊的模塊間通訊方式,對開發效率基本無損。
五、總結移動應用的業務模塊化架構設計,其真正的目標是提升開發質量和效率。單從實現角度來看并沒有什么黑魔法或技術難點,更多的是結合團隊實際開發協作方式和業務場景的具體考量——“適合自己的才是最好的”。有贊移動團隊通過過往3年的實踐,發現一味的追求性能,絕對的追求模塊間編譯隔離,過早的追求模塊代碼管理隔離等方式都偏離了模塊化設計的真正目的,是得不償失的。更合適的方式是在可控的改造代價下,一定程度考慮未來的優化方式,更多的考慮當前的實際場景,來設計適合自己的模塊化方式。希望通過本文提供的具體案例和思考方式,大家都能找到適合自己應用的業務模塊化之路。
文章版權歸作者所有,未經允許請勿轉載,若此文章存在違規行為,您可以聯系管理員刪除。
轉載請注明本文地址:http://specialneedsforspecialkids.com/yun/75702.html
摘要:簡潔直觀強悍的前端開發框架,讓開發更迅速簡單。是一套基于的前端框架。首個版本發布于年金秋,她區別于那些基于底層的框架,卻并非逆道而行,而是信奉返璞歸真之道。 2017-1209 ZanUI (Vue) 2017-1218 Onsen UI(Vue, React, Angular) 2017-1215 增加 Vuetify, Weex UI, Semantic UI React,ele...
閱讀 1669·2021-11-16 11:41
閱讀 2462·2021-11-08 13:14
閱讀 3113·2019-08-29 17:16
閱讀 3083·2019-08-29 16:30
閱讀 1849·2019-08-29 13:51
閱讀 362·2019-08-23 18:38
閱讀 3231·2019-08-23 17:14
閱讀 635·2019-08-23 15:09