蘑菇街 App 的組件化之路
在組件化之前庶橱,蘑菇街 App 的代碼都是在一個工程里開發(fā)的躲因,在人比較少喧半,業(yè)務(wù)發(fā)展不是很快的時候吃谣,這樣是比較合適的伐蒂,能一定程度地保證開發(fā)效率烙博。
慢慢地代碼量多了起來百揭,開發(fā)人員也多了起來存和,業(yè)務(wù)發(fā)展也快了起來奕剃,這時單一工程開發(fā)模式就會顯露出一些弊端
- 耦合比較嚴重(因為沒有明確的約束,
組件
間引用的現(xiàn)象會比較多) - 容易出現(xiàn)沖突(尤其是使用 Xib捐腿,還有就是 Xcode Project纵朋,雖說有腳本可以改善)
- 業(yè)務(wù)方的開發(fā)效率不夠高(只關(guān)心自己的組件,卻要編譯整個項目茄袖,與其他不相干的代碼糅合在一起)
為了解決這些問題操软,就采取了組件化
策略。它能帶來這些好處
- 加快編譯速度(不用編譯主客那一大坨代碼了)
- 自由選擇開發(fā)姿勢(MVC / MVVM / FRP)
- 方便 QA 有針對性地測試
- 提高業(yè)務(wù)開發(fā)效率
先來看下宪祥,組件化之后的一個大概架構(gòu)
組件化
顧名思義就是把一個大的 App 拆成一個個小的組件聂薪,相互之間不直接引用。那如何做呢蝗羊?
實現(xiàn)方式
組件間通信
以 iOS 為例藏澳,由于之前就是采用的 URL 跳轉(zhuǎn)模式,理論上頁面之間的跳轉(zhuǎn)只需 open 一個 URL 即可耀找。所以對于一個組件來說翔悠,只要定義「支持哪些 URL」即可,比如詳情頁野芒,大概可以這么做的
[MGJRouter registerURLPattern:@"mgj://detail?id=:id" toHandler:^(NSDictionary *routerParameters) {
NSNumber *id = routerParameters[@"id"];
// create view controller with id
// push view controller
}];
首頁只需調(diào)用[MGJRouter openURL:@"mgj://detail?id=404"]
就可以打開相應(yīng)的詳情頁蓄愁。
那問題又來了,我怎么知道有哪些可用的 URL狞悲?為此撮抓,我們做了一個后臺專門來管理。
然后可以把這些短鏈生成不同平臺所需的文件效诅,iOS 平臺生成 .{h,m} 文件胀滚,Android 平臺生成 .java 文件趟济,并注入到項目中。這樣開發(fā)人員只需在項目中打開該文件就知道所有的可用 URL 了咽笼。
目前還有一塊沒有做顷编,就是參數(shù)這塊,雖然描述了短鏈剑刑,但真想要生成完整的 URL媳纬,還需要知道如何傳參數(shù),這個正在開發(fā)中施掏。
還有一種情況會稍微麻煩點钮惠,就是「組件A」要調(diào)用「組件B」的某個方法,比如在商品詳情頁要展示購物車的商品數(shù)量七芭,就涉及到向購物車組件拿數(shù)據(jù)素挽。
類似這種同步調(diào)用,iOS 之前采用了比較簡單的方案狸驳,還是依托于 MGJRouter预明,不過添加了新的方法- (id)objectForURL:
,注冊時也使用新的方法進行注冊耙箍。
[MGJRouter registerURLPattern:@"mgj://cart/ordercount" toObjectHandler:^id(NSDictionary *routerParamters){
// do some calculation
return @42;
}];
使用時 NSNumber *orderCount = [MGJRouter objectForURL:@"mgj://cart/ordercount"]
這樣就拿到了購物車里的商品數(shù)撰糠。
稍微復(fù)雜但更具通用性的方法是使用「協(xié)議」 <-> 「類」綁定的方式,還是以購物車為例辩昆,購物車組件可以提供這么個 Protocol阅酪。
@protocol MGJCart <NSObject>
+ (NSInteger)orderCount;
@end
可以看到通過協(xié)議可以直接指定返回的數(shù)據(jù)類型。然后在購物車組件內(nèi)再新建個類實現(xiàn)這個協(xié)議汁针,假設(shè)這個類名為MGJCartImpl术辐,接著就可以把它與協(xié)議關(guān)聯(lián)起來 [ModuleManager registerClass:MGJCartImpl forProtocol:@protocol(MGJCart)]
,對于使用方來說扇丛,要拿到這個 MGJCartImpl术吗,需要調(diào)用 [ModuleManager classForProtocol:@protocol(MGJCart)]
。拿到之后再調(diào)用 + (NSInteger)orderCount 就可以了帆精。
那么较屿,這個協(xié)議放在哪里比較合適呢?如果跟組件放在一起卓练,使用時還是要先引入組件隘蝎,如果有多個這樣的組件就會比較麻煩了。所以我們把這些公共的協(xié)議統(tǒng)一放到了 PublicProtocolDomain.h 下襟企,到時只依賴這一個文件就可以了嘱么。
Android 也是采用類似的方式。
組件生命周期管理
理想中的組件可以很方便地集成到主客中顽悼,并且有跟 AppDelegate 一致的回調(diào)方法曼振。這也是 ModuleManager 做的事情几迄。
先來看看現(xiàn)在的入口方法
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
[MGJApp startApp];
[[ModuleManager sharedInstance] loadModuleFromPlist:[[NSBundle mainBundle] pathForResource:@"modules" ofType:@"plist"]];
NSArray *modules = [[ModuleManager sharedInstance] allModules];
for (id<ModuleProtocol> module in modules) {
if ([module respondsToSelector:_cmd]) {
[module application:application didFinishLaunchingWithOptions:launchOptions];
}
}
[self trackLaunchTime];
return YES;
}
其中 [MGJApp startApp] 主要負責一些 SDK 的初始化。[self trackLaunchTime] 是我們打的一個點冰评,用來監(jiān)測從 main 方法開始到入口方法調(diào)用結(jié)束花了多長時間映胁。其他的都由 ModuleManager 搞定,loadModuleFromPlist:pathForResource: 方法會讀取 bundle 里的一個 plist 文件甲雅,這個文件的內(nèi)容大概是這樣的
每個 Module 都實現(xiàn)了 ModuleProtocol解孙,其中有一個 - (BOOL)applicaiton:didFinishLaunchingWithOptions: 方法,如果實現(xiàn)了的話抛人,就會被調(diào)用弛姜。
還有一個問題就是,系統(tǒng)的一些事件會有通知妖枚,比如 applicationDidBecomeActive 會有對應(yīng)的 UIApplicationDidBecomeActiveNotification廷臼,組件如果要做響應(yīng)的話,只需監(jiān)聽這個系統(tǒng)通知即可盅惜。但也有一些事件是沒有通知的中剩,比如 - application:didRegisterUserNotificationSettings:,這時組件如果也要做點事情抒寂,怎么辦?
一個簡單的解決方法是在 AppDelegate 的各個方法里掠剑,手動調(diào)一遍組件的對應(yīng)的方法屈芜,如果有就執(zhí)行。
- (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken
{
NSArray *modules = [[ModuleManager sharedInstance] allModules];
for (id<ModuleProtocol> module in modules) {
if ([module respondsToSelector:_cmd]) {
[module application:application didRegisterForRemoteNotificationsWithDeviceToken:deviceToken];
}
}
}
殼工程
既然已經(jīng)拆出去了朴译,那拆出去的組件總得有個載體井佑,這個載體就是殼工程,殼工程主要包含一些基礎(chǔ)組件和業(yè)務(wù)SDK眠寿,這也是主工程包含的一些內(nèi)容躬翁,所以如果在殼工程可以正常運行的話,到了主工程也沒什么問題盯拱。不過這里存在版本同步問題盒发,之后會說到。
遇到的問題
組件拆分
由于之前的代碼都是在一個工程下的狡逢,所以要單獨拿出來作為一個組件就會遇到不少問題宁舰。首先是組件的劃分,當時在定義組件粒度時也花了些時間討論奢浑,究竟是粒度粗點好蛮艰,還是細點好。粗點的話比較有利于拆分雀彼,細點的話靈活度比較高壤蚜。最終還是選擇粗一點的粒度即寡,先拆出來再說。
假如要把詳情頁遷出來袜刷,就會發(fā)現(xiàn)它依賴了一些其他部分的代碼嘿悬,那最快的方式就是直接把代碼拷過來,改個名使用水泉。比較簡單暴力善涨。說起來比較簡單,做的時候也是挺有挑戰(zhàn)的草则,因為正常的業(yè)務(wù)并不會因為「組件化」而停止钢拧,所以開發(fā)同學(xué)們需要同時兼顧正常的業(yè)務(wù)和組件的拆分。
版本管理
我們的組件包括第三方庫都是通過 Cocoapods 來管理的炕横,其中組件使用了私有庫源内。之所以選擇 Cocoapods,一個是因為它比較方便份殿,還有就是用戶基數(shù)比較大膜钓,且社區(qū)也比較活躍(活躍到了會時不時地觸發(fā) Github 的 rate limit,導(dǎo)致長時間 clone 不下來··· 見此)卿嘲,當然也有其他的管理方式颂斜,比如 submodule / subtree,在開發(fā)人員比較多的情況下拾枣,方便沃疮、靈活的方案容易占上風(fēng),雖然它也有自己的問題梅肤。主要有版本同步和更新/編譯慢的問題司蔬。
假如基礎(chǔ)組件做了個 API 接口升級,這個升級會對原有的接口做改動姨蝴,自然就會升一個中位的版本號俊啼,比如原先是 1.6.19,那么現(xiàn)在就變成 1.7.0 了左医。而我們在 Podfile 里都是用 ~ 指定的授帕,這樣就會出現(xiàn)主工程的 pod 版本升上去了,但是殼工程沒有同步到炒辉,然后群里就會各種反饋編譯不過豪墅,而且這個編譯不過的長尾有時能拖上兩三天。
然后我們就想了個辦法黔寇,如果不在殼工程里指定基礎(chǔ)庫的版本偶器,只在主工程里指定呢,理論上應(yīng)該可行,只要不出現(xiàn)某個基礎(chǔ)庫要同時維護多個版本的情況屏轰。但實踐中發(fā)現(xiàn)颊郎,殼工程有時會莫名其妙地升不上去,在 podfile 里指定最新的版本又可以升上去霎苗,所以此路不通姆吭。
還有一個問題是 pod update 時間過長,經(jīng)常會在 Analyzing Dependency 上卡 10 多分鐘唁盏,非常影響效率内狸。后來排查下來是跟組件的 Podspec 有關(guān),配置了 subspec厘擂,且依賴比較多昆淡。
然后就是 pod update 之后的編譯,由于是源碼編譯刽严,所以這塊的時間花費也不少昂灵,接下去會考慮 framework 的方式。
持續(xù)集成
在剛開始舞萄,持續(xù)集成還不是很完善眨补,業(yè)務(wù)方升級組件,直接把 podspec 扔到 private repo 里就完事了倒脓。這樣最簡單撑螺,但也經(jīng)常會帶來編譯通不過的問題。而且這種隨意的版本升級也不太能保證質(zhì)量把还。于是我們就搭建了一套持續(xù)集成系統(tǒng)实蓬,大概如此
每個組件升級之前都需要先通過編譯,然后再決定是否升級吊履。這套體系看起來不復(fù)雜,但在實施過程中經(jīng)常會遇到后端的并發(fā)問題调鬓,導(dǎo)致業(yè)務(wù)方要么集成失敗艇炎,要么要等不少時間。而且也沒有一個地方可以呈現(xiàn)當前版本的組件版本信息腾窝。還有就是業(yè)務(wù)方對于這種命令行的升級方式接受度也不是很高缀踪。
基于此,在經(jīng)過了幾輪討論之后虹脯,有了新版的持續(xù)集成平臺驴娃,升級操作通過網(wǎng)頁端來完成。
大致思路是循集,業(yè)務(wù)方如果要升級組件唇敞,假設(shè)現(xiàn)在的版本是 0.1.7,添加了一些 feature 之后,殼工程測試通過疆柔,想集成到主工程里看看效果咒精,或者其他組件也想引用這個最新的,就可以在后臺手動把版本升到 0.1.8-rc.1旷档,這樣的話模叙,原先依賴 ~> 0.1.7 的組件,不會升到 0.1.8鞋屈,同時想要測試這個組件的話范咨,只要手動把版本調(diào)到 0.1.8-rc.1 就可以了。這個過程不會觸發(fā) CI 的編譯檢查厂庇。
當測試通過后渠啊,就可以把尾部的 -rc.n 去掉,然后點擊「集成」宋列,就會走 CI 編譯檢查昭抒,通過的話,會在主工程的 podfile 里寫上固定的版本號 0.1.8炼杖。也就是說灭返,podfile 里所有的組件版本號都是固定的。
周邊設(shè)施
基礎(chǔ)組件及組件的文檔 / Demo / 單元測試
無線基礎(chǔ)的職能是為集團提供解決方案坤邪,只是在蘑菇街 App 里能 work 是遠遠不夠的熙含,所以就需要提供入口,知道有哪些可用組件艇纺,并且如何使用怎静,就像這樣(目前還未實現(xiàn))
這就要求組件的負責人需要及時地更新 README / CHANGELOG / API,并且當發(fā)生 API 變更時黔衡,能夠快速通知到使用方蚓聘。
公共 UI 組件
組件化之后還有一個問題就是資源的重復(fù)性,以前在一個工程里的時候盟劫,資源都可以很方便地拿到夜牡,現(xiàn)在獨立出去了,也不知道哪些是公用的侣签,哪些是獨有的塘装,索性都放到自己的組件里,這樣就會導(dǎo)致包變大影所。還有一個問題是每個組件可能是不同的產(chǎn)品經(jīng)理在跟蹦肴,而他們很可能只關(guān)注于自己關(guān)心的頁面長什么樣,而忽略了整體的樣式猴娩。公共 UI 組件就是用來解決這些問題的阴幌,這些組件甚至可以跨 App 使用勺阐。(目前還未實現(xiàn))
小結(jié)
「組件化」是 App 膨脹到一定體積后的解決方案,能一定程度上解決問題裂七,在提高開發(fā)效率的過程中皆看,采坑是難免的,希望這篇文章能夠帶來些幫助背零。