火掌櫃iOS端基於CocoaPods的元件二進位制化實踐
火掌櫃 iOS 客戶端經過近兩年的元件化推進,元件數量已經頗具規模,達到了近 100 個。隨著元件數量和程式碼量越來越多,主工程的打包時間從最初的十幾分鍾,增加到了現在的四十分鐘左右。依賴元件較多,改動相對頻繁的上層業務元件,其釋出時間也較為漫長。編譯時長的困擾,已經明顯影響了日常開發體驗,同時也造成 CI pipeline 執行時間過長,在 runner 資源匱乏的情況下,不利於內部 CI 的推廣。當前時間節點下,如何減少編譯時長,已經成為開發團隊較為迫切的需求。
前言
元件化除了讓模組複用更加便捷,業務開發更加輕量,還有一個不可忽視的優勢———元件二進位制化,即可通過將非開發中的元件預先編譯打包成靜態 / 動態庫並存放至某處,待整合此元件時,直接使用二進位制包,從而提升整合此元件的 App 或者上層元件的編譯速度。
對比原始碼依賴,二進位制依賴的元件只需要進行連結而無需編譯,可以極大地提升整合效率。掌櫃主工程在大部分元件都二進位制化的情況下,打包時長從四十分鐘左右,下降到最快十二分鐘,整整減少了三倍多, CI pipeline 涉及到編譯環節的 lint、打包、釋出,其耗時也成數倍減少,二進位制化所帶來的好處不言而喻。
在實踐二進位制化過程中,由於沒有找到較為成熟的依賴切換工具,我們編寫了 cocoapods-bin 通用外掛,有需要的開發者可以嘗試下。
需要說明的是有些二進位制方案是在首次編譯後,保留元件生成的二進位制包,後續編譯直接使用此二進位制包。在大多數情況下,比如 App 打包,元件 lint 與釋出,這類只進行一次編譯的操作,首次編譯才是主要關注點。本文所說的二進位制化和此類方案的最大區別,就是將元件二進位制包製作放到首次編譯前,更多的是在元件釋出時,同時生成二進位制包。
另外,鑑於 CocoaPods 在 1.3.0 後的版本,增加了類似增量編譯的功能,在首次 install / update 編譯之後,後續再進行 install / update 操作,會根據更改結果進行增量編譯,個人感覺針對 “非首次 install / update 後的編譯“ 優化,並不是必須的,因為 CocoaPods 已經幫我們做好了。
二進位制化需求
以下是根據掌櫃團隊日常開發情況,提出的二進位制化需求點:
- 不影響未接入二進位制化方案的業務團隊
- 元件級別的原始碼 / 二進位制依賴切換功能
- 無二進位制版本時,自動採用原始碼版本
- 接近原生 CocoaPods 的使用體驗 (為了滿足此需求,我們決定開發自定義的 CocoaPods 外掛。)
- 不增加過多額外的工作量
下面我會參照這幾個需求點,逐步說明掌櫃 iOS 團隊的二進位制化過程。
巨集定義處理
預編譯階段處理的巨集定義,在元件進行二進位制化後會失效,特別是某些依賴 DEBUG 巨集的除錯工具,在二進位制化之後就不可見了。為了方便處理,我把使用巨集的地方分為兩種:
- 方法內部
- 方法外部
針對方法內部,我們建立了 TDFMacro 類來替換巨集,將邏輯挪到執行時處理:
// 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 元件中的巨集有效就可以了————不對其進行二進位制化。
針對方法外部,我們儘量將能改寫到方法內部的程式碼改寫後按第一種情況處理,不能處理的對程式碼進行重構以消除巨集定義,比如我們網路層的常量,重寫前後:
// 前#if DEBUG NSString * kTDFRootAPI = @\u0026quot;xxx\u0026quot;;#elseNSString * const kTDFRootAPI = @\u0026quot;xxx\u0026quot;;#end// 後NSString * kTDFRootAPI = @\u0026quot;xxx\u0026quot;;
個人建議儘量不要跨模組使用巨集定義,特別是可以用常量或函式代替的巨集。比如有元件 A、B ,B 依賴 A,它們包含如下程式碼:
// A#define TDF_THEME_BACKGROUNDCOLOR [[UIColor whiteColor] colorWithAlphaComponent:0.7]// B// .m 使用了 TDF_THEME_BACKGROUNDCOLOR
假設 A 和 B 都已二進位制化,假設後續我們修改了 A :
// A#define TDF_THEME_BACKGROUNDCOLOR [[UIColor whiteColor] colorWithAlphaComponent:0.4]
由於 B 中的 TDF_THEME_BACKGROUNDCOLOR 巨集已經在二進位制化打包預編譯時被替換為 [[UIColor whiteColor] colorWithAlphaComponent:0.7]
,所以 B 並不會感知到此次 A 的變更,這時我們就不得不重新打包元件 B 以同步 A 的變更,即使 B 並未做任何更改,當存在較多使用 TDF_THEME_BACKGROUNDCOLOR 巨集的元件時,就容易遺漏同步某些元件。
製作二進位制包
二進位制化第一步,先要把元件的二進位制包打出來。這裡說下比較通用的打包工具 cocoapods-packager 和 Carthage ,目前我們使用 cocoapods-packager 將元件構建 static-framework 。
cocoapods-packager 的工作原理和 pod spec/lib lint
差不多,都是通過 podspec 動態生成 Podfile ,然後 install 出目標工程,最後通過 xcodebuild
命令構建出二進位制包。這種方式有一個好處,只要保證元件 lint 通過了,就可以打出二進位制包,不需要和 Example 工程掛鉤,很方便。但是這個外掛作者幾乎不維護了,很多較久之前的 issue 和 pull request 都是未處理狀態。
以下是我們用來構建 static-framework 的命令:
pod package TDFNavigationBarKit.podspec --exclude-deps --force --no-mangle --spec-sources=http://git.xxxxx.net/ios/cocoapods-spec.git
在使用過程中,我遇到了兩個關於元件資源的問題 :
- 使用了
--exclude-deps
option 後,雖然沒有把 dependency 的符號資訊打進可執行檔案,但是它把 dependency 的 bundle 給拷貝過來了 (見builder.rb 229 copy_resources
方法) - subspec 宣告的 resource 不會被拷貝進 framework 中
鑑於 cocoapods-packager 近期沒有釋出新版本的計劃,我只能 fork 並更新程式碼之後,重新發布 cocoapods-packager-pro 來修復這兩個問題。使用 cocoapods-packager-pro 之後,構建 static-framework 的命令變為:
pod package-pro TDFNavigationBarKit.podspec --exclude-deps --force --no-mangle --spec-sources=http://git.xxxxx.net/ios/cocoapods-spec.git
二級命令 package 改成 package-pro 即可。
cocoapods-packager 建立二進位制包中的 modulemap 時,會先檢視目標元件的 podspec 是否設定了 module_map 欄位,如有直接拷貝,否則會檢視是否有和元件同名的標頭檔案,如有則建立 modulemap ,並設定 umbrella header
為此檔案,如無則不建立 modulemap 。所以 cocoapods-packager 給沒有和元件同名的標頭檔案,又沒有指定 module_map 的元件打二進位制包時,是不會建立 modulemap 的,比如 SDWebImage ,這時候需要我們自行新增 modulemap,否則使用 swift 的 import
就會找不到對應的 module,這點需要注意下。
CocoaPods 目前釋出了 1.6.0 beta 版本,試用之後,發現由於某些類的建構函式引數發生了變更, 導致 cocoapods-packager 現有程式碼已經無法正常工作了,所以 cocoapods-packager 只適用低於 1.6.0 版本的 CocoaPods,後期如果官方 cocoapods-packager 還是沒有更新的話,我們應該會在 cocoapods-packager-pro 中適配新版本 CocoaPods。
cocoapods-packager 作者最近還建立了外掛 cocoapods-generate ,此外掛可以直接根據 podspec 生成目標工程,相當於 cocoapods-packager 前半部分功能的增強版。目前這個外掛支援 CocoaPods 1.6.0 beta 版本,不想用 cocoapods-packager 的開發者,可以先利用 cocoapods-generate 建立目標工程,然後接管構建二進位制包的後續操作,可以選擇自己實現打包指令碼,也可以選擇使用 Carthage。
關於 Carthage 如何打 static-framework ,可以參照 Build static frameworks to speed up your app’s launch times 。其中有一步是將需要打包的 scheme 設定為 shared ,這個 scheme 對應 CocoaPods 元件的 develpement pod ,一般來說通過 CocoaPods 模版工程或者 cocoapods-generate 外掛生成目標工程的 scheme 都是 shared 的,如果沒有 shared ,可參照讓 CocoaPods 元件支援 Carthage 打包一文進行設定。
構建出 .framework
檔案後,需要對其進行壓縮,我們使用以下命令將檔案壓縮成 zip 格式:
zip --symlinks -r TDFNavigationBarKit.framework.zip TDFNavigationBarKit.framework
通過上述兩個步驟,我們就得到了元件的二進位制 zip 包。
需要注意的是,如果使用 cocoapods-packager 打包,其 .framework
中的目錄結構如下 :
TDFNavigationBarKit.framework/├── Headers -\u0026gt; Versions/Current/Headers├── Modules│ └── module.modulemap├── Resources -\u0026gt; Versions/Current/Resources├── TDFNavigationBarKit -\u0026gt; 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 -\u0026gt; A
可以看到,其中的 Headers
、Resources
、Versions/Current
都是軟連結。podspec 中涉及到檔案匹配的欄位,如 source_files
、public_header_files
、resources
等,對軟連結是無效的,所以需要設定為檔案實際存放的路徑:
s.source_files = TDFNavigationBarKit.framework/Versions/A/Headers/*.hs.public_header_files = TDFNavigationBarKit.framework/Versions/A/Headers/*.h# 或者更全面一點s.source_files = TDFNavigationBarKit.framework/Versions/A/Headers/*.h, TDFNavigationBarKit.framework/Headers/*.hs.public_header_files = TDFNavigationBarKit.framework/Versions/A/Headers/*.h, TDFNavigationBarKit.framework/Headers/*.h
針對二進位制包的製作,我們建立了以下命令供團隊內部使用:
# 將原始碼打包成二進位制,並壓縮成 zip 包pod binary package
儲存二進位制包
通常二進位制包存放的地址有兩種,目前我們使用的是第二種 ( 伺服器程式碼可參照 binary-server ):
- 元件所在 git 倉庫
- 靜態檔案伺服器
相較於 git 倉庫,我認為存放至靜態檔案伺服器的優勢如下:
- 介面訪問,易於擴充套件與自動化處理
- 原始碼和二進位制分離,依賴二進位制時,只下載二進位制包比 clone 倉庫快
- 不會增大 git 倉庫大小,這點也涉及到原始碼依賴的下載速度
這裡說下為什麼我們對元件的下載速度這麼敏感。
首先,CocoaPods 針對下載的元件是有快取的,在第一次下載後,CocoaPods 會將元件存放在 Caches 資料夾中,後續 install 操作會先從 Caches 中查詢是否有此元件的快取,如果沒有的話,再執行下載流程(是不是感覺和 SDWebImage 有點像)。但是目前 CocoaPods 在同一臺機器上,只能有一個版本的快取 ( ~/Library/Caches/CocoaPods/Pods 下的 VERSION 記錄著當前快取對應的 CocoaPods 版本 ),也就是說我第一次使用 pod _1.5.3_ install
下載了所有元件,再執行 pod _1.4.1_ install
, CocoaPods 會把 1.5.3 版本的所有元件快取清空,然後重新下載 。
由於團隊內部只有 5 臺 Mac mini 機器,我們只能在機器上同時部署 GitLab CI Runner 和 Jenkins Slaver ,CI 指令碼中使用的 CocoaPods 版本可以統一控制成 1.4.0 ( 這裡不使用最新的 1.5.3 是由於這個 bug 會導致 lint 失敗),但是其他業務線打包時使用的 CocoaPods 版本就沒法統一了,有 1.5.3 的,有 1.6.0.beta 的,加上各業務線的打包頻率還比較高,導致機器頻繁地在不同 CocoaPods 版本中切換 。
結合上訴兩個原因,我們趨向採用下載速度更快的方案。
針對二進位制包的增刪查,我們建立了以下命令供團隊內部使用:
# 檢視所有二進位制版本資訊pod binary list # 查詢元件二進位制版本資訊pod binary search NAME# 下載二進位制 zip 包pod binary pull NAME VERSION# 推送二進位制 zip 包 pod binary push [PATH] [-name=元件名] [--version=版本號] [--commit=版本日誌]
切換依賴方式
二進位制化後,整體構建速度變快了,但是不利於開發人員跟蹤除錯,所以就需要有依賴切換功能。這裡所說的依賴切換功能包括整個工程、單個元件的切換,以及二進位制版本的使用封裝,這也是元件二進位制化耗費時間和精力最多的地方。
在整個過程中,我總共嘗試了三種方案,分別是單私有源單版本、單私有源雙版本以及最終採用的雙私有源單版本。下面我會簡單地說下各方案以及實踐中遇到的問題。
單私有源單版本
在不更改私有源和元件版本的前提下,通過動態變更原始碼 podspec,達到切換依賴的目的
單私有源單版本是我第一次實踐採用的方案,也建立了對應的外掛 cocoapods-tdfire-binary ,這裡結合外掛的實現過程,聊聊實現這類方案時遇到的坑。
前期調研二進位制化方案時,我主要參考了 iOS CocoaPods元件平滑二進位制化解決方案 一文,所以整體思路和這篇文章差不多,也是通過環境變數加判斷語句實現 podspec 的內容變更(雖說 podspec 支援使用 ruby 語法定製,我還是建議最終以 json 格式釋出到私有源上,因為 CocoaPods 內部會將 podspec json 化後再執行一些操作,比如快取,如果這一動作不冪等,操作結果便是不可預知的,從而破壞 CocoaPods 自身的執行機制)。
這種方案最大的困擾在於切換依賴時,如何規避元件快取帶來的負面影響,處理不當容易出現工程元件目錄為空的情況,以下是我實踐過的兩種方案:
確保快取中同時存在原始碼和二進位制的資源及檔案(設定 preserve_paths)
切換依賴前,刪除目標元件快取以及本地 Pods 下的元件目錄
在使用二進位制伺服器的前提下,方案一的常見實現方式為,在 pre_command 中設定下載二進位制包指令碼,並設定 preserve_paths ,讓 CocoaPods 同時保留兩種依賴方式所需要的檔案即可。考慮到元件本身有二進位制版本,元件 Cache 還沒有下載的情況,這種方案通常輔以方案二。由於需要同時下載兩種依賴的資源,個人並不是很喜歡這種方案,這也是我們棄用 cocoapods-tdfire-binary 的主要原因。
方案二需要 hook Pod::Installer 類的 resolve_dependencies 方法,在這個方法中清除快取及本地資源,並且設定元件的沙盒變動標記,這樣 CocoaPods 就會重新下載對應的元件了:
def cache_descriptors @cache_descriptors ||= begin cache = Downloader::Cache.new(Config.instance.cache_root + 'Pods') cache_descriptors = cache.cache_descriptors_per_pod endenddef clean_local_cache(spec) pod_dir = Config.instance.sandbox.pod_dir(spec.root.name) framework_file = pod_dir + \u0026quot;#{spec.root.name}.framework\u0026quot; if pod_dir.exist? \u0026amp;\u0026amp; !framework_file.exist? # 設定沙盒變動標記,去 cache 中拿 # 只有 :changed 、:added 兩種狀態才會重新去 cache 中拿 @analysis_result.sandbox_state.add_name(spec.name, :changed) begin FileUtils.rm_rf(pod_dir) rescue =\u0026gt; err puts err end endenddef 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 檔名由檔案內容的 sha1 組成,由於生成時使用的是 podspec,獲取時使用的是 podspec.json 導致生成的目錄名不一致 # Downloader::Request slug # cache_descriptors_per_pod 表明,specs_dir 中都是以 .json 形式儲存 spec slug = d[:slug].dirname + \u0026quot;#{spec.version}-#{spec.checksum[0, 5]}\u0026quot; framework_file = slug + \u0026quot;#{spec.root.name}.framework\u0026quot; unless (framework_file.exist?) begin FileUtils.rm(d[:spec_file]) FileUtils.rm_rf(slug) rescue =\u0026gt; err puts err end end endend
需要注意的是,CocoaPods 在 podspec 不是 json 格式時,快取目錄是有問題的,所以需要我們自己去拼裝快取路徑後再執行刪除動作。
使用 cocoapods-tdfire-binary 時,我們需要在 podspec 檔案中新增以下程式碼:
....tdfire_source_configurator = lambda do |s| # 原始碼依賴配置 s.source_files = '${POD_NAME}/Classes/**/*' s.public_header_files = '${POD_NAME}/Classes/**/*.{h}'endunless %w[tdfire_set_binary_download_configurations tdfire_source tdfire_binary].reduce(true) { |r, m| s.respond_to?(m) \u0026amp; r } tdfire_source_configurator.call selse # 內部生成原始碼依賴配置 s.tdfire_source tdfire_source_configurator # 內部生成二進位制依賴配置 s.tdfire_binary tdfire_source_configurator # 設定下載指令碼,preseve_paths s.tdfire_set_binary_download_configurationsend
然後在 Podfile 使用以下語句切換依賴:
...plugin 'cocoapods-tdfire-binary'tdfire_use_binary!# tdfire_third_party_use_binary!tdfire_use_source_pods ['AFNetworking']...
由於編寫此外掛時,我對 CocoaPods 原始碼以及 ruby 並不熟悉,導致我沒有把 podspec 的配置放到外掛內部,現在回過頭看,更加合理的做法應該是在 podspec 中設定依賴標誌,然後在 hook 的 resolve_dependencies 方法中,變更 podspec 的 source 及依賴相關的欄位,這樣的話,只需要採用上訴的方案二即可。
可以看到,單私有源單版本對 CocoaPods 快取策略的侵入還是比較大的。
這裡順便說下 cocoapods-tdfire-binary 是如何處理 subspec 的,首先要說明的是,對於存在 subspec 的元件,我們將其整體打為一個二進位制包,並沒有分 subspec 構建。假設有元件 A ,B,他們對應的部分 podspec 如下:
# APod::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# BPod::Spec.new do |s| s.name = 'B' ... s.dependency 'A/Model' ...end
當 B 為原始碼版本,A 為二進位制版本時,A 的 subspec 必須要包含 Model ,也就是說 A 的二進位制 podspec 必須保證原始碼 podspec 中的 subspec 都存在,這樣切換依賴時才不會出錯。 cocoapods-tdfire-binary 在元件 A 為二進位制版本時,會動態建立一個名為 TdfireBinary 的 default subspec ,然後將原始碼 subspec 的依賴上移至 TdfireBinary :
# APod::Spec.new do |s| s.name = 'A' ... s.subspec 'TdfireBinary' do |ss|\tss.vendored_frameworks = \u0026quot;A.framework\u0026quot; ss.source_files = \u0026quot;A.framework/Headers/*\u0026quot;, \u0026quot;A.framework/Versions/A/Headers/*\u0026quot; ss.public_header_files = \u0026quot;A.framework/Headers/*\u0026quot;, \u0026quot;A.framework/Versions/A/Headers/*\u0026quot; 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|\tss.dependency 'A/TdfireBinary' end ...end
以下是我們實現過程中遇到的部分問題:
- 二進位制版本時,依賴 subspec 會引入整個元件
- 需要拷貝 subspec 的屬性至 TdfireBinary ,實現起來比較繁瑣
- 由於是在外掛內部對 podspec 進行轉化,擴充套件性比較差
基於以上問題,我們後續建立 cocoapods-bin 外掛時,就把這部分工作交給使用者處理了,如果元件擁有 subspec,那麼就需要使用者提供一個模版二進位制 podspec ,外掛只負責同步 source 和 version。
另外,在大部分情況下,我更建議對功能不純粹的元件進行物理剝離,而不是元件內部再劃分 subspec ,subspec 這種結構不僅會增加元件二進位制化的難度,而且會造成 lint 耗時成倍增加,大大降低 lint 執行效率。
單私有源雙版本
在不更改私有源的前提下,通過變更元件版本(版本號加
-binary
),達到切換依賴的目的
由於單私有源單版本要麼需要同時下載兩種版本的資源,要麼切換依賴時需要重新下載目標版本的資源,我們決定以元件快取為切入點,按照 CocoaPods 的設計規則,將二進位制版本和原始碼版本從物理上區分開來。
最初我想到的就是使用雙版本,在原始碼版本號後新增 -binary
,即預釋出版本,作為二進位制版本的版本號。接下來只要在 CocoaPods 使用原始碼 podspec 下載資源前,將其替換為二進位制 podspec 就可以實現二進位制版本的切換了。
首先,我們來看下 Pod::Resolver 類,這個類會給 target 建立最終可用的 specifications ,只不過依賴分析工作並不在 Pod::Resolver 中進行,它扮演了類似 DataSource 的角色,將需要分析的資料提供給 Molinillo::Resolver 類處理。
這裡說下我嘗試從依賴分析切入時遇到的問題。要成為 Molinillo::Resolver 的資料來源,需要實現/覆蓋 Molinillo::SpecificationProvider 模組中的方法,以下是 Pod::Resolver 實現的 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].dupenddef specifications_for_dependency(dependency, additional_requirements = []) requirement = Requirement.new(dependency.requirement.as_list + additional_requirements.flat_map(\u0026amp;: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) }. compactend
當時我通過 hook specifications_for_dependency 方法,更改了 requirement ,以使方法返回我想要的 specification,最終也實現了替換 specification 的目的。但是在執行 lint, push 等操作時,由於 Podfile 為內部自動生成,很多元件都是間接依賴的,在目標元件的 podspec 中並沒有宣告版本,比如間接依賴了 YYModel ,requirement 為 ~\u0026gt; 1.0
,如果替換 requirement 為 = 1.0.1-binary
就會出現以下錯誤:
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 byadding the version requirement to your Podfile (e.g. `pod 'YYModel', '= 1.0.1-binary, ~\u0026gt; 1.0'`) orrevert to a stable version by running `pod update YYModel`
要解決這個問題,可以顯式依賴一個預釋出版本,也可以更改 requirement_satisfied_by?
方法的處理邏輯。
當然,我們也不可能會在 podspec 中顯式依賴一個預釋出版本,也不想過多幹涉 CocoaPods 的依賴分析邏輯,所以這條路最終失敗了。實際上我們並不需要關心依賴是如何分析的,只需要等依賴分析完,將最終生成的 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(\u0026amp;:uniq!) @activated = Molinillo::Resolver.new(self, self).resolve(dependencies, locked_dependencies) resolver_specs_by_targetrescue Molinillo::ResolverError =\u0026gt; e handle_resolver_error(e)enddef 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) \u0026lt;\u0026lt; node).map { |s| [s, node.payload.test_specification?] } end resolver_specs_by_target[target] = specs. group_by(\u0026amp;: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) \u0026amp;\u0026amp; payload.spec_source ResolverSpecification.new(payload, test_only, spec_source) end. sort_by(\u0026amp;:name) end endend
上面的 resolver_specs_by_target 方法返回就是最終結果,我們只需要變更其返回值就可以了。為了不汙染原始碼私有源以及能更好地維護原始碼和二進位制 podspec ,我們最終沒有采用單私有源雙版本,而是採用了雙私有源單版本,不過兩者的實現思路和入口差不多是一致的,這次嘗試也給後續的實踐鋪了路。
雙私有源單版本
在不更改元件版本的前提下,通過變更元件的私有源,達到切換依賴的目的
雙私有源分別為原始碼私有源和二進位制私有源,這兩個私有源中有相同版本元件,只是 podspec 中的 source 和依賴等欄位不一樣,所以切換了元件對應的私有源即切換了元件的依賴方式。
以 YYModel 為例,現有原始碼私有源 cocoapods-spec 及 二進位制私有源 cocoapods-spec-binary ,它們都有 YYModel 元件 1.0.4.2 版本的 podspec 如下:
# cocoapods-spec { \u0026quot;name\u0026quot;: \u0026quot;YYModel\u0026quot;, \u0026quot;summary\u0026quot;: \u0026quot;High performance model framework for iOS/OSX.\u0026quot;, \u0026quot;version\u0026quot;: \u0026quot;1.0.4.2\u0026quot;, \u0026quot;license\u0026quot;: { \u0026quot;type\u0026quot;: \u0026quot;MIT\u0026quot;, \u0026quot;file\u0026quot;: \u0026quot;LICENSE\u0026quot; }, \u0026quot;authors\u0026quot;: { \u0026quot;ibireme\u0026quot;: \u0026quot;ibireme@gmail.com\u0026quot; }, \u0026quot;social_media_url\u0026quot;: \u0026quot;http://blog.ibireme.com\u0026quot;, \u0026quot;homepage\u0026quot;: \u0026quot;https://github.com/ibireme/YYModel\u0026quot;, \u0026quot;platforms\u0026quot;: { \u0026quot;ios\u0026quot;: \u0026quot;6.0\u0026quot;, \u0026quot;osx\u0026quot;: \u0026quot;10.7\u0026quot;, \u0026quot;watchos\u0026quot;: \u0026quot;2.0\u0026quot;, \u0026quot;tvos\u0026quot;: \u0026quot;9.0\u0026quot; }, \u0026quot;source\u0026quot;: { \u0026quot;git\u0026quot;: \u0026quot;git@git.xxxxx.net:cocoapods-repos/YYModel.git\u0026quot;, \u0026quot;tag\u0026quot;: \u0026quot;1.0.4.2\u0026quot; }, \u0026quot;frameworks\u0026quot;: [ \u0026quot;Foundation\u0026quot;, \u0026quot;CoreFoundation\u0026quot; ], \u0026quot;requires_arc\u0026quot;: true, \u0026quot;source_files\u0026quot;: \u0026quot;YYModel/*.{h,m}\u0026quot;, \u0026quot;public_header_files\u0026quot;: \u0026quot;YYModel/*.{h}\u0026quot;}# cocoapods-spec-binary { \u0026quot;name\u0026quot;: \u0026quot;YYModel\u0026quot;, \u0026quot;summary\u0026quot;: \u0026quot;High performance model framework for iOS/OSX.\u0026quot;, \u0026quot;version\u0026quot;: \u0026quot;1.0.4.2\u0026quot;, \u0026quot;authors\u0026quot;: { \u0026quot;ibireme\u0026quot;: \u0026quot;ibireme@gmail.com\u0026quot; }, \u0026quot;social_media_url\u0026quot;: \u0026quot;http://blog.ibireme.com\u0026quot;, \u0026quot;homepage\u0026quot;: \u0026quot;https://github.com/ibireme/YYModel\u0026quot;, \u0026quot;platforms\u0026quot;: { \u0026quot;ios\u0026quot;: \u0026quot;6.0\u0026quot; }, \u0026quot;source\u0026quot;: { \u0026quot;http\u0026quot;: \u0026quot;http://iosframeworkserver-shopkeeperclient.app.2dfire.com/download/YYModel/1.0.4.2.zip\u0026quot; }, \u0026quot;frameworks\u0026quot;: [ \u0026quot;Foundation\u0026quot;, \u0026quot;CoreFoundation\u0026quot; ], \u0026quot;requires_arc\u0026quot;: true, \u0026quot;source_files\u0026quot;: [ \u0026quot;YYModel.framework/Headers/*\u0026quot;, \u0026quot;YYModel.framework/Versions/A/Headers/*\u0026quot; ], \u0026quot;public_header_files\u0026quot;: [ \u0026quot;YYModel.framework/Headers/*\u0026quot;, \u0026quot;YYModel.framework/Versions/A/Headers/*\u0026quot; ], \u0026quot;vendored_frameworks\u0026quot;: \u0026quot;YYModel.framework\u0026quot;}
當採用 YYModel 的原始碼版本時,我們從 cocoapods-spec 私有源獲取元件的 podspec,那麼下載地址為 git@git.xxxxx.net:cocoapods-repos/YYModel.git
的 1.0.4.2
tag ;當採用 YYModel 的二進位制版本時,我們從 cocoapods-spec-binary 私有源獲取元件的 podspec,那麼下載地址為http://iosframeworkserver-shopkeeperclient.app.2dfire.com/download/YYModel/1.0.4.2.zip
。
通過上個方案,我們可以知道 resolver_specs_by_target 方法建立了最終使用的 specifications ,接下來我們結合 cocoapods-bin 外掛程式碼,看下如何切換元件的私有源:
module Pod class Resolver # \u0026gt;= 1.4.0 才有 resolver_specs_by_target 以及 ResolverSpecification # \u0026gt;= 1.5.0 ResolverSpecification 才有 source,供 install 或者其他操作時,輸入 source 變更 # if Pod.match_version?('~\u0026gt; 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] \u0026amp; use_source_pods).empty? \u0026amp;\u0026amp; (podfile.use_binaries_selector.nil? || podfile.use_binaries_selector.call(rspec.spec)) end else [] end specs_by_target[target] = rspecs.map do |rspec| # developments 元件採用預設輸入的 spec (development pods 的 source 為 nil) next rspec unless rspec.spec.respond_to?(:spec_source) \u0026amp;\u0026amp; rspec.spec.spec_source # 採用二進位制依賴並且不為開發元件 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? # 這裡可能出現分析依賴的 source 和切換後的 source 對應 specification 的 subspec 對應不上 # 造成 subspec_by_name 返回 nil,這個是正常現象 next unless specification # 組裝新的 rspec ,替換原 rspec rspec = if Pod.match_version?('~\u0026gt; 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 =\u0026gt; error # 沒有從新的 source 找到對應版本元件,直接返回原 rspec missing_binary_specs \u0026lt;\u0026lt; rspec.spec if use_binary rspec end rspec end.compact end missing_binary_specs.uniq.each do |spec| UI.message \u0026quot;【#{spec.name} | #{spec.version}】元件無對應二進位制版本 , 將採用原始碼依賴.\u0026quot; end if missing_binary_specs.any? specs_by_target end end end if Pod.match_version?('~\u0026gt; 1.4.0') # 1.4.0 沒有 spec_source class Specification class Set class LazySpecification \u0026lt; 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 endend
上面就是切換私有源的程式碼邏輯,可以看到還是比較簡短的,這裡只單獨說三點:
- 我們預設 Development Pods 中的元件為未釋出元件,沒有二進位制版本,所以始終採用原版本
- 因為無法直接從 source 中獲取元件的 subspec ,所以這裡統一獲取 root spec ,如果目標 spec 是 subspec 再從 root spec 中獲取 subspec
- 其他業務線的元件可能沒有二進位制化版本,這裡我們如果沒有找到元件目標版本的 spec ,會讓元件採用原版本,這樣就不會因為某個元件版本的缺失而導致 install 失敗。
存在兩個私有源意味著會有兩個不同的 podspec ,分別為原始碼 podspec 和二進位制 podspec ,手動同步這兩個 podspec 將會是一個很耗費精力的事情,這時候就需要 cocoapods-bin 外掛的輔助命令了。針對沒有 subspec 的元件,cocoapods-bin 會根據原始碼 podspec 自動生成對應的二進位制 podspec ;針對有 subspec 的元件,cocoapods-bin 會根據使用者提供的 template podspec 和原始碼 podspec 自動生成對應的二進位制 podspec 。由於原始碼 podspec 和二進位制 podspec 的 diff 是可預見的,我們就可以通過這種半自動的方式避免同時維護兩套 podspec 。
更多使用資訊可以檢視 cocoapods-bin 的 README ,這裡就不贅述了。
整合 CI
從上文可以看出,二進位制化還是增加了重複性工作,包括製作二進位制包、釋出二進位制版本等,如果不輔以自動化工具,無疑會增加元件維護者的工作。
在火掌櫃 iOS 團隊 GitLab CI 整合實踐的基礎上,我們對 CI 配置檔案做了些調整:
variables: # 二進位制優先 BINARY_FIRST: 1 # 不允許通知 DISABLE_NOTIFY: 0before_script: # https://gitlab.com/gitlab-org/gitlab-ce/issues/14983 # shared runner 會出現,special runner只會報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.shafter_script: - rm -fr ci-yaml-shellstages: - check - lint - test - package - publish - report - cleanupcomponent_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: productionpublish_code_pod: stage: publish only: - tags retry: 0 script: - ci-yaml-shell/publish_code_pod.sh tags: - iOSCD environment: name: productionpublish_binary_pod: stage: publish only: - tags retry: 0 script: - ci-yaml-shell/publish_binary_pod.sh tags: - iOSCD environment: name: productionreport_to_director: stage: report script: - ci-yaml-shell/report_executor.sh only: - master - tags when: on_failure tags: - iOSCD
推送 tag 後,如果一切順利,可以看到 pipeline 執行結果如下:
其中的 package 、 publish 這兩個 stage 囊括了二進位制化資源製作的主要工作,元件維護者依然可以像二進位制化前一樣,關注原始碼版本的釋出流程即可。
這裡需要注意的是,由於 CocoaPods push 的 Validator 和 lint 基本一致,上文提到的這個 bug ,對 publish stage 也會有影響,需要暫時指定 CocoaPods 為 1.4.0 版本(pod _1.4.0_ bin repo push
)。
總結
整個元件二進位制化的嘗試與實踐,耗費了我大半年的主要精力,並且我們還需要多維護一個二進位制檔案伺服器,以及對應的二進位制版本,在元件 / 程式碼不多時,做這件事情費時費力,還收效甚微,因此我並不建議還未進行業務元件化並且沒有上 CI 的團隊去做這件事情。
結合我們團隊目前的業務性質以及業務元件化程式,在團隊實施了元件二進位制化之後,團隊內部工程編譯速度的提升還是顯而易見的,並且受益於編譯時間的減少,元件自動釋出平臺的釋出時間也大大減少,所以對於我們來說,花時間去做這件事情還是值得的。
參考
相關文章
- Cocoapods 二進位制
- 基於CocoaPods的元件化原理及私有庫實踐元件化
- 手淘架構組最新實踐 | iOS基於靜態庫插樁的⼆進位制重排啟動優化架構iOS優化
- iOS 元件化 使用cocoapods整合實戰演練iOS元件化
- 基於LINUX的MySql二進位制本地升級實施文件LinuxMySql
- 二進位制、十進位制與十六進位制相互轉化
- 手淘架構組最新實踐 | iOS基於靜態庫插樁的⼆進位制重排啟動最佳化架構iOS
- iOS元件化實踐iOS元件化
- 計算機基礎進位制轉換(二進位制、八進位制、十進位制、十六進位制)計算機
- 『Note』基於斜二進位制的資料結構資料結構
- 基於Linux的MySQL5.7的二進位制安裝LinuxMySql
- [計算機基礎] 計算機進位制轉換:二進位制、八進位制、十進位制、十六進位制計算機
- 二進位制,八進位制,十進位制,十六進位制的相互轉換
- 3416:【例72.1】 二進位制轉化為十進位制
- 二進位制與二進位制運算
- java中二進位制、八進位制、十進位制、十六進位制的轉換Java
- 二進位制,八進位制,十進位制,十六進位制之間的轉換
- 進位制詳解:二進位制、八進位制和十六進位制
- 基於LINUX的MySql二進位制本地安裝和部署實施測試LinuxMySql
- JavaScript 二進位制、八進位制與十六進位制JavaScript
- 關於二進位制世界的祕密
- 二進位制檔案視覺化(二)視覺化
- 二進位制
- (二進位制)
- 十進位制——二 (八、十六 )進位制
- 基於 MVP 的 Android 元件化開發框架實踐MVPAndroid元件化框架
- 【進位制轉換】二進位制、十六進位制、十進位制、八進位制對應關係
- Java基礎系列-二進位制操作Java
- 進位制之間的轉換之“十六進位制 轉 十進位制 轉 二進位制 方案”
- iOS 解藕、元件化最佳實踐iOS元件化
- 遞迴函式實現十進位制正整數轉換為二進位制,八進位制,十六進位制遞迴函式
- 二進位制轉十進位制快速方法
- JAVA 二進位制,八進位制,十六進位制,十進位制間進行相互轉換Java
- 什麼是二進位制?二進位制如何轉換?
- 元件化工具BeeHive(二):元件化實踐元件化Hive
- 元件化實踐詳解(二)元件化
- 04 二進位制
- leetcode -- 二進位制LeetCode