火掌柜 iOS 客戶端經(jīng)過近兩年的組件化推進(jìn)涩盾,組件數(shù)量已經(jīng)頗具規(guī)模防泵,達(dá)到了近 100 個(gè)脱盲。隨著組件數(shù)量和代碼量越來越多,主工程的打包時(shí)間從最初的十幾分鐘每界,增加到了現(xiàn)在的四十分鐘左右捅僵。依賴組件較多,改動(dòng)相對(duì)頻繁的上層業(yè)務(wù)組件眨层,其發(fā)布時(shí)間也較為漫長(zhǎng)命咐。編譯時(shí)長(zhǎng)的困擾,已經(jīng)明顯影響了日常開發(fā)體驗(yàn)谐岁,同時(shí)也造成 CI pipeline 執(zhí)行時(shí)間過長(zhǎng)醋奠,在 runner 資源匱乏的情況下榛臼,不利于內(nèi)部 CI 的推廣。當(dāng)前時(shí)間節(jié)點(diǎn)下窜司,如何減少編譯時(shí)長(zhǎng)沛善,已經(jīng)成為開發(fā)團(tuán)隊(duì)較為迫切的需求。
前言
組件化除了讓模塊復(fù)用更加便捷塞祈,業(yè)務(wù)開發(fā)更加輕量金刁,還有一個(gè)不可忽視的優(yōu)勢(shì)———組件二進(jìn)制化,即可通過將非開發(fā)中的組件預(yù)先編譯打包成靜態(tài) / 動(dòng)態(tài)庫(kù)并存放至某處议薪,待集成此組件時(shí)尤蛮,直接使用二進(jìn)制包,從而提升集成此組件的 App 或者上層組件的編譯速度斯议。
對(duì)比源碼依賴产捞,二進(jìn)制依賴的組件只需要進(jìn)行鏈接而無需編譯,可以極大地提升集成效率哼御。掌柜主工程在大部分組件都二進(jìn)制化的情況下坯临,打包時(shí)長(zhǎng)從四十分鐘左右,下降到最快十二分鐘恋昼,整整減少了三倍多看靠, CI pipeline 涉及到編譯環(huán)節(jié)的 lint、打包液肌、發(fā)布挟炬,其耗時(shí)也成數(shù)倍減少,二進(jìn)制化所帶來的好處不言而喻嗦哆。
在實(shí)踐二進(jìn)制化過程中谤祖,由于沒有找到較為成熟的依賴切換工具,我們編寫了 cocoapods-bin 通用插件吝秕,有需要的開發(fā)者可以嘗試下。
需要說明的是有些二進(jìn)制方案是在首次編譯后空幻,保留組件生成的二進(jìn)制包烁峭,后續(xù)編譯直接使用此二進(jìn)制包。在大多數(shù)情況下秕铛,比如 App 打包约郁,組件 lint 與發(fā)布,這類只進(jìn)行一次編譯的操作但两,首次編譯才是主要關(guān)注點(diǎn)鬓梅。本文所說的二進(jìn)制化和此類方案的最大區(qū)別,就是將組件二進(jìn)制包制作放到首次編譯前谨湘,更多的是在組件發(fā)布時(shí)绽快,同時(shí)生成二進(jìn)制包芥丧。
另外,鑒于 CocoaPods 在 1.3.0 后的版本坊罢,增加了類似增量編譯的功能续担,在首次 install / update 編譯之后,后續(xù)再進(jìn)行 install / update 操作活孩,會(huì)根據(jù)更改結(jié)果進(jìn)行增量編譯物遇,個(gè)人感覺針對(duì) “非首次 install / update 后的編譯“ 優(yōu)化,并不是必須的憾儒,因?yàn)?CocoaPods 已經(jīng)幫我們做好了询兴。
二進(jìn)制化需求
以下是根據(jù)掌柜團(tuán)隊(duì)日常開發(fā)情況,提出的二進(jìn)制化需求點(diǎn):
- 不影響未接入二進(jìn)制化方案的業(yè)務(wù)團(tuán)隊(duì)
- 組件級(jí)別的源碼 / 二進(jìn)制依賴切換功能
- 無二進(jìn)制版本時(shí)起趾,自動(dòng)采用源碼版本
- 接近原生 CocoaPods 的使用體驗(yàn) (為了滿足此需求诗舰,我們決定開發(fā)自定義的 CocoaPods 插件。)
- 不增加過多額外的工作量
下面我會(huì)參照這幾個(gè)需求點(diǎn)阳掐,逐步說明掌柜 iOS 團(tuán)隊(duì)的二進(jìn)制化過程始衅。
宏定義處理
預(yù)編譯階段處理的宏定義,在組件進(jìn)行二進(jìn)制化后會(huì)失效缭保,特別是某些依賴 DEBUG 宏的調(diào)試工具汛闸,在二進(jìn)制化之后就不可見了。為了方便處理艺骂,我把使用宏的地方分為兩種:
- 方法內(nèi)部
- 方法外部
針對(duì)方法內(nèi)部诸老,我們創(chuàng)建了 TDFMacro 類來替換宏,將邏輯挪到運(yùn)行時(shí)處理:
// TDFMacro.h
@interface TDFMacro : NSObject
+ (BOOL)enterprise;
+ (BOOL)debug;
+ (void)debugExecute:(void(^)(void))debugExecute elseExecute:(void(^)(void))elseExecute;
+ (void)enterpriseExecute:(void(^)(void))enterpriseExecute elseExecute:(void(^)(void))elseExecute;
@end
// TDFMacro.m
@implementation TDFMacro
+ (BOOL)enterprise {
#if ENTERPRISE
return YES;
#else
return NO;
#endif
}
+ (BOOL)debug {
#if DEBUG
return YES;
#else
return NO;
#endif
}
+ (void)debugExecute:(void (^)(void))debugExecute elseExecute:(void (^)(void))elseExecute {
if ([self debug]) {
!debugExecute ?: debugExecute();
} else {
!elseExecute ?: elseExecute();
}
}
+ (void)enterpriseExecute:(void (^)(void))enterpriseExecute elseExecute:(void (^)(void))elseExecute {
if ([self enterprise]) {
!enterpriseExecute ?: enterpriseExecute();
} else {
!elseExecute ?: elseExecute();
}
}
@end
這樣一來钳恕,只需要確保 TDFMacro 組件中的宏有效就可以了————不對(duì)其進(jìn)行二進(jìn)制化别伏。
針對(duì)方法外部,我們盡量將能改寫到方法內(nèi)部的代碼改寫后按第一種情況處理忧额,不能處理的對(duì)代碼進(jìn)行重構(gòu)以消除宏定義厘肮,比如我們網(wǎng)絡(luò)層的常量,重寫前后:
// 前
#if DEBUG
NSString * kTDFRootAPI = @"xxx";
#else
NSString * const kTDFRootAPI = @"xxx";
#end
// 后
NSString * kTDFRootAPI = @"xxx";
個(gè)人建議盡量不要跨模塊使用宏定義睦番,特別是可以用常量或函數(shù)代替的宏类茂。比如有組件 A、B 托嚣,B 依賴 A巩检,它們包含如下代碼:
// A
#define TDF_THEME_BACKGROUNDCOLOR [[UIColor whiteColor] colorWithAlphaComponent:0.7]
// B
// .m 使用了 TDF_THEME_BACKGROUNDCOLOR
假設(shè) A 和 B 都已二進(jìn)制化,假設(shè)后續(xù)我們修改了 A :
// A
#define TDF_THEME_BACKGROUNDCOLOR [[UIColor whiteColor] colorWithAlphaComponent:0.4]
由于 B 中的 TDF_THEME_BACKGROUNDCOLOR 宏已經(jīng)在二進(jìn)制化打包預(yù)編譯時(shí)被替換為 [[UIColor whiteColor] colorWithAlphaComponent:0.7]
示启,所以 B 并不會(huì)感知到此次 A 的變更兢哭,這時(shí)我們就不得不重新打包組件 B 以同步 A 的變更,即使 B 并未做任何更改夫嗓,當(dāng)存在較多使用 TDF_THEME_BACKGROUNDCOLOR 宏的組件時(shí)迟螺,就容易遺漏同步某些組件冲秽。
制作二進(jìn)制包
二進(jìn)制化第一步,先要把組件的二進(jìn)制包打出來煮仇。這里說下比較通用的打包工具 cocoapods-packager 和 Carthage 劳跃,目前我們使用 cocoapods-packager 將組件構(gòu)建 static-framework 。
cocoapods-packager 的工作原理和 pod spec/lib lint
差不多浙垫,都是通過 podspec 動(dòng)態(tài)生成 Podfile 刨仑,然后 install 出目標(biāo)工程,最后通過 xcodebuild
命令構(gòu)建出二進(jìn)制包夹姥。這種方式有一個(gè)好處杉武,只要保證組件 lint 通過了,就可以打出二進(jìn)制包辙售,不需要和 Example 工程掛鉤轻抱,很方便。但是這個(gè)插件作者幾乎不維護(hù)了旦部,很多較久之前的 issue 和 pull request 都是未處理狀態(tài)祈搜。
以下是我們用來構(gòu)建 static-framework 的命令:
pod package TDFNavigationBarKit.podspec --exclude-deps --force --no-mangle --spec-sources=http://git.xxxxx.net/ios/cocoapods-spec.git
在使用過程中,我遇到了兩個(gè)關(guān)于組件資源的問題 :
- 使用了
--exclude-deps
option 后士八,雖然沒有把 dependency 的符號(hào)信息打進(jìn)可執(zhí)行文件容燕,但是它把 dependency 的 bundle 給拷貝過來了 (見builder.rb 229 copy_resources
方法) - subspec 聲明的 resource 不會(huì)被拷貝進(jìn) framework 中
鑒于 cocoapods-packager 近期沒有發(fā)布新版本的計(jì)劃,我只能 fork 并更新代碼之后婚度,重新發(fā)布 cocoapods-packager-pro 來修復(fù)這兩個(gè)問題蘸秘。使用 cocoapods-packager-pro 之后,構(gòu)建 static-framework 的命令變?yōu)椋?/p>
pod package-pro TDFNavigationBarKit.podspec --exclude-deps --force --no-mangle --spec-sources=http://git.xxxxx.net/ios/cocoapods-spec.git
二級(jí)命令 package 改成 package-pro 即可蝗茁。
cocoapods-packager 創(chuàng)建二進(jìn)制包中的 modulemap 時(shí)醋虏,會(huì)先查看目標(biāo)組件的 podspec 是否設(shè)置了 module_map 字段,如有直接拷貝哮翘,否則會(huì)查看是否有和組件同名的頭文件颈嚼,如有則創(chuàng)建 modulemap ,并設(shè)置 umbrella header
為此文件饭寺,如無則不創(chuàng)建 modulemap 阻课。所以 cocoapods-packager 給沒有和組件同名的頭文件,又沒有指定 module_map 的組件打二進(jìn)制包時(shí)佩研,是不會(huì)創(chuàng)建 modulemap 的柑肴,比如 SDWebImage 霞揉,這時(shí)候需要我們自行添加 modulemap旬薯,否則使用 swift 的 import
就會(huì)找不到對(duì)應(yīng)的 module,這點(diǎn)需要注意下适秩。
CocoaPods 目前發(fā)布了 1.6.0 beta 版本绊序,試用之后硕舆,發(fā)現(xiàn)由于某些類的構(gòu)造函數(shù)參數(shù)發(fā)生了變更, 導(dǎo)致 cocoapods-packager 現(xiàn)有代碼已經(jīng)無法正常工作了骤公,所以 cocoapods-packager 只適用低于 1.6.0 版本的 CocoaPods抚官,后期如果官方 cocoapods-packager 還是沒有更新的話,我們應(yīng)該會(huì)在 cocoapods-packager-pro 中適配新版本 CocoaPods阶捆。
cocoapods-packager 作者最近還創(chuàng)建了插件 cocoapods-generate 凌节,此插件可以直接根據(jù) podspec 生成目標(biāo)工程,相當(dāng)于 cocoapods-packager 前半部分功能的增強(qiáng)版洒试。目前這個(gè)插件支持 CocoaPods 1.6.0 beta 版本倍奢,不想用 cocoapods-packager 的開發(fā)者,可以先利用 cocoapods-generate 創(chuàng)建目標(biāo)工程垒棋,然后接管構(gòu)建二進(jìn)制包的后續(xù)操作卒煞,可以選擇自己實(shí)現(xiàn)打包腳本,也可以選擇使用 Carthage叼架。
關(guān)于 Carthage 如何打 static-framework 畔裕,可以參照 Build static frameworks to speed up your app’s launch times 。其中有一步是將需要打包的 scheme 設(shè)置為 shared 乖订,這個(gè) scheme 對(duì)應(yīng) CocoaPods 組件的 develpement pod 扮饶,一般來說通過 CocoaPods 模版工程或者 cocoapods-generate 插件生成目標(biāo)工程的 scheme 都是 shared 的,如果沒有 shared 垢粮,可參照讓 CocoaPods 組件支持 Carthage 打包一文進(jìn)行設(shè)置贴届。
構(gòu)建出 .framework
文件后,需要對(duì)其進(jìn)行壓縮蜡吧,我們使用以下命令將文件壓縮成 zip 格式:
zip --symlinks -r TDFNavigationBarKit.framework.zip TDFNavigationBarKit.framework
通過上述兩個(gè)步驟毫蚓,我們就得到了組件的二進(jìn)制 zip 包。
需要注意的是昔善,如果使用 cocoapods-packager 打包元潘,其 .framework
中的目錄結(jié)構(gòu)如下 :
TDFNavigationBarKit.framework/
├── Headers -> Versions/Current/Headers
├── Modules
│ └── module.modulemap
├── Resources -> Versions/Current/Resources
├── TDFNavigationBarKit -> Versions/Current/TDFNavigationBarKit
└── Versions
├── A
│ ├── Headers
│ │ ├── TDFNavigationBarKit.h
│ │ ├── UIViewController+BackgroundConfigure.h
│ │ └── UIViewController+NavigationBarConfigure.h
│ ├── Resources
│ │ └── Media.xcassets
│ │ ├── Contents.json
│ │ ├── common_nbc_back.imageset
│ │ │ ├── Contents.json
│ │ │ └── common_nbc_back.png
│ │ ├── common_nbc_cancel.imageset
│ │ │ ├── Contents.json
│ │ │ └── common_nbc_cancel.png
│ │ ├── common_nbc_ok.imageset
│ │ │ ├── Contents.json
│ │ │ └── common_nbc_ok.png
│ └── TDFNavigationBarKit
└── Current -> A
可以看到,其中的 Headers
君仆、Resources
翩概、Versions/Current
都是軟鏈接。podspec 中涉及到文件匹配的字段返咱,如 source_files
钥庇、public_header_files
、resources
等咖摹,對(duì)軟鏈接是無效的评姨,所以需要設(shè)置為文件實(shí)際存放的路徑:
s.source_files = TDFNavigationBarKit.framework/Versions/A/Headers/*.h
s.public_header_files = TDFNavigationBarKit.framework/Versions/A/Headers/*.h
# 或者更全面一點(diǎn)
s.source_files = TDFNavigationBarKit.framework/Versions/A/Headers/*.h, TDFNavigationBarKit.framework/Headers/*.h
s.public_header_files = TDFNavigationBarKit.framework/Versions/A/Headers/*.h, TDFNavigationBarKit.framework/Headers/*.h
針對(duì)二進(jìn)制包的制作,我們創(chuàng)建了以下命令供團(tuán)隊(duì)內(nèi)部使用:
# 將源碼打包成二進(jìn)制萤晴,并壓縮成 zip 包
pod binary package
存儲(chǔ)二進(jìn)制包
通常二進(jìn)制包存放的地址有兩種吐句,目前我們使用的是第二種 ( 服務(wù)器代碼可參照 binary-server ):
- 組件所在 git 倉(cāng)庫(kù)
- 靜態(tài)文件服務(wù)器
相較于 git 倉(cāng)庫(kù)胁后,我認(rèn)為存放至靜態(tài)文件服務(wù)器的優(yōu)勢(shì)如下:
- 接口訪問,易于擴(kuò)展與自動(dòng)化處理
- 源碼和二進(jìn)制分離嗦枢,依賴二進(jìn)制時(shí)攀芯,只下載二進(jìn)制包比 clone 倉(cāng)庫(kù)快
- 不會(huì)增大 git 倉(cāng)庫(kù)大小,這點(diǎn)也涉及到源碼依賴的下載速度
這里說下為什么我們對(duì)組件的下載速度這么敏感文虏。
首先侣诺,CocoaPods 針對(duì)下載的組件是有緩存的,在第一次下載后氧秘,CocoaPods 會(huì)將組件存放在 Caches 文件夾中紧武,后續(xù) install 操作會(huì)先從 Caches 中查找是否有此組件的緩存,如果沒有的話敏储,再執(zhí)行下載流程(是不是感覺和 SDWebImage 有點(diǎn)像)阻星。但是目前 CocoaPods 在同一臺(tái)機(jī)器上,只能有一個(gè)版本的緩存 ( ~/Library/Caches/CocoaPods/Pods 下的 VERSION 記錄著當(dāng)前緩存對(duì)應(yīng)的 CocoaPods 版本 )已添,也就是說我第一次使用 pod _1.5.3_ install
下載了所有組件妥箕,再執(zhí)行 pod _1.4.1_ install
, CocoaPods 會(huì)把 1.5.3 版本的所有組件緩存清空更舞,然后重新下載 畦幢。
由于團(tuán)隊(duì)內(nèi)部只有 5 臺(tái) Mac mini 機(jī)器,我們只能在機(jī)器上同時(shí)部署 GitLab CI Runner 和 Jenkins Slaver 缆蝉,CI 腳本中使用的 CocoaPods 版本可以統(tǒng)一控制成 1.4.0 ( 這里不使用最新的 1.5.3 是由于這個(gè) bug 會(huì)導(dǎo)致 lint 失敗)宇葱,但是其他業(yè)務(wù)線打包時(shí)使用的 CocoaPods 版本就沒法統(tǒng)一了,有 1.5.3 的刊头,有 1.6.0.beta 的黍瞧,加上各業(yè)務(wù)線的打包頻率還比較高,導(dǎo)致機(jī)器頻繁地在不同 CocoaPods 版本中切換 原杂。
結(jié)合上訴兩個(gè)原因印颤,我們趨向采用下載速度更快的方案。
針對(duì)二進(jìn)制包的增刪查穿肄,我們創(chuàng)建了以下命令供團(tuán)隊(duì)內(nèi)部使用:
# 查看所有二進(jìn)制版本信息
pod binary list
# 查找組件二進(jìn)制版本信息
pod binary search NAME
# 下載二進(jìn)制 zip 包
pod binary pull NAME VERSION
# 推送二進(jìn)制 zip 包
pod binary push [PATH] [-name=組件名] [--version=版本號(hào)] [--commit=版本日志]
切換依賴方式
二進(jìn)制化后年局,整體構(gòu)建速度變快了,但是不利于開發(fā)人員跟蹤調(diào)試咸产,所以就需要有依賴切換功能矢否。這里所說的依賴切換功能包括整個(gè)工程、單個(gè)組件的切換脑溢,以及二進(jìn)制版本的使用封裝僵朗,這也是組件二進(jìn)制化耗費(fèi)時(shí)間和精力最多的地方。
在整個(gè)過程中,我總共嘗試了三種方案衣迷,分別是單私有源單版本、單私有源雙版本以及最終采用的雙私有源單版本酱酬。下面我會(huì)簡(jiǎn)單地說下各方案以及實(shí)踐中遇到的問題壶谒。
單私有源單版本
在不更改私有源和組件版本的前提下,通過動(dòng)態(tài)變更源碼 podspec膳沽,達(dá)到切換依賴的目的
單私有源單版本是我第一次實(shí)踐采用的方案汗菜,也創(chuàng)建了對(duì)應(yīng)的插件 cocoapods-tdfire-binary ,這里結(jié)合插件的實(shí)現(xiàn)過程挑社,聊聊實(shí)現(xiàn)這類方案時(shí)遇到的坑陨界。
前期調(diào)研二進(jìn)制化方案時(shí),我主要參考了 iOS CocoaPods組件平滑二進(jìn)制化解決方案 一文痛阻,所以整體思路和這篇文章差不多菌瘪,也是通過環(huán)境變量加判斷語(yǔ)句實(shí)現(xiàn) podspec 的內(nèi)容變更(雖說 podspec 支持使用 ruby 語(yǔ)法定制,我還是建議最終以 json 格式發(fā)布到私有源上阱当,因?yàn)?CocoaPods 內(nèi)部會(huì)將 podspec json 化后再執(zhí)行一些操作俏扩,比如緩存,如果這一動(dòng)作不冪等弊添,操作結(jié)果便是不可預(yù)知的录淡,從而破壞 CocoaPods 自身的運(yùn)行機(jī)制)。
這種方案最大的困擾在于切換依賴時(shí)油坝,如何規(guī)避組件緩存帶來的負(fù)面影響嫉戚,處理不當(dāng)容易出現(xiàn)工程組件目錄為空的情況,以下是我實(shí)踐過的兩種方案:
確保緩存中同時(shí)存在源碼和二進(jìn)制的資源及文件(設(shè)置 preserve_paths)
切換依賴前澈圈,刪除目標(biāo)組件緩存以及本地 Pods 下的組件目錄
在使用二進(jìn)制服務(wù)器的前提下彬檀,方案一的常見實(shí)現(xiàn)方式為,在 pre_command 中設(shè)置下載二進(jìn)制包腳本瞬女,并設(shè)置 preserve_paths 凤覆,讓 CocoaPods 同時(shí)保留兩種依賴方式所需要的文件即可〔鹞海考慮到組件本身有二進(jìn)制版本盯桦,組件 Cache 還沒有下載的情況,這種方案通常輔以方案二渤刃。由于需要同時(shí)下載兩種依賴的資源拥峦,個(gè)人并不是很喜歡這種方案,這也是我們棄用 cocoapods-tdfire-binary 的主要原因卖子。
方案二需要 hook Pod::Installer 類的 resolve_dependencies 方法略号,在這個(gè)方法中清除緩存及本地資源,并且設(shè)置組件的沙盒變動(dòng)標(biāo)記,這樣 CocoaPods 就會(huì)重新下載對(duì)應(yīng)的組件了:
def cache_descriptors
@cache_descriptors ||= begin
cache = Downloader::Cache.new(Config.instance.cache_root + 'Pods')
cache_descriptors = cache.cache_descriptors_per_pod
end
end
def clean_local_cache(spec)
pod_dir = Config.instance.sandbox.pod_dir(spec.root.name)
framework_file = pod_dir + "#{spec.root.name}.framework"
if pod_dir.exist? && !framework_file.exist?
# 設(shè)置沙盒變動(dòng)標(biāo)記玄柠,去 cache 中拿
# 只有 :changed 突梦、:added 兩種狀態(tài)才會(huì)重新去 cache 中拿
@analysis_result.sandbox_state.add_name(spec.name, :changed)
begin
FileUtils.rm_rf(pod_dir)
rescue => err
puts err
end
end
end
def clean_pod_cache(spec)
descriptors = cache_descriptors[spec.root.name]
return if descriptors.nil?
descriptors = descriptors.select { |d| d[:version] == spec.version}
descriptors.each do |d|
# pod cache 文件名由文件內(nèi)容的 sha1 組成,由于生成時(shí)使用的是 podspec羽利,獲取時(shí)使用的是 podspec.json 導(dǎo)致生成的目錄名不一致
# Downloader::Request slug
# cache_descriptors_per_pod 表明宫患,specs_dir 中都是以 .json 形式保存 spec
slug = d[:slug].dirname + "#{spec.version}-#{spec.checksum[0, 5]}"
framework_file = slug + "#{spec.root.name}.framework"
unless (framework_file.exist?)
begin
FileUtils.rm(d[:spec_file])
FileUtils.rm_rf(slug)
rescue => err
puts err
end
end
end
end
需要注意的是,CocoaPods 在 podspec 不是 json 格式時(shí)这弧,緩存目錄是有問題的娃闲,所以需要我們自己去拼裝緩存路徑后再執(zhí)行刪除動(dòng)作。
使用 cocoapods-tdfire-binary 時(shí)匾浪,我們需要在 podspec 文件中添加以下代碼:
....
tdfire_source_configurator = lambda do |s|
# 源碼依賴配置
s.source_files = '${POD_NAME}/Classes/**/*'
s.public_header_files = '${POD_NAME}/Classes/**/*.{h}'
end
unless %w[tdfire_set_binary_download_configurations tdfire_source tdfire_binary].reduce(true) { |r, m| s.respond_to?(m) & r }
tdfire_source_configurator.call s
else
# 內(nèi)部生成源碼依賴配置
s.tdfire_source tdfire_source_configurator
# 內(nèi)部生成二進(jìn)制依賴配置
s.tdfire_binary tdfire_source_configurator
# 設(shè)置下載腳本皇帮,preseve_paths
s.tdfire_set_binary_download_configurations
end
然后在 Podfile 使用以下語(yǔ)句切換依賴:
...
plugin 'cocoapods-tdfire-binary'
tdfire_use_binary!
# tdfire_third_party_use_binary!
tdfire_use_source_pods ['AFNetworking']
...
由于編寫此插件時(shí),我對(duì) CocoaPods 源碼以及 ruby 并不熟悉蛋辈,導(dǎo)致我沒有把 podspec 的配置放到插件內(nèi)部属拾,現(xiàn)在回過頭看,更加合理的做法應(yīng)該是在 podspec 中設(shè)置依賴標(biāo)志冷溶,然后在 hook 的 resolve_dependencies 方法中捌年,變更 podspec 的 source 及依賴相關(guān)的字段,這樣的話挂洛,只需要采用上訴的方案二即可礼预。
可以看到,單私有源單版本對(duì) CocoaPods 緩存策略的侵入還是比較大的虏劲。
這里順便說下 cocoapods-tdfire-binary 是如何處理 subspec 的托酸,首先要說明的是,對(duì)于存在 subspec 的組件柒巫,我們將其整體打?yàn)橐粋€(gè)二進(jìn)制包励堡,并沒有分 subspec 構(gòu)建。假設(shè)有組件 A 堡掏,B应结,他們對(duì)應(yīng)的部分 podspec 如下:
# A
Pod::Spec.new do |s|
s.name = 'A'
...
s.subspec 'Core' do |ss|
ss.source_files = 'A/Classes/A.{h,m}'
end
s.subspec 'Model' do |ss|
ss.dependency 'A/Core'
ss.dependency 'YYModel'
ss.source_files = 'A/Classes/Next.{h,m}'
end
s.subspec 'Image' do |ss|
ss.dependency 'A/Core'
ss.dependency 'SDWebImage'
ss.source_files = 'A/Classes/Prev.{h,m}'
end
...
end
# B
Pod::Spec.new do |s|
s.name = 'B'
...
s.dependency 'A/Model'
...
end
當(dāng) B 為源碼版本,A 為二進(jìn)制版本時(shí)泉唁,A 的 subspec 必須要包含 Model 鹅龄,也就是說 A 的二進(jìn)制 podspec 必須保證源碼 podspec 中的 subspec 都存在,這樣切換依賴時(shí)才不會(huì)出錯(cuò)亭畜。 cocoapods-tdfire-binary 在組件 A 為二進(jìn)制版本時(shí)扮休,會(huì)動(dòng)態(tài)創(chuàng)建一個(gè)名為 TdfireBinary 的 default subspec ,然后將源碼 subspec 的依賴上移至 TdfireBinary :
# A
Pod::Spec.new do |s|
s.name = 'A'
...
s.subspec 'TdfireBinary' do |ss|
ss.vendored_frameworks = "A.framework"
ss.source_files = "A.framework/Headers/*", "A.framework/Versions/A/Headers/*"
ss.public_header_files = "A.framework/Headers/*", "A.framework/Versions/A/Headers/*"
ss.dependency 'YYModel'
ss.dependency 'SDWebImage'
end
s.subspec 'Core' do |ss|
ss.dependency 'A/TdfireBinary'
end
s.subspec 'Model' do |ss|
ss.dependency 'A/TdfireBinary'
end
s.subspec 'Image' do |ss|
ss.dependency 'A/TdfireBinary'
end
...
end
以下是我們實(shí)現(xiàn)過程中遇到的部分問題:
- 二進(jìn)制版本時(shí)拴鸵,依賴 subspec 會(huì)引入整個(gè)組件
- 需要拷貝 subspec 的屬性至 TdfireBinary 玷坠,實(shí)現(xiàn)起來比較繁瑣
- 由于是在插件內(nèi)部對(duì) podspec 進(jìn)行轉(zhuǎn)化蜗搔,擴(kuò)展性比較差
基于以上問題,我們后續(xù)創(chuàng)建 cocoapods-bin 插件時(shí)八堡,就把這部分工作交給使用者處理了樟凄,如果組件擁有 subspec,那么就需要使用者提供一個(gè)模版二進(jìn)制 podspec 兄渺,插件只負(fù)責(zé)同步 source 和 version缝龄。
另外,在大部分情況下溶耘,我更建議對(duì)功能不純粹的組件進(jìn)行物理剝離,而不是組件內(nèi)部再劃分 subspec 服鹅,subspec 這種結(jié)構(gòu)不僅會(huì)增加組件二進(jìn)制化的難度凳兵,而且會(huì)造成 lint 耗時(shí)成倍增加,大大降低 lint 執(zhí)行效率企软。
單私有源雙版本
在不更改私有源的前提下庐扫,通過變更組件版本(版本號(hào)加
-binary
),達(dá)到切換依賴的目的
由于單私有源單版本要么需要同時(shí)下載兩種版本的資源仗哨,要么切換依賴時(shí)需要重新下載目標(biāo)版本的資源形庭,我們決定以組件緩存為切入點(diǎn),按照 CocoaPods 的設(shè)計(jì)規(guī)則厌漂,將二進(jìn)制版本和源碼版本從物理上區(qū)分開來萨醒。
最初我想到的就是使用雙版本,在源碼版本號(hào)后添加 -binary
苇倡,即預(yù)發(fā)布版本富纸,作為二進(jìn)制版本的版本號(hào)。接下來只要在 CocoaPods 使用源碼 podspec 下載資源前旨椒,將其替換為二進(jìn)制 podspec 就可以實(shí)現(xiàn)二進(jìn)制版本的切換了晓褪。
首先,我們來看下 Pod::Resolver 類综慎,這個(gè)類會(huì)給 target 創(chuàng)建最終可用的 specifications 涣仿,只不過依賴分析工作并不在 Pod::Resolver 中進(jìn)行,它扮演了類似 DataSource 的角色示惊,將需要分析的數(shù)據(jù)提供給 Molinillo::Resolver 類處理好港。
這里說下我嘗試從依賴分析切入時(shí)遇到的問題。要成為 Molinillo::Resolver 的數(shù)據(jù)源米罚,需要實(shí)現(xiàn)/覆蓋 Molinillo::SpecificationProvider 模塊中的方法媚狰,以下是 Pod::Resolver 實(shí)現(xiàn)的 search_for :
def search_for(dependency)
@search ||= {}
@search[dependency] ||= begin
locked_requirement = requirement_for_locked_pod_named(dependency.name)
additional_requirements = Array(locked_requirement)
specifications_for_dependency(dependency, additional_requirements)
end
@search[dependency].dup
end
def specifications_for_dependency(dependency, additional_requirements = [])
requirement = Requirement.new(dependency.requirement.as_list + additional_requirements.flat_map(&:as_list))
find_cached_set(dependency).
all_specifications(installation_options.warn_for_multiple_pod_sources).
select { |s| requirement.satisfied_by? s.version }.
map { |s| s.subspec_by_name(dependency.name, false, true) }.
compact
end
當(dāng)時(shí)我通過 hook specifications_for_dependency 方法,更改了 requirement 阔拳,以使方法返回我想要的 specification崭孤,最終也實(shí)現(xiàn)了替換 specification 的目的类嗤。但是在執(zhí)行 lint, push 等操作時(shí)辨宠,由于 Podfile 為內(nèi)部自動(dòng)生成遗锣,很多組件都是間接依賴的,在目標(biāo)組件的 podspec 中并沒有聲明版本嗤形,比如間接依賴了 YYModel 精偿,requirement 為 ~> 1.0
,如果替換 requirement 為 = 1.0.1-binary
就會(huì)出現(xiàn)以下錯(cuò)誤:
Due to the previous na?ve CocoaPods resolver, you were using a pre-release version of `YYModel`,
without explicitly asking for a pre-release version, which now leads to a conflict.
Please decide to either use that pre-release version by
adding the version requirement to your Podfile (e.g. `pod 'YYModel', '= 1.0.1-binary, ~> 1.0'`) or
revert to a stable version by running `pod update YYModel`
要解決這個(gè)問題赋兵,可以顯式依賴一個(gè)預(yù)發(fā)布版本笔咽,也可以更改 requirement_satisfied_by?
方法的處理邏輯。
當(dāng)然霹期,我們也不可能會(huì)在 podspec 中顯式依賴一個(gè)預(yù)發(fā)布版本叶组,也不想過多干涉 CocoaPods 的依賴分析邏輯,所以這條路最終失敗了历造。實(shí)際上我們并不需要關(guān)心依賴是如何分析的甩十,只需要等依賴分析完,將最終生成的 specification 替換掉即可吭产,讓我們看下 Pod::Resolver 的 resolve 方法:
def resolve
dependencies = @podfile_dependency_cache.target_definition_list.flat_map do |target|
@podfile_dependency_cache.target_definition_dependencies(target).each do |dep|
next unless target.platform
@platforms_by_dependency[dep].push(target.platform)
end
end
@platforms_by_dependency.each_value(&:uniq!)
@activated = Molinillo::Resolver.new(self, self).resolve(dependencies, locked_dependencies)
resolver_specs_by_target
rescue Molinillo::ResolverError => e
handle_resolver_error(e)
end
def resolver_specs_by_target
@resolver_specs_by_target ||= {}.tap do |resolver_specs_by_target|
dependencies = {}
@podfile_dependency_cache.target_definition_list.each do |target|
specs = @podfile_dependency_cache.target_definition_dependencies(target).flat_map do |dep|
name = dep.name
node = @activated.vertex_named(name)
(valid_dependencies_for_target_from_node(target, dependencies, node) << node).map { |s| [s, node.payload.test_specification?] }
end
resolver_specs_by_target[target] = specs.
group_by(&:first).
map do |vertex, spec_test_only_tuples|
test_only = spec_test_only_tuples.all? { |tuple| tuple[1] }
payload = vertex.payload
spec_source = payload.respond_to?(:spec_source) && payload.spec_source
ResolverSpecification.new(payload, test_only, spec_source)
end.
sort_by(&:name)
end
end
end
上面的 resolver_specs_by_target 方法返回就是最終結(jié)果侣监,我們只需要變更其返回值就可以了。為了不污染源碼私有源以及能更好地維護(hù)源碼和二進(jìn)制 podspec 臣淤,我們最終沒有采用單私有源雙版本橄霉,而是采用了雙私有源單版本,不過兩者的實(shí)現(xiàn)思路和入口差不多是一致的邑蒋,這次嘗試也給后續(xù)的實(shí)踐鋪了路酪劫。
雙私有源單版本
在不更改組件版本的前提下,通過變更組件的私有源寺董,達(dá)到切換依賴的目的
雙私有源分別為源碼私有源和二進(jìn)制私有源覆糟,這兩個(gè)私有源中有相同版本組件,只是 podspec 中的 source 和依賴等字段不一樣遮咖,所以切換了組件對(duì)應(yīng)的私有源即切換了組件的依賴方式滩字。
以 YYModel 為例,現(xiàn)有源碼私有源 cocoapods-spec 及 二進(jìn)制私有源 cocoapods-spec-binary 御吞,它們都有 YYModel 組件 1.0.4.2 版本的 podspec 如下:
# cocoapods-spec
{
"name": "YYModel",
"summary": "High performance model framework for iOS/OSX.",
"version": "1.0.4.2",
"license": {
"type": "MIT",
"file": "LICENSE"
},
"authors": {
"ibireme": "ibireme@gmail.com"
},
"social_media_url": "http://blog.ibireme.com",
"homepage": "https://github.com/ibireme/YYModel",
"platforms": {
"ios": "6.0",
"osx": "10.7",
"watchos": "2.0",
"tvos": "9.0"
},
"source": {
"git": "git@git.xxxxx.net:cocoapods-repos/YYModel.git",
"tag": "1.0.4.2"
},
"frameworks": [
"Foundation",
"CoreFoundation"
],
"requires_arc": true,
"source_files": "YYModel/*.{h,m}",
"public_header_files": "YYModel/*.{h}"
}
# cocoapods-spec-binary
{
"name": "YYModel",
"summary": "High performance model framework for iOS/OSX.",
"version": "1.0.4.2",
"authors": {
"ibireme": "ibireme@gmail.com"
},
"social_media_url": "http://blog.ibireme.com",
"homepage": "https://github.com/ibireme/YYModel",
"platforms": {
"ios": "6.0"
},
"source": {
"http": "http://iosframeworkserver-shopkeeperclient.app.2dfire.com/download/YYModel/1.0.4.2.zip"
},
"frameworks": [
"Foundation",
"CoreFoundation"
],
"requires_arc": true,
"source_files": [
"YYModel.framework/Headers/*",
"YYModel.framework/Versions/A/Headers/*"
],
"public_header_files": [
"YYModel.framework/Headers/*",
"YYModel.framework/Versions/A/Headers/*"
],
"vendored_frameworks": "YYModel.framework"
}
當(dāng)采用 YYModel 的源碼版本時(shí)麦箍,我們從 cocoapods-spec 私有源獲取組件的 podspec,那么下載地址為 git@git.xxxxx.net:cocoapods-repos/YYModel.git
的 1.0.4.2
tag 陶珠;當(dāng)采用 YYModel 的二進(jìn)制版本時(shí)挟裂,我們從 cocoapods-spec-binary 私有源獲取組件的 podspec,那么下載地址為http://iosframeworkserver-shopkeeperclient.app.2dfire.com/download/YYModel/1.0.4.2.zip
揍诽。
通過上個(gè)方案诀蓉,我們可以知道 resolver_specs_by_target 方法創(chuàng)建了最終使用的 specifications 栗竖,接下來我們結(jié)合 cocoapods-bin 插件代碼,看下如何切換組件的私有源:
module Pod
class Resolver
# >= 1.4.0 才有 resolver_specs_by_target 以及 ResolverSpecification
# >= 1.5.0 ResolverSpecification 才有 source渠啤,供 install 或者其他操作時(shí)狐肢,輸入 source 變更
#
if Pod.match_version?('~> 1.4')
old_resolver_specs_by_target = instance_method(:resolver_specs_by_target)
define_method(:resolver_specs_by_target) do
specs_by_target = old_resolver_specs_by_target.bind(self).call()
sources_manager = Config.instance.sources_manager
use_source_pods = podfile.use_source_pods
missing_binary_specs = []
specs_by_target.each do |target, rspecs|
# use_binaries 并且 use_source_pods 不包含
use_binary_rspecs = if podfile.use_binaries? || podfile.use_binaries_selector
rspecs.select do |rspec|
([rspec.name, rspec.root.name] & use_source_pods).empty? &&
(podfile.use_binaries_selector.nil? || podfile.use_binaries_selector.call(rspec.spec))
end
else
[]
end
specs_by_target[target] = rspecs.map do |rspec|
# developments 組件采用默認(rèn)輸入的 spec (development pods 的 source 為 nil)
next rspec unless rspec.spec.respond_to?(:spec_source) && rspec.spec.spec_source
# 采用二進(jìn)制依賴并且不為開發(fā)組件
use_binary = use_binary_rspecs.include?(rspec)
source = use_binary ? sources_manager.binary_source : sources_manager.code_source
spec_version = rspec.spec.version
begin
# 從新 source 中獲取 spec
specification = source.specification(rspec.root.name, spec_version)
# 組件是 subspec
specification = specification.subspec_by_name(rspec.name, false, true) if rspec.spec.subspec?
# 這里可能出現(xiàn)分析依賴的 source 和切換后的 source 對(duì)應(yīng) specification 的 subspec 對(duì)應(yīng)不上
# 造成 subspec_by_name 返回 nil,這個(gè)是正沉げ埽現(xiàn)象
next unless specification
# 組裝新的 rspec 份名,替換原 rspec
rspec = if Pod.match_version?('~> 1.4.0')
ResolverSpecification.new(specification, rspec.used_by_tests_only)
else
ResolverSpecification.new(specification, rspec.used_by_tests_only, source)
end
rspec
rescue Pod::StandardError => error
# 沒有從新的 source 找到對(duì)應(yīng)版本組件,直接返回原 rspec
missing_binary_specs << rspec.spec if use_binary
rspec
end
rspec
end.compact
end
missing_binary_specs.uniq.each do |spec|
UI.message "【#{spec.name} | #{spec.version}】組件無對(duì)應(yīng)二進(jìn)制版本 , 將采用源碼依賴."
end if missing_binary_specs.any?
specs_by_target
end
end
end
if Pod.match_version?('~> 1.4.0')
# 1.4.0 沒有 spec_source
class Specification
class Set
class LazySpecification < BasicObject
attr_reader :spec_source
old_initialize = instance_method(:initialize)
define_method(:initialize) do |name, version, source|
old_initialize.bind(self).call(name, version, source)
@spec_source = source
end
def respond_to?(method, include_all = false)
return super unless method == :spec_source
true
end
end
end
end
end
end
上面就是切換私有源的代碼邏輯妓美,可以看到還是比較簡(jiǎn)短的僵腺,這里只單獨(dú)說三點(diǎn):
- 我們默認(rèn) Development Pods 中的組件為未發(fā)布組件,沒有二進(jìn)制版本壶栋,所以始終采用原版本
- 因?yàn)闊o法直接從 source 中獲取組件的 subspec 辰如,所以這里統(tǒng)一獲取 root spec ,如果目標(biāo) spec 是 subspec 再?gòu)?root spec 中獲取 subspec
- 其他業(yè)務(wù)線的組件可能沒有二進(jìn)制化版本委刘,這里我們?nèi)绻麤]有找到組件目標(biāo)版本的 spec 丧没,會(huì)讓組件采用原版本鹰椒,這樣就不會(huì)因?yàn)槟硞€(gè)組件版本的缺失而導(dǎo)致 install 失敗锡移。
存在兩個(gè)私有源意味著會(huì)有兩個(gè)不同的 podspec ,分別為源碼 podspec 和二進(jìn)制 podspec 漆际,手動(dòng)同步這兩個(gè) podspec 將會(huì)是一個(gè)很耗費(fèi)精力的事情淆珊,這時(shí)候就需要 cocoapods-bin 插件的輔助命令了。針對(duì)沒有 subspec 的組件奸汇,cocoapods-bin 會(huì)根據(jù)源碼 podspec 自動(dòng)生成對(duì)應(yīng)的二進(jìn)制 podspec 施符;針對(duì)有 subspec 的組件,cocoapods-bin 會(huì)根據(jù)使用者提供的 template podspec 和源碼 podspec 自動(dòng)生成對(duì)應(yīng)的二進(jìn)制 podspec 擂找。由于源碼 podspec 和二進(jìn)制 podspec 的 diff 是可預(yù)見的戳吝,我們就可以通過這種半自動(dòng)的方式避免同時(shí)維護(hù)兩套 podspec 。
更多使用信息可以查看 cocoapods-bin 的 README 贯涎,這里就不贅述了听哭。
整合 CI
從上文可以看出,二進(jìn)制化還是增加了重復(fù)性工作塘雳,包括制作二進(jìn)制包陆盘、發(fā)布二進(jìn)制版本等,如果不輔以自動(dòng)化工具败明,無疑會(huì)增加組件維護(hù)者的工作隘马。
在火掌柜 iOS 團(tuán)隊(duì) GitLab CI 集成實(shí)踐的基礎(chǔ)上,我們對(duì) CI 配置文件做了些調(diào)整:
variables:
# 二進(jìn)制優(yōu)先
BINARY_FIRST: 1
# 不允許通知
DISABLE_NOTIFY: 0
before_script:
# https://gitlab.com/gitlab-org/gitlab-ce/issues/14983
# shared runner 會(huì)出現(xiàn)妻顶,special runner只會(huì)報(bào)warning
- export LANG=en_US.UTF-8
- export LANGUAGE=en_US:en
- export LC_ALL=en_US.UTF-8
- pwd
- git clone git@git.xxxxx.net:ios/ci-yaml-shell.git
- ci-yaml-shell/before_shell_executor.sh
after_script:
- rm -fr ci-yaml-shell
stages:
- check
- lint
- test
- package
- publish
- report
- cleanup
component_check:
stage: check
script:
- ci-yaml-shell/component_check_executor.rb
only:
- master
- /^release.*$/
- /^hotfix.*$/
- tags
- CI
tags:
- iOSCI
environment:
name: qa
...
package_framework:
stage: package
only:
- tags
script:
- ci-yaml-shell/framework_pack_executor.sh
tags:
- iOSCD
environment:
name: production
publish_code_pod:
stage: publish
only:
- tags
retry: 0
script:
- ci-yaml-shell/publish_code_pod.sh
tags:
- iOSCD
environment:
name: production
publish_binary_pod:
stage: publish
only:
- tags
retry: 0
script:
- ci-yaml-shell/publish_binary_pod.sh
tags:
- iOSCD
environment:
name: production
report_to_director:
stage: report
script:
- ci-yaml-shell/report_executor.sh
only:
- master
- tags
when: on_failure
tags:
- iOSCD
推送 tag 后酸员,如果一切順利蜒车,可以看到 pipeline 執(zhí)行結(jié)果如下:
[圖片上傳失敗...(image-20773b-1552478286453)]
其中的 package 、 publish 這兩個(gè) stage 囊括了二進(jìn)制化資源制作的主要工作沸呐,組件維護(hù)者依然可以像二進(jìn)制化前一樣醇王,關(guān)注源碼版本的發(fā)布流程即可。
這里需要注意的是崭添,由于 CocoaPods push 的 Validator 和 lint 基本一致寓娩,上文提到的這個(gè) bug ,對(duì) publish stage 也會(huì)有影響呼渣,需要暫時(shí)指定 CocoaPods 為 1.4.0 版本(pod _1.4.0_ bin repo push
)棘伴。
總結(jié)
整個(gè)組件二進(jìn)制化的嘗試與實(shí)踐,耗費(fèi)了我大半年的主要精力屁置,并且我們還需要多維護(hù)一個(gè)二進(jìn)制文件服務(wù)器焊夸,以及對(duì)應(yīng)的二進(jìn)制版本,在組件 / 代碼不多時(shí)蓝角,做這件事情費(fèi)時(shí)費(fèi)力阱穗,還收效甚微,因此我并不建議還未進(jìn)行業(yè)務(wù)組件化并且沒有上 CI 的團(tuán)隊(duì)去做這件事情使鹅。
結(jié)合我們團(tuán)隊(duì)目前的業(yè)務(wù)性質(zhì)以及業(yè)務(wù)組件化進(jìn)程揪阶,在團(tuán)隊(duì)實(shí)施了組件二進(jìn)制化之后,團(tuán)隊(duì)內(nèi)部工程編譯速度的提升還是顯而易見的患朱,并且受益于編譯時(shí)間的減少鲁僚,組件自動(dòng)發(fā)布平臺(tái)的發(fā)布時(shí)間也大大減少,所以對(duì)于我們來說裁厅,花時(shí)間去做這件事情還是值得的冰沙。
參考
iOS CocoaPods組件平滑二進(jìn)制化解決方案