火掌櫃iOS端基於CocoaPods的元件二進位制化實踐

weixin_33766168發表於2019-02-21

火掌櫃 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 已經幫我們做好了。

二進位制化需求

以下是根據掌櫃團隊日常開發情況,提出的二進位制化需求點:

  1. 不影響未接入二進位制化方案的業務團隊
  2. 元件級別的原始碼 / 二進位制依賴切換功能
  3. 無二進位制版本時,自動採用原始碼版本
  4. 接近原生 CocoaPods 的使用體驗 (為了滿足此需求,我們決定開發自定義的 CocoaPods 外掛。)
  5. 不增加過多額外的工作量

下面我會參照這幾個需求點,逐步說明掌櫃 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-packagerCarthage ,目前我們使用 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

可以看到,其中的 HeadersResourcesVersions/Current 都是軟連結。podspec 中涉及到檔案匹配的欄位,如 source_filespublic_header_filesresources 等,對軟連結是無效的,所以需要設定為檔案實際存放的路徑:

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 自身的執行機制)。

這種方案最大的困擾在於切換依賴時,如何規避元件快取帶來的負面影響,處理不當容易出現工程元件目錄為空的情況,以下是我實踐過的兩種方案:

  1. 確保快取中同時存在原始碼和二進位制的資源及檔案(設定 preserve_paths)

  2. 切換依賴前,刪除目標元件快取以及本地 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.git1.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 執行結果如下:

\"pipeline

其中的 package 、 publish 這兩個 stage 囊括了二進位制化資源製作的主要工作,元件維護者依然可以像二進位制化前一樣,關注原始碼版本的釋出流程即可。

這裡需要注意的是,由於 CocoaPods push 的 Validator 和 lint 基本一致,上文提到的這個 bug ,對 publish stage 也會有影響,需要暫時指定 CocoaPods 為 1.4.0 版本(pod _1.4.0_ bin repo push)。

總結

整個元件二進位制化的嘗試與實踐,耗費了我大半年的主要精力,並且我們還需要多維護一個二進位制檔案伺服器,以及對應的二進位制版本,在元件 / 程式碼不多時,做這件事情費時費力,還收效甚微,因此我並不建議還未進行業務元件化並且沒有上 CI 的團隊去做這件事情。

結合我們團隊目前的業務性質以及業務元件化程式,在團隊實施了元件二進位制化之後,團隊內部工程編譯速度的提升還是顯而易見的,並且受益於編譯時間的減少,元件自動釋出平臺的釋出時間也大大減少,所以對於我們來說,花時間去做這件事情還是值得的。

參考

iOS CocoaPods元件平滑二進位制化解決方案

iOS CocoaPods元件平滑二進位制化解決方案及詳細教程二之subspecs篇

元件化-二進位制方案

相關文章