摘要:一背景業(yè)務(wù)組件化或者叫模塊化作為移動(dòng)端應(yīng)用架構(gòu)的主流方式之一,近年來一直是業(yè)界積極探索和實(shí)踐的方向。有贊移動(dòng)團(tuán)隊(duì)自年起也在不斷嘗試各種組件化方案,在有贊微商城,有贊零售,有贊美業(yè)等多個(gè)應(yīng)用中進(jìn)行了實(shí)踐。相比組件,個(gè)人感覺稱之為模塊更為合適。
一、背景
業(yè)務(wù)組件化(或者叫模塊化)作為移動(dòng)端應(yīng)用架構(gòu)的主流方式之一,近年來一直是業(yè)界積極探索和實(shí)踐的方向。有贊移動(dòng)團(tuán)隊(duì)自16年起也在不斷嘗試各種組件化方案,在有贊微商城,有贊零售,有贊美業(yè)等多個(gè)應(yīng)用中進(jìn)行了實(shí)踐。我們踩過一些坑,也收獲了很多寶貴的經(jīng)驗(yàn),并沉淀出 iOS 相關(guān)框架 Bifrost (雷神里的彩虹橋)。在過程中我們深刻體會(huì)到“沒有絕對(duì)正確的架構(gòu),只有最合適的架構(gòu)”這句話的意義。很多通用方案只是組件化的冰山一角,實(shí)際落地過程中還有相當(dāng)多的東西需要考量。
本文并不準(zhǔn)備對(duì)組件化架構(gòu)設(shè)計(jì)方案給出一份標(biāo)準(zhǔn)答案,而是希望通過我們的實(shí)踐經(jīng)驗(yàn)和思考分析,提供一種思路,對(duì)遇到類似問題的同學(xué)能有所啟發(fā)。
注:
區(qū)別于功能模塊/組件(比如圖片庫,網(wǎng)絡(luò)庫),本文討論的是業(yè)務(wù)模塊/組件(比如訂單模塊,商品模塊)相關(guān)的架構(gòu)設(shè)計(jì)。
相比組件(Component),個(gè)人感覺稱之為模塊(Module)更為合適。組件強(qiáng)調(diào)物理拆分,以便復(fù)用;模塊強(qiáng)調(diào)邏輯拆分,以便解耦。而且如果用過 Android Studio, 會(huì)發(fā)現(xiàn)它創(chuàng)建的子系統(tǒng)都叫 Module. 但介于業(yè)界習(xí)慣稱之為組件化,所以我們繼續(xù)使用這個(gè)術(shù)語。本文下面所用名詞,“模塊”等同于“組件”。
二、什么是業(yè)務(wù)模塊化(組件化)傳統(tǒng)的 App 架構(gòu)設(shè)計(jì)更多強(qiáng)調(diào)的是分層,基于設(shè)計(jì)模式六大原則之一的單一職責(zé)原則,將系統(tǒng)劃分為基礎(chǔ)層,網(wǎng)絡(luò)層,UI層等等,以便于維護(hù)和擴(kuò)展。但隨著業(yè)務(wù)的發(fā)展,系統(tǒng)變得越來越復(fù)雜,只做分層就不夠了。App 內(nèi)各子系統(tǒng)之間耦合嚴(yán)重, 邊界越來越模糊,經(jīng)常發(fā)生你中有我我中有你的情況(圖一)。這對(duì)代碼質(zhì)量,功能擴(kuò)展,以及開發(fā)效率都會(huì)造成很大的影響。此時(shí),一般會(huì)將各個(gè)子系統(tǒng)劃分為相對(duì)獨(dú)立的模塊,通過中介者模式收斂交互代碼,把模塊間交互部分進(jìn)行集中封裝, 所有模塊間調(diào)用均通過中介者來做(圖二)。這時(shí)架構(gòu)邏輯會(huì)清晰很多,但因?yàn)橹薪檎呷匀恍枰聪蛞蕾嚇I(yè)務(wù)模塊,這并沒有從根本上解除循壞依賴等問題。時(shí)不時(shí)發(fā)生一個(gè)模塊進(jìn)行改動(dòng),多個(gè)模塊受影響編譯不過的情況。進(jìn)一步的,通過技術(shù)手段,消除中介者對(duì)業(yè)務(wù)模塊依賴,即形成了業(yè)務(wù)模塊化架構(gòu)設(shè)計(jì)(圖三)。
業(yè)務(wù)模塊化設(shè)計(jì)通過對(duì)各業(yè)務(wù)模塊的解耦改造,避免循環(huán)雙向依賴,達(dá)到提升開發(fā)效率和質(zhì)量的目的。但業(yè)務(wù)需求的依賴是無法消除的,所以模塊化方案首先要解決的是如何在無代碼依賴的情況下實(shí)現(xiàn)跨模塊通信的問題。iOS 因?yàn)槠鋸?qiáng)大的運(yùn)行時(shí)特性,無論是基于 NSInvocation 還是基于 peformSelector 方法, 都可以很很容易做到這一點(diǎn)。但不能為了解耦而解耦,提升質(zhì)量與效率才是我們的目的。直接基于 hardcode 字符串 + 反射的代碼明顯會(huì)極大損害開發(fā)質(zhì)量與效率,與目標(biāo)背道而馳。所以,模塊化解耦需求的更準(zhǔn)確的描述應(yīng)該是“如何在保證開發(fā)質(zhì)量和效率的前提下做到無代碼依賴的跨模塊通信”。
目前業(yè)界常見的模塊間通訊方案大致如下幾種:
基于路由 URL 的 UI 頁面統(tǒng)跳管理。
基于反射的遠(yuǎn)程接口調(diào)用封裝。
基于面向協(xié)議思想的服務(wù)注冊(cè)方案。
基于通知的廣播方案。
根據(jù)具體業(yè)務(wù)和需求的不同,大部分公司會(huì)采用以上一種或者某幾種的組合。
3.1 路由 URL 統(tǒng)跳方案統(tǒng)跳路由是頁面解耦的最常見方式,大量應(yīng)用于前端頁面。通過把一個(gè) URL 與一個(gè)頁面綁定,需要時(shí)通過 URL 可以方便的打開相應(yīng)頁面。
//通過路由URL跳轉(zhuǎn)到商品列表頁面 //kRouteGoodsList = @"http://goods/goods_list" UIViewController *vc = [Router handleURL:kRouteGoodsList]; if(vc) { [self.navigationController pushViewController:vc animated:YES]; }
當(dāng)然有些場(chǎng)景會(huì)比這個(gè)復(fù)雜,比如有些頁面需要更多參數(shù)。
基本類型的參數(shù),URL 協(xié)議天然支持:
//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]; }
復(fù)雜類型的參數(shù),可以提供一個(gè)額外的字典參數(shù) complexParams, 將復(fù)雜參數(shù)放到字典中即可:
+ (nullable id)handleURL:(nonnull NSString *)urlStr complexParams:(nullable NSDictionary*)complexParams completion:(nullable RouteCompletion)completion;
上面方法里的 completion 參數(shù),是一個(gè)回調(diào) block, 處理打開某個(gè)頁面需要有回調(diào)功能的場(chǎng)景。比如打開會(huì)員選擇頁面,搜索會(huì)員,搜到之后點(diǎn)擊確定,回傳會(huì)員數(shù)據(jù):
//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]; }
考慮到實(shí)現(xiàn)的靈活性,提供路由服務(wù)的頁面,會(huì)將 URL 與一個(gè) block 相綁定。block 中放入所需的初始化代碼??梢栽诤线m的地方將初始化 block 與路由 URL 綁定,比如在 +load 方法里:
+ (void)load { [Router bindURL:kRouteGoodsList toHandler:^id _Nullable(NSDictionary * _Nullable parameters) { return [[GoodsListViewController alloc] init]; }]; }
更多路由 URL 相關(guān)例子,可以參考 Bifrost 項(xiàng)目中的 Demo.
URL 本身是一種跨多端的通用協(xié)議。使用路由URL統(tǒng)跳方案的優(yōu)勢(shì)是動(dòng)態(tài)性及多端統(tǒng)一 (H5, iOS,Android,Weex/RN); 缺點(diǎn)是能處理的交互場(chǎng)景偏簡(jiǎn)單。所以一般更適用于簡(jiǎn)單 UI 頁面跳轉(zhuǎn)。一些復(fù)雜操作和數(shù)據(jù)傳輸,雖然也可以通過此方式實(shí)現(xiàn),但都不是很效率。
目前天貓和蘑菇街都有使用路由 URL 作為自己的頁面統(tǒng)跳方案,達(dá)到解耦的目的。
當(dāng)無法 import 某個(gè)類的頭文件但仍需調(diào)用其方法時(shí),最常想到的就是基于反射來實(shí)現(xiàn)了。例:
Class manager = NSClassFromString(@"YZGoodsManager"); NSArray *list = [manager performSelector:@selector(getGoodsList)]; //code to handle the list ...
但這種方式存在大量的 hardcode 字符串。無法觸發(fā)代碼自動(dòng)補(bǔ)全,容易出現(xiàn)拼寫錯(cuò)誤,而且這類錯(cuò)誤只能在運(yùn)行時(shí)觸發(fā)相關(guān)方法后才能發(fā)現(xiàn)。無論是開發(fā)效率還是開發(fā)質(zhì)量都有較大的影響。
如何進(jìn)行優(yōu)化呢?這其實(shí)是各端遠(yuǎn)程調(diào)用都需要解決的問題。移動(dòng)端最常見的遠(yuǎn)程調(diào)用就是向后端接口發(fā)網(wǎng)絡(luò)請(qǐng)求。針對(duì)這類問題,我們很容易想到創(chuàng)建一個(gè)網(wǎng)絡(luò)層,將這類“危險(xiǎn)代碼”封裝到里面。上層業(yè)務(wù)調(diào)用時(shí)網(wǎng)絡(luò)層接口時(shí),不需要 hardcode 字符串,也不需要理解內(nèi)部麻煩的邏輯。
類似的,我可以將模塊間通訊也封裝到一個(gè)“網(wǎng)絡(luò)層”中(或者叫消息轉(zhuǎn)發(fā)層)。這樣危險(xiǎn)代碼只存在某幾個(gè)文件里,可以特別地進(jìn)行 code review 和聯(lián)調(diào)測(cè)試。后期還可以通過單元測(cè)試來保障質(zhì)量。模塊化方案中,我們可以稱這類“轉(zhuǎn)發(fā)層”為 Mediator (當(dāng)然你也可以起個(gè)別的名字)。同時(shí)因?yàn)?performSelector 方法附帶參數(shù)數(shù)量有限,也沒有返回值,所以更適合使用 NSInvocation 來實(shí)現(xiàn)。
//Mediator提供基于NSInvocation的遠(yuǎn)程接口調(diào)用方法的統(tǒng)一封裝 - (id)performTarget:(NSString *)targetName action:(NSString *)actionName params:(NSDictionary *)params; //Goods模塊所有對(duì)外提供的方法封裝在一個(gè)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
然后各個(gè)業(yè)務(wù)模塊依賴Mediator, 就可以直接調(diào)用這些方法了。
//業(yè)務(wù)方依賴Mediator模塊,可以直接調(diào)用相關(guān)方法 ... NSArray *list = [[Mediator sharedInstance] goods_getGoodsList]; ...
這種方案的優(yōu)勢(shì)是調(diào)用簡(jiǎn)單方便,代碼自動(dòng)補(bǔ)全和編譯時(shí)檢查都仍然有效。
劣勢(shì)是 category 存在重名覆蓋的風(fēng)險(xiǎn),需要通過開發(fā)規(guī)范以及一些檢查機(jī)制來規(guī)避。同時(shí) Mediator 只是收斂了 hardcode, 并未消除 hardcode, 仍然對(duì)開發(fā)效率有一定影響。
業(yè)界的 CTMediator 開源庫,以及美團(tuán)都是采用類似方案。
3.3 服務(wù)注冊(cè)方案有沒有辦法絕對(duì)的避免 hardcode 呢?如果接觸過后端的服務(wù)化改造,會(huì)發(fā)現(xiàn)和移動(dòng)端的業(yè)務(wù)模塊化很相似。Dubbo 就是服務(wù)化的經(jīng)典框架之一。它是通過服務(wù)注冊(cè)的方式來實(shí)現(xiàn)遠(yuǎn)程接口調(diào)用的。即每個(gè)模塊提供自己對(duì)外服務(wù)的協(xié)議聲明,然后將此聲明注冊(cè)到中間層。調(diào)用方能從中間層看到存在哪些服務(wù)接口,然后直接調(diào)用即可。例:
//Goods模塊提供的所有對(duì)外服務(wù)都放在GoodsModuleService中 @protocol GoodsModuleService - (NSArray*)getGoodsList; - (NSInteger)getGoodsCount; ... @end //Goods模塊提供實(shí)現(xiàn)GoodsModuleService的對(duì)象, //并在+load方法中注冊(cè) @interface GoodsModule : NSObject@end @implementation GoodsModule + (void)load { //注冊(cè)服務(wù) [ServiceManager registerService:@protocol(service_protocol) withModule:self.class] } //提供具體實(shí)現(xiàn) - (NSArray*)getGoodsList {...} - (NSInteger)getGoodsCount {...} @end //將GoodsModuleService放在某個(gè)公共模塊中,對(duì)所有業(yè)務(wù)模塊可見 //業(yè)務(wù)模塊可以直接調(diào)用相關(guān)接口 ... id module = [ServiceManager objByService:@protocol(GoodsModuleService)]; NSArray *list = [module getGoodsList]; ...
這種方式的優(yōu)勢(shì)也包括調(diào)用簡(jiǎn)單方便。代碼自動(dòng)補(bǔ)全和編譯時(shí)檢查都有效。實(shí)現(xiàn)起來也簡(jiǎn)單,協(xié)議的所有實(shí)現(xiàn)仍然在模塊內(nèi)部,所以不需要寫反射代碼了。同時(shí)對(duì)外暴露的只有協(xié)議,符合團(tuán)隊(duì)協(xié)作的“面向協(xié)議編程”的思想。劣勢(shì)是如果服務(wù)提供方和使用方依賴的是公共模塊中的同一份協(xié)議(protocol), 當(dāng)協(xié)議內(nèi)容改變時(shí),會(huì)存在所有服務(wù)依賴模塊編譯失敗的風(fēng)險(xiǎn)。同時(shí)需要一個(gè)注冊(cè)過程,將 Protocol 協(xié)議與具體實(shí)現(xiàn)綁定起來。
業(yè)界里,蘑菇街的 ServiceManager 和阿里的 BeeHive 都是采用的這個(gè)方案。
3.4 通知廣播方案基于通知的模塊間通訊方案,實(shí)現(xiàn)思路非常簡(jiǎn)單, 直接基于系統(tǒng)的 NSNotificationCenter 即可。
優(yōu)勢(shì)是實(shí)現(xiàn)簡(jiǎn)單,非常適合處理一對(duì)多的通訊場(chǎng)景。
劣勢(shì)是僅適用于簡(jiǎn)單通訊場(chǎng)景。復(fù)雜數(shù)據(jù)傳輸,同步調(diào)用等方式都不太方便。
模塊化通訊方案中,更多的是把通知方案作為以上幾種方案的補(bǔ)充。
除了模塊間通訊的實(shí)現(xiàn),業(yè)務(wù)模塊化架構(gòu)還需要考慮每個(gè)模塊內(nèi)部的設(shè)計(jì),比如其生命周期控制,復(fù)雜對(duì)象傳輸,重復(fù)資源的處理等??赡芤?yàn)槊總€(gè)公司都有自己的實(shí)際場(chǎng)景,業(yè)界方案里對(duì)這些問題描述的并不是很多。但實(shí)際上他們非常重要,有贊在模塊化過程中做了很多相關(guān)思考和嘗試,會(huì)在后面環(huán)節(jié)進(jìn)行介紹。
四、有贊的模塊化實(shí)踐有贊移動(dòng)自 16 年起開始實(shí)踐業(yè)務(wù)模塊化架構(gòu)方式,大致經(jīng)歷了 2016 年的嘗試+摸索,2017 年的思考+優(yōu)化以及 2018 年的成熟+沉淀幾個(gè)階段。期間有過對(duì)已有 App 的模塊化改造,也試過直接應(yīng)用于新起項(xiàng)目。模塊化方案經(jīng)歷過幾次改版,踩過一些坑,也收獲了很多寶貴的經(jīng)驗(yàn)。
4.1 v1.0: 嘗試+摸索16 年,有贊微商城、有贊收銀等 App 經(jīng)歷了初期的功能快速迭代,內(nèi)部依賴混亂,耦合嚴(yán)重,急需優(yōu)化重構(gòu)。傳統(tǒng)的 MVVM、MVP 等優(yōu)化方式無法從全局層面解決這些問題。后來在 InfoQ 的"移動(dòng)開發(fā)前線"微信群里聽了蘑菇街的組件化方案分享,非常受啟發(fā)。不過當(dāng)時(shí)還是有一些顧慮,比如微商城和收銀當(dāng)時(shí)都屬于中小型項(xiàng)目,每端開發(fā)人員都只有 4-6 人。業(yè)務(wù)模塊化改造后會(huì)形成一定的開發(fā)門檻,帶來一定的開發(fā)效率下降。小項(xiàng)目適合模塊化改造嗎?其收益是否能匹配付出呢?但考慮到當(dāng)時(shí) App 各模塊邊界已經(jīng)穩(wěn)定,即使模塊化改造出現(xiàn)問題,也可以用很小的代價(jià)將其降級(jí)到傳統(tǒng)的中介者模式,所以改造開始了。
4.1.1 模塊間通信方式設(shè)計(jì)首先是梳理我們的模塊間通信需求,主要包括以下三種:
UI 頁面跳轉(zhuǎn)。比如IM模塊點(diǎn)擊用戶頭像打開會(huì)員模塊的用戶詳情頁。
動(dòng)作執(zhí)行及復(fù)雜數(shù)據(jù)傳輸。比如商品模塊向開單模塊傳遞商品數(shù)據(jù)模型并進(jìn)行價(jià)格計(jì)算。
一對(duì)多的通知廣播。比如 logout 時(shí)賬號(hào)模塊發(fā)出廣播,各業(yè)務(wù)模塊進(jìn)行 cache 清理及其它相應(yīng)操作。
我們選擇了路由 URL + 遠(yuǎn)程接口調(diào)用封裝 + 廣播相結(jié)合的方式。
對(duì)于遠(yuǎn)程接口調(diào)用的封裝方式,我們沒有完全照抄 Mediator 方案。當(dāng)時(shí)非常期望保留模塊化的編譯隔離屬性。比如當(dāng) A 模塊對(duì)外提供的某個(gè)接口發(fā)生變化時(shí),不會(huì)引發(fā)依賴這個(gè)接口的模塊的編譯錯(cuò)誤。這樣可以避免依賴模塊被迫中斷手頭的工作先去解決編譯問題。當(dāng)時(shí)也沒有采用Beehive的服務(wù)注冊(cè)方式,也是因?yàn)橥瑯拥脑颉?經(jīng)過討論,當(dāng)時(shí)選擇參考網(wǎng)絡(luò)層封裝方式,在每個(gè)模塊中設(shè)計(jì)一個(gè)對(duì)外的“網(wǎng)絡(luò)層” ModuleService。將對(duì)其它模塊的接口的反射調(diào)用,放入各個(gè)模塊的 ModuleService 中。
同時(shí),我們希望各業(yè)務(wù)模塊不需要去理解所依賴模塊的內(nèi)部復(fù)雜實(shí)現(xiàn)。比如 A 模塊依賴 D 模塊的 class D1 的接口 method1, class D2 的接口method2, class D3 的接口 method3. A 需要了解 D 模塊的這些內(nèi)部信息才能完成反射功能的實(shí)現(xiàn)。如果 D 模塊中這些命名有所變化,還會(huì)出現(xiàn)調(diào)用失敗。所以我們對(duì)各個(gè)模塊使用外觀(Facade)模式進(jìn)行重構(gòu)。D 模塊創(chuàng)建一個(gè)外觀層 FacadeD. 通過 FacadeD 對(duì)象對(duì)外提供所有服務(wù),同時(shí)隱藏內(nèi)部復(fù)雜實(shí)現(xiàn)。調(diào)用方也只需要理解 FacadeD 的頭文件 包含哪些接口即可。
外觀(Facade)模式: 為子系統(tǒng)中的一組接口提供一個(gè)一致的界面, Facade 模式定義了一個(gè)高層接口,這個(gè)接口使得這一子系統(tǒng)更加容易使用。引入外觀角色之后,用戶只需要直接與外觀角色交互,用戶與子系統(tǒng)之間的復(fù)雜關(guān)系由外觀角色來實(shí)現(xiàn),從而降低了系統(tǒng)的耦合度。
另外,為什么還需要路由 URL 呢?
其實(shí)從功能角度,遠(yuǎn)程接口的網(wǎng)絡(luò)層,完全可以取代路由 URL 實(shí)現(xiàn)頁面跳轉(zhuǎn),而且沒有路由 URL 的一些 hardcode 的問題。而且路由 URL 和
遠(yuǎn)程接口存在一定的功能重合,還會(huì)造成后續(xù)實(shí)現(xiàn)新功能時(shí),分不清應(yīng)選擇路由 URL 還是選擇遠(yuǎn)程接口的困惑。這里選擇支持路由 URL 的主要原因是我們存在動(dòng)態(tài)化且多端統(tǒng)一的需求。比如消息模塊下發(fā)的各種消息數(shù)據(jù)模型完全是動(dòng)態(tài)的。后端配好展示內(nèi)容以及跳轉(zhuǎn)需求后,客戶端不需要理解具體需求,只需要通過統(tǒng)一的路由跳轉(zhuǎn)協(xié)議執(zhí)行跳轉(zhuǎn)動(dòng)作即可。
每個(gè)模塊除了 Facade 模式改造之外,還需要考慮以下問題:
合適的注冊(cè)及初始化方式。
接收并處理全局事件。
App 層和 Common 層設(shè)計(jì)。
模塊編譯產(chǎn)出以及集成到 App 中的方式。
因?yàn)榭紤]到每個(gè) App 中業(yè)務(wù)模塊數(shù)量不會(huì)很多(我們幾個(gè) App 內(nèi)大多是20個(gè)左右),所以我們?yōu)槊總€(gè)模塊創(chuàng)建了一個(gè) Module 對(duì)象并令其為單例。在 +load 方法中將自身注冊(cè)給模塊化 SDK Bifrost. 經(jīng)測(cè)試,這里因?yàn)閱卫斐傻膬?nèi)存占用以及 +load 方法引起的啟動(dòng)速度影響都微乎其微。模塊需要監(jiān)聽的全局事件主要為 UIApplicationDelegate 中的那些方法。所以我們定義了一個(gè)繼承 UIApplicationDelegate 的協(xié)議 BifrostModuleProtocol,令每個(gè)模塊的 Module 對(duì)象都服從這個(gè)協(xié)議。App 的 AppDelegate對(duì)象,會(huì)輪詢所有注冊(cè)了的業(yè)務(wù)模塊并進(jìn)行必要的調(diào)用。
@protocol BifrostModuleProtocol@required + (instancetype)sharedInstance; - (void)setup; ... @optional + (BOOL)setupModuleSynchronously; ... @end
所有業(yè)務(wù)代碼挪入各業(yè)務(wù)模塊的 Module 對(duì)象后,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
每個(gè)業(yè)務(wù)模塊都作為一個(gè)子 Project 集成入 App Project. 同時(shí)創(chuàng)建一個(gè)特殊的模塊 Common,用于放置一些通用業(yè)務(wù)和全局的基類。App 層只保留 AppDelegate 等全局類和 plist 等特殊配置,基本沒有任何業(yè)務(wù)代碼。Common 層因?yàn)闆]有明確的業(yè)務(wù)組來負(fù)責(zé),所以也應(yīng)該盡量輕薄。各業(yè)務(wù)模塊之間互不可見,但可以直接依賴 Common 模塊。通過search path來設(shè)置模塊依賴關(guān)系。
每個(gè)業(yè)務(wù)模塊的產(chǎn)出包括可執(zhí)行文件和資源文件兩部分。有2種選擇:生成 framework 和生成靜態(tài)庫 + 資源 bundle.
使用framework的優(yōu)點(diǎn)是輸出在同一個(gè)對(duì)象內(nèi),方便管理。缺點(diǎn)是作為動(dòng)態(tài)庫載入,影響加載速度。所以當(dāng)時(shí)選擇了靜態(tài)庫 + bundle 的形式。不過個(gè)人感覺這塊還是需要具體測(cè)一下會(huì)慢做少再做決定更合適。但因?yàn)槎卟顒e不大,所以后續(xù)我們也一直沒作調(diào)整。
另外如果使用framework,需要注意資源讀取的問題。因?yàn)閭鹘y(tǒng)的資源讀取方式無法定位到framework內(nèi)資源,需要通過 bundleForClass: 才行。
//傳統(tǒng)方式只能定位到指定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 復(fù)雜對(duì)象傳輸
當(dāng)時(shí)最糾結(jié)的點(diǎn)就是復(fù)雜對(duì)象的傳輸。例如商品模型,它包含幾十個(gè)字段。如果是傳字典或傳 json, 那么數(shù)據(jù)提供方(商品模塊)和使用方(開單模塊)都需要專門理解并實(shí)現(xiàn)一下這種模型的各種字段,對(duì)開發(fā)效率影響很大.
有沒有辦法直接傳遞模型對(duì)象呢?這里涉及到模型的類文件放在哪里。最容易想到的方案是沉入 Common 模塊。但一旦這個(gè)口子放開,后續(xù)會(huì)有越來越多的模型放入 Common,和前面提到的簡(jiǎn)化 Common 層的目標(biāo)是相悖的。而且因?yàn)?Common 模塊沒有明確業(yè)務(wù)組歸屬,所有小組都能編輯, 其質(zhì)量和穩(wěn)定性難以保障。最終我們采用了一個(gè) tricky 的方案,把要傳遞的復(fù)雜模型的代碼復(fù)制一份放在使用方模塊中,同時(shí)通過修改類名前綴加以區(qū)分,這樣就可以避免打包時(shí)的鏈接沖突錯(cuò)誤。比如商品模塊內(nèi)叫 YZGGoodsModel, 開單模塊內(nèi)叫 YZSGoodsModel. 商品模塊的接口返回的是 YZGGoodsModel,開單模塊將其強(qiáng)轉(zhuǎn)為 YZSGoodsModel 即可。
//YZSaleModuleService.m內(nèi) #import "YZSGoodsModel.h" - (YZSGoodsModel*)goodsById:(NSString*)goodsId { //Sale Module遠(yuǎn)程調(diào)用Goods Module的接口 id obj = [Bifrost performTarget:@"YZGoodsModule" action:@"goodsById:" params:@[goodsId]]; //做一次強(qiáng)轉(zhuǎn) YZSGoodsModel *goods = (YZSGoodsModel*)obj; return goods; }
這種方式雖然比較粗暴,但考慮到兩個(gè)模塊間交互的復(fù)雜對(duì)象應(yīng)該不會(huì)很多(如果太多則應(yīng)考慮這兩個(gè)模塊是否劃分合適),同時(shí)拷貝粘貼操作起來成本可控,所以可以接受。同時(shí)這種方法也能達(dá)到預(yù)期的編譯隔離的效果。但兩邊模型定義及實(shí)現(xiàn)還是有不一致的風(fēng)險(xiǎn)。為了解決一致性問題,我們做了個(gè)檢查腳本工具,在編譯時(shí)觸發(fā)。會(huì)根據(jù)命名規(guī)則查找這類“同名” model 的代碼,并做一個(gè)比較。如果發(fā)現(xiàn)不一致,則報(bào) warning. 注意不是報(bào)error, 因?yàn)槲覀兿M粋€(gè)模塊做了接口修改,另一個(gè)模塊可以存在一種選擇,是馬上更新接口,還是先完成手頭的工作將來再更新。
4.1.4 重復(fù)資源處理這類資源主要包括圖片、音視頻,數(shù)據(jù)模型等等。
首先我們排除了無腦放入 Common 的方案。因?yàn)橄鲁寥?Common 會(huì)破壞各業(yè)務(wù)模塊的完整性,同時(shí)也會(huì)影響 Common 的質(zhì)量。經(jīng)過討論后,決定把資源分為三類:
通用功能所用資源,將相關(guān)代碼整理為功能組件后一起放入 Common.
業(yè)務(wù)功能的大部分資源可以通過無損壓縮控制體積,體積不大的資源允許一定程度上的重復(fù)。
較大體積的資源放到服務(wù)端,App 端動(dòng)態(tài)拉取放在本地緩存中。
同時(shí)平時(shí)定期通過自動(dòng)化工具檢測(cè)無用資源,以及重復(fù)資源的大小,以便及時(shí)優(yōu)化包體積。
4.1.5 體驗(yàn)與成果基于以上設(shè)計(jì),我們大概花了 3 的個(gè)月的時(shí)間對(duì)已有項(xiàng)目進(jìn)行了業(yè)務(wù)模塊化改造(邊做業(yè)務(wù)邊改造)。因?yàn)榉桨讣?xì)節(jié)考慮的比較多,大家對(duì)一些可能存在的問題也都有預(yù)期,所以當(dāng)時(shí)改造后大家多持肯定態(tài)度,成本 vs 收益還是可觀的。
v1.0 版本改造后,App 架構(gòu)關(guān)系如圖:
16 年的第一版模塊化設(shè)計(jì)方案雖然可行,但還存在兩個(gè)痛點(diǎn):
模塊間網(wǎng)絡(luò)層的封裝基于反射代碼,寫起來仍然有些麻煩。而且需要額外寫單測(cè)保證質(zhì)量。
復(fù)雜對(duì)象的處理方式也存在一些問題,比如拷貝粘貼的方式比較丑陋,重復(fù)代碼會(huì)帶來包體積的增加。
上述問題在團(tuán)隊(duì)規(guī)模擴(kuò)大,新同學(xué)到來時(shí)格外明顯,經(jīng)常需要答疑講解。甚至有一次業(yè)務(wù)項(xiàng)目時(shí)間特別緊張時(shí),有些小伙伴私下更改模塊間頭文件 search path,直接依賴的了別的模塊,以便重用復(fù)雜模型類的情況。
這些問題的根本原因還是存在效率損失,"不方便",怎么優(yōu)化呢?
4.2.1 遠(yuǎn)程接口封裝優(yōu)化首先是如何避免反射及 hardcode. 阿里 Beehive 的基于服務(wù)注冊(cè)的方式 是不需要 hardcode 代碼的。但它有額外的服務(wù)注冊(cè)過程,可能會(huì)影響啟動(dòng)速度,性能弱于基于反射的接口封裝方案。這里對(duì)啟動(dòng)速度的影響究竟有多少呢?我們做了個(gè)測(cè)試,在 +load 方法中注冊(cè)了 1000 個(gè) Sevice Protocol, 啟動(dòng)時(shí)間影響大概是 2-4 ms, 非常少。
之前的業(yè)務(wù)模塊化方案沒有使用 Beehive 還有個(gè)原因,就是服務(wù)提供方和使用方共同依賴同一個(gè) Protocol,不符合我們編譯隔離的需求。但既然我們可以拷貝粘貼復(fù)雜對(duì)象代碼,是否也可以拷貝粘貼 Protocol 聲明呢?答案是可行的。而且即使工程中同時(shí)存在多個(gè)同名的 Protocol 也不會(huì)引起編譯問題,連改名這一步都省去了。以商品模型為例,為它定義一個(gè) GoodModelProtocol, 服務(wù)使用方開單模塊可以直接將這個(gè) Protocol 的聲明 copy 到自己模塊中,也不需要改名,操作成本非常低。然后商品模塊內(nèi)就可以使用這個(gè) Protocol 了。同時(shí)因?yàn)橛玫氖峭粋€(gè)協(xié)議對(duì)象,所以 v1.0 中的類型強(qiáng)轉(zhuǎn)風(fēng)險(xiǎn)也沒有了。
跨模塊進(jìn)行方法調(diào)用和數(shù)據(jù)讀取非常便捷:
NSString *goodsID = @"123123123"; idgoods = [BFModule(YZGoodsModuleService) goodsById:goodsID]; self.goodsCell.name = goods.name; self.goodsCell.price = goods.price; ...
為盡量減少拷貝粘貼頻率,我們將每個(gè)模塊對(duì)外提供的接口服務(wù),路由定義,通知定義,以及復(fù)雜對(duì)象 Protocol 定義都放在 ModuleService.h 中。管理非常方便規(guī)范,別的模塊 copy 起來也簡(jiǎn)單,只需要把這個(gè) ModuleService.h 文件
copy 到自己模塊內(nèi)部,就可以直接依賴并調(diào)用接口了。而且如果將來需要從服務(wù)器拉取相關(guān)配置,一個(gè)文件會(huì)方便很多。但是也需要考慮如果以上內(nèi)容都放入同一個(gè)頭文件,會(huì)不會(huì)導(dǎo)致文件過大的問題。當(dāng)時(shí)分析模塊間交互是有限的,否則就需要考慮模塊劃分是否合適。所以問題應(yīng)該不大。從結(jié)果來看,目前我們最大的 ModuleService.h, 加上注釋大概是 300 多行。
另外,我們發(fā)現(xiàn)每個(gè)模塊對(duì)初始化順序也有需求。比如賬號(hào)模塊的初始化可能要優(yōu)先于別的模塊,以便別的模塊在初始化時(shí)使用其服務(wù)。所以我們也對(duì) ModuleProtocol 增加了優(yōu)先級(jí)接口。每個(gè)模塊可以定義自己的初始化優(yōu)先級(jí)。
/** 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;
經(jīng)過以上優(yōu)化改造,基本解決了 v1.0 的所有質(zhì)量及效率方面的隱患,業(yè)務(wù)模塊化方案趨近成熟。
4.3 v3.0: 成熟+沉淀17 年優(yōu)化后的模塊化方案,基本算是具有有贊特色的相對(duì)成熟的方案了,支撐了包括零售在內(nèi)的多個(gè)大型app的開發(fā)。
4.3.1 編譯隔離的思考Copy 頭文件的方式仍然有一些理解成本。移動(dòng)團(tuán)隊(duì)規(guī)??焖侔l(fā)展,一些新來的小伙伴還是會(huì)提出疑問。18 年年中我們做了幾次檢查,發(fā)現(xiàn)模塊間 ModuleService 版本不一致的情況時(shí)有發(fā)生。當(dāng)時(shí)零售移動(dòng)團(tuán)隊(duì)雖然達(dá)到 30 多人,但仍然是一個(gè)協(xié)作緊密的整體,發(fā)版節(jié)奏基本一致。各業(yè)務(wù)模塊代碼都在同一個(gè) git 工程中,基本每次發(fā)版用的都是各個(gè)模塊的最新版本。而且實(shí)際做了幾次調(diào)查,發(fā)現(xiàn) ModuleService 中接口改變導(dǎo)致的依賴模塊的修改,其實(shí)成本很低,改起來很快。此時(shí)我們開始思考之前追求的編譯隔離是否適合當(dāng)前階段,是否有實(shí)際價(jià)值。
最終我們決定節(jié)省每一份精力,效率最大化。將各業(yè)務(wù)的 ModuleService進(jìn)行下沉到 Commom 模塊,各業(yè)務(wù)模塊直接依賴 Common 中的這些 ModuleServie 頭文件,不再需要 copy 操作。這樣改造的代價(jià)是形成了更多的依賴。本來一個(gè)業(yè)務(wù)模塊是可以不依賴 Common 的,但現(xiàn)在就必須依賴了。但考慮到實(shí)際情況,還沒有不依賴 Common 的業(yè)務(wù)模塊存在,這種追求沒有價(jià)值,所以應(yīng)該問題不大。同時(shí)因?yàn)橄鲁恋亩际且恍╊^文件,沒有具體實(shí)現(xiàn),將來如果需要模塊間的進(jìn)一步隔離,比如模塊多帶帶打包等,只需要將這些 Moduleservie 做到服務(wù)端可配置 + 自動(dòng)化下載生成即可,改造成本非常小。
但這樣改造后又發(fā)生了一件事。某個(gè)新來的同學(xué),直接在 Common 模塊中寫代碼通過這些 ModuleService 調(diào)用了上層業(yè)務(wù)模塊的功能,形成了底層 Commmon 模塊對(duì)上層業(yè)務(wù)模塊的反向依賴。于是我們進(jìn)一步拆分出了一個(gè)新模塊 Mediator, 將 Bifrost SDK 和這些 ModuleSevice 放入其中。Common 模塊和 Mediator 互不可見。
最終形成的 App 架構(gòu)為:
注:業(yè)界有些方案是把 ModuleServie 分開存放的,相當(dāng)于把以上方案里的 Mediator 部分進(jìn)行分拆,每個(gè)業(yè)務(wù)模塊都有一個(gè)。這種方式的優(yōu)點(diǎn)是職責(zé)明確,大家不用同時(shí)對(duì)一個(gè)公共模塊進(jìn)行修改,同時(shí)可以做到依賴關(guān)系很清晰;劣勢(shì)是模塊的數(shù)量增加了一倍,維護(hù)成本增加很多??紤]到我們目前的情況,Mediator 模塊是很薄的一層,共同修改維護(hù)這個(gè)模塊也可以接受,所以目前沒有將其拆開。將來如果需要,再將其做分拆改造即可,改造工作量很小。
4.3.2 代碼隔離的思考除了不在不合適的階段追求編譯隔離,我們還發(fā)現(xiàn)代碼隔離并不適合我們。
業(yè)務(wù)模塊化的效果之一就是個(gè)業(yè)務(wù)模塊可以多帶帶打包,放入殼工程運(yùn)行。很容易想到的一個(gè)改造就是把各個(gè)模塊拆到不同的 git 中。好處很多,比如多帶帶的權(quán)限控制,獨(dú)立的版本號(hào),萬一發(fā)版時(shí)發(fā)現(xiàn)問題可以及時(shí) rollback 用老版本打包。我們的微商城 App 就做了這種嘗試。將代碼遷到了很多 git 中,通過 pod 的方式進(jìn)行管理。但后續(xù)開發(fā)中體驗(yàn)并不是很好。當(dāng)時(shí)微商城 App 的模塊數(shù)量比開發(fā)同學(xué)數(shù)量多很多,每個(gè)同學(xué)都同時(shí)維護(hù)著多個(gè)模塊。有時(shí)一個(gè)項(xiàng)目,一個(gè)人需要同時(shí)在多個(gè) git 中修改多個(gè)模塊的代碼。修改完成后,要多次執(zhí)行提交、打版本號(hào)以及集成測(cè)試等操作,很不效率。同時(shí)因?yàn)樯婕暗蕉鄠€(gè) git,代碼提交的 Merge Request 和相關(guān)的編譯檢查也復(fù)雜了很多。同樣的,因?yàn)槲⑸坛?App 中不同模塊的開發(fā)發(fā)版節(jié)奏也基本一致,所以多 git 多 pod 的不同版本管理及回退的優(yōu)勢(shì)也沒有體現(xiàn)出來。最終還是將各模塊代碼遷回了主 git 中。
4.3.3 沒價(jià)值的隔離?但編譯隔離和代碼隔離真的沒有價(jià)值嗎?當(dāng)然不是,主要是我們當(dāng)前階段并不需要。過早的調(diào)整增加了成本卻沒有價(jià)值產(chǎn)出,所以并不合適。實(shí)際上我們還有一些業(yè)務(wù)模塊是跨 App 使用的,比如IM模塊,資產(chǎn)模塊等等。他們都是獨(dú)立 git 獨(dú)立發(fā)版的。編譯隔離和代碼隔離屬性對(duì)他們很有效。
另外,每個(gè)模塊多帶帶git可以有更細(xì)粒度的權(quán)限管理。我們因?yàn)樵谝粋€(gè)git中,曾發(fā)生過好幾次小伙伴改別人的模塊改出問題的例子(雖然有MR, 但人難免有遺漏)。后來我們是通過 git commit hook + 修改文件路徑來控制修改權(quán)限才解決了這個(gè)問題。后續(xù)介紹有贊移動(dòng)基礎(chǔ)設(shè)施建設(shè)的文章中會(huì)有更多相關(guān)細(xì)節(jié)。
4.3.4 Bifrost (雷神里的彩虹橋)最終,我們總結(jié)了所有我們需要的業(yè)務(wù)模塊化需求,沉淀出了輕量級(jí)的模塊化 SDK Bifrost.
為什么不直接使用業(yè)界的 CTMediator 或者 Beehive 或者 MGJRouter, 要再造個(gè)輪子呢?主要有三個(gè)原因:一是我們開始嘗試模塊化改造時(shí),業(yè)界還沒有相關(guān)框架開源出來,所以需要自己實(shí)現(xiàn)。二是我們的需求和業(yè)界的開源庫不完全相符。MGJRouter 缺少服務(wù)管理,CTMediator 和設(shè)計(jì)不符,Beehive 沒有路由管理同時(shí)不夠輕量(很多接口還是基于阿里的需求提供的,我們用不到,會(huì)形成理解成本)。原因三其實(shí)是最關(guān)鍵的,就是模塊化 SDK 的實(shí)現(xiàn)其實(shí)不難。通過前面的介紹,可以發(fā)現(xiàn)其中并沒有什么黑魔法,代碼量也不多,實(shí)現(xiàn)成本很低。模塊化過程更多精力花在了全局架構(gòu)設(shè)計(jì),與之配合的開發(fā)規(guī)范,以及結(jié)合自己團(tuán)隊(duì)情況的一些取舍。模塊化 SDK 只是模塊化整體設(shè)計(jì)的冰山一角。我們也推薦讀者所在團(tuán)隊(duì),如果有時(shí)間可以嘗試自己實(shí)現(xiàn)模塊化工具,Bifrost 只用做參考即可。
4.3.5 業(yè)務(wù)模塊化時(shí)機(jī)我們建議所有進(jìn)入業(yè)務(wù)領(lǐng)域劃分穩(wěn)定期(業(yè)務(wù)模塊基本確定,不會(huì)發(fā)生較大變動(dòng))的團(tuán)隊(duì)采用業(yè)務(wù)模塊化架構(gòu)設(shè)計(jì)。即使模塊劃分還沒完全明確,也可以考慮對(duì)部分明確了模塊進(jìn)行模塊化改造。因?yàn)檫t早要用,晚用不如早用。目前基于路由 URL + 協(xié)議注冊(cè)的模塊間通訊方式,對(duì)開發(fā)效率基本無損。
五、總結(jié)移動(dòng)應(yīng)用的業(yè)務(wù)模塊化架構(gòu)設(shè)計(jì),其真正的目標(biāo)是提升開發(fā)質(zhì)量和效率。單從實(shí)現(xiàn)角度來看并沒有什么黑魔法或技術(shù)難點(diǎn),更多的是結(jié)合團(tuán)隊(duì)實(shí)際開發(fā)協(xié)作方式和業(yè)務(wù)場(chǎng)景的具體考量——“適合自己的才是最好的”。有贊移動(dòng)團(tuán)隊(duì)通過過往3年的實(shí)踐,發(fā)現(xiàn)一味的追求性能,絕對(duì)的追求模塊間編譯隔離,過早的追求模塊代碼管理隔離等方式都偏離了模塊化設(shè)計(jì)的真正目的,是得不償失的。更合適的方式是在可控的改造代價(jià)下,一定程度考慮未來的優(yōu)化方式,更多的考慮當(dāng)前的實(shí)際場(chǎng)景,來設(shè)計(jì)適合自己的模塊化方式。希望通過本文提供的具體案例和思考方式,大家都能找到適合自己應(yīng)用的業(yè)務(wù)模塊化之路。
文章版權(quán)歸作者所有,未經(jīng)允許請(qǐng)勿轉(zhuǎn)載,若此文章存在違規(guī)行為,您可以聯(lián)系管理員刪除。
轉(zhuǎn)載請(qǐng)注明本文地址:http://systransis.cn/yun/75702.html
摘要:簡(jiǎn)潔直觀強(qiáng)悍的前端開發(fā)框架,讓開發(fā)更迅速簡(jiǎn)單。是一套基于的前端框架。首個(gè)版本發(fā)布于年金秋,她區(qū)別于那些基于底層的框架,卻并非逆道而行,而是信奉返璞歸真之道。 2017-1209 ZanUI (Vue) 2017-1218 Onsen UI(Vue, React, Angular) 2017-1215 增加 Vuetify, Weex UI, Semantic UI React,ele...
閱讀 1679·2021-11-16 11:41
閱讀 2468·2021-11-08 13:14
閱讀 3119·2019-08-29 17:16
閱讀 3089·2019-08-29 16:30
閱讀 1852·2019-08-29 13:51
閱讀 367·2019-08-23 18:38
閱讀 3236·2019-08-23 17:14
閱讀 640·2019-08-23 15:09