前言:之前對(duì)于組件化的認(rèn)知,僅停留于
模塊相互獨(dú)立、分層的概念,另一方面,由于公司產(chǎn)品線較少,對(duì)于業(yè)務(wù)模塊抽離以及模塊間通信的方案沒有明確的認(rèn)知,這次就是需要全面學(xué)習(xí)了解一下。
1. 組件化介紹
1.1 什么是組件化
組件化就是將模塊單獨(dú)抽離、分層,并制定模塊間通信的方式,從而實(shí)現(xiàn)解耦,主要適用于大型團(tuán)隊(duì)開發(fā)項(xiàng)目。
這里的模塊包含基礎(chǔ)模塊、功能模塊、業(yè)務(wù)模塊。
1.2 組件化產(chǎn)生的原因
有人說從來沒用過組件化,也不影響項(xiàng)目開發(fā)。確實(shí)項(xiàng)目組件化不是項(xiàng)目開發(fā)的必要條件,但是項(xiàng)目實(shí)施組件化之后可以大大提高項(xiàng)目的開發(fā)效率,當(dāng)項(xiàng)目越來越大的時(shí)候,維護(hù)的人員也不只是一兩個(gè)人了,各個(gè)模塊之間如果直接互相引用,就會(huì)產(chǎn)生許多耦合,當(dāng)某個(gè)模塊需要修改時(shí),那么就需要修改依賴于這個(gè)模塊的所有模塊,想想這是不是一件很恐怖的事。
實(shí)施組件化,主要有 4 個(gè)原因:
- 模塊間解耦
- 模塊重用
- 提高團(tuán)隊(duì)協(xié)作開發(fā)效率
- 單元測(cè)試
對(duì)應(yīng)的問題主要體現(xiàn)在:
- 修改某個(gè)模塊的功能時(shí),需要修改其他引用該模塊的代碼,這樣會(huì)導(dǎo)致開發(fā)成本增加
- 模塊對(duì)外接口不明確,外部甚至?xí){(diào)用不應(yīng)暴露的私有接口,修改時(shí)耗費(fèi)大量時(shí)間
- 修改代碼時(shí),涉及到其他的模塊,容易影響其他成員的開發(fā),產(chǎn)生代碼沖突
- 當(dāng)某個(gè)模塊需要在其他產(chǎn)品線復(fù)用時(shí),會(huì)發(fā)現(xiàn)耦合嚴(yán)重導(dǎo)致無法單獨(dú)抽離
- 模塊間的耦合導(dǎo)致接口和依賴混亂,難以編寫單元測(cè)試
所以需要減少模塊之間的耦合,用更規(guī)范的方式進(jìn)行模塊間交互。這就是組件化,也可以叫做模塊化。
1.3 實(shí)施組件化的前提
上面有提到組件化并不是項(xiàng)目開發(fā)的必要條件,實(shí)施組件化是需要成本的,需要花費(fèi)時(shí)間設(shè)計(jì)接口,分離代碼,像以下這些情況就不需要組件化了,當(dāng)然也需要結(jié)合實(shí)際情況進(jìn)行考慮:
- 項(xiàng)目比較小,由于需求原因,模塊間交互簡(jiǎn)單,耦合少
- 模塊沒有被多個(gè)外部模塊引用,只是一個(gè)單獨(dú)的小模塊
- 模塊不需要重用,代碼幾乎不會(huì)修改了
- 項(xiàng)目只有一兩個(gè)人維護(hù)的時(shí)候
- 不需要編寫單元測(cè)試
當(dāng)有以下幾個(gè)現(xiàn)象時(shí),就需要考慮組件化了:
- 模塊邏輯復(fù)雜,模塊間耦合嚴(yán)重
- 項(xiàng)目規(guī)模變大,修改一個(gè)代碼需要設(shè)計(jì)好幾個(gè)地方
- 團(tuán)隊(duì)人數(shù)變多,經(jīng)常代碼沖突
- 項(xiàng)目編譯耗時(shí)較大
- 模塊的單元測(cè)試經(jīng)常由于其他模塊的修改而失敗
1.4 組件化方案的幾條指標(biāo)
當(dāng)我們需要組件化的時(shí)候,也需要設(shè)定一個(gè)目標(biāo),來標(biāo)明組件化之后會(huì)帶來什么樣的效果,比如:
- 模塊間沒有直接耦合,一個(gè)模塊內(nèi)部的修改不會(huì)影響到另一個(gè)模塊
- 模塊可以單獨(dú)被編譯
- 模塊間能夠清晰的進(jìn)行數(shù)據(jù)傳遞
- 模塊可以被重用或者被另一個(gè)提供了相同功能的模塊替換
- 模塊的對(duì)外接口容易查找和維護(hù)
- 當(dāng)模塊的接口改變時(shí),使用此模塊的外部代碼能夠被高效的重構(gòu)
- 盡量使用最少的修改和代碼,讓現(xiàn)有的項(xiàng)目實(shí)現(xiàn)模塊化
- 支持 OC 和 Swift,以及混編
前 4 條用于衡量一個(gè)模塊是否被真正解耦,后面 4 條用于衡量在項(xiàng)目實(shí)踐中的易用程度。
2. 組件劃分
一般項(xiàng)目會(huì)分為基礎(chǔ)組件、通用組件、業(yè)務(wù)組件三種,相應(yīng)也劃分成了不同的層級(jí),當(dāng)然,這里只是給個(gè)建議,具體的劃分需要結(jié)合項(xiàng)目進(jìn)行分析,如下圖所示:

同時(shí),需要注意的是:
- 只能上層對(duì)下層進(jìn)行依賴
- 如果同一層組件之間有依賴,則將依賴部分提取出來,抽離為下一層的組件(
依賴下沉)
3. 組件間通信
對(duì)于通用組件和基礎(chǔ)組件,這兩層很少會(huì)產(chǎn)生橫向依賴,我們可以使用cocoapods把相應(yīng)的代碼封裝成私有庫(kù),具體可見Cocoapods私有庫(kù)的創(chuàng)建,這里就不做贅述了。
比較麻煩的是業(yè)務(wù)組件,或者稱為業(yè)務(wù)模塊,因?yàn)楫a(chǎn)品很多天馬星空的想法,就讓不同業(yè)務(wù)組件產(chǎn)生了相互依賴,這是不可避免的,沒有耦合、沒有依賴就無法形成一個(gè)項(xiàng)目,所以如何處理業(yè)務(wù)組件之間的依賴成為了組件化實(shí)施的重點(diǎn)。
有的項(xiàng)目中模塊之間的關(guān)系如下圖所示(圖是隨便畫的,就是為了描述模塊之間相互依賴的亂七八糟的關(guān)系):

從上圖可以看到,每個(gè)模塊都離不開其他模塊,最終成了一坨,再改需求的時(shí)候,很容易形成連鎖反應(yīng)。
這樣的一坨代碼對(duì)于測(cè)試、編譯、開發(fā)效率、后續(xù)擴(kuò)展都有壞處,那怎么解決呢?
在程序員的自我修養(yǎng)這本書中,看到過這樣一句話:計(jì)算機(jī)科學(xué)領(lǐng)域的任何問題都可以通過增加一個(gè)間接的中間層來解決。這樣理解的話,我們的問題瞬間逼格上升了,居然涉及到計(jì)算機(jī)系統(tǒng)軟件體系結(jié)構(gòu)了。
那我們就增加一個(gè)中間層,負(fù)責(zé)轉(zhuǎn)發(fā)業(yè)務(wù)組件之間的信息,如下圖所示:

現(xiàn)在看起來順眼多了,中間層就是負(fù)責(zé)轉(zhuǎn)發(fā)業(yè)務(wù)組件之間的信息,現(xiàn)在還會(huì)有幾個(gè)問題:
- 中間層怎么去轉(zhuǎn)發(fā)組件間調(diào)用?
- 一個(gè)模塊只跟中間層通信,怎么知道另一個(gè)模塊提供了什么接口?
- 上圖中,模塊和中間層之間相互依賴,怎么破除這個(gè)相互依賴?
3.1 Target-action
對(duì)于前兩個(gè)問題,我們可以在中間層對(duì)外提供接口,實(shí)現(xiàn)時(shí)去調(diào)用對(duì)應(yīng)模塊的方法,如下:
// 中間層
#import "BookDetailComponent.h"
#import "ReviewComponent.h"
@implementation Mediator
+ (UIViewController *)BookDetailComponent_viewController:(NSString *)bookId {
return [BookDetailComponent detailViewController:bookId];
}
+ (UIViewController *)ReviewComponent_viewController:(NSString *)bookId reviewType:(NSInteger)type {
return [ReviewComponent reviewViewController:bookId type:type];
}
@end
//BookDetailComponent 組件
#import "Mediator.h"
#import "WRBookDetailViewController.h"
@implementation BookDetailComponent
+ (UIViewController *)detailViewController:(NSString *)bookId {
WRBookDetailViewController *detailVC = [[WRBookDetailViewController alloc] initWithBookId:bookId];
return detailVC;
}
@end
//ReviewComponent 組件
#import "Mediator.h"
#import "WRReviewViewController.h"
@implementation ReviewComponent
+ (UIViewController *)reviewViewController:(NSString *)bookId type:(NSInteger)type {
UIViewController *reviewVC = [[WRReviewViewController alloc] initWithBookId:bookId type:type];
return reviewVC;
}
@end
然后比如在閱讀模塊里這樣使用:
//WRReadingViewController.m
#import "Mediator.h"
@implementation WRReadingViewController
- (void)gotoDetail:(NSString *)bookId {
UIViewController *detailVC = [Mediator BookDetailComponent_viewControllerForDetail:bookId];
[self.navigationController pushViewController:detailVC];
UIViewController *reviewVC = [Mediator ReviewComponent_viewController:bookId type:1];
[self.navigationController pushViewController:reviewVC];
}
@end
這就是上面那個(gè)架構(gòu)圖的實(shí)現(xiàn),這樣看來依賴關(guān)系沒有解除,中間層(Mediator)和模塊之間仍然是相互依賴的關(guān)系。
對(duì)于OC 來說有個(gè)辦法可以解決這個(gè)問題,就是runtime 反射調(diào)用:
//Mediator.m
@implementation Mediator
+ (UIViewController *)BookDetailComponent_viewController:(NSString *)bookId {
Class cls = NSClassFromString(@"BookDetailComponent");
return [cls performSelector:NSSelectorFromString(@"detailViewController:") withObject:@{@"bookId":bookId}];
}
+ (UIViewController *)ReviewComponent_viewController:(NSString *)bookId type:(NSInteger)type {
Class cls = NSClassFromString(@"ReviewComponent");
return [cls performSelector:NSSelectorFromString(@"reviewViewController:") withObject:@{@"bookId":bookId, @"type": @(type)}];
}
@end
這下中間層(Mediator)沒有再對(duì)組件有依賴了,也不需要 #import什么東西了,對(duì)應(yīng)的架構(gòu)圖就變成:

只有調(diào)用其他組件接口時(shí)才需要依賴Mediator,組件開發(fā)者不需要知道 Mediator 的存在,但是既然可以用runtime 就可以解耦取消依賴,那還用Mediator干啥?組件間調(diào)用時(shí)直接用 runtime 接口調(diào)就行了,比如:
//WRReadingViewController.m
@implementation WRReadingViewController
- (void)gotoReview:(NSString *)bookId {
Class cls = NSClassFromString(@"ReviewComponent");
UIViewController *reviewVC = [cls performSelector:NSSelectorFromString(@"reviewViewController:") withObject:@{@"bookId":bookId, @"type": @(1)}];
[self.navigationController pushViewController:reviewVC];
}
@end
但是這樣就會(huì)另外的問題:
- 寫起來很惡心,代碼提示都沒有,每次調(diào)用寫一坨
-
runtime方法的參數(shù)個(gè)數(shù)和類型限制,導(dǎo)致只能每個(gè)接口都統(tǒng)一傳一個(gè)NSDictionary。這個(gè)NSDictionary里的key value是什么不明確,需要找個(gè)地方寫文檔說明和查看。 - 編譯器層面不依賴其他組件,實(shí)際上還是依賴了,直接在這里調(diào)用,沒有引入調(diào)用的組件時(shí)就掛了
所以需要將它移植到Mediator中間層后:
- 調(diào)用者寫起來不惡心,代碼提示也有了
- 參數(shù)類型和個(gè)數(shù)無限制,由
Mediator去轉(zhuǎn)就行了,組件提供的還是一個(gè)NSDictionary參數(shù)的接口,但在Mediator里可以提供任意類型和個(gè)數(shù)的參數(shù),像上面的例子顯式要求參數(shù)NSString *bookId和NSInteger type -
Mediator可以做統(tǒng)一處理,調(diào)用某個(gè)組件方法時(shí)如果某個(gè)組件不存在,可以做相應(yīng)操作,讓調(diào)用者與組件間沒有耦合
到這里,基本上能解決我們的問題:各組件互不依賴,組件間調(diào)用只依賴中間件Mediator,Mediator不依賴其他組件。接下來就是優(yōu)化這套寫法,有兩個(gè)優(yōu)化點(diǎn):
-
Mediator每一個(gè)方法里都要寫runtime方法,格式是確定的,這是可以抽取出來的 - 每個(gè)組件對(duì)外方法都要在
Mediator寫一遍,組件一多Mediator類的長(zhǎng)度是恐怖的
優(yōu)化后就成了casa 的方案CTMediator,target-action 對(duì)應(yīng)第一點(diǎn),target就是class,action就是selector,通過一些規(guī)則簡(jiǎn)化動(dòng)態(tài)調(diào)用。Category 對(duì)應(yīng)第二點(diǎn),每個(gè)組件寫一個(gè) Mediator 的 Category,讓 Mediator 不至于太長(zhǎng)。
總結(jié)起來就是,組件通過中間層通信,中間層利用 OC 的 runtime、category 特性動(dòng)態(tài)獲取模塊,例如通過 NSClassFromString 獲取類并創(chuàng)建實(shí)例,通過performSelector:+NSInvocation動(dòng)態(tài)調(diào)用方法。
對(duì)于CTMediator的具體分析可以查看組件化方案學(xué)習(xí) - CTMediator這篇文章。
3.2 URL路由
這種方式是采用注冊(cè)表的方式,用URL 來表示接口,在模塊啟動(dòng)時(shí)注冊(cè)模塊提供的接口,可以看下面這個(gè)簡(jiǎn)化的實(shí)現(xiàn):
//Mediator.m 中間件
@implementation Mediator
typedef void (^componentBlock) (id param);
@property (nonatomic, storng) NSMutableDictionary *cache
- (void)registerURLPattern:(NSString *)urlPattern toHandler:(componentBlock)blk {
[cache setObject:blk forKey:urlPattern];
}
- (void)openURL:(NSString *)url withParam:(id)param {
componentBlock blk = [cache objectForKey:url];
if (blk) blk(param);
}
@end
//BookDetailComponent 組件
#import "Mediator.h"
#import "WRBookDetailViewController.h"
+ (void)initComponent {
[[Mediator sharedInstance] registerURLPattern:@"weread://bookDetail" toHandler:^(NSDictionary *param) {
WRBookDetailViewController *detailVC = [[WRBookDetailViewController alloc] initWithBookId:param[@"bookId"]];
[[UIApplication sharedApplication].keyWindow.rootViewController.navigationController pushViewController:detailVC animated:YES];
}];
}
//WRReadingViewController.m 調(diào)用者
//ReadingViewController.m
#import "Mediator.h"
+ (void)gotoDetail:(NSString *)bookId {
[[Mediator sharedInstance] openURL:@"weread://bookDetail" withParam:@{@"bookId": bookId}];
}
這樣也可以做到每個(gè)模塊之間沒有依賴,中間層也不會(huì)依賴其他組件,不過這里不同的是組件本身和調(diào)用者都依賴了 Mediator,不過這不是重點(diǎn),架構(gòu)圖還是和之前的一樣。
各個(gè)組件初始化時(shí)向 Mediator 注冊(cè)對(duì)外提供的接口,Mediator 通過保存在內(nèi)存的表去查找模塊需要哪些接口,接口的形式是 URL->block。
這里先不談URL 的遠(yuǎn)程調(diào)用和本地調(diào)用混在一起導(dǎo)致的問題,先說一下本地調(diào)用的情況,對(duì)于本地調(diào)用,URL 只是一個(gè)表示組件的key,沒有其他作用,這樣做有三個(gè)問題:
- 需要有個(gè)地方列出各個(gè)組件里有什么
URL接口可供調(diào)用。蘑菇街做了個(gè)后臺(tái)專門管理,相當(dāng)于一個(gè)說明文檔 - 每個(gè)組件都需要初始化,內(nèi)存里需要保存一份表,組件多了會(huì)有內(nèi)存問題
- 參數(shù)的格式不明確,是個(gè)靈活的
dictionary,也需要有個(gè)地方可以查參數(shù)格式
第二點(diǎn)沒法解決,第一點(diǎn)和第三點(diǎn)可以跟前面那個(gè)方案一樣,在 Mediator 每個(gè)組件暴露方法的轉(zhuǎn)接口,然后使用起來就跟前面那種方式一樣了。
拋開URL不說,這種方案跟Target+Action的共同思路就是:Mediator 不能直接去調(diào)用組件的方法,因?yàn)檫@樣會(huì)產(chǎn)生依賴,那我就要通過其他方法去調(diào)用,也就是通過 字符串->方法 的映射去調(diào)用。runtime 接口的className + selectorName -> IMP 是一種,注冊(cè)表的 key -> block是一種,而前一種是 OC自帶的特性,后一種需要內(nèi)存維持一份注冊(cè)表,這是不必要的。
現(xiàn)在說回URL,組件化是不應(yīng)該跟URL 扯上關(guān)系的,因?yàn)榻M件對(duì)外提供的接口主要是模塊間代碼層面上的調(diào)用,我們先稱為本地調(diào)用,而URL 主要用于APP 間通信,姑且稱為遠(yuǎn)程調(diào)用。按常規(guī)思路者應(yīng)該是對(duì)于遠(yuǎn)程調(diào)用,再加個(gè)中間層轉(zhuǎn)發(fā)到本地調(diào)用,讓這兩者分開。那這里這兩者混在一起有什么問題呢?
如果是URL 的形式,那組件對(duì)外提供接口時(shí)就要同時(shí)考慮本地調(diào)用和遠(yuǎn)程調(diào)用兩種情況,而遠(yuǎn)程調(diào)用有個(gè)限制,傳遞的參數(shù)類型有限制,只能傳能被字符串化的數(shù)據(jù),或者說只能傳能被轉(zhuǎn)成json的數(shù)據(jù),像 UIImage 這類對(duì)象是不行的,所以如果組件接口要考慮遠(yuǎn)程調(diào)用,這里的參數(shù)就不能是這類非常規(guī)對(duì)象,接口的定義就受限了。
3.3 protocol-class
這種方案其實(shí)是用于本地調(diào)用,就是通過 protocol-class 注冊(cè)表的方式實(shí)現(xiàn)的:
- 首先由一個(gè)中間件
//ProtocolMediator.m 新中間件
@implementation ProtocolMediator
@property (nonatomic, storng) NSMutableDictionary *protocolCache
- (void)registerProtocol:(Protocol *)proto forClass:(Class)cls {
NSMutableDictionary *protocolCache;
[protocolCache setObject:cls forKey:NSStringFromProtocol(proto)];
}
- (Class)classForProtocol:(Protocol *)proto {
return protocolCache[NSStringFromProtocol(proto)];
}
@end
- 然后有一個(gè)公共
Protocol文件,定義了每一個(gè)組件對(duì)外提供的接口:
//ComponentProtocol.h
@protocol BookDetailComponentProtocol <NSObject>
- (UIViewController *)bookDetailController:(NSString *)bookId;
- (UIImage *)coverImageWithBookId:(NSString *)bookId;
@end
@protocol ReviewComponentProtocol <NSObject>
- (UIViewController *)ReviewController:(NSString *)bookId;
@end
- 再在模塊里實(shí)現(xiàn)這些接口,并在初始化時(shí)調(diào)用
registerProtocol注冊(cè):
//BookDetailComponent 組件
#import "ProtocolMediator.h"
#import "ComponentProtocol.h"
#import "WRBookDetailViewController.h"
+ (void)initComponent
{
[[ProtocolMediator sharedInstance] registerProtocol:@protocol(BookDetailComponentProtocol) forClass:[self class];
}
- (UIViewController *)bookDetailController:(NSString *)bookId {
WRBookDetailViewController *detailVC = [[WRBookDetailViewController alloc] initWithBookId:param[@"bookId"]];
return detailVC;
}
- (UIImage *)coverImageWithBookId:(NSString *)bookId {
….
}
- 通過 protocol 從 ProtocolMediator 拿到提供這些方法的 Class,再進(jìn)行調(diào)用:
//WRReadingViewController.m 調(diào)用者
//ReadingViewController.m
#import "ProtocolMediator.h"
#import "ComponentProtocol.h"
+ (void)gotoDetail:(NSString *)bookId {
Class cls = [[ProtocolMediator sharedInstance] classForProtocol:BookDetailComponentProtocol];
id bookDetailComponent = [[cls alloc] init];
UIViewController *vc = [bookDetailComponent bookDetailController:bookId];
[[UIApplication sharedApplication].keyWindow.rootViewController.navigationController pushViewController:vc animated:YES];
}
我們可以看到,這種方案相當(dāng)于將組件和協(xié)議對(duì)應(yīng)存儲(chǔ)起來,每個(gè)組件都實(shí)現(xiàn)了相應(yīng)的協(xié)議,這些個(gè)協(xié)議就是組件對(duì)外提供的接口,在業(yè)務(wù)方都是直接可見的,當(dāng)業(yè)務(wù)方需要使用某個(gè)組件的時(shí)候,會(huì)通過中間層根據(jù)協(xié)議獲取對(duì)應(yīng)的組件,然后調(diào)用該組件的方法,簡(jiǎn)而言之就是:
- 將
protocol和對(duì)應(yīng)的類進(jìn)行字典匹配 - 通過用
protocol獲取class,再動(dòng)態(tài)創(chuàng)建實(shí)例,調(diào)用方法
這個(gè)方案跟Target-Action最大的不同是,它不是直接通過Mediator調(diào)用組件方法,而是通過Mediator拿到對(duì)應(yīng)的組件對(duì)象,再自行去調(diào)用組件方法。
結(jié)果就是組件方法的調(diào)用是分散在各地的,沒有統(tǒng)一的入口,也沒法做組件不存在時(shí)的處理。
4. 總結(jié)
每個(gè)方案都有優(yōu)劣,各個(gè)公司實(shí)施組件化的方案都是上面的一種或者多種的組合,這個(gè)就需要根據(jù)自己的項(xiàng)目制定出合適的方案,畢竟組件化也是需要一些成本的。