1. 程式人生 > >元件化架構漫談

元件化架構漫談

前段時間公司專案打算重構,準確來說應該是按之前的產品邏輯重寫一個專案。在重構專案之前涉及到架構選型的問題,我和組裡小夥伴一起研究了一下元件化架構,打算將專案重構為元件化架構。當然不是直接拿來照搬,還是要根據公司具體的業務需求設計架構。

在學習元件化架構的過程中,從很多高質量的部落格中學到不少東西,例如蘑菇街李忠casatwybang的部落格。在學習過程中也遇到一些問題,在微博和QQ上和一些做iOS的朋友進行了交流,非常感謝這些朋友的幫助。

本篇文章主要針對於之前蘑菇街提出的元件化方案,以及casatwy提出的元件化方案進行分析,後面還會簡單提到滴滴、淘寶、微信的元件化架構,最後會簡單說一下我公司設計的元件化架構。

元件化架構的由來

隨著移動網際網路的不斷髮展,很多程式程式碼量和業務越來越多現有架構已經不適合公司業務的發展速度了,很多都面臨著重構的問題。
在公司專案開發中,如果專案比較小,普通的單工程+MVC架構就可以滿足大多數需求了。但是像淘寶、蘑菇街、微信這樣的大型專案,原有的單工程架構就不足以滿足架構需求了。

就拿淘寶來說,淘寶在13年開啟的“All in 無線”戰略中,就將阿里系大多數業務都加入到手機淘寶中,使客戶端出現了業務的爆發。在這種情況下,單工程架構則已經遠遠不能滿足現有業務需求了。所以在這種情況下,淘寶在13年開啟了外掛化架構的重構,後來在14年迎來了手機淘寶有史以來最大規模的重構,將其徹底重構為元件化架構

蘑菇街的元件化架構

原因

在一個專案越來越大,開發人員越來越多的情況下,專案會遇到很多問題。

  • 業務模組間劃分不清晰,模組之間耦合度很大,非常難維護。

  • 所有模組程式碼都編寫在一個專案中,測試某個模組或功能需要編譯執行整個專案

1.png

耦合嚴重的工程

為了解決上面的問題,可以考慮加一個中間層來協調模組間的呼叫,所有的模組間的呼叫都會經過中間層中轉(注意看兩張圖的箭頭方向)

2.png

新增中間層

但是發現增加這個中間層後,耦合還是存在的。中間層對被呼叫模組存在耦合,其他模組也需要耦合中間層才能發起呼叫。這樣還是存在之前的相互耦合的問題,而且本質上比之前更麻煩了。

大體結構

所以應該做的是,只讓其他模組對中間層產生耦合關係,中間層不對其他模組發生耦合


對於這個問題,可以採用元件化的架構,將每個模組作為一個元件。並且建立一個主專案,這個主專案負責整合所有元件。這樣帶來的好處是很多的:

  • 業務劃分更佳清晰,新人接手更佳容易,可以按元件分配開發任務。

  • 專案可維護性更強,提高開發效率。

  • 更好排查問題,某個元件出現問題,直接對元件進行處理。

  • 開發測試過程中,可以只編譯自己那部分程式碼,不需要編譯整個專案程式碼。

3.png

元件化結構

進行元件化開發後,可以把每個元件當做一個獨立的app每個元件甚至可以採取不同的架構,例如分別使用MVVMMVCMVCS等架構。

MGJRouter方案

蘑菇街通過MGJRouter實現中間層,通過MGJRouter進行元件間的訊息轉發,從名字上來說更像是路由器。實現方式大致是,在提供服務的元件中提前註冊block,然後在呼叫方元件中通過URL呼叫block,下面是呼叫方式。

架構設計

4.png

MGJRouter元件化架構

MGJRouter是一個單例物件,在其內部維護著一個“URL -> block”格式的登錄檔,通過這個登錄檔來儲存服務方註冊的block,以及使呼叫方可以通過URL映射出block,並通過MGJRouter對服務方發起呼叫。

在服務方元件中都對外提供一個介面類,在介面類內部實現block的註冊工作,以及block對外提供服務的程式碼實現。每一個block都對應著一個URL,呼叫方可以通過URLblock發起呼叫。

在程式開始執行時,需要將所有服務方的介面類例項化,以完成這個註冊工作,使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和引數,AndroidiOS都使用這一套URL,可以保持統一性。

基礎元件

在專案中存在很多公共部分的東西,例如封裝的網路請求、快取、資料處理等功能,以及專案中所用到的資原始檔。

蘑菇街將這些部分也當做元件,劃分為基礎元件,位於業務元件下層。所有業務元件都使用同一個基礎元件,也可以保證公共部分的統一性。

Protocol方案

整體架構

5.png

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當做值儲存。通過ProtocolClass的時候,就是通過ProtocolModuleManager中將Class映射出來。

[ModuleManager registerClass:MGJUserImpl forProtocol:@protocol(MGJUserProtocol)];

呼叫時通過ProtocolModuleManager中映射出註冊的Class,將獲取到的Class例項化,並呼叫Class實現的協議方法完成服務呼叫。

Class cls = [[ModuleManager sharedInstance] classForProtocol:@protocol(MGJUserProtocol)];id userComponent = [[cls alloc] init];NSString *userName = [userComponent getUserName];

整體呼叫流程

蘑菇街是OpenURLProtocol混用的方式,兩種實現的呼叫方式不同,但大體呼叫邏輯和實現思路類似,所以下面的呼叫流程二者差不多。在OpenURL不能滿足需求或呼叫不方便時,就可以通過Protocol的方式呼叫。

  1. 在進入程式後,先使用MGJRouter對服務方元件進行註冊。每個URL對應一個block的實現,block中的程式碼就是服務方對外提供的服務,呼叫方可以通過URL呼叫這個服務。

  2. 呼叫方通過MGJRouter呼叫openURL:方法,並將被呼叫程式碼對應的URL傳入,MGJRouter會根據URL查詢對應的block實現,從而呼叫服務方元件的程式碼進行通訊。

  3. 呼叫和註冊block時,block有一個字典用來傳遞引數。這樣的優勢就是引數型別和數量理論上是不受限制的,但是需要很多硬編碼的key名在專案中。

記憶體管理

蘑菇街元件化方案有兩種,ProtocolMGJRouter的方式,但都需要進行register操作。Protocol註冊的是ClassMGJRouter註冊的是Block,登錄檔是一個NSMutableDictionary型別的字典,而字典的擁有者又是一個單例物件,這樣會造成記憶體的常駐

下面是對兩種實現方式記憶體消耗的分析:

  • 首先說一下block實現方式可能導致的記憶體問題,block如果使用不當,很容易造成迴圈引用的問題。
    經過暴力測試,證明並不會導致記憶體問題。被儲存在字典中是一個block物件,而block物件本身並不會佔用多少記憶體。在呼叫block後會對block體中的方法進行執行,執行完成後block體中的物件釋放。
    block自身的實現只是一個結構體,也就相當於字典中存放的是很多結構體,所以記憶體的佔用並不是很大。

  • 對於協議這種實現方式,和block記憶體常駐方式差不多。只是將儲存的block物件換成Class物件,如果不是已經例項化的物件,記憶體佔用還是比較小的。

casatwy元件化方案

整體架構

casatwy元件化方案分為兩種呼叫方式,遠端呼叫和本地呼叫,對於兩個不同的呼叫方式分別對應兩個介面。

  • 遠端呼叫通過AppDelegate代理方法傳遞到當前應用後,呼叫遠端介面並在內部做一些處理,處理完成後會在遠端介面內部呼叫本地介面,以實現本地呼叫為遠端呼叫服務

  • 本地呼叫由performTarget:action:params:方法負責,但呼叫方一般不直接呼叫performTarget:方法CTMediator會對外提供明確引數和方法名的方法,在方法內部呼叫performTarget:方法和引數的轉換。

6.png

casatwy提出的元件化架構

架構設計思路

casatwy是通過CTMediator類實現元件化的,在此類中對外提供明確引數型別的介面,介面內部通過performTarget方法呼叫服務方元件的TargetAction。由於CTMediator類的呼叫是通過runtime主動發現服務的,所以服務方對此類是完全解耦的。

但如果CTMediator類對外提供的方法都放在此類中,將會對CTMediator造成極大的負擔和程式碼量。解決方法就是對每個服務方元件建立一個CTMediatorCategory,並將對服務方的performTarget呼叫放在對應的Category中,這些Category都屬於CTMediator中介軟體,從而實現了感官上的介面分離。

7.png

casatwy元件化實現細節

對於服務方的元件來說,每個元件都提供一個或多個Target類,在Target類中宣告Action方法。Target類是當前元件對外提供的一個“服務類”Target將當前元件中所有的服務都定義在裡面,CTMediator通過runtime主動發現服務

Target中的所有Action方法,都只有一個字典引數,所以可以傳遞的引數很靈活,這也是casatwy提出的Model化的概念。在Action的方法實現中,對傳進來的字典引數進行解析,再呼叫元件內部的類和方法。

架構分析

casatwy為我們提供了一個Demo,通過這個Demo可以很好的理解casatwy的設計思路,下面按照我的理解講解一下這個Demo

8.png

檔案目錄

開啟Demo後可以看到檔案目錄非常清楚,在上圖中用藍框框出來的就是中介軟體部分,紅框框出來的就是業務元件部分。我對每個資料夾做了一個簡單的註釋,包含了其在架構中的職責。

CTMediator中定義遠端呼叫和本地呼叫的兩個方法,其他業務相關的呼叫由Category完成。

// 遠端App呼叫入口- (id)performActionWithUrl:(NSURL *)url completion:(void(^)(NSDictionary *info))completion;// 本地元件呼叫入口- (id)performTarget:(NSString *)targetName action:(NSString *)actionName params:(NSDictionary *)params;

CTMediator中定義的ModuleACategory,對外提供了一個獲取控制器並跳轉的功能,下面是程式碼實現。由於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採取的是加字首的方式,從casatwyDemo中也可以看出,其元件ModuleATarget命名為Target_A,被呼叫的Action命名為Action_nativeFetchDetailViewController:

casatwy將類和方法的命名,都統一按照其功能做區分當做字首,這樣很好的將元件相關和元件內部程式碼進行了劃分。

標準組件化架構設計

這個章節叫做“標準組件化架構設計”,對於專案架構來說並沒有絕對意義的標準之說。這裡說到的“標準組件化架構設計”只是因為採取這樣的方式的人比較多,且這種方式相比而言較合理。

在上面文章中提到了casatwy方案的CTMediator,蘑菇街方案的MGJRouterModuleManager,下面統稱為中介軟體。

整體架構

元件化架構中,首先有一個主工程,主工程負責整合所有元件。每個元件都是一個單獨的工程,建立不同的git私有倉庫來管理,每個元件都有對應的開發人員負責開發。開發人員只需要關注與其相關元件的程式碼,其他業務程式碼和其無關,來新人也好上手。

元件的劃分需要注意元件粒度,粒度根據業務可大可小。元件劃分後屬於業務元件,對於一些多個元件共同的東西,例如網路、資料庫之類的,應該劃分到單獨的元件或基礎元件中。對於圖片或配置表這樣的資原始檔,應該再單獨劃分一個資源元件,這樣避免資源的重複性。

服務方元件對外提供服務,由中介軟體呼叫或發現服務服務對當前元件無侵入性,只負責對傳遞過來的資料進行解析和元件內呼叫的功能。需要被其他元件呼叫的元件都是服務方,服務方也可以呼叫其他元件的服務。

通過這樣的元件劃分,元件的開發進度不會受其他業務的影響,可以多個元件單獨的並行開發。元件間的通訊都交給中介軟體來進行,需要通訊的類只需要接觸中介軟體,而中介軟體不需要耦合其他元件,這就實現了元件間的解耦。中介軟體負責處理所有元件之間的排程,在所有元件之間起到控制核心的作用。

這套框架清晰的劃分了不同元件,從整體架構上來約束開發人員進行元件化開發,避免某個開發人員偷懶直接引用標頭檔案,產生元件間的耦合,破壞整體架構。假設以後某個業務發生大的改變,需要對相關程式碼進行重構,可以在單個元件進行重構。元件化架構降低了重構的風險,保證了程式碼的健壯性。

元件整合

9.png

元件化架構圖

每個元件都是一個單獨的工程,在元件開發完成後上傳到git倉庫。主工程通過Cocoapods整合各個元件,整合和更新元件時只需要pod update即可。這樣就是把每個元件當做第三方來管理,管理起來非常方便。

Cocoapods可以控制每個元件的版本,例如在主專案中回滾某個元件到特定版本,就可以通過修改podfile檔案實現。選擇Cocoapods主要因為其本身功能很強大,可以很方便的整合整個專案,也有利於程式碼的複用。通過這種整合方式,可以很好的避免在傳統專案中程式碼衝突的問題。

整合方式

對於元件化架構的整合方式,我在看完bang的部落格後專門請教了一下bang。根據在微博上和bang的聊天以及其他部落格中的學習,在主專案中整合元件主要分為兩種方式——原始碼和framework,但都是通過CocoaPods來整合。
無論是用CocoaPods管理原始碼,還是直接管理framework,效果都是一樣的,都是可以直接進行pod update之類的操作的。

這兩種元件整合方案,實踐中也是各有利弊。直接在主工程中整合程式碼檔案,可以在主工程中進行除錯。整合framework的方式,可以加快編譯速度,而且對每個元件的程式碼有很好的保密性。如果公司對程式碼安全比較看重,可以考慮framework的形式,但framework不利於主工程中的除錯。

例如手機QQ或者支付寶這樣的大型程式,一般都會採取framework的形式。而且一般這樣的大公司,都會有自己的元件庫,這個元件庫往往可以代表一個大的功能或業務元件,直接新增專案中就可以使用。關於元件化庫在後面講淘寶元件化架構的時候會提到。

不推薦的整合方式

之前有些專案是直接用workspace的方式整合的,或者直接在原有專案中建立子專案,直接做檔案引用。但這兩點都是不建議做的,因為沒有真正意義上實現業務元件的剝離,只是像之前的專案一樣從檔案目錄結構上進行了劃分。

元件化開發總結

對於專案架構來說,一定要建立於業務之上來設計架構。不同的專案業務不同,元件化方案的設計也會不同,應該設計最適合公司業務的架構。

架構對比

在除蘑菇街Protocol方案外,其他兩種方案都或多或少的存在硬編碼問題,硬編碼如果量比較大的話挺麻煩的。
casatwyCTMediator方案中需要硬編碼TargetAction字串,只不過這個缺陷被封閉在中介軟體裡面了,將這些字串都統一定義為常量,外界使用不需要接觸到硬編碼。蘑菇街的MGJRouter的方案也是一樣的,也有硬編碼URL的問題,蘑菇街可能也做了類似的處理。

casatwy和蘑菇街提出的兩套元件化方案,大體結構是類似的,三套方案都分為呼叫方中介軟體服務方,只是在具體實現過程中有些不同。例如Protocol方案在中介軟體中加入了Protocol檔案,casatwy的方案在中介軟體中加入了Category
三種方案內部都有容錯處理,所以三種方案的穩定性都是比較好的,而且都可以拿出來單獨執行,在服務方不存在的情況下也不會有問題。

在三套方案中,服務方都對外提供一個供外界呼叫的介面類,這個類中實現元件對外提供的服務,中介軟體通過介面類來實現元件間的通訊。在此類中統一定義對外提供的服務,外界呼叫時就知道服務方可以做什麼。

呼叫流程也不大一樣,蘑菇街的兩套方案都需要註冊操作,無論是Block還是Protocol都需要註冊後才可以提供服務。而casatwy的方案則不需要,直接通過runtime呼叫。casatwy的方案實現了真正的對服務方解耦,而蘑菇街的兩套方案則沒有,對服務方和呼叫方都造成了耦合。

我認為三套方案中,Protocol方案是呼叫和維護最麻煩的一套方案。維護時需要同時維護Protocol、介面類兩部分。而且呼叫時需要將服務方的介面類返回給呼叫方,並由呼叫方執行一系列呼叫邏輯,呼叫一個服務的邏輯非常複雜,這在開發中是非常影響開發效率的。

總結

下面是元件化開發中的一個小總結,也是開發過程中的一些注意點。
  • MGJRouter方案中,是通過呼叫OpenURL:方法並傳入URL來發起呼叫。鑑於URL協議名等固定格式,可以通過判斷協議名的方式,使用配置表控制H5native的切換配置表可以從後臺更新,只需要將協議名更改一下即可。

mgj://detail?id=123456
http://www.mogujie.com/detail?id=123456

假設現在線上的native元件出現嚴重bug在後臺將配置檔案中原有的本地URL換成H5URL並更新客戶端配置檔案。在呼叫MGJRouter時傳入這個H5URL即可完成切換,MGJRouter判斷如果傳進來的是一個H5URL就直接跳轉webView。而且URL可以傳遞引數給MGJRouter,只需要MGJRouter內部做引數擷取即可。

  • casatwy方案和蘑菇街Protocol方案,都提供了傳遞明確型別引數的方法。在MGJRouter方案中,傳遞引數主要是通過類似GET請求一樣在URL後面拼接引數,和在字典中傳遞引數兩種方式組成。這兩種方式會造成傳遞引數型別不明確,傳遞引數型別受限(GET請求不能傳遞物件)等問題,後來使用Protocol方案彌補這個問題。

  • 元件化開發可以很好的提升程式碼複用性,元件可以直接拿到其他專案中使用,這個優點在下面淘寶架構中會著重講一下。

  • 對於除錯工作,應該放在每個元件中完成。單獨的業務元件可以直接提交給測試提測,這樣測試起來也比較方便。最後元件開發完成並測試通過後,再將所有元件更新到主專案,提交給測試進行整合測試即可。

  • 使用元件化架構開發,元件間的通訊都是有成本的。所以儘量將業務封裝在元件內部,對外只提供簡單的介面。即“高內聚、低耦合”原則

  • 把握好劃分粒度的細化程度,太細則專案過於分散,太大則專案元件臃腫。但是專案都是從小到大的一個發展過程,所以不斷進行重構是掌握這個元件的細化程度最好的方式。

我公司架構

下面就簡單說說我公司專案架構,公司專案是一個地圖導航應用,業務層之下的基礎元件佔比較大。且基礎元件相對比較獨立,對外提供了很多呼叫介面。剛開始想的是採用MGJRouter的方案,但如果這些呼叫都通過Router進行,開發起來比較複雜,反而會適得其反。最主要我們專案也並不是非常大,沒必要都用Router轉發。

對於這個問題,公司專案的架構設計是:層級架構+元件化架構,元件化架構處於層級架構的最上層,也就是業務層。採取這種結構混合的方式進行整體架構,這個對於公共元件的管理和層級劃分比較有利,符合公司業務需求。

10.png

公司元件化架構