最近在思考團隊擴張及項目數量增長的狀況下,如何持續保障團隊高效產出的問題,很天然的想到了組件化這個話題。重翻了前段時間iOS開發圈關於組件化的討論,這裏作下梳理和本身的思考。html
在開始討論組件化技術方案以前,能夠先思考下驅動項目組件化背後的原動力。咱們假設這樣一個場景,公司有 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在前三篇文章基礎之上作了清晰的梳理總結。通讀以後,獲益頗多,組件化所面臨的問題,和可能的解決思路也變得更清晰。web
首先須要對組件進行定義,叫組件也好,模塊也罷,咱們姑且認爲咱們討論的範疇是【獨立的業務或者功能單位】。至於這個單位的粒度大小,須要工程師本身把握。當咱們寫一個類的時候,咱們會謹記高內聚,低耦合的原則去設計這個類,當涉及多個類之間交互的時候,咱們也會運用SOLID原則,或者已有的設計模式去優化設計,但在實現完整的業務模塊的時候,咱們很容易忘記對這個模塊去作設計上的思考,粒度越大,越難作出精細穩定的設計,我暫且把這個粒度認爲是組件的粒度。組件是由一個或多個類構成,能完整描述一個業務場景,並能被其餘業務場景複用的功能單位。組件就像是PC時代我的組裝電腦時購買的一個個部件,好比內存,硬盤,CPU,顯示器等,拿出其中任何一個部件都能被其餘的PC所使用。算法
因此組件能夠是個廣義上的概念,並不必定是頁面跳轉,還能夠是其餘不具有UI屬性的服務提供者,好比日誌服務,VOIP服務,內存管理服務等等。說白了咱們目標是站在更高的維度去封裝功能單元。對這些功能單元進行進一步的分類,才能在具體的業務場景下作更合理的設計。按我我的經驗能夠將組件分爲如下幾類:設計模式
第一類是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去表達和接入會顯得很是變扭。試想下經過以下的代碼調用啓用日誌:markdown
[[MGJRouter sharedInstance] openURL:@"mgj://log/start" withParams:@{}];
複製代碼
這也是蘑菇街以URL方案來實現組件化不合理的地方,按Casa的分法,組件被調用分爲遠程和本地,這種日誌服務的調用是本地類型的調用,用URL來標這類記本地服務很有些繞遠路的感受。網絡
第三類模塊和具體的業務場景無關,好比Database模塊,提供數據的讀寫服務,包含多線程的處理。好比Network模塊,提供和Server數據交互的方式,包含併發數控制,網絡優化等處理。好比圖片處理類,提供異步繪製圓角頭像。這些模塊能夠被任意模塊使用,但不和任何業務相關。這種組件屬於咱們app的基礎服務提供者,更像是一個個SDK,或是toolkit。我不知道蘑菇街是怎麼處理這類組件接入的,很明顯URL的接入方式並不適合。咱們經過Pods使用的不少著名第三方庫都屬於這一類,像FMDB,SDWebImage等。多線程
接下來咱們再看看各家方案對上面三種組件的接入能力及優缺點。架構
首先從上面的分析能夠看出,這種方案在針對第一類組件是並無什麼大問題,只是不太適合第二類和第三類組件。
URL方案在啓動的時候有個模塊初始化的過程,初始化的時候註冊模塊本身提供的各類服務:
[MGJRouter registerURLPattern:@"mgj://detail?id=:id" toHandler:^(NSDictionary *routerParameters) {
NSNumber *id = routerParameters[@"id"];
// create view controller with id
// push view controller
}];
複製代碼
組件的使用方使用的時候經過傳入具體的URL Pattern來完成調用:
[MGJRouter openURL:@"mgj://detail?id=404"]
複製代碼
Bang針對這種方式提出了三個問題:
第一個問題是最明顯的問題,組件的使用方必須經過查閱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方案,他的方案當中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當中完成一個服務接入的代碼以下:
//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:
- (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上引入了額外的人力消耗。
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做爲接入方式會有必定程度的耦合,畢竟須要@import。protocol所帶來的耦合介於runtime和類的 .h文件之間,protocol相較於runtime雖然存在頭文件的編譯耦合,但在業務描述上更加清晰,函數名稱和參數類型都有明肯定義,不少時候甚至不須要查閱文檔就能明白組件的使用方式。我我的更偏向於使用protocol做爲組件的接入和使用方式。咱們用兩種類型的protocol來規範組件。
組件通用protocol
不一樣的組件類型接入的方式也不一樣。
第三類組件屬於基礎組件,相似工具箱。咱們所使用的大部分第三方庫都屬於這一類,平時通常使用CocoaPods直接接入,講究一點的話能夠對這些第三方庫接口再作一層封裝,再升級或替換的時候會更省力。大廠通常都會編寫本身的基礎組件,放到私有的Pods源。這類組件每每比較穩定,適合已Framework的方式集成,咱們在接入的時候不須要作特別的處理。
第一類和第二類組件都具有業務場景和業務狀態,他們的接入和業務聯繫緊密,須要有專門的protocol來定義他們的行爲。這個protocol用來規定每一個組件通用的行爲,以及組件完整生命週期的一些回調處理。相似:
@protocol IAppModule
//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跳轉的場景用以下代碼處理:
for (int i = 0; i < _modules.count; i ++) {
id module = _modules[i];
if ([module respondsToSelector:@selector(handleUrl:)]) {
BOOL ret = [module handleUrl:url];
if (ret) {
break;
}
}
}
複製代碼
Url Pattern須要有個統一的web後臺管理頁面,各組件須要註冊本身的Controller。
對於須要接入Controller的場景(第一類組件,有入口Controller),以下處理:
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的優點所在。好比咱們須要導入購物車組件:
//IOrderCartModule.h
@protocol IOrderCartModule
- (int)getOrderCount;
- (Order*)getOrderByID:(NSString*)orderID;
- (void)insertNewOrder:(Order*)order;
- (void)removeOrderByID:(NSString*)orderID;
- (void)clearCart;
@end
複製代碼
//OrderCartModule
@interface OrderCartModule : NSObject
@end
複製代碼
直接@import IOrderCartModule, @import OrderCartModule就能夠開始使用購物車組件。
id orderCart = [OrderCartModule new];
int orderCount = [orderCart getOrderCount];
lbOrderCount.text = @(orderCount).stringValue;
複製代碼
組件的生成代碼須要統一管理,因此咱們須要一個ModuleManager來管理接入的業務組件(遵循IAppModule的組件),包含組件的初始化和生命週期管理等等。
//ModuleManager.h
@interface ModuleManager : NSObject
+ (instancetype)sharedInstance;
- (id)getOrderCartModule;
- (void)handleModuleURL:(NSString*)url;
@end
複製代碼
ModuleManager只負責管理組件的聲明週期,及通用的組件行爲。不會像MGJRouter作URL註冊,也不須要像Mediator作接口的再次封裝。
再看下這種組件接入方式帶來的耦合:
除了引入IOrderCartModule.h, OrderCartModule.h以外,還有一些model也被引用了,好比
- (void)insertNewOrder:(Order*)order;
複製代碼
這裏涉及到複雜業務對象的描述,至於究竟是引入Order.h仍是使用NSDictionary來描述又是一次取捨。我我的還傾向於使用model來描述,和使用protocol而非runtime的理由一致,更清晰更直觀。不能否認這種方式耦合度會更高一些,咱們看下實際工程當中對咱們開發會帶來哪些影響。
假設購物車組件是由團隊D開發完成,初版本的Order定義以下:
@interface Order : NSObject
@property (nonatomic, strong) NSString* orderID;
@property (nonatomic, strong) NSString* orderName;
@end
複製代碼
第二版本的Order新增功能能夠查詢訂單的生成時間:
@interface Order : NSObject
@property (nonatomic, strong) NSString* orderID;
@property (nonatomic, strong) NSString* orderName;
@property (nonatomic, strong) NSNumber* createdDate;
@end
複製代碼
這種場景對組件接入方几乎沒有影響,屬於新增功能,createdDate是否使用取決於接入方的業務進展。
但若是是改變orderID的管理方式:
@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。
咱們能夠經過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屬性。固然我我的並無大量組件化的實戰經驗,以上都是理論分析,一家之言,具體業務環境下是否須要組件化,在我看來是個值得權衡的問題。對於小型的創業團隊,去實施組件化到底能有多少「效率」收益呢?