一、中小型App為什么要組件化
當(dāng)項(xiàng)目App處于起步階段辙售、各個(gè)需求模塊趨于成熟穩(wěn)定的過程中旦部,組件化也許并沒有那么迫切士八,甚至考慮組件化的架構(gòu)可能會(huì)影響開發(fā)效率和需求迭代。而當(dāng)項(xiàng)目迭代到一定時(shí)期之后缰趋,便會(huì)出現(xiàn)一些相對獨(dú)立的業(yè)務(wù)功能模塊,而團(tuán)隊(duì)的規(guī)模也會(huì)隨著項(xiàng)目迭代逐漸增長味抖,這便是中小型應(yīng)用考慮組件化的時(shí)機(jī)了仔涩。
為了更好的分工協(xié)作,團(tuán)隊(duì)會(huì)安排團(tuán)隊(duì)成員各自維護(hù)一個(gè)相對獨(dú)立的業(yè)務(wù)組件佩研。這個(gè)時(shí)候我們引入組件化方案旬薯,一是為了解除組件之間相互引用的代碼硬依賴适秩,二是為了規(guī)范組件之間的通信接口; 讓各個(gè)組件對外都提供一個(gè)黑盒服務(wù)骤公,而組件工程本身可以獨(dú)立開發(fā)測試阶捆,減少溝通和維護(hù)成本洒试,提高效率。
進(jìn)一步發(fā)展娱挨,當(dāng)團(tuán)隊(duì)涉及到轉(zhuǎn)型或者有了新的立項(xiàng)之后跷坝,一個(gè)團(tuán)隊(duì)會(huì)開始維護(hù)多個(gè)項(xiàng)目App碉碉,而多個(gè)項(xiàng)目App的需求模塊往往存在一定的交叉垢粮,而這個(gè)時(shí)候組件化給我們的幫助會(huì)更大蜡吧,我只需要將之前的多個(gè)業(yè)務(wù)組件模塊在新的主App中進(jìn)行組裝即可快速迭代出下一個(gè)全新App昔善。
二、如何開始組件化工作
2.1 組件化的架構(gòu)目標(biāo)
在詳細(xì)說如何具體開始組件化工作之前翩概,我們對于組件化的期望應(yīng)該是這樣的钥庇,一個(gè)團(tuán)隊(duì)維護(hù)一到兩個(gè)獨(dú)立App咖摹,每個(gè)獨(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ǔ)庫蔼囊,在某個(gè)產(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)滾動(dòng)頁面羽利、AFNeworking封裝庫(對上層業(yè)務(wù)隱藏AF的直接引用)铐伴、以及其他自定義的UI基礎(chǔ)組件庫当宴。
2.2 組件化第二步-獨(dú)立業(yè)務(wù)模塊單獨(dú)成庫
在基礎(chǔ)庫成體系的基礎(chǔ)上,我們就可以開始按照需求定性將一些相對獨(dú)立的業(yè)務(wù)模塊獨(dú)立成庫玲献,單獨(dú)在一個(gè)工程上進(jìn)行開發(fā)捌年、測試礼预。
往往在這個(gè)階段有一個(gè)誤區(qū)托酸,千萬不能為了組件化而強(qiáng)行將一些耦合嚴(yán)重的業(yè)務(wù)模塊分出励堡。如果在拆分過程中应结,拆分模塊跟其他模塊耦合太嚴(yán)重泉唁,那就先放棄這部分模塊的獨(dú)立鹅龄,畢竟產(chǎn)品是不會(huì)單獨(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ù)诙酵瓿芍螅覀兛梢栽诖嘶A(chǔ)上總結(jié)其他組件和主工程的需求調(diào)用,根據(jù)需求總結(jié)和抽象出當(dāng)前業(yè)務(wù)組件對外服務(wù)的最小化接口以及頁面跳轉(zhuǎn)調(diào)用庐扫。經(jīng)過多次總結(jié),我們可以發(fā)現(xiàn)組件之間的通信需求無外乎三個(gè)方面:URL導(dǎo)航+服務(wù)接口調(diào)用+消息變量定義。如下圖所示:
在這個(gè)階段,我們大多數(shù)應(yīng)用會(huì)選擇JLRoute(蘑菇街的MGJRoute方案也類似)去做URL導(dǎo)航的需求胜嗓,會(huì)通過OpenServiceImpl + Protocol的方案(將所有對外服務(wù)提供的接口都在OpenServiceImpl中實(shí)現(xiàn))去做組件間的服務(wù)調(diào)用寥粹,消息變量的聲明可以放到對外服務(wù)接口的Protocol定義中媚狰。
到了這個(gè)階段,我們的業(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中的代碼會(huì)越來越多锡移,難以維護(hù)和管理施符。 另外Protocol文件和OpenServiceImpl的頭文件都需要對外披露柬采,如果放到組件實(shí)現(xiàn)中肩刃,兩個(gè)組件相互之間有調(diào)用,就會(huì)導(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返回一個(gè)controller實(shí)例胸竞;(TabController也就無法從獨(dú)立業(yè)務(wù)組件中不引用Controller頭文件獲取Controller實(shí)例完成設(shè)置)
- class的load方法完成注冊马篮,太多對啟動(dòng)時(shí)Main線程有影響掷匠;
- 同一個(gè)url短鏈的導(dǎo)航方式單一固定擎值,依賴注冊
- 單一業(yè)務(wù)組件中可導(dǎo)航URL分散汹粤,無法統(tǒng)一查看;
- Debug階段url傳遞參數(shù)錯(cuò)誤互捌、not found沒有提示牲阁;
3.2 LDBusMediator總體方案
針對組件化不徹底的實(shí)際問題客税,結(jié)合之前手淘分享的總線架構(gòu)以及蘑菇街的組件化分享博客目胡,我們完成了一個(gè)通用的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
/**
* 每個(gè)組件的實(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)這個(gè)方法跟connectToOpenURL:params配套實(shí)現(xiàn)矛渴;如果不實(shí)現(xiàn),則調(diào)用方無法判斷某個(gè)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ù)錯(cuò)誤凛忿,則返回一個(gè)錯(cuò)誤提示的[UIViewController paramsError]; 如果不需要中間件進(jìn)行present展示壕吹,則返回一個(gè)[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ù)一個(gè)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中,而無需每個(gè)URL注冊時(shí)重復(fù)拷貝休溶。
- 無法通過URL返回一個(gè)controller實(shí)例;(TabController)
- *URL-Block —> URL-ViewController實(shí)例:將之前JLRoute的url-block方式改成了url-ViewController方式熏挎,即可滿足。
- class的load方法完成注冊磅轻,太多對啟動(dòng)時(shí)Main線程有影響;
- 服務(wù)發(fā)現(xiàn)的方式,只在load時(shí)注冊Connector實(shí)例:中間件只對每個(gè)業(yè)務(wù)組件的connector實(shí)例進(jìn)行注冊僚饭,相比URL注冊量大量減少load使用乌妒。
- 同一個(gè)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ù)錯(cuò)誤啸蜜、not found沒有提示蜂林;
- Debug階段的錯(cuò)誤Controller提示、包括參數(shù)錯(cuò)誤芝加、notFound敢辩、notSupportController:如果參數(shù)錯(cuò)誤、notfound無法生成一個(gè)viewController實(shí)例帐姻,中間件在debug階段會(huì)提示。如果URL不支持返回一個(gè)Controller纱皆,同樣會(huì)給與提示。
3.4 LDBusMediator-服務(wù)調(diào)用方案
為了更好的通過中間件支撐組件間的服務(wù)調(diào)用方案,我們在組件實(shí)現(xiàn)和中間件之間增加了一層協(xié)議接口層呻拌。 每個(gè)業(yè)務(wù)組件將自己對外提供的服務(wù)接口抽象到一個(gè)統(tǒng)一的業(yè)務(wù)組件協(xié)議集合中乌助。 業(yè)務(wù)組件的實(shí)現(xiàn)依賴自己的對外服務(wù)接口集并進(jìn)行接口的實(shí)現(xiàn)炕泳。
每個(gè)業(yè)務(wù)組件中的協(xié)議部分有兩種:一種是服務(wù)協(xié)議,其他組件可以通過Mediator拿到對外開放的服務(wù)實(shí)例調(diào)用服務(wù)接口;一種是Model協(xié)議,服務(wù)協(xié)議中的接口可以給其他組件一個(gè)協(xié)議化對象,其他組件也可以組裝一個(gè)協(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ú)通過一個(gè)Git地址進(jìn)行管理氛改,單獨(dú)配置podspec帐萎,單獨(dú)通過協(xié)議的版本倉庫進(jìn)行管理;所有的協(xié)議集合的git統(tǒng)一放到Git的一個(gè)組中進(jìn)行管理胜卤。
具體方案如下:
@protocol LDBusConnectorPrt <NSObject>
/**
* 業(yè)務(wù)模塊掛接中間件疆导,注冊自己提供的service,實(shí)現(xiàn)服務(wù)接口的調(diào)用葛躏;
*
* 通過protocol協(xié)議找到組件中對應(yīng)的服務(wù)實(shí)現(xiàn)澈段,生成一個(gè)服務(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中引用囤耳,返回一個(gè)服務(wù)實(shí)例即可篙顺;
* (3)如果不能處理,返回一個(gè)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不暴露任何頭文件宰僧;
- 每個(gè)業(yè)務(wù)組件提供黑盒服務(wù)
- 調(diào)用者不用關(guān)心具體實(shí)現(xiàn)細(xì)節(jié);
- 業(yè)務(wù)組件的實(shí)現(xiàn)升級观挎、或者更換(包括整個(gè)業(yè)務(wù)組件更換)不影響調(diào)用者的調(diào)用修改琴儿;
- 為業(yè)務(wù)組件Framework化、自動(dòng)化構(gòu)建奠定基礎(chǔ)