Monorepo 解決方案 — 基于 Bazel 的 Xcode 性能優化實踐
背景介紹
書接上回《Monorepo 解決方案 — Bazel 在頭條 iOS 的實踐》,在頭條工程切換至 Bazel 構建系統后,為了支持用戶使用 Xcode 開發的習慣,我們使用了開源項目 Tulsi 作為生成工具,用于將 Bazel 工程轉換為 Xcode 工程。但是在使用的過程中,我們發現了一些問題,其中影響較大的是,
- Xcode 工程卡頓:對于頭條這種大型項目來說,Xcode 卡頓一直是本地研發的痛點問題,在切換 Bazel 構建系統之后卡頓現象明顯加劇。
- Xcode 功能支持受限:Tulsi 支持的功能有限,很多功能都年久失修,并未持續適配 Bazel 與 rules 的更新。
在做了一些前期調研后,我們發現 rules_xcodeproj 提供了更好的解決方案。rules_xcodeproj 是一個由一系列 Bazel rules 組成的開源項目,使用它可以從一個 Bazel 工程生成對應的 Xcode 工程,實現在 Xcode 中寫代碼同時使用 Bazel 進行真正的構建任務,相比于 Tulsi,rules_xcodeproj 在以下幾個方面有著更為明顯的優勢。
- Xcode 工程更加流暢: 頭條工程遷移到 rules_xcodeproj 后,工程首次冷啟、二次啟動和文件增刪操作的時間有了明顯縮短,工程卡頓情況也明顯好轉。
- Xcode 功能支持度更高: rules_xcodeproj 對 Xcode 的支持更全面(包括單元測試、SwiftUI Previews),能夠更好地滿足我們的需求。
- 社區更加活躍:隨著 rules_xcodeproj 在 2023 年 2 月份發布正式版,Tulsi 項目也正式宣布停止維護,這意味著對于新版本的 Bazel,Tulsi 將不再提供適配和支持;同時 rules_xcodeproj 幾乎每月都會更新一個中版本,對于后續適配 Bazel 更新的成本會更低。
- 更符合 BitSky 的演進路線:由 Bazel 驅動的工程生成方式更符合 BitSky 的 Bazel Native 演進路線,可以完全在 Bazel 環境下生成工程。
因此,我們將 Xcode 生成工具從 Tulsi 遷移到了 rules_xcodeproj,并對 BitSky 工具鏈進行了適配。適配過程中,我們在修復 rules_xcodeproj 索引問題的同時,對工程結構進行了一些優化,進一步提升了工程流暢度。頭條 iOS 工程的測試數據如下:
測試設備 MacBook Pro,芯片 M1 Pro,內存 32GB
ps. 本文介紹均基于 rules_xcodeproj 1.4.0 版本,build with bazel 模式
pps. 由于 Xcode 主線程的卡頓較難監測,此處用主動操作的執行時間衡量流暢度
Tulsi | rules_xcodeproj | |
工程首次冷啟 | 47s | 16s |
二次啟動 | 33s | 12s |
文件操作 | 新增 20s 刪除 23s | 新增 8s 刪除 6s |
下面來看下我們具體做的適配工作。
適配過程
從 Tulsi 遷移到 rules_xcodeproj 后,我們發現 Xcode 卡頓有明顯改善,仔細分析發現 Tulsi 工程的卡頓主要有兩方面原因:
- 頭條工程中組件數量多、依賴關系復雜:Tulsi 的索引方案需要 Xcode Target 之間保留這些依賴關系,但這些依賴關系并不會被 Xcode 構建消費,卻增加了 Xcode 對工程進行解析和計算的成本。
圖中 Pod X_A 與 Pod X_B 為 Pod X 分別在 App A 與 App B 下被引用的 Target
- 全源碼構建:我們在切換 Bazel 時還推進了工程的全源碼構建,源碼數量大幅增加,也給索引任務帶來更大的壓力。
接下來詳細說明整個分析和適配過程,帶大家更全面地了解我們結合 rules_xcodeproj 在 Xcode 背后做了什么。
在 Xcode 中開發時主要會用到三部分功能:構建、索引和調試。而 Xcode 并不能直接理解 Bazel 項目的工程文件(BUILD 和 WORKSPACE),所以我們需要通過工具(rules_xcodeproj 或 Tulsi)將 Bazel 的工程文件轉換為 Xcode 可以理解的 .xcodeproj
來支持這些功能正常工作。
各功能的適配點如下表所示。
rules_xcodeproj 原生方案的調試模塊基本能正常工作,而索引和構建兩個模塊中我們對原生方案的改造較大,因此本文主要對這兩個模塊進行展開介紹。
索引
前面提到支持 Xcode 索引功能需要提供各個源文件的編譯參數,rules_xcodeproj 實現這一點的工作流程是:
- rules_xcodeproj 在 Bazel 的分析階段獲取到源碼文件和編譯參數;
- 用這些信息創建 Xcode Target 的 Compile Sources 和 Build Setting 產出工程文件
.xcodeproj
; - Xcode 加載工程文件,獲取源碼文件對應的索引參數,調用 clang 或 swiftc 執行索引命令。
遷移過程中我們注意到 rules_xcodeproj 工程的索引在多 Target 共用源文件的場景存在語法高亮異常的問題,對比 Tulsi 工程的處理方式后我們得出兩個結論:
- 索引異常是由于 rules_xcodeproj 移除了所有 Library Target 間的依賴導致的;
- Tulsi 工程中 Library Target 間的依賴關系正是導致 Tulsi 工程更為卡頓的關鍵原因。
后文會先分析依賴關系在索引中發揮的作用,以及 rules_xcodeproj 如何處理依賴關系移除帶來的問題,然后介紹我們如何通過源碼合并方案解決 rules_xcodeproj 索引方面的缺陷。
依賴關系在索引中的作用
這里提到的“依賴關系”主要是指 Xcode 工程文件(.xcodeproj
,準確來說是其中的project.pbxproj
文件)中記錄的 Xcode Target 之間的依賴關系。
需要明確的是這部分依賴關系只會在 Xcode 索引過程被用到,被移除后也只會影響 Xcode 的索引功能。構建時用到依賴關系 Bazel 會從 BUILD 文件中獲取,不會關心 .xcodeproj
中的信息。
這些依賴關系在 Xcode 中的表現形式如下圖所示,既有直接在 Build Phases 的 Target Dependencies 中聲明的依賴,也會對聲明在 Link Binary With Libraries 中 Target 產生依賴。
概括來說依賴關系在 Xcode 的索引過程中發揮著以下兩方面作用:
- 正確的中間產物生成順序:clang/Swift Module 中間產物的生成需要依賴關系來確定構建順序;
- 正確的多 Target 編譯參數:多 Target 共用源文件時應用正確的編譯參數,以便于正確地高亮代碼。
下面分別對其進行展開介紹。
中間產物生成順序
以一個 Swift 組件為例,當它被 OC 組件引用時,需要生成一個 XX-Swift.h 文件,把方法和聲明暴露給 OC 組件,當它被其他 Swift 組件引用時,也需要生成一個 swiftmodule 文件供其他 Swift 組件引用。XX-Swift.h 和 swiftmodule 并不是原始的源碼文件,也不是最終的二進制產物,是構建時的中間產物。在 Xcode 對一個組件的源代碼索引時,需要這個組件的依賴組件已經準備好中間產物供索引時消費。這里,我們先分析下 Xcode 是怎樣解決這個問題的。
首先,Xcode 通過 Target 來描述產物是如何構建出來的,每個 Target 擁有自己的 Build Phases 和 Build Settings(通常一個組件對應一個 Target)。Build Phases 中的 Compile Sources 記錄了構建這個 Target 需要編譯哪些源碼文件。Build Settings 里記錄了構建這個 Target 時的各種配置,這其中就包括了編譯參數。Xcode 可以從 Build Settings 里去獲取編譯參數,然后對 Compile Sources 中記錄的源碼文件進行索引編譯。
一個 Target 引用另外一個 Target 時,需要將依賴關系添加到 Build Phases 的 Target Dependencies 中。Xcode 在構建時會根據這些依賴關系來決定構建 Target 的順序,保證一個 Target 構建時,它依賴的 Target 已經完成構建。
索引時也是類似的處理,Xcode 有一個 Index Build 的階段,在這個階段也會根據依賴關系按照順序去觸發 Target 的 Build Phase,完成之后才會開始索引這個 Target 的源碼文件。
這里有一個例子,SwiftDemo 依賴了 HelloLib,兩個 Target 均為 Swift 組件。
在對 SwiftDemo 的源碼文件進行索引編譯之前,會先觸發 Index Build。Index Build 時根據依賴關系,先 Build HelloLib,這個時候會進行 Compile Swift source files,參數中包含 -emit-module -emit-module-path /path
,最終會生成 swiftmodule 。
如果將 HelloLib 從 SwiftDemo 的 Target Dependencies 中移除,在 SwiftDemo 的 Index Build 時,不會先 Build HelloLib,并且在 HelloLib 的 Index Build 中,也不會生成 swiftmodule。
可以看出,Xcode 正是通過依賴關系來保證構建時的順序,索引也依賴構建順序來保證 Module 中間產物的生成時機。
在 Tulsi 生成的 Xcode 工程中,依然保留了 Target 之間的依賴關系,用于解決索引的中間產物生成問題。這里就不做過多介紹。
而在 rules_xcodeproj 生成的工程中,Target 之間的依賴關系是完全去掉的,每個 Target 都有且僅有一個額外添加的依賴 BazelDependencies。對于 Module 中間產物的處理,在生成的 Xcode Target 中,我們可以看到這樣一條 Build Phase。
原理是在 Index Build 階段,執行到這 Target 時去跑一個 shell 腳本。
以 NewsInHouse 這個 Target 為例,這個腳本里經過一系列的處理,最終會去調用這樣一條 Bazel Build 命令,
在這條命令中有一些關鍵信息:
@rules_xcodeproj_generated//generator/xcodeproj:xcodeproj
Bazel Build 的 Target,可以認為是我們使用 rules_xcodeproj 定義的生成工程的 Bazel Target。
bc //Article:NewsInHouse applebin_ios-*
在上述 Target 里 rules_xcodeproj 添加了一些 OutputGroupInfo。Bazel Build 時可以通過 --output_groups 參數指定輸出產物。
這一條 output group 對應的是 //Article:NewsInHouse 及其依賴 Target 的產物中, swiftmodule 之類的編譯依賴部分。
bg //Article:NewsInHouse applebin_ios-*
這一條 output group 對應的是 //Article:NewsInHouse 及其依賴 Target 的輸入文件中的非源文件的部分,比如編譯時依賴的 hmap 。
這樣的 Bazel Build 命令可以在 Index Build 時將 hmap 和 swiftmodule 之類的索引中間產物生成出來,然后再索引具體文件時就不會因為缺少這些中間產物而失敗。
并且這里的依賴關系是由 Bazel 去處理的,不是必須像 Xcode 原生機制那樣,按照依賴關系來決定 Index Build 中 Target 的順序。
所以僅從 "Module 中間產物" 這方面來說,Xcode 中的依賴關系并不是必需的。
多 Target 編譯參數
除了中間產物生成之外,依賴關系在多個 Target 共用源文件時的編譯參數計算也發揮著作用。這里的“編譯參數計算”是指當一個源文件被不同 Target 引用時,應用的編譯參數可能不同的情況。這么介紹比較抽象,來看下具體的例子:
下面 Demo 工程中有兩個 App Target:AppA 和 AppB
- 在兩個 App Target 的 Build Settings 中分別注入了宏
IS_APP_A
和IS_APP_B
。
- 有一份公共的代碼文件 public.m 分別被添加到兩個 App Target 的 Compile Sources 中。
- public.m 內用預編譯宏隔離了存在差異的邏輯
- 隨著我們切換構建的 App Scheme,由于編譯參數的差異,宏作用域中高亮的代碼區域也會隨之變化(如下圖)。
此時的工程結構如下圖所示,Xcode 可以通過選中的 AppA Scheme 獲取到 AppA Target 的 Build Settings(圖中紅線路徑),正確地傳遞編譯參數-DIS_APP_A=1
。
實際的情況會復雜一些,因為工程的組件化建設將代碼下沉到了一個個組件內,而非直接與 App Target 關聯。此時同一份代碼文件在不同 App Target 內的索引參數計算,則是通過 Target 之間的依賴關系實現的。
對應到 Xcode 中,Xcode 可以通過 App Target -> Library Target 的依賴關系,應用對應的 Build Settings 生成索引。
此時的工程結構則變成了下圖的模式,Xcode 依然可以通過 AppA Target 與 PublicLibraryA Target 的依賴關系應用正確的編譯參數(圖中紅線路徑)。
Xcode 原生工程和 Tulsi 生成的 Xcode 工程都是通過這種依賴關系來保證編譯參數的正確計算的。
而 rules_xcodeproj 生成的工程完全移除了 Target 之間的依賴關系,轉而給所有 Target 都添加了對 BazelDependencies 的依賴(如下圖所示)。
從圖中可以看到,在缺少 AppA Target 對 PublicLibraryA Target 依賴的情況下,對于同時被 PublicLibraryA 和 PublicLibraryB 引用的 public.m ,Xcode 無法感知應該應用哪個 Target 中的編譯參數(圖中紅線路徑無法關聯 AppA Scheme 與 public.m)。此時 Xcode 觸發索引時應用的 Build Settings 是固定的,不會隨著構建 App Scheme 切換而更改。
具體的表現當構建目標從 AppB 切換到 AppA 時,IS_APP_B
宏中的代碼仍然會展示為高亮,而不會隨之切換,從而給開發者帶來困惑。
對于這個問題,rules_xcodeproj 可以通過構建索引解決,因為依賴信息在 Bazel 側(BUILD 文件中)是完整的,所以觸發構建后可以讓代碼高亮正確展示。
但由于編輯索引使用的參數是 Xcode 從文件所屬的 Library Target 的 Build Settings 中獲取的,因此在代碼編輯過程中仍然會出現高亮錯誤的問題。
構建索引:指在構建過程中輸出索引產物,需要通過 index-import 導入 Xcode 緩存目錄(Derived Data)下供 Xcode 消費。
編輯索引:在代碼編輯過程中實時生成的索引,在內存中消費索引結果,不會將產物寫入磁盤。
在這個場景下,rules_xcodeproj 移除依賴的做法是有缺陷的。
那么我們要在 rules_xcodeproj 恢復 Target 間的依賴關系么?
答案是不需要。首先,前文有提及大量復雜的依賴關系會導致 Xcode 卡頓,不應恢復;其次,要解決這類代碼高亮錯誤的問題,需要的其實并不是所有 Target 之間的依賴關系,而是源碼文件與當前構建 App Target 的關系。
回顧一下 Demo 工程最簡單的結構,當源碼文件直接被對應 App Target 引用時,是不需要 Library Target 間的依賴關系來建立聯系的。
基于這一思路,我們將所有 Library Target 的源碼合并到了對應 App Target。當然,直接合并源碼以后索引并不能正常工作,需要對受影響的功能點進行適配,這些適配將在下一節源碼合并方案中展開介紹。
源碼合并方案
索引參數接管
將所有源碼合并至 App Target 雖然能解決文件與 App 的關聯問題,但各個 Library Target 編譯參數是不同的,聚合之后不同 Target 下源文件的參數就無法通過 Build Settings 區分了。
對于這個問題,我們是通過 XCBBuildServiceProxy 接管索引參數計算解決的。
索引構建時,Xcode 會先將文件所屬 Target 的 Build Settings 發送給 XCBBuildService 處理成編譯器理解的參數,再交給 SKAgent 觸發編譯器進行實際編譯行為。而我們在 XCBBuildServiceProxy 的基礎上實現了 BitSkyXCBBuildService,可以攔截 Xcode 發給 XCBBuildService 的請求,通過 Bazel aquery 查詢到具體文件的編譯參數,直接返回給 Xcode 完成后續的索引構建行為。
完成源碼合并與索引參數接管這兩步改造以后,工程結構如下圖所示。可以看到 AppA Scheme 能夠直接通過 AppA Target 關聯到 public.m(圖中紅線),從而正確地應用編譯參數,高亮對應代碼塊。rules_xcodeproj 移除依賴關系的副作用也完全被修復。
Library Target 移除
完成源碼合并以及索引參數的接管之后,Library Target 中的主要信息( Build Settings 和源碼)都不再有意義了,是否能將這些 Target 信息直接移除呢?
經過梳理,Library Target 主要有以下三個作用,在完成源碼合并以及索引參數接管后,僅需對“Module 中間產物生成”進行一些改造即可將幾百個 Library Target 的信息進行移除,大幅精簡工程文件的內容。
Library Target 作用 | 說明 | 適配方案 |
觸發 Xcode 索引 | Xcode 只會對添加到 Build Phase - Compile Sources 中的源碼文件生成索引 | 將源碼添加到 App Target 的 Compile Sources 中有相同的效果; |
按 Target 維度隔離 Build Settings | Xcode 原生的索引功能會通過 Build Settings 生成文件的編譯參數 | 通過 XCBBuildServiceProxy 接管索引參數請求,交由 Bazel aquery 查詢具體文件的編譯參數 |
Module 中間產物生成 | 在 Library Target 的 Build Phase 觸發各個 Target 維度的中間產物生成 | 將所需產物聚合到各個 App Target 的 Build Phase 觸發生成 |
最終的工程結構如下圖所示。
整體方案上線后,頭條工程文件(project.pbxproj)行數從 45w 減少至 35w,工程啟動與文件操作耗時也比原來的 Tulsi 工程減少了 60% 以上。
Tulsi | rules_xcodeproj(原生) | rules_xcodeproj(源碼合并) | |
工程首次冷啟 | 47s | 22s | 16s |
二次啟動 | 33s | 16s | 12s |
文件操作 | 新增 20s 刪除 23s | 新增 13s 刪除 11s | 新增 8s 刪除 6s |
p.s. 源碼合并后存在一個副作用是 Xcode Build Phase 頁面加載時間會增加很多,但考慮到使用 bazel 構建后我們并不需要在 Build Phase 修改配置,這個副作用是可以接受的。
構建
rules_xcodeproj 目前提供了兩種 Build 模式,分別是 "Build with Xcode" 和 "Build with Bazel"。
- "Build with Xcode" 模式下,構建行為是由 Xcode 接管的。
- "Build with Bazel" 模式下,構建行為是由 Bazel 接管的。
據 rules_xcodeproj 官方介紹,"Build with Xcode" 模式在 Bazel 7 下將很難支持,并且即將到來的新的增量生成模式也會放棄 "Build with Xcode" 。
所以這里主要看一下 "Build with Bazel" ,這個模式生成的工程中,宿主 Target 對應一個 XCScheme,這個 Scheme 的 Build Pre-actions 里生成一個 SCHEME_Target_IDS_FILE 用于記錄 Target 的 Bazel Label。然后宿主 Target 依賴了 Target BazelDependencies,Xcode 在構建宿主 Target 之前會先構建 BazelDependencies。BazelDependencies 通過 Build Phase 去調用 Bazel Build,這個時候會解析 SCHEME_Target_IDS_FILE 獲取需要構建的 Bazel Target。BazelDependencies 構建完成后,宿主 Target 的 Build Phase 里會去把相應的 Bazel 的產物拷貝到 Xcode Derivedata 目錄下。
另外,在 rules_xcodeproj 的規劃中,未來還會提供一種新的模式,叫做 "Build with Proxy",在這個模式下,會通過 XCBBuildServiceProxy 完全繞過 Xcode build system,由 Bazel 控制整個 build 過程。相比 "Build with Bazel" ,這個模式可以帶來一些更貼近原生的 Xcode 使用體驗,比如:
- 無需添加
BazelDependencies
Target - 可以去掉重復的 warnings/errors
- 可以有更穩定的索引效果
- 可以在進度條展示更多信息
- 可以有更詳細的 Build 報告
當然這種模式也存在比較大的問題
- 在不同 Xcode 版本之間,Xcode 和 XCBBuildService 交互的 API 可能會有一些破壞性的變更,需要逐一適配
- 需要在 Xcode 啟動時注入環境變量,將 XCBBuildService 指向自定義的 XCBBuildServiceProxy
BitSky 目前采用的方案和 "Build with Proxy" 是類似的,通過 BitSkyXCBBuildService 接管 Xcode 的 build 行為。在用戶點擊 Build 時,BitSkyXCBBuildService 里可以從宿主 Target 的 Build Settings 里解析獲取對應的 Bazel Target,然后再由 BitSky 生成調用 Bazel Build 的命令,這樣可以保證 Bazel Build 的參數完全由 BitSky 控制,同時可以通過 Bazel 的 Build Event Protocol 來更好的提供 Xcode 的進度 和 Build 日志展示。
同時為了保證在打開生成的 Xcode 工程時,都能夠使用 BitSkyXCBBuildService,BitSky 在生成工程同時,會生成一個 Xcode 的影子分身 BitSkyXcode。使用這個 BitSkyXcode 打開工程,無需手動注入環境變量,體驗上和使用原生 Xcode 打開工程基本一致。
總結
本文主要介紹了我們將 Xcode 工程生成工具切換到 rules_xcodeproj 過程中做的一些適配和優化工作:
- 索引方面:
- 在分析 Tulsi 與 rules_xcodeproj 工程文件的過程中我們注意到最大的差異在于 rules_xcodeproj 移除了 Library Target 間的依賴關系,這也是 Tulsi 工程更加卡頓的罪魁禍首。
- rules_xcodeproj 移除依賴關系后會導致多 Target 共用的源文件語法高亮異常,我們通過源碼合并方案解決了這個問題,并且精簡了工程文件信息,提升了 Xcode 流暢度。
- 構建方面:
我們通過 BitSkyXCBBuildService 接管了 Xcode 的 build 行為,能夠更好地管理構建參數并在 Xcode 提供構建進度和日志的展示。
在完成切換之后,雖然 Xcode 代碼編輯過程中的卡頓得到了明顯的緩解,但本地研發的調試過程,仍然存在 Xcode 卡頓/卡死等現象,對研發同學的開發工作存在較大困擾。后續,我們將針對調試體驗,從生成工程的角度做一些優化工作。
目前考慮基于 Focus Mode 的理念,從底層能力上支持研發同學僅關注與當前需求開發相關聯的部分代碼,比如:
- 裁剪 Xcode 工程中需要索引的源代碼;
- 裁剪構建過程中需要執行編譯源代碼;
- 裁剪調試時調試器需要加載調試信息;
另外在用戶側,通過策略智能幫助研發同學,選擇和添加需要 "Focus" 的源碼。
參考文檔
Tulsi (https://github.com/bazelbuild/tulsi)
rules_xcodeproj (https://github.com/MobileNativeFoundation/rules_xcodeproj)
Migrating from Xcode to Bazel (https://bazel.build/migrate/xcode)