一、中小型App為什么要組件化
當(dāng)項(xiàng)目App處于起步階段、各個需求模塊趨于成熟穩(wěn)定的過程中,組件化也許并沒有那么迫切,甚至考慮組件化的架構(gòu)可能會影響開發(fā)效率和需求迭代。而當(dāng)項(xiàng)目迭代到一定時(shí)期之后,便會出現(xiàn)一些相對獨(dú)立的業(yè)務(wù)功能模塊,而團(tuán)隊(duì)的規(guī)模也會隨著項(xiàng)目迭代逐漸增長,這便是中小型應(yīng)用考慮組件化的時(shí)機(jī)了。
為了更好的分工協(xié)作,團(tuán)隊(duì)會安排團(tuán)隊(duì)成員各自維護(hù)一個相對獨(dú)立的業(yè)務(wù)組件。這個時(shí)候我們引入組件化方案,一是為了解除組件之間相互引用的代碼硬依賴,二是為了規(guī)范組件之間的通信接口; 讓各個組件對外都提供一個黑盒服務(wù),而組件工程本身可以獨(dú)立開發(fā)測試,減少溝通和維護(hù)成本,提高效率。
進(jìn)一步發(fā)展,當(dāng)團(tuán)隊(duì)涉及到轉(zhuǎn)型或者有了新的立項(xiàng)之后,一個團(tuán)隊(duì)會開始維護(hù)多個項(xiàng)目App,而多個項(xiàng)目App的需求模塊往往存在一定的交叉,而這個時(shí)候組件化給我們的幫助會更大,我只需要將之前的多個業(yè)務(wù)組件模塊在新的主App中進(jìn)行組裝即可快速迭代出下一個全新App。
二、如何開始組件化工作
2.1 組件化的架構(gòu)目標(biāo)
在詳細(xì)說如何具體開始組件化工作之前,我們對于組件化的期望應(yīng)該是這樣的,一個團(tuán)隊(duì)維護(hù)一到兩個獨(dú)立App,每個獨(dú)立App除開包含一些產(chǎn)品相關(guān)的非獨(dú)立模塊集之外,還需要用一些獨(dú)立的業(yè)務(wù)組件進(jìn)行組裝。 而不管是產(chǎn)品的非獨(dú)立模塊集、還是獨(dú)立業(yè)務(wù)組件都需要底層公共庫和基礎(chǔ)庫的支持。如下圖所示:

2.2 組件化第一步-剝離公共庫和產(chǎn)品基礎(chǔ)庫
在具體的項(xiàng)目開發(fā)過程中,我們使用cocoapod的組件依賴管理利器已經(jīng)開始從Github上引入了一些第三方開源的基礎(chǔ)庫,比如說AFNetworking、SDWebImage、SVProgressHUD、ZipArchive等。除開這些第三方開源基礎(chǔ)庫之外,我們還需要做的事情就是將一些基礎(chǔ)組件從主工程剝離出來,形成產(chǎn)品自己的私有基礎(chǔ)庫倉庫,為我們進(jìn)行業(yè)務(wù)獨(dú)立組件的分離做準(zhǔn)備。
這部分我將其分為兩類:一類是公共基礎(chǔ)庫,用于跨產(chǎn)品使用;一類是產(chǎn)品基礎(chǔ)庫,在某個產(chǎn)品中強(qiáng)相關(guān)依賴使用。這里以我們自己產(chǎn)品劃分為例,概述一下這兩類庫都包括哪些基礎(chǔ)組件:
公共庫包括:組件化中間件、網(wǎng)絡(luò)診斷、第三方SDK管理封裝、長連接相關(guān)、Patch相關(guān)、網(wǎng)絡(luò)和頁面監(jiān)控相關(guān)、用戶行為統(tǒng)計(jì)庫、第三方分享庫、JSBridge相關(guān)、關(guān)于Device+file+crypt+http的基礎(chǔ)方法等。
產(chǎn)品基礎(chǔ)庫包括:通用的WebViewContainer組件(封裝了JSBridge)、自定義數(shù)字鍵盤、表情鍵盤、自定義下拉列表、循環(huán)滾動頁面、AFNeworking封裝庫(對上層業(yè)務(wù)隱藏AF的直接引用)、以及其他自定義的UI基礎(chǔ)組件庫。
2.2 組件化第二步-獨(dú)立業(yè)務(wù)模塊單獨(dú)成庫
在基礎(chǔ)庫成體系的基礎(chǔ)上,我們就可以開始按照需求定性將一些相對獨(dú)立的業(yè)務(wù)模塊獨(dú)立成庫,單獨(dú)在一個工程上進(jìn)行開發(fā)、測試。
往往在這個階段有一個誤區(qū),千萬不能為了組件化而強(qiáng)行將一些耦合嚴(yán)重的業(yè)務(wù)模塊分出。如果在拆分過程中,拆分模塊跟其他模塊耦合太嚴(yán)重,那就先放棄這部分模塊的獨(dú)立,畢竟產(chǎn)品是不會單獨(dú)拿出時(shí)間給你做組件化的。
另外拆分的粒度需要大一點(diǎn),需要在功能模塊的基礎(chǔ)上,將業(yè)務(wù)獨(dú)立性考慮進(jìn)去,如果沒有就不拆,等以后有了相對獨(dú)立的模塊之后再拆。
2.3 組件化第三步-對外服務(wù)接口最小化
組件化不是一蹴而就的,我們在完成第二步的時(shí)候并不要強(qiáng)行要求去掉組件之間代碼的硬依賴,只需要保證單獨(dú)拆分出來的工程可以獨(dú)立運(yùn)行和測試,并且能夠通過引用保證其他業(yè)務(wù)組件和主工程的依賴使用即可。
當(dāng)?shù)诙酵瓿芍?,我們可以在此基礎(chǔ)上總結(jié)其他組件和主工程的需求調(diào)用,根據(jù)需求總結(jié)和抽象出當(dāng)前業(yè)務(wù)組件對外服務(wù)的最小化接口以及頁面跳轉(zhuǎn)調(diào)用。經(jīng)過多次總結(jié),我們可以發(fā)現(xiàn)組件之間的通信需求無外乎三個方面:URL導(dǎo)航+服務(wù)接口調(diào)用+消息變量定義。如下圖所示:

在這個階段,我們大多數(shù)應(yīng)用會選擇JLRoute(蘑菇街的MGJRoute方案也類似)去做URL導(dǎo)航的需求,會通過OpenServiceImpl + Protocol的方案(將所有對外服務(wù)提供的接口都在OpenServiceImpl中實(shí)現(xiàn))去做組件間的服務(wù)調(diào)用,消息變量的聲明可以放到對外服務(wù)接口的Protocol定義中。
到了這個階段,我們的業(yè)務(wù)組件也已經(jīng)相對獨(dú)立,JLRoute能夠去掉頁面引用的頭頭文件依賴。OpenServiceImpl+Protocol也將我們最小化的對外服務(wù)接口約束到Protocol接口文件中。 如果對于項(xiàng)目組件化要求不高的話,到這一步就可以了。
三、徹底組件化-LDBusMediator煉就
3.1 組件化方案不徹底之處和JLRoute的缺陷
通過第二部分的講述,我們的組件化工作差不多完成了80%,但是我們依然發(fā)現(xiàn),組件化并不夠徹底。
先來看服務(wù)調(diào)用方面,我們需要對外提供OpenServiceImpl的頭文件,外部模塊仍然保持著對業(yè)務(wù)組件的強(qiáng)依賴,OpenServiceImpl的不兼容變化必然導(dǎo)致所有調(diào)用部分的更改,我們期望的黑盒服務(wù)便無法實(shí)現(xiàn)。如果所有類別的服務(wù)接口都在OpenServiceImpl中實(shí)現(xiàn),OpenServiceImpl中的代碼會越來越多,難以維護(hù)和管理。 另外Protocol文件和OpenServiceImpl的頭文件都需要對外披露,如果放到組件實(shí)現(xiàn)中,兩個組件相互之間有調(diào)用,就會導(dǎo)致Podspec的相互循環(huán)依賴。
再看URL導(dǎo)航方面,在我們的項(xiàng)目中,我們在ViewController的類別中通過load方法注冊URL-Block,這樣能夠解決JLRoute的中心化注冊問題,但是JLRoute仍然存在其他一些缺陷。JLRoute去中心化的具體使用方式如下:
+ (void)load
{
@autoreleasepool {
[JLRoutes addRoute:@"/xxxx" handler:^BOOL(NSDictionary *parameters) {
UIViewController *baseViewController = parameters[kLDRouteViewControllerKey];
if (!baseViewController) {
baseViewController = [UIViewController topmostViewController];
}
if (!baseViewController) {
return YES;
}
XXXXViewController *viewController = [[XXXXViewController alloc] init];
if ([baseViewController isKindOfClass:[UINavigationController class]]) {
[(UINavigationController*)baseViewController pushViewController:viewController animated:YES];
}else if (baseViewController.navigationController) {
[baseViewController.navigationController pushViewController:viewController animated:YES];
} else {
UINavigationController *navController = [[UINavigationController alloc] initWithRootViewController:viewController];
[baseViewController presentViewController:navController animated:YES completion:NULL];
}
return YES;
}];
}
}
如上所用,JLRoute的缺陷如下:
- url短鏈分布式注冊時(shí),導(dǎo)航代碼的重復(fù)拷貝;
- 無法通過URL返回一個controller實(shí)例;(TabController也就無法從獨(dú)立業(yè)務(wù)組件中不引用Controller頭文件獲取Controller實(shí)例完成設(shè)置)
- class的load方法完成注冊,太多對啟動時(shí)Main線程有影響;
- 同一個url短鏈的導(dǎo)航方式單一固定,依賴注冊
- 單一業(yè)務(wù)組件中可導(dǎo)航URL分散,無法統(tǒng)一查看;
- Debug階段url傳遞參數(shù)錯誤、not found沒有提示;
3.2 LDBusMediator總體方案
針對組件化不徹底的實(shí)際問題,結(jié)合之前手淘分享的總線架構(gòu)以及蘑菇街的組件化分享博客,我們完成了一個通用的LDBusMediator中間件幫助我們徹底完成組件化。
LDBusMediator開源Git地址:
我們先來看總體的組件化方案:所有的業(yè)務(wù)組件通過Connector連接到總線中,Connector需要遵循Connector Protocol方可接入。Connector協(xié)議規(guī)定了URL導(dǎo)航接入和服務(wù)接入的協(xié)議,Connector通過Class的Load方法將自己的實(shí)例注冊到中間件的Cache數(shù)組中,方便其他組件在調(diào)用時(shí)中間件可以通過服務(wù)發(fā)現(xiàn)的方式進(jìn)行URL導(dǎo)航和服務(wù)調(diào)用。(具體見如下的圖示)
@implementation Connector_A
#pragma mark - register connector
/**
* 每個組件的實(shí)現(xiàn)必須自己通過load完成掛載;
* load只需要在掛載connector的時(shí)候完成當(dāng)前connecotor的初始化,掛載量、掛載消耗、掛載所耗內(nèi)存都在可控范圍內(nèi);
*/
+(void)load{
@autoreleasepool{
[LDBusMediator registerConnector:[self sharedConnector]];
}
}
@end
3.3 LDBusMediator-URL導(dǎo)航方案
URL導(dǎo)航的總線中間件方案很簡單,只需要在Connector中實(shí)現(xiàn)URL導(dǎo)航接入的接口即可,如圖所示:

具體使用如下:
@protocol LDBusConnectorPrt <NSObject>
-(BOOL)canOpenURL:(nonnull NSURL *)URL;
- (nullable UIViewController *)connectToOpenURL:(nonnull NSURL *)URL params:(nullable NSDictionary *)params;
@end
@implementation Connector_A
#pragma mark - LDBusConnectorPrt
/**
* (1)當(dāng)調(diào)用方需要通過判斷URL是否可導(dǎo)航顯示界面的時(shí)候,告訴調(diào)用方該組件實(shí)現(xiàn)是否可導(dǎo)航URL;可導(dǎo)航,返回YES,否則返回NO;
* (2)這個方法跟connectToOpenURL:params配套實(shí)現(xiàn);如果不實(shí)現(xiàn),則調(diào)用方無法判斷某個URL是否可導(dǎo)航;
*/
-(BOOL)canOpenURL:(nonnull NSURL *)URL{
if ([URL.host isEqualToString:@"ADetail"]) {
return YES;
}
return NO;
}
@end
/**
* (1)通過connector向busMediator掛載可導(dǎo)航的URL,具體解析URL的host還是path,由connector自行決定;
* (2)如果URL在本業(yè)務(wù)組件可導(dǎo)航,則從params獲取參數(shù),實(shí)例化對應(yīng)的viewController進(jìn)行返回;如果參數(shù)錯誤,則返回一個錯誤提示的[UIViewController paramsError]; 如果不需要中間件進(jìn)行present展示,則返回一個[UIViewController notURLController],表示當(dāng)前可處理;如果無法處理,返回nil,交由其他組件處理;
* (3)需要在connector中對參數(shù)進(jìn)行驗(yàn)證,不同的參數(shù)調(diào)用生成不同的ViewController實(shí)例;也可以通過參數(shù)決定是否自行展示,如果自行展示,則用戶定義的展示方式無效;
* (4)如果掛接的url較多,這里的代碼比較長,可以將處理方法分發(fā)到當(dāng)前connector的category中;
*/
- (nullable UIViewController *)connectToOpenURL:(nonnull NSURL *)URL params:(nullable NSDictionary *)params{
//處理scheme://ADetail的方式
// tip: url較少的時(shí)候可以通過if-else去處理,如果url較多,可以自己維護(hù)一個url和ViewController的map,加快遍歷查找,生成viewController;
if ([URL.host isEqualToString:@"ADetail"]) {
DemoModuleADetailViewController *viewController = [[DemoModuleADetailViewController alloc] init];
if (params[@"key"] != nil) {
viewController.valueLabel.text = params[@"key"];
} else if(params[@"image"]) {
id imageObj = params[@"image"];
if (imageObj && [imageObj isKindOfClass:[UIImage class]]) {
viewController.valueLabel.text = @"this is image";
viewController.imageView.image = params[@"image"];
[[UIApplication sharedApplication].keyWindow.rootViewController presentViewController:viewController animated:YES completion:nil];
return [UIViewController notURLController];
} else {
viewController.valueLabel.text = @"no image";
viewController.imageView.image = [UIImage imageNamed:@"noImage"];
[[UIApplication sharedApplication].keyWindow.rootViewController presentViewController:viewController animated:YES completion:nil];
return [UIViewController notURLController];
}
} else {
// nothing to do
}
return viewController;
}
else {
// nothing to to
}
return nil;
}
通過LDBusMediator的URL導(dǎo)航方案,有效的解決了前文提出的JLRoute的缺陷:
- url短鏈分布式注冊時(shí),導(dǎo)航代碼的重復(fù)拷貝
- LDBusNavigator+PresentMode:將通用的導(dǎo)航方式即成到LDBusNavigator中,而無需每個URL注冊時(shí)重復(fù)拷貝。
- 無法通過URL返回一個controller實(shí)例;(TabController)
- *URL-Block —> URL-ViewController實(shí)例:將之前JLRoute的url-block方式改成了url-ViewController方式,即可滿足。
- class的load方法完成注冊,太多對啟動時(shí)Main線程有影響;
- 服務(wù)發(fā)現(xiàn)的方式,只在load時(shí)注冊Connector實(shí)例:中間件只對每個業(yè)務(wù)組件的connector實(shí)例進(jìn)行注冊,相比URL注冊量大量減少load使用。
- 同一個url短鏈的導(dǎo)航方式單一固定,依賴注冊
- 調(diào)用時(shí)指定Present、Push、Share方式:之前JLRoute只能在注冊時(shí)候決定導(dǎo)航方式,通過LDBusMediator如何導(dǎo)航顯示由調(diào)用方?jīng)Q定,默認(rèn)是Push;Share方式是指pop到導(dǎo)航層次中已經(jīng)存在的viewController處。
- 單一業(yè)務(wù)組件中可導(dǎo)航URL分散,無法統(tǒng)一查看;
- 單一組件的connector中集中管理所有可導(dǎo)航URL
- Debug階段url傳遞參數(shù)錯誤、not found沒有提示;
- Debug階段的錯誤Controller提示、包括參數(shù)錯誤、notFound、notSupportController:如果參數(shù)錯誤、notfound無法生成一個viewController實(shí)例,中間件在debug階段會提示。如果URL不支持返回一個Controller,同樣會給與提示。
3.4 LDBusMediator-服務(wù)調(diào)用方案
為了更好的通過中間件支撐組件間的服務(wù)調(diào)用方案,我們在組件實(shí)現(xiàn)和中間件之間增加了一層協(xié)議接口層。 每個業(yè)務(wù)組件將自己對外提供的服務(wù)接口抽象到一個統(tǒng)一的業(yè)務(wù)組件協(xié)議集合中。 業(yè)務(wù)組件的實(shí)現(xiàn)依賴自己的對外服務(wù)接口集并進(jìn)行接口的實(shí)現(xiàn)。
每個業(yè)務(wù)組件中的協(xié)議部分有兩種:一種是服務(wù)協(xié)議,其他組件可以通過Mediator拿到對外開放的服務(wù)實(shí)例調(diào)用服務(wù)接口;一種是Model協(xié)議,服務(wù)協(xié)議中的接口可以給其他組件一個協(xié)議化對象,其他組件也可以組裝一個協(xié)議化對象通過參數(shù)傳入。
為了方便業(yè)務(wù)組件實(shí)現(xiàn)和協(xié)議集合的版本對應(yīng),需要保證協(xié)議集合的大版本(如x.y)和業(yè)務(wù)組件的大版本(如x.y.z)中的x保持一致;協(xié)議集合中一般沒有補(bǔ)丁版本的迭代,當(dāng)其他業(yè)務(wù)組件調(diào)用需要增加接口進(jìn)行兼容版本升級(y+1),減少或者修改接口則需要協(xié)議集合和業(yè)務(wù)組件中的x同時(shí)+1(x+1); 如果自身業(yè)務(wù)組件升級不能影響對外協(xié)議接口的調(diào)用,升級版本主要為補(bǔ)丁版本迭代(z+1)或 兼容版本升級(y+1);
組件協(xié)議集合 單獨(dú)通過一個Git地址進(jìn)行管理,單獨(dú)配置podspec,單獨(dú)通過協(xié)議的版本倉庫進(jìn)行管理;所有的協(xié)議集合的git統(tǒng)一放到Git的一個組中進(jìn)行管理。
具體方案如下:

@protocol LDBusConnectorPrt <NSObject>
/**
* 業(yè)務(wù)模塊掛接中間件,注冊自己提供的service,實(shí)現(xiàn)服務(wù)接口的調(diào)用;
*
* 通過protocol協(xié)議找到組件中對應(yīng)的服務(wù)實(shí)現(xiàn),生成一個服務(wù)單例;
* 傳遞給調(diào)用者進(jìn)行protocol接口中屬性和方法的調(diào)用;
*/
- (nullable id)connectToHandleProtocol:(nonnull Protocol *)servicePrt;
@end
@implementation Connector_A
/**
* (1)通過connector向BusMediator掛接可處理的Protocol,根據(jù)Protocol獲取當(dāng)前組件中可處理protocol的服務(wù)實(shí)例;
* (2)具體服務(wù)協(xié)議的實(shí)現(xiàn)可放到其他類實(shí)現(xiàn)文件中,只需要在當(dāng)前connetor中引用,返回一個服務(wù)實(shí)例即可;
* (3)如果不能處理,返回一個nil;
*/
- (nullable id)connectToHandleProtocol:(nonnull Protocol *)servicePrt{
if (servicePrt == @protocol(ModuleAXXXServicePrt)) {
return [[self class] sharedConnector];
}
return nil;
}
@end
LDBusMediator中間件的服務(wù)調(diào)用方案的優(yōu)勢:
- 通過中間件支撐,不暴露任何實(shí)現(xiàn)文件的頭文件;
- 組件對外提供的服務(wù)通過最小化抽象的“協(xié)議接口集”披露;
- 組件的實(shí)現(xiàn)Pod不暴露任何頭文件;
- 每個業(yè)務(wù)組件提供黑盒服務(wù)
- 調(diào)用者不用關(guān)心具體實(shí)現(xiàn)細(xì)節(jié);
- 業(yè)務(wù)組件的實(shí)現(xiàn)升級、或者更換(包括整個業(yè)務(wù)組件更換)不影響調(diào)用者的調(diào)用修改;
- 為業(yè)務(wù)組件Framework化、自動化構(gòu)建奠定基礎(chǔ)