最近在思考團隊擴張及專案數量增加的情況下,如何持續保障團隊高效產出的問題,很自然的想到了元件化這個話題。重翻了前段時間iOS開發圈關於元件化的討論,這裡做下梳理和自己的思考。
元件化的驅動力
在開始討論元件化技術方案之前,可以先思考下驅動專案元件化背後的原動力。我們假設這樣一個場景,公司有 A,B,C三個專案在appstore運作,三個專案分別由Team A,Team B,Team C開發維護,每個Team由五名工程師組成,其中一名擔任小組長,三個Team之上再配備一位Leader,一位架構師。這時,公司決定開闢新的業務領域,成立專案D,並新招了5名工程師來開發。架構師和Leader此時首要工作是選定技術方案,讓專案D能又快又穩的啟動,同時要規避新工程師磨合期可能引入的副作用。如果之前有過元件化的設計,專案D可以重用之前A,B,C的部分元件,比如【使用者登入】,【記憶體管理】,【日誌打點系統】,【個人Profile模組】等等,新成員也可以在已有的codebase基礎之上快速上手。如果沒有做過元件化的處理,那麼要從A,B,C中抽離出諸如【使用者登入】的獨立模組,會相當的痛苦,高度耦合的程式碼盤根錯節,重用起來費時費力,對團隊的人力是浪費,更影響整體的專案進度。我們的目標是重用高度抽象的程式碼單元。
回到元件化的技術方案,最早是Limboy分享了一篇蘑菇街元件化的技術方案,接著Casa提出了不同意見,後來Limboy在Casa反饋之上對自己方案做了進一步優化,最後Bang在前三篇文章基礎之上做了清晰的梳理總結。通讀之後,獲益頗多,元件化所面臨的問題,和可能的解決思路也變得更清晰。
元件的定義
首先需要對元件進行定義,叫元件也好,模組也罷,我們姑且認為我們討論的範疇是【獨立的業務或者功能單位】。至於這個單位的粒度大小,需要工程師自己把握。當我們寫一個類的時候,我們會謹記高內聚,低耦合的原則去設計這個類,當涉及多個類之間互動的時候,我們也會運用SOLID原則,或者已有的設計模式去優化設計,但在實現完整的業務模組的時候,我們很容易忘記對這個模組去做設計上的思考,粒度越大,越難做出精細穩定的設計,我暫且把這個粒度認為是元件的粒度。元件是由一個或多個類構成,能完整描述一個業務場景,並能被其他業務場景複用的功能單位。元件就像是PC時代個人組裝電腦時購買的一個個部件,比如記憶體,硬碟,CPU,顯示器等,拿出其中任何一個部件都能被其他的PC所使用。
所以元件可以是個廣義上的概念,並不一定是頁面跳轉,還可以是其他不具備UI屬性的服務提供者,比如日誌服務,VOIP服務,記憶體管理服務等等。說白了我們目標是站在更高的維度去封裝功能單元。對這些功能單元進行進一步的分類,才能在具體的業務場景下做更合理的設計。按我個人經驗可以將元件分為以下幾類:
- 帶UI屬性的獨立業務模組。
- 不具備UI屬性的獨立業務模組。
- 不具備業務場景的功能模組。
第一類是Limboy,Casa討論較多的元件,這些元件有很具體的業務場景。比如一個App的主頁模組,從Server獲取列表,並通過controller展示。這類模組一般有個入口Controller,可以通過Push或Present的方式作為入口接入。電商類App的大部分場景都可以歸於這一類,Controller作為頁面的基本單位和Web Page有很高的相似度,我想這也是為什麼蘑菇街會採取URL註冊的實現方式,用URL來標記本地的每一個Controller,不僅方便本地的跳轉,還能支援Server下發跳轉指令,對運營團隊來說再合適不過。從理論上來說,元件化和URL本身並沒有什麼聯絡,URL只是接入元件的方式之一,這種接入方式還存在一定侷限性,比如無法傳遞像UIImage這類非primitive資料。這種侷限性在電商app業務環境下,會帶來多少副作用值得商榷,按我的經驗,在完整獨立的業務模組間傳遞複雜物件的場景並不多,即使有也可以通過memory cache或者disk cache來做中轉。我沒記錯的話,之前天貓無線客戶端不同業務模組間跳轉也是通過URL的方式來實現的,有個類似Router的中間類來出來URL的解析及跳轉,並沒有Mediator去對元件做進一步的封裝。以URL註冊方式來接入元件,在副作用小,業務運營方便的背景下,蘑菇街的選擇或許並不能算作‘’錯誤的方向“。
第二類業務模組不具備UI場景,但卻和具體的業務相關。比如日誌上報模組,app可能需要統計使用者註冊模組每個Controller進入的路徑,便於分析每一步使用者的流失率。這類業務模組如果要用URL去表達和接入會顯得非常變扭。試想下通過如下的程式碼呼叫啟用日誌:
1 |
[[MGJRouter sharedInstance] openURL:@"mgj://log/start" withParams:@{}]; |
這也是蘑菇街以URL方案來實現元件化不合理的地方,按Casa的分法,元件被呼叫分為遠端和本地,這種日誌服務的呼叫是本地型別的呼叫,用URL來標這類記本地服務頗有些繞遠路的感覺。
第三類模組和具體的業務場景無關,比如Database模組,提供資料的讀寫服務,包含多執行緒的處理。比如Network模組,提供和Server資料互動的方式,包含併發數控制,網路優化等處理。比如圖片處理類,提供非同步繪製圓角頭像。這些模組可以被任意模組使用,但不和任何業務相關。這種元件屬於我們app的基礎服務提供者,更像是一個個SDK,或是toolkit。我不知道蘑菇街是怎麼處理這類元件接入的,很明顯URL的接入方式並不適合。我們通過Pods使用的很多著名第三方庫都屬於這一類,像FMDB,SDWebImage等。
接下來我們再看看各家方案對上面三種元件的接入能力及優缺點。
蘑菇街的URL方案
首先從上面的分析可以看出,這種方案在針對第一類元件是並沒有什麼大問題,只是不太適合第二類和第三類元件。
URL方案在啟動的時候有個模組初始化的過程,初始化的時候註冊模組自己提供的各種服務:
1 2 3 4 5 |
[MGJRouter registerURLPattern:@"mgj://detail?id=:id" toHandler:^(NSDictionary *routerParameters) { NSNumber *id = routerParameters[@"id"]; // create view controller with id // push view controller }]; |
元件的使用方使用的時候通過傳入具體的URL Pattern來完成呼叫:
1 |
[MGJRouter openURL:@"mgj://detail?id=404"] |
Bang針對這種方式提出了三個問題:
- 需要有個地方列出各個元件裡有什麼 URL 介面可供呼叫。蘑菇街做了個後臺專門管理。
- 每個元件都需要初始化,記憶體裡需要儲存一份表,元件多了會有記憶體問題。
- 引數的格式不明確,是個靈活的 dictionary,也需要有個地方可以查引數格式。
第一個問題是最明顯的問題,元件的使用方必須通過查閱web文件之後,再手寫string來完成呼叫。這種元件呼叫方式確實會有一定的效率問題。
第二個問題所說的表和記憶體問題我沒理解具體是指哪一塊。我算了下Router當中的額外記憶體開銷,一個用來儲存Mapping的NSMutableDictionary,iOS App當中使用Dictionary的場景會很多,Dictionary帶來的記憶體開銷主要看其所強引用的key和value。二是以URLPattern為Key的各種string,這個估計是大頭,但Casa的方案裡將Action以String的方式hardcode,也會導致這些String常住記憶體,其本質是將原本處於Text區的函式符號換成了位於Data區的string,此消彼長,這部分記憶體消耗也在正常範圍之內,最後是handler block,這部分開銷也屬於常規使用,和一次函式呼叫並沒有本質區別,看上去記憶體消耗總量並沒有特別增長,或許還有其他我沒考慮到的部分。
第三個問題其實和第一個問題是類似的,需要查閱文件來hardcode引數名稱。
在我看來這種URL註冊的方式本質是以string來替換原本的函式宣告,string可以避免標頭檔案引用,實現了編譯上的解耦,但付出的代價是沒有介面和引數宣告,給元件使用方的效率帶來了影響。
MGJRouter其實也是充當了Mediator的角色,只不過是大部分時候是在元件和元件使用方之間傳遞資料。Router如果自己解析URL,也可以加入中間邏輯來判斷元件是否存在等。
Casa的Mediator方案
Casa在提出Mediator方案之前,首先指出了蘑菇街方案混淆本地呼叫和遠端呼叫的問題。這點很有意義,將元件化的使用場景描述的更明確。
Casa提出了Mediator方案,他的方案當中Mediator承接了大部分的元件接入程式碼,可以用如下圖示:
圖中虛線箭頭表示Casa所提出的”通過runtime發現服務的過程“,Bang也認為虛線箭頭部分實現瞭解耦,不需要import標頭檔案,可以通過runtime來完成元件的接入。
這裡我對”發現服務“這個概念存有疑惑,我所瞭解的wsdl可以用來發現web sevice所提供的具體服務,你需要傳送一個web請求來獲取wsdl檔案,這可以稱作是”發現服務“的過程。但是使用OC的runtime機制以String來完成函式呼叫是”使用服務“的一種方式,你還是需要元件方提供額外文件來描述具體有哪些服務,不然從何處去”發現“這些String呢?所以私以為runtime並不能發現服務,只是換了一種方式去呼叫服務,把原來的[object sendMessage]換成了[object performSelector:@””]。當然runtime的方式看起來沒有耦合。
這裡我們再來探討下耦合的概念,我們可以從多種維度去理解耦合,import標頭檔案算一種耦合,因為標頭檔案缺失會導致編譯出錯。業務耦合是另一種維度的耦合,我不認為業務的耦合可以被消除多少,你需要使用的元件服務因為業務需要一個都不能少,如果元件方修改了業務介面,即使你能編譯通過,你所呼叫的元件也無法正常工作了。你可以選擇不同的呼叫方式,但呼叫本身是一定存在的,我在上圖中用虛線箭頭表示了這種業務耦合,它無法被消除,可以從語法上,從程式碼技巧上去”弱化“,但這種”弱化“也有其代價。
這種代價和蘑菇街URL註冊方式是同一種代價,以String來替換原先的函式和引數宣告,配合runtime來完成元件呼叫。這種方式同樣會加大接入的難度,我們來看下Casa Demo的工程結構:
Mediator對元件的使用方提供了Category來暴露所支援的服務,對使用方來說看上去很清晰。但Mediator其實也是由元件使用方來維護的,我們看看Mediator當中的程式碼。CTMediator+CTMediatorModuleAActions.m當中完成一個服務接入的程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
//CTMediator+CTMediatorModuleAActions.m NSString * const kCTMediatorTargetA = @"A"; NSString * const kCTMediatorActionNativFetchDetailViewController = @"nativeFetchDetailViewController"; - (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]; } } |
Target,Action,Params全是用String去描述的,這裡會有個問題:
如果元件使用團隊在杭州,元件開發團隊位於北京,如何去獲取這些String?
如果是通過web文件的方式,那麼使用方需要依照文件將Target,Action,每個Param全部手敲一遍,一個都不能出錯,傳入param value的時候要看清楚對方是需要long還是NSNumber,因為沒有型別檢查,只能靠肉眼。如果沒有文件,使用方需要自己檢視元件的標頭檔案,再把標頭檔案當中暴露的介面翻譯成String。這個方式看起來效率並不高且易出錯,尤其是在元件數量多的情況下。
DemoModule下有兩個問題。
第一是target在解析元件param的時候需要再次的hardcode:
1 2 3 4 5 6 7 |
- (UIViewController *)Action_nativeFetchDetailViewController:(NSDictionary *)params { // 因為action是從屬於ModuleA的,所以action直接可以使用ModuleA裡的所有宣告 DemoModuleADetailViewController *viewController = [[DemoModuleADetailViewController alloc] init]; viewController.valueLabel.text = params[@"key"]; return viewController; } |
同一個@”key“同時出現在了元件方和元件呼叫方,我不知道該如何去高效的協調這種hardcode,或許還是隻能依賴web文件,但查文件對於程式設計師編寫程式碼來說是個低效的過程。
第二個問題是引數是以Dictionary傳入的,我不知道有多少開發SDK或者元件的團隊會選擇以Dictionary的方式定義”函式入參“。使用Dictionary很符合Casa”去Model化“的風格,我對於Casa所提的”去Model化“始終存疑,我仔細讀過其部落格關於“去model化”的解釋,也拜讀了Martin Fowler反對Anemic Domain Model的文章,Martin Fowler並沒有反對使用model,而是提倡讓model去承擔更多的domain logic。就我個人寫程式碼體驗而言,使用model來描述資料比dictionary更清晰直觀,這裡使用顯示的函式入參宣告也更直觀。第三方庫在提供介面的時候也鮮少有以Dictionary作為入參的。
從上面兩個問題可以看出,Mediator的方式並沒有減少元件使用方的接入工作,反而因為要降低耦合,使用runtime,在hardcode String上引入了額外的人力消耗。
Protocol+Version方案
Bang在梳理各種方案的時候畫了兩張很有意思的圖:
第一張圖看上去雜亂無章,互相耦合。第二張圖通過Mediator將結構變得清晰很多。
這兩張圖其實表達了一個業界經典的話題:Distributed Design Vs Centralized Design
第一張圖看上去是一坨,但它卻是典型的Distributed Design。第二種圖更符合人腦的”審美“,Centralized在結構上更容易被大腦梳理清楚。具體到工程場景,孰優孰劣還真不一定。
不知道大家有沒有了解過IP協議的路由定址演算法,這也是Distributed Design Vs Centralized Design的一個經典場景。如果採用Centralized Design,我們可以用一個cache空間無限大,packet處理能力沒有瓶頸的中央路由器來”瞬時“的算出兩個路由器之間的最短路徑,但顯然並不存在這樣的路由器。現實是每個路由器所能快取的周邊路由器資訊相當有限,packet處理能力也十分有限,結果是每個路由器只能在自己所認知的範圍內算最短路徑,但這就是今天的網際網路所使用的設計,Distributed Design。
Centralized設計在Node增加的情形下會增加中央節點的負擔。Mediator就是這個中央節點,工作量並沒有減少,未來的風險不可預知。
我個人在元件化上還是傾向於Distributed Design。各個元件”自掃門前雪“,用規範的protocol宣告,加上嚴格的版本控制來提供元件服務。姑且稱之為Protocol+Version方案。
這種方案可以分兩部分去講解。
Protocol
選擇protocol作為接入方式會有一定程度的耦合,畢竟需要@import。protocol所帶來的耦合介於runtime和類的 .h檔案之間,protocol相較於runtime雖然存在標頭檔案的編譯耦合,但在業務描述上更加清晰,函式名稱和引數型別都有明確定義,很多時候甚至不需要查閱文件就能明白元件的使用方式。我個人更偏向於使用protocol作為元件的接入和使用方式。我們用兩種型別的protocol來規範元件。
元件通用protocol
不同的元件型別接入的方式也不同。
第三類元件屬於基礎元件,類似工具箱。我們所使用的大部分第三方庫都屬於這一類,平時一般使用CocoaPods直接接入,講究一點的話可以對這些第三方庫介面再做一層封裝,再升級或替換的時候會更省力。大廠一般都會編寫自己的基礎元件,放到私有的Pods源。這類元件往往比較穩定,適合已Framework的方式整合,我們在接入的時候不需要做特別的處理。
第一類和第二類元件都具備業務場景和業務狀態,他們的接入和業務聯絡緊密,需要有專門的protocol來定義他們的行為。這個protocol用來規定每個元件通用的行為,以及元件完整生命週期的一些回撥處理。類似:
1 2 3 4 5 6 7 8 9 |
@protocol IAppModule NSObject> //module life cycle - (void)initModule; - (void)destroyModule; //common behavior - (NSString*)getModuleVersion; - (BOOL)handleUrl:(NSString*)url; - (UIViewController*)getDefaultController; @end |
每一個元件如果單獨編譯可以作為一個獨立的App,所以應該能經歷一個iOS App的完整生命週期。
在didFinishLaunchingWithOptions的時候initModule。
在退出或需要銷燬元件的時候呼叫destroyModule。
至於applicationWillResignActive,applicationWillEnterForeground等可以在元件當中通過通知自行處理。
針對外部URL跳轉的場景用如下程式碼處理:
1 2 3 4 5 6 7 8 |
for (int i = 0; i module = _modules[i]; if ([module respondsToSelector:@selector(handleUrl:)]) { BOOL ret = [module handleUrl:url]; if (ret) { break; } } } |
Url Pattern需要有個統一的web後臺管理頁面,各元件需要註冊自己的Controller。
對於需要接入Controller的場景(第一類元件,有入口Controller),如下處理:
1 2 3 4 5 6 7 8 |
id homeModule = [HomeModule new]; [homeModule initModule]; if ([homeModule respondsToSelector:@selector(getDefaultController)]) { UIViewController* defaultCtrl = [homeModule getDefaultController]; if (defaultCtrl) { [self.navigationController pushViewController:defaultCtrl animated:true]; } } |
隨著接入的業務越來越多,業務元件的形態應更加多樣化,我們可能需要在IAppModule加入更多的通用介面來規範行為。
元件業務protocol
元件都需要自己的業務protocol,業務protocol能完整的描述該元件所提供的業務清單。不需要查閱額外文件就能大致瞭解業務的型別和細節,這得益於OC詳細到甚至囉嗦的方法簽名。也是protocol較之runtime的優勢所在。比如我們需要匯入購物車元件:
1 2 3 4 5 6 7 8 |
//IOrderCartModule.h @protocol IOrderCartModule NSObject> - (int)getOrderCount; - (Order*)getOrderByID:(NSString*)orderID; - (void)insertNewOrder:(Order*)order; - (void)removeOrderByID:(NSString*)orderID; - (void)clearCart; @end |
1 2 3 |
//OrderCartModule @interface OrderCartModule : NSObject IAppModule, IOrderCartModule> @end |
直接@import IOrderCartModule, @import OrderCartModule就可以開始使用購物車元件。
1 2 3 |
id orderCart = [OrderCartModule new]; int orderCount = [orderCart getOrderCount]; lbOrderCount.text = @(orderCount).stringValue; |
元件的生成程式碼需要統一管理,所以我們需要一個ModuleManager來管理接入的業務元件(遵循IAppModule的元件),包含元件的初始化和生命週期管理等等。
1 2 3 4 5 6 |
//ModuleManager.h @interface ModuleManager : NSObject + (instancetype)sharedInstance; - (idIOrderCartModule>)getOrderCartModule; - (void)handleModuleURL:(NSString*)url; @end |
ModuleManager只負責管理元件的宣告週期,及通用的元件行為。不會像MGJRouter做URL註冊,也不需要像Mediator做介面的再次封裝。
再看下這種元件接入方式帶來的耦合:
除了引入IOrderCartModule.h, OrderCartModule.h之外,還有一些model也被引用了,比如
1 |
- (void)insertNewOrder:(Order*)order; |
這裡涉及到複雜業務物件的描述,至於到底是引入Order.h還是使用NSDictionary來描述又是一次取捨。我個人還傾向於使用model來描述,和使用protocol而非runtime的理由一致,更清晰更直觀。不可否認這種方式耦合度會更高一些,我們看下實際工程當中對我們開發會帶來哪些影響。
假設購物車元件是由團隊D開發完成,第一版本的Order定義如下:
1 2 3 4 |
@interface Order : NSObject @property (nonatomic, strong) NSString* orderID; @property (nonatomic, strong) NSString* orderName; @end |
第二版本的Order新增功能可以查詢訂單的生成時間:
1 2 3 4 5 |
@interface Order : NSObject @property (nonatomic, strong) NSString* orderID; @property (nonatomic, strong) NSString* orderName; @property (nonatomic, strong) NSNumber* createdDate; @end |
這種場景對元件接入方几乎沒有影響,屬於新增功能,createdDate是否使用取決於接入方的業務進展。
但如果是改變orderID的管理方式:
1 2 3 4 |
@interface Order : NSObject @property (nonatomic, strong) NSNumber* orderID; @property (nonatomic, strong) NSString* orderName; @end |
將原本的NSString換成了NSNumber,這種改變會產生較大的影響,元件接入方所有使用orderID的地方都需要將型別做一次修改。這是不是說明import model的方式實際效率較差呢?假設我們是使用NSDitionary來描述Order資料,接入方沒法第一時間通過編譯來發現Order改變,需要除錯在runtime的crash場景下發現type的改變,反而不如使用model效率高。因為這種場景下的業務改動是屬於必須去適配的,所以我們更需要的是一種快速定位元件變化的方式來更新元件。業務的接入本身就是“侵入式”的,即使在語言層面做了隔離,元件的改變還是會牽動接入方的改變,否則新的業務邏輯如何生效呢?
可見我們的重點不是如何在語言層面去降低業務耦合,而是通過合理的流程去規範元件的演進和變化,也就是我們元件方案的第二部分Version Control。
Version Control
我們可以通過Semantic Versioning來規範我們元件的版本演進方式,再配合CocoaPods進行版本配置。Semantic Versioning定義如下:
Given a version number MAJOR.MINOR.PATCH, increment the: MAJOR version when you make incompatible API changes, MINOR version when you add functionality in a backwards-compatible manner, and PATCH version when you make backwards-compatible bug fixes. Additional labels for pre-release and build metadata are available as extensions to the MAJOR.MINOR.PATCH format.
所以上述orderID型別的修改需要改變Major版本號,元件接入方看到Major的更新,可以在第一時間安排更新計劃。
最後我們可以得到如下的架構圖:
底部的三類元件就是我們總體的元件庫,任何新啟的專案都可以從這三類元件當中選取合適的元件作為codebase。
這類還值得一提的話題是元件的粒度,在什麼時候我們需要重新抽象一個新的元件。我個人認為並不是所有的業務模組都適合抽象成元件,現在移動網際網路公司業務變化都非常快,大部分的業務都不會被重用,不被重用的模組去花精力做封裝設計並不划算,另外還會造成元件庫的膨脹和維護問題。至於哪些業務需要被抽象成元件,需要各小組組長也移動端總架構師去溝通協商。一個5人小團隊內部將不同的tab都做元件化的封裝是多此一舉,可能反而會延緩專案進度。比如Project A裡的首頁模組,使用者詳情頁被其他Project複用的可能性非常小,元件化有其代價存在。
Dependency Hell
元件過多的時候很容易出現Dependency Hell的問題,比如上圖中購物車元件和支付元件依賴於不同版本的log元件,解決這種依賴衝突會耗費額外的團隊溝通時間,反而會因為元件化降低開發效率。
總結
說了這麼多元件化方式,最後還是回到了最基礎的protocol方案,大巧不工,返璞歸真的方案可能是更好的方案,runtime雖然巧妙,又有多少語言自帶runtime屬性。當然我個人並沒有大量元件化的實戰經驗,以上都是理論分析,一家之言,具體業務環境下是否需要元件化,在我看來是個值得權衡的問題。對於小型的創業團隊,去實施元件化到底能有多少“效率”收益呢?
打賞支援我寫出更多好文章,謝謝!
打賞作者
打賞支援我寫出更多好文章,謝謝!