[貝聊科技]如何將 iOS 專案的編譯速度提高5倍

貝聊科技發表於2017-06-28

前言

貝聊目前開發的兩款App分別是貝聊家長版和貝聊老師版,最近因為在快速迭代開發新功能,專案規模急速增長,單個端業務程式碼約23萬行,私有庫約6萬行,第三方庫程式碼約15萬行,單個客戶端的程式碼行數約60萬。現在打包一次耗時需要11~12分鐘。雖然還遠遠比不上 Facebook 的40分鐘,但是我們在內測的時候,經常一天要釋出內測版兩到三次。打包時CPU佔用基本上是百分百的,因為沒有專門的 CI 機器,對負責打包的同事(其實就是我自己)的工作時間佔用比較多,所以最近一直在尋找加快打包速度的方案。

目前的專案架構

我們的專案使用 CocoaPods 來管理第三方庫和私有庫的依賴,對大部分專案來說應該是標配了。目前還是純 Objective-C 的專案,沒有引入 Swift。

調研過的方案

下面列出我研究過的一些主流方案以及我最後沒有采用的原因,這些方案有各自的侷限性,但是也給了我不少啟發,思考過程跟最終方案一樣有價值。

cocoapods-packager

cocoapods-packager 可以將任意的 pod 打包成 Static Library,省去重複編譯的時間,一定程度上可以加快編譯時間,但是也有自身的缺點:

  1. 優化不徹底,只能優化第三方和私有 Pod 的編譯速度,對於其他改動頻繁的業務程式碼無能為力
  2. 私有庫和第三方庫的後續更新很麻煩,當有原始碼修改後,需要重新打包上傳到內部的 Git 倉庫
  3. 過多的二進位制檔案會拖慢 Git 的操作速度(目前還沒部署 Git 的 LFS
  4. 難以除錯原始碼

Carthage

這個方案跟 cocoapods-packager 比較類似,優缺點都差不多,但 Carthage 可以比較方便地除錯原始碼。因為我們目前已經大規模使用 CocoaPods,轉用 Carthage 來做包管理需要做大量的轉換工作,所以不考慮這個方案了。

Buck

Buck 是一套通用的構建系統,由 Facebook 開源。最大的特色是智慧的增量編譯可以極大地提高構建速度。最早聽說 Buck 的時候,它還只能用在安卓上,現在已經適配了 iOS。

它能增快構建速度的主要原因是快取了編譯結果,通過持續監視專案目錄的檔案變化,每次編譯時只編譯有改動的檔案。另外一個讓我很受啟發的功能是 HTTP Cache Server,通過一臺快取檔案伺服器來儲存大家的編譯結果,這樣只要團隊裡其中一人編譯過的檔案,其他人就不用再編譯了,直接下載就行。

Buck 是個相當完備的解決方案,很多國外的大公司例如 Uber 都已經用上。我也花了很多時間來研究,最終還是認為對我們的專案和團隊來說,目前並不是很適合,主要原因是:

  1. Buck 拋棄了 Xcode 的專案檔案,需要手工編寫配置檔案來指定編譯規則,這要對現有專案作出大幅度的調整。我們目前還在快速迭代新功能,沒有餘暇和人手來實施。
  2. 開發和除錯的流程都得做出很大的改變。因為 Buck 接管了專案編譯的過程,想除錯專案不能簡單地在 Xcode 裡面 ⌘+R 了,得先反過來讓 Buck 生成 Xcode 的專案檔案。Uber 的工程師甚至推薦使用 Nuclide 來代替 Xcode 作為開發環境。雖然原理上是可行的,但是團隊需要花不少時間來適應,短期內效率降低無可避免。
  3. 用 Xcode 除錯程式碼享受不到加快編譯速度的好處。雖然可以用 buck 命令啟動 App,然後在命令列裡啟動 lldb 來除錯,但那就無法使用 Xcode 的除錯工具 例如 View Debugging 和 Memory Graph Debugger。

Bazel

Bazel 跟 Buck 很相似,是 Google 開源的,優缺點跟 Buck 都差不多,不再詳細說了。

distcc 分散式編譯

原理是把一部分需要編譯的檔案傳送到伺服器上,伺服器編譯完成後把編譯產物傳回來。我嘗試了一下比較出名的 distcc,搭建過程比較簡單,最後也能成功地把編譯任務分派到內網的多臺伺服器上。但是其他編譯伺服器的 CPU 佔用總是很低,只有 20% 左右;也就是說分派任務的速度甚至還趕不上伺服器編譯的速度,分派任務然後回傳編譯產物這個過程所耗費的時間超過了本地直接編譯。不停調整引數反覆試驗了很多次,最後發現編譯時間完全沒有變快,甚至還有點變慢了。可能以我們目前專案的規模並不適合使用分散式編譯。

最終方案:CCache

先來看看我對於解決方案的訴求:

  1. 能大幅度地提升編譯速度,起碼要減少掉 50% 的編譯時間
  2. 不需要對專案作出重大調整
  3. 不需要改變開發工具鏈

CCache 是一個能夠把編譯的中間產物快取起來的工具,在其他領域已經有不少應用,只是在 iOS 界的實踐比較少。經過我的實踐,它能夠滿足我前面的三點要求。我最早認識到它是搜到了這篇文章:pspdfkit.com/blog/2015/c…

如果你不使用 CocoaPods,參照上面的文章即可。因為針對 CocoaPods 需要作出一些額外的調整,所以還是說明一下。下面就來說說要怎樣把 CCache 應用在用 CocoaPods 作為包管理工具的 iOS 專案中。

安裝步驟:

注意:專案路徑不能有中文,否則會影響 CCache 的正常工作

安裝 CCache

首先你需要在電腦上安裝 Homebrew,對使用 macOS 的程式設計師來說應該是標配,略過。

通過 Homebrew 安裝 CCache, 在命令列中執行
$ brew install ccache

命令跑完後即安裝成功。

建立 CCache 編譯指令碼

為了能讓 CCache 介入到整個編譯的過程,我們要把 CCache 作為專案的 C 編譯器,當 CCache 找不到編譯快取時,它會再把編譯指令傳遞給真正的編譯器 clang。

新建一個檔案命名為ccache-clang, 內容為下面這段指令碼,放到你的專案裡

ccache-clang

#!/bin/sh
if type -p ccache >/dev/null 2>&1; then
  export CCACHE_MAXSIZE=10G
  export CCACHE_CPP2=true
  export CCACHE_HARDLINK=true
  export CCACHE_SLOPPINESS=file_macro,time_macros,include_file_mtime,include_file_ctime,file_stat_matches

  # 指定日誌檔案路徑到桌面,等下排查整合問題有用,整合成功後刪除,否則很佔磁碟空間
  export CCACHE_LOGFILE='~/Desktop/CCache.log'
  exec ccache /usr/bin/clang "$@"
else
  exec clang "$@"
fi複製程式碼

在命令列中,cd 到 ccache-clang 檔案的目錄,把它的許可權改成可執行檔案
$ chmod 777 ccache-clang

如果你的程式碼或者是第三方庫的程式碼用到了C++,則把ccache-clang這個檔案複製一份,重新命名成ccache-clang++。相應的對clang的呼叫也要改成clang++,否則 CCache 不會應用在 C++ 的程式碼上。

ccache-clang++

#!/bin/sh
if type -p ccache >/dev/null 2>&1; then
  export CCACHE_MAXSIZE=10G
  export CCACHE_CPP2=true
  export CCACHE_HARDLINK=true
  export CCACHE_SLOPPINESS=file_macro,time_macros,include_file_mtime,include_file_ctime,file_stat_matches

  # 指定日誌檔案路徑到桌面,等下排查整合問題有用,整合成功後刪除,否則很佔磁碟空間
  export CCACHE_LOGFILE='~/Desktop/CCache.log'
  exec ccache /usr/bin/clang++ "$@"
else
  exec clang++ "$@"
fi複製程式碼

完成後專案中應該有這兩個檔案

scripts
scripts

Xcode 專案的調整

定義CC常量

在你專案的構建設定(Build Settings)中,新增一個常量CC,這個值會讓 Xcode 在編譯時把執行路徑的可執行檔案當做 C 編譯器。

user-defined-build-settings
user-defined-build-settings

CC
CC

CC常量的值為 $(SRCROOT)/ccache-clang,如果你的指令碼不是放在專案根目錄,則自行調整路徑。如果一執行專案就報錯,檢查下路徑是不是填錯了。

關閉 Clang Modules

因為 CCache 不支援 Clang Modules,所以需要把 Enable Modules 的選項關掉。這個問題在 CocoaPods 上如何處理,後面會講。

enable-modules
enable-modules

關閉了 Enable Modules 後需要作出的調整

因為關閉了 Enable Modules,所以必須刪除所有的 @import語句,替換為#import的語法
例如將 @import UIKit 替換為 #import <UIKit/UIKit.h>。之後,如果你用到了其他的系統框架例如 AVFoundation、CoreLocation等,現在 Xcode 不會再幫你自動引入了,你得要在專案 Target 的 Build Phrase -> Link Binary With Libraries 裡面自己手動引入。

測試效果

嘗試編譯一遍,然後在命令列裡輸入 ccache -s 就能看見類似下面的 ccache 執行情況統計:

cache directory                     /Users/mac/.ccache
primary config                      /Users/mac/.ccache/ccache.conf
secondary config      (readonly)    /usr/local/Cellar/ccache/3.3.4_1/etc/ccache.conf
cache hit (direct)                 14378
cache hit (preprocessed)            1029
cache miss                          7875
cache hit rate                     66.18 %
called for link                       61
called for preprocessing              48
compile failed                         2
preprocessor error                     4
can't use precompiled header          70
unsupported compiler option         2332
no input file                         11
cleanups performed                     0
files in cache                     35495
cache size                           1.3 GB
max cache size                       5.0 GB複製程式碼

如果成功接入,就能看見 cache miss 不為0。因為第一次編譯沒有快取,肯定是全 miss 的。接著編譯第二遍,如果能看見 cache hit 的數字開始飆升,恭喜你,接入成功了。

CocoaPods 的 處理

如果你的專案不用 CocoaPods 來做包管理,那你已經完全接入成功了,不用執行下面的操作。

因為 CocoaPods 會單獨把第三方庫打包成一個 Static Library(或者是Dynamic Framework,如果用了 use_frameworks!選項),所以 CocoaPods 生成的 Static Library 也需要把 Enable Modules 選項給關掉。但是因為 CocoaPods 每次執行 pod update 的時候都會把 Pods 專案重新生成一遍,如果直接在 Xcode 裡面修改 Pods 專案裡面的 Enable Modules 選項,下次執行pod update的時候又會被改回來。我們需要在 Podfile 裡面加入下面的程式碼,讓生成的專案關閉 Enable Modules 選項,同時加入 CC 引數,否則 pod 在編譯的時候就無法使用 CCache 加速:

post_install do |installer_representation|
  installer_representation.pods_project.targets.each do |target|
    target.build_configurations.each do |config|
      #關閉 Enable Modules
      config.build_settings['CLANG_ENABLE_MODULES'] = 'NO'

      # 在生成的 Pods 專案檔案中加入 CC 引數,路徑的值根據你自己的專案來修改
      config.build_settings['CC'] = '$(PODS_ROOT)/../ccache-clang' 
    end
  end
end複製程式碼

需要注意的是,如果你使用的某個 Pod 引用了系統框架,例如AFNetworking引用了System Configuration,你需要在你自己專案的Build Phrase -> Link Binary With Libraries裡面代為引入,否則你編譯時可能會收到 Undefined symbols xxx for architecture yyy一類的錯誤。有點回到了原始時代的感覺,但考慮到編譯速度的極大提升,這一點代價可以接受。

整合問題排查

重點關注日誌檔案的輸出和ccache -s 命令的統計,如果在日誌中看到了 unsupported compiler option -fmodules 這樣的字眼,就是你的 Enable Modules 沒有關掉了,根據前面的步驟仔細檢查。其他問題,參考官方文件的 Troubleshooting

進一步的優化

移除 Precompiled Header File

PCH 的內容會被附加在每個檔案前面,而 CCache 是根據檔案內容的 MD4 摘要來查詢快取的,因此當你修改了 PCH 或者 PCH 引用到的標頭檔案的內容時,會造成全部快取失效,只能全體重新編譯。CCache 在首次編譯的時候因為需要更新快取,會造成編譯時間變長,對貝聊的專案來說變長了差不多一倍。因此如果 PCH 或者 PCH 引入的檔案被頻繁修改的話,快取就會頻繁地 miss,這種情況下還不如不用 CCache。

為了避免以上這種情況,我建議在 PCH 裡面儘量少引入標頭檔案,只保留比較少更改的系統框架和第三方類庫的標頭檔案。最好是把 PCH 徹底刪除,反正蘋果現在也不建議使用 PCH 了,Xcode 新建的專案預設都是不帶 PCH 的。

在團隊內部共享快取資料夾

這個優化方式我嘗試過,最終效果不是很好,因此沒有采用。CCache 的官方文件中有一段關於共享快取資料夾的說明,描述瞭如何修改 CCache 的配置,讓編譯快取能夠在多臺電腦之間公用,理論上只要其中一個人編譯過的檔案其他人就能直接下載到了,節約了整個團隊的時間。因為 Buck 也有類似的機制,我覺得值得嘗試一下,便在公司區域網內搭建了一個 OwnCloud 網盤,讓大家把自己電腦上的 CCache 快取目錄放上去共享。雖然試驗是成功了,但是實際效果並不好。因為同步在多臺電腦上大小達到幾個G的快取目錄,需要在後臺進行很多檔案的對比和傳輸的工作,在編譯的同時進行這些操作會耗費不少計算資源,反而會拖慢編譯速度。加上移除掉 PCH 後,其實快取的命中率已經相當可觀了,不太需要通過共享快取來進一步提高快取命中率,所以我最後放棄了共享快取這個想法。如果你對快取命中率還是不滿意的話,可以考慮往這個方向嘗試一下。

總結

通過整合 CCache,我們的專案在 Xcode 裡面的打包(在選單裡面選擇 Product -> Archive)時間從 11~12分鐘減少到了 130 秒,大概有五倍的提升,成果喜人。整合的過程其實很簡單,我從開始嘗試到整合成功總共就花了兩個小時。如果你也被過長的編譯時間困擾,建議嘗試一下。

文章同步釋出在 zhuanlan.zhihu.com/p/27584726

相關文章