元件化架構漫談
前段時間公司專案打算重構,準確來說應該是按之前的產品邏輯重寫一個專案。在重構專案之前涉及到架構選型的問題,我和組裡小夥伴一起研究了一下元件化架構,打算將專案重構為元件化架構。當然不是直接拿來照搬,還是要根據公司具體的業務需求設計架構。
在學習元件化架構的過程中,從很多高質量的部落格中學到不少東西,例如蘑菇街李忠、casatwy、bang的部落格。在學習過程中也遇到一些問題,在微博和QQ上和一些做
iOS
的朋友進行了交流,非常感謝這些朋友的幫助。本篇文章主要針對於之前蘑菇街提出的元件化方案,以及casatwy提出的元件化方案進行分析,後面還會簡單提到滴滴、淘寶、微信的元件化架構,最後會簡單說一下我公司設計的元件化架構。
元件化架構的由來
隨著移動網際網路的不斷髮展,很多程式程式碼量和業務越來越多,現有架構已經不適合公司業務的發展速度了,很多都面臨著重構的問題。
在公司專案開發中,如果專案比較小,普通的單工程+MVC架構
就可以滿足大多數需求了。但是像淘寶、蘑菇街、微信這樣的大型專案,原有的單工程架構就不足以滿足架構需求了。
就拿淘寶來說,淘寶在13年開啟的“All in 無線”
戰略中,就將阿里系大多數業務都加入到手機淘寶中,使客戶端出現了業務的爆發。在這種情況下,單工程架構則已經遠遠不能滿足現有業務需求了。所以在這種情況下,淘寶在13年開啟了外掛化架構的重構,後來在14年迎來了手機淘寶有史以來最大規模的重構,將其徹底重構為元件化架構
蘑菇街的元件化架構
原因
在一個專案越來越大,開發人員越來越多的情況下,專案會遇到很多問題。
-
業務模組間劃分不清晰,模組之間耦合度很大,非常難維護。
-
所有模組程式碼都編寫在一個專案中,測試某個模組或功能,需要編譯執行整個專案。
耦合嚴重的工程
為了解決上面的問題,可以考慮加一個中間層來協調模組間的呼叫,所有的模組間的呼叫都會經過中間層中轉。(注意看兩張圖的箭頭方向)
新增中間層
但是發現增加這個中間層後,耦合還是存在的。中間層對被呼叫模組存在耦合,其他模組也需要耦合中間層才能發起呼叫。這樣還是存在之前的相互耦合的問題,而且本質上比之前更麻煩了。
大體結構
所以應該做的是,只讓其他模組對中間層產生耦合關係,中間層不對其他模組發生耦合
對於這個問題,可以採用元件化的架構,將每個模組作為一個元件。並且建立一個主專案,這個主專案負責整合所有元件。這樣帶來的好處是很多的:
-
業務劃分更佳清晰,新人接手更佳容易,可以按元件分配開發任務。
-
專案可維護性更強,提高開發效率。
-
更好排查問題,某個元件出現問題,直接對元件進行處理。
-
開發測試過程中,可以只編譯自己那部分程式碼,不需要編譯整個專案程式碼。
元件化結構
進行元件化開發後,可以把每個元件當做一個獨立的app,每個元件甚至可以採取不同的架構,例如分別使用MVVM
、MVC
、MVCS
等架構。
MGJRouter方案
蘑菇街通過MGJRouter
實現中間層,通過MGJRouter
進行元件間的訊息轉發,從名字上來說更像是路由器。實現方式大致是,在提供服務的元件中提前註冊block
,然後在呼叫方元件中通過URL
呼叫block
,下面是呼叫方式。
架構設計
MGJRouter元件化架構
MGJRouter
是一個單例物件,在其內部維護著一個“URL -> block”
格式的登錄檔,通過這個登錄檔來儲存服務方註冊的block
,以及使呼叫方可以通過URL
映射出block
,並通過MGJRouter
對服務方發起呼叫。
在服務方元件中都對外提供一個介面類,在介面類內部實現block
的註冊工作,以及block
對外提供服務的程式碼實現。每一個block
都對應著一個URL
,呼叫方可以通過URL
對block
發起呼叫。
在程式開始執行時,需要將所有服務方的介面類例項化,以完成這個註冊工作,使MGJRouter
中所有服務方的block
可以正常提供服務。在這個服務註冊完成後,就可以被呼叫方調起並提供服務。
蘑菇街專案使用git
作為版本控制工具,將每個元件都當做一個獨立工程,並建立主專案來整合所有元件。整合方式是在主專案中通過CocoaPods
來整合,將所有元件當做二方庫整合到專案中。詳細的整合技術點在下面“標準組件化架構設計”
章節中會講到。
MGJRouter呼叫
程式碼模擬對詳情頁的註冊、呼叫,在呼叫過程中傳遞id
引數。下面是註冊的示例程式碼:
[MGJRouter registerURLPattern:@"mgj://detail?id=id" toHandler:^(NSDictionary *routerParameters) { // 下面可以在拿到引數後,為其他元件提供對應的服務 NSString uid = routerParameters[@"id"]; }];
通過openURL:
方法傳入的URL
引數,對詳情頁已經註冊的block
方法發起呼叫。呼叫方式類似於GET
請求,URL
地址後面拼接引數。
[MGJRouter openURL:@"mgj://detail?id=404"];
也可以通過字典方式傳參,MGJRouter
提供了帶有字典引數的方法,這樣就可以傳遞非字串之外的其他型別引數。
[MGJRouter openURL:@"mgj://detail?" withParam:@{@"id" : @"404"}];
元件間傳值
有的時候元件間呼叫過程中,需要服務方在完成呼叫後返回相應的引數。蘑菇街提供了另外的方法,專門來完成這個操作。
[MGJRouter registerURLPattern:@"mgj://cart/ordercount" toObjectHandler:^id(NSDictionary *routerParamters){ return @42; }];
通過下面的方式發起呼叫,並獲取服務方返回的返回值,要做的就是傳遞正確的URL
和引數即可。
NSNumber *orderCount = [MGJRouter objectForURL:@"mgj://cart/ordercount"];
短鏈管理
這時候會發現一個問題,在蘑菇街元件化架構中,存在了很多硬編碼的URL和引數。在程式碼實現過程中URL
編寫出錯會導致呼叫失敗,而且引數是一個字典型別,呼叫方不知道服務方需要哪些引數,這些都是個問題。
對於這些資料的管理,蘑菇街開發了一個web
頁面,這個web
頁面統一來管理所有的URL
和引數,Android
和iOS
都使用這一套URL
,可以保持統一性。
基礎元件
在專案中存在很多公共部分的東西,例如封裝的網路請求、快取、資料處理等功能,以及專案中所用到的資原始檔。
蘑菇街將這些部分也當做元件,劃分為基礎元件,位於業務元件下層。所有業務元件都使用同一個基礎元件,也可以保證公共部分的統一性。
Protocol方案
整體架構
Protocol方案的中介軟體
為了解決MGJRouter
方案中URL
硬編碼,以及字典引數型別不明確等問題,蘑菇街在原有元件化方案的基礎上推出了Protocol
方案。Protocol
方案由兩部分組成,進行元件間通訊的ModuleManager
類以及MGJComponentProtocol
協議類。
通過中介軟體ModuleManager
進行訊息的呼叫轉發,在ModuleManager
內部維護一張對映表,對映表由之前的"URL -> block"
變成"Protocol
-> Class"
。
在中介軟體中建立MGJComponentProtocol
檔案,服務方元件將可以用來呼叫的方法都定義在Protocol
中,將所有服務方的Protocol
都分別定義到MGJComponentProtocol
檔案中,如果協議比較多也可以分開幾個檔案定義。這樣所有呼叫方依然是隻依賴中介軟體,不需要依賴除中介軟體之外的其他元件。
Protocol
方案中每個元件也需要一個“介面類”,此類負責實現當前元件對應的協議方法,也就是對外提供服務的實現。在程式開始執行時將自身的Class
註冊到ModuleManager
中,並將Protocol
反射出字串當做key
。這個註冊過程和MGJRouter
是類似的,都需要提前註冊服務。
示例程式碼
建立MGJUserImpl
類當做User
模組的服務類,並在MGJComponentProtocol.h
中定義MGJUserProtocol
協議,由MGJUserImpl
類實現協議中定義的方法,完成對外提供服務的過程。下面是協議定義:
@protocol MGJUserProtocol - (NSString *)getUserName;@end
Class
遵守協議並實現定義的方法,外界通過Protocol
獲取的Class
例項化為物件,呼叫服務方實現的協議方法。
ModuleManager
的協議註冊方法,註冊時將Protocol
反射為字串當做儲存的key
,將實現協議的Class
當做值儲存。通過Protocol
取Class
的時候,就是通過Protocol
從ModuleManager
中將Class
映射出來。
[ModuleManager registerClass:MGJUserImpl forProtocol:@protocol(MGJUserProtocol)];
呼叫時通過Protocol
從ModuleManager
中映射出註冊的Class
,將獲取到的Class
例項化,並呼叫Class
實現的協議方法完成服務呼叫。
Class cls = [[ModuleManager sharedInstance] classForProtocol:@protocol(MGJUserProtocol)];id userComponent = [[cls alloc] init];NSString *userName = [userComponent getUserName];
整體呼叫流程
蘑菇街是OpenURL
和Protocol
混用的方式,兩種實現的呼叫方式不同,但大體呼叫邏輯和實現思路類似,所以下面的呼叫流程二者差不多。在OpenURL
不能滿足需求或呼叫不方便時,就可以通過Protocol
的方式呼叫。
-
在進入程式後,先使用
MGJRouter
對服務方元件進行註冊。每個URL
對應一個block
的實現,block
中的程式碼就是服務方對外提供的服務,呼叫方可以通過URL
呼叫這個服務。 -
呼叫方通過
MGJRouter
呼叫openURL:
方法,並將被呼叫程式碼對應的URL
傳入,MGJRouter
會根據URL
查詢對應的block
實現,從而呼叫服務方元件的程式碼進行通訊。 -
呼叫和註冊
block
時,block
有一個字典用來傳遞引數。這樣的優勢就是引數型別和數量理論上是不受限制的,但是需要很多硬編碼的key
名在專案中。
記憶體管理
蘑菇街元件化方案有兩種,Protocol
和MGJRouter
的方式,但都需要進行register
操作。Protocol
註冊的是Class
,MGJRouter
註冊的是Block
,登錄檔是一個NSMutableDictionary
型別的字典,而字典的擁有者又是一個單例物件,這樣會造成記憶體的常駐。
下面是對兩種實現方式記憶體消耗的分析:
-
首先說一下
block
實現方式可能導致的記憶體問題,block
如果使用不當,很容易造成迴圈引用的問題。
經過暴力測試,證明並不會導致記憶體問題。被儲存在字典中是一個block
物件,而block
物件本身並不會佔用多少記憶體。在呼叫block
後會對block
體中的方法進行執行,執行完成後block
體中的物件釋放。
而block
自身的實現只是一個結構體,也就相當於字典中存放的是很多結構體,所以記憶體的佔用並不是很大。 -
對於協議這種實現方式,和
block
記憶體常駐方式差不多。只是將儲存的block
物件換成Class
物件,如果不是已經例項化的物件,記憶體佔用還是比較小的。
casatwy元件化方案
整體架構
casatwy元件化方案分為兩種呼叫方式,遠端呼叫和本地呼叫,對於兩個不同的呼叫方式分別對應兩個介面。
-
遠端呼叫通過
AppDelegate
代理方法傳遞到當前應用後,呼叫遠端介面並在內部做一些處理,處理完成後會在遠端介面內部呼叫本地介面,以實現本地呼叫為遠端呼叫服務。 -
本地呼叫由
performTarget:action:params:
方法負責,但呼叫方一般不直接呼叫performTarget:
方法。CTMediator
會對外提供明確引數和方法名的方法,在方法內部呼叫performTarget:
方法和引數的轉換。
casatwy提出的元件化架構
架構設計思路
casatwy是通過CTMediator
類實現元件化的,在此類中對外提供明確引數型別的介面,介面內部通過performTarget
方法呼叫服務方元件的Target
、Action
。由於CTMediator
類的呼叫是通過runtime
主動發現服務的,所以服務方對此類是完全解耦的。
但如果CTMediator
類對外提供的方法都放在此類中,將會對CTMediator
造成極大的負擔和程式碼量。解決方法就是對每個服務方元件建立一個CTMediator
的Category
,並將對服務方的performTarget
呼叫放在對應的Category
中,這些Category
都屬於CTMediator
中介軟體,從而實現了感官上的介面分離。
casatwy元件化實現細節
對於服務方的元件來說,每個元件都提供一個或多個Target
類,在Target
類中宣告Action
方法。Target
類是當前元件對外提供的一個“服務類”,Target
將當前元件中所有的服務都定義在裡面,CTMediator
通過runtime
主動發現服務。
在Target
中的所有Action
方法,都只有一個字典引數,所以可以傳遞的引數很靈活,這也是casatwy提出的去Model
化的概念。在Action
的方法實現中,對傳進來的字典引數進行解析,再呼叫元件內部的類和方法。
架構分析
casatwy為我們提供了一個Demo,通過這個Demo
可以很好的理解casatwy的設計思路,下面按照我的理解講解一下這個Demo
。
檔案目錄
開啟Demo
後可以看到檔案目錄非常清楚,在上圖中用藍框框出來的就是中介軟體部分,紅框框出來的就是業務元件部分。我對每個資料夾做了一個簡單的註釋,包含了其在架構中的職責。
在CTMediator
中定義遠端呼叫和本地呼叫的兩個方法,其他業務相關的呼叫由Category
完成。
// 遠端App呼叫入口- (id)performActionWithUrl:(NSURL *)url completion:(void(^)(NSDictionary *info))completion;// 本地元件呼叫入口- (id)performTarget:(NSString *)targetName action:(NSString *)actionName params:(NSDictionary *)params;
在CTMediator
中定義的ModuleA
的Category
,對外提供了一個獲取控制器並跳轉的功能,下面是程式碼實現。由於casatwy的方案中使用performTarget
的方式進行呼叫,所以涉及到很多硬編碼字串的問題,casatwy採取定義常量字串來解決這個問題,這樣管理也更方便。
#import "CTMediator+CTMediatorModuleAActions.h"NSString * const kCTMediatorTargetA = @"A";NSString * const kCTMediatorActionNativFetchDetailViewController = @"nativeFetchDetailViewController";@implementation CTMediator (CTMediatorModuleAActions)- (UIViewController *)CTMediator_viewControllerForDetail { UIViewController *viewController = [self performTarget:kCTMediatorTargetA action:kCTMediatorActionNativFetchDetailViewController params:@{@"key":@"value"}]; if ([viewController isKindOfClass:[UIViewController class]]) { // view controller 交付出去之後,可以由外界選擇是push還是present return viewController; } else { // 這裡處理異常場景,具體如何處理取決於產品 return [[UIViewController alloc] init]; } }
下面是ModuleA
元件中提供的服務,被定義在Target_A
類中,這些服務可以被CTMediator
通過runtime
的方式呼叫,這個過程就叫做發現服務。
我們發現,在這個方法中其實做了引數處理和內部呼叫的功能,這樣就可以保證元件內部的業務不受外部影響,對內部業務沒有侵入性。
- (UIViewController *)Action_nativeFetchDetailViewController:(NSDictionary *)params { // 對傳過來的字典引數進行解析,並呼叫ModuleA內部的程式碼 DemoModuleADetailViewController *viewController = [[DemoModuleADetailViewController alloc] init]; viewController.valueLabel.text = params[@"key"]; return viewController; }
命名規範
在大型專案中程式碼量比較大,需要避免命名衝突的問題。對於這個問題casatwy採取的是加字首的方式,從casatwy的Demo
中也可以看出,其元件ModuleA
的Target
命名為Target_A
,被呼叫的Action
命名為Action_nativeFetchDetailViewController:
。
casatwy將類和方法的命名,都統一按照其功能做區分當做字首,這樣很好的將元件相關和元件內部程式碼進行了劃分。
標準組件化架構設計
這個章節叫做“標準組件化架構設計”,對於專案架構來說並沒有絕對意義的標準之說。這裡說到的“標準組件化架構設計”只是因為採取這樣的方式的人比較多,且這種方式相比而言較合理。
在上面文章中提到了casatwy方案的CTMediator
,蘑菇街方案的MGJRouter
和ModuleManager
,下面統稱為中介軟體。
整體架構
元件化架構中,首先有一個主工程,主工程負責整合所有元件。每個元件都是一個單獨的工程,建立不同的git
私有倉庫來管理,每個元件都有對應的開發人員負責開發。開發人員只需要關注與其相關元件的程式碼,其他業務程式碼和其無關,來新人也好上手。
元件的劃分需要注意元件粒度,粒度根據業務可大可小。元件劃分後屬於業務元件,對於一些多個元件共同的東西,例如網路、資料庫之類的,應該劃分到單獨的元件或基礎元件中。對於圖片或配置表這樣的資原始檔,應該再單獨劃分一個資源元件,這樣避免資源的重複性。
服務方元件對外提供服務,由中介軟體呼叫或發現服務,服務對當前元件無侵入性,只負責對傳遞過來的資料進行解析和元件內呼叫的功能。需要被其他元件呼叫的元件都是服務方,服務方也可以呼叫其他元件的服務。
通過這樣的元件劃分,元件的開發進度不會受其他業務的影響,可以多個元件單獨的並行開發。元件間的通訊都交給中介軟體來進行,需要通訊的類只需要接觸中介軟體,而中介軟體不需要耦合其他元件,這就實現了元件間的解耦。中介軟體負責處理所有元件之間的排程,在所有元件之間起到控制核心的作用。
這套框架清晰的劃分了不同元件,從整體架構上來約束開發人員進行元件化開發,避免某個開發人員偷懶直接引用標頭檔案,產生元件間的耦合,破壞整體架構。假設以後某個業務發生大的改變,需要對相關程式碼進行重構,可以在單個元件進行重構。元件化架構降低了重構的風險,保證了程式碼的健壯性。
元件整合
元件化架構圖
每個元件都是一個單獨的工程,在元件開發完成後上傳到git
倉庫。主工程通過Cocoapods
整合各個元件,整合和更新元件時只需要pod update
即可。這樣就是把每個元件當做第三方來管理,管理起來非常方便。
Cocoapods
可以控制每個元件的版本,例如在主專案中回滾某個元件到特定版本,就可以通過修改podfile
檔案實現。選擇Cocoapods
主要因為其本身功能很強大,可以很方便的整合整個專案,也有利於程式碼的複用。通過這種整合方式,可以很好的避免在傳統專案中程式碼衝突的問題。
整合方式
對於元件化架構的整合方式,我在看完bang的部落格後專門請教了一下bang。根據在微博上和bang的聊天以及其他部落格中的學習,在主專案中整合元件主要分為兩種方式——原始碼和framework
,但都是通過CocoaPods
來整合。
無論是用CocoaPods
管理原始碼,還是直接管理framework
,效果都是一樣的,都是可以直接進行pod update
之類的操作的。
這兩種元件整合方案,實踐中也是各有利弊。直接在主工程中整合程式碼檔案,可以在主工程中進行除錯。整合framework
的方式,可以加快編譯速度,而且對每個元件的程式碼有很好的保密性。如果公司對程式碼安全比較看重,可以考慮framework
的形式,但framework
不利於主工程中的除錯。
例如手機QQ或者支付寶這樣的大型程式,一般都會採取framework
的形式。而且一般這樣的大公司,都會有自己的元件庫,這個元件庫往往可以代表一個大的功能或業務元件,直接新增專案中就可以使用。關於元件化庫在後面講淘寶元件化架構的時候會提到。
不推薦的整合方式
之前有些專案是直接用workspace
的方式整合的,或者直接在原有專案中建立子專案,直接做檔案引用。但這兩點都是不建議做的,因為沒有真正意義上實現業務元件的剝離,只是像之前的專案一樣從檔案目錄結構上進行了劃分。
元件化開發總結
對於專案架構來說,一定要建立於業務之上來設計架構。不同的專案業務不同,元件化方案的設計也會不同,應該設計最適合公司業務的架構。
架構對比
在除蘑菇街Protocol
方案外,其他兩種方案都或多或少的存在硬編碼問題,硬編碼如果量比較大的話挺麻煩的。
在casatwy的CTMediator
方案中需要硬編碼Target
、Action
字串,只不過這個缺陷被封閉在中介軟體裡面了,將這些字串都統一定義為常量,外界使用不需要接觸到硬編碼。蘑菇街的MGJRouter
的方案也是一樣的,也有硬編碼URL
的問題,蘑菇街可能也做了類似的處理。
casatwy和蘑菇街提出的兩套元件化方案,大體結構是類似的,三套方案都分為呼叫方、中介軟體、服務方,只是在具體實現過程中有些不同。例如Protocol
方案在中介軟體中加入了Protocol
檔案,casatwy的方案在中介軟體中加入了Category
。
三種方案內部都有容錯處理,所以三種方案的穩定性都是比較好的,而且都可以拿出來單獨執行,在服務方不存在的情況下也不會有問題。
在三套方案中,服務方都對外提供一個供外界呼叫的介面類,這個類中實現元件對外提供的服務,中介軟體通過介面類來實現元件間的通訊。在此類中統一定義對外提供的服務,外界呼叫時就知道服務方可以做什麼。
呼叫流程也不大一樣,蘑菇街的兩套方案都需要註冊操作,無論是Block
還是Protocol
都需要註冊後才可以提供服務。而casatwy的方案則不需要,直接通過runtime
呼叫。casatwy的方案實現了真正的對服務方解耦,而蘑菇街的兩套方案則沒有,對服務方和呼叫方都造成了耦合。
我認為三套方案中,Protocol
方案是呼叫和維護最麻煩的一套方案。維護時需要同時維護Protocol
、介面類兩部分。而且呼叫時需要將服務方的介面類返回給呼叫方,並由呼叫方執行一系列呼叫邏輯,呼叫一個服務的邏輯非常複雜,這在開發中是非常影響開發效率的。
總結
下面是元件化開發中的一個小總結,也是開發過程中的一些注意點。
-
在
MGJRouter
方案中,是通過呼叫OpenURL:
方法並傳入URL
來發起呼叫。鑑於URL
協議名等固定格式,可以通過判斷協議名的方式,使用配置表控制H5
和native
的切換,配置表可以從後臺更新,只需要將協議名更改一下即可。
mgj://detail?id=123456
http://www.mogujie.com/detail?id=123456
假設現在線上的native
元件出現嚴重bug
,在後臺將配置檔案中原有的本地URL
換成H5
的URL
,並更新客戶端配置檔案。在呼叫MGJRouter
時傳入這個H5
的URL
即可完成切換,MGJRouter
判斷如果傳進來的是一個H5
的URL
就直接跳轉webView
。而且URL
可以傳遞引數給MGJRouter
,只需要MGJRouter
內部做引數擷取即可。
-
casatwy方案和蘑菇街
Protocol
方案,都提供了傳遞明確型別引數的方法。在MGJRouter
方案中,傳遞引數主要是通過類似GET
請求一樣在URL
後面拼接引數,和在字典中傳遞引數兩種方式組成。這兩種方式會造成傳遞引數型別不明確,傳遞引數型別受限(GET
請求不能傳遞物件)等問題,後來使用Protocol
方案彌補這個問題。 -
元件化開發可以很好的提升程式碼複用性,元件可以直接拿到其他專案中使用,這個優點在下面淘寶架構中會著重講一下。
-
對於除錯工作,應該放在每個元件中完成。單獨的業務元件可以直接提交給測試提測,這樣測試起來也比較方便。最後元件開發完成並測試通過後,再將所有元件更新到主專案,提交給測試進行整合測試即可。
-
使用元件化架構開發,元件間的通訊都是有成本的。所以儘量將業務封裝在元件內部,對外只提供簡單的介面。即“高內聚、低耦合”原則。
-
把握好劃分粒度的細化程度,太細則專案過於分散,太大則專案元件臃腫。但是專案都是從小到大的一個發展過程,所以不斷進行重構是掌握這個元件的細化程度最好的方式。
我公司架構
下面就簡單說說我公司專案架構,公司專案是一個地圖導航應用,業務層之下的基礎元件佔比較大。且基礎元件相對比較獨立,對外提供了很多呼叫介面。剛開始想的是採用MGJRouter
的方案,但如果這些呼叫都通過Router
進行,開發起來比較複雜,反而會適得其反。最主要我們專案也並不是非常大,沒必要都用Router
轉發。
對於這個問題,公司專案的架構設計是:層級架構+元件化架構,元件化架構處於層級架構的最上層,也就是業務層。採取這種結構混合的方式進行整體架構,這個對於公共元件的管理和層級劃分比較有利,符合公司業務需求。
公司元件化架構