騰訊會議10秒編譯百萬代碼|鵝廠編譯加速標杆案例公開

圖片

圖片

👉騰小云導讀

作爲一個天然跨平臺的產品,騰訊會議從第一行代碼開始,團隊就堅持同源同構的思想,即同一套架構,同一套代碼,服務所有場景。過去一年,騰訊會議,迭代優化了 20000 個功能,穩定支持了數億用戶,其客戶端僅上層業務邏輯代碼就超 100 萬行,經過優化,目前在 Windows 平臺上的編譯時間最快縮短到 10秒,成爲行業 C++ 跨平臺項目的標杆。本文將詳細介紹背後的優化邏輯,希望給業界同行提供參考。

👉看目錄,點收藏

1 編譯加速有哪些方向?

2 如何優雅的預編譯 Module 產物?

2.1 構建在哪執行

2.2 如何增量發佈產物

2.3 預編譯產物上傳到何處

2.4 如何使用預編譯產物

3 發佈 Module 產物

4 使用Module產物

4.1 匹配產物

4.2 CMake產物替換源碼編譯

4.3 自動Generate

4.4 半自動Generate

4.5  IDE顯示源碼

5 斷點調試

5.1 Android產物替換

5.2 成也Maven,敗也Maven

5.3 Android Studio顯示產物源碼

6 萬物皆可增量

7 構建參數

8  總結

01、 編譯加速有哪些方向?

大家知道,編譯是將源代碼經過編譯器的預處理、編譯、彙編等步驟,生成能夠被計算機直接執行的機器碼的過程,而這個過程是十分耗時的。

常規的開發工具如 xcode、gradle 爲了提高效率都會自帶編譯緩存的功能,即將上一次編譯的結果緩存起來,對於沒有修改的代碼再次編譯就直接使用緩存。

但此這些緩存文件一般存在於本地,更新代碼後難免需要一次重編,生成新的編譯緩存。在會議這樣一個上百人的團隊裏,修改提交十分頻繁,更新一次代碼所需要重編的代碼量往往是十分巨大的。特別是一些被深度依賴的頭文件被修改,往往等價於需要全量編譯了。

雖說也有一些工具能夠支持雲端共享編譯緩存,如 gradle 的 remote build cache,但是對 C++ 部分並沒有 cache,而且方案也不能跨平臺通用。

騰訊會議經歷了框架 3.0 的模塊化改造後,原本一整塊代碼按照業務拆分出了若干個小模塊,開發需求修改代碼逐漸集中在模塊內部。這爲我們的編譯加速提供了新思路:每個業務模塊之間是不存在依賴關係的,那麼開發沒有修改的模塊是否可以免編譯呢?

圖片

那麼想要模塊免編譯,可行的方案有兩種:

  • Module 獨立運行,即不依賴其他 module 業務代碼,任意一個 module 能夠單獨調試運行,這樣就不需要編譯其他 module 了;
  • Module 預編譯,將所有 module 預先編譯好,本地開發直接下載預先編譯的 module 產物,在編譯完整app的時候直接組裝即可,類似雲端緩存的概念。

從長遠來看,如果 Module 獨立運行肯定是最優的,但是現階段比較難實現,雖然會議的模塊代碼沒有相互依賴,但業務功能間的相互依賴還是較高,模塊要獨立運行很難跑通完整功能;而 Module 預編譯方案在會議項目中的可行性更高,不需要改動業務邏輯。

02、 如何優雅的預編譯 Module 產物?

那麼既然要預編譯所有的 module,就需要有一個機器自動構建 module 產物,並上傳構建產物到雲端。本地編譯時從雲端拉取預先編譯好的產物來加速APP 編譯。

那麼,這裏有幾個問題需要確定:

1.構建在哪裏執行;

2.如何增量發佈產物;

3.預編譯產物上傳到何處;

4.如何使用預編譯產物

2.1 構建在哪執行

首先,產物構建需要一臺機器自動觸發,很自然會想到持續集成(Continuous Integration,簡稱 CI)機器,這裏我們選擇了 Coding CI 來自動觸發構建。首先來看看會議的開發模式:

圖片

會議的開發模式是從主分支拉子分支開發新需求,開發完成後再合入主幹。那麼 CI 應該在哪條流水線構建 module 產物呢?需要爲每條流水線都構建嗎?

如果在 master 流水線構建:那麼開發分支一旦 module 代碼有修改,module 緩存就失效了。只改動一兩個 module 還好,但如果是 module 全部失效(比如 module 依賴的公共接口有變更),那增量構建的邏輯就形同虛設了。

如果在 feature/bugfix 流水線構建:開發分支那麼多,每個分支流水線都跑一次 module 構建,時間、倉庫的存儲成本都激增。而且,每個分支的構建產物相互獨立,本地如果想要用產物加速編譯,就必須得先啓動流水線跑一次,等預編譯產物構建完成了纔可以使用。這對於拉一個 bugfix 分支修改兩行代碼就修復一個 bug 的場景來說是不可接受的。

2.1.1 有沒有更加優雅的方式呢?

這裏首先分析一下 module 之間的的關係,我們的module 相互之間代碼是沒有依賴的,module 共同依賴一些基礎代碼,我們稱之爲 module API。其實,大多數情況下,module 構建出來的產物,對於其他分支來說,只要module API 沒有變更就是可以複用的。那怎麼最大化的利用上這些產物呢?這取決於如何管理 module 產物的版本號,只要分支代碼有可用的版本號就可以複用產物。

比如,從 master 拉一個 bugfix 分支,只需要改幾行代碼,就可以直接用master 的版本號就行了。如果是 feature 分支,一開始也是從 master 繼承module 的版本號,隨着功能開發的進行,當我們修改了 module A 的代碼,再手動更新一下 module A 的版本號,最後隨着 feature 一起合入 master。

這樣一套流程似乎可行,只是實際操作起來會給開發者帶來一定的使用成本,因爲需要開發者手動管理 module 的版本號。

試想一下,如果有兩個 feature 分支都同時修改了同一個 module 的代碼,那他們都會去更新這個 module 的版本號,MR 的時候就會發生版本衝突:

圖片

這時就必須是 feature A merge feature B 的代碼,然後重新更新 Module B的版本號再構建。可能一個 module 代碼衝突還好,但如果是 module API 的修改了呢?那就是所有 module 都需要更新了,這是非常機械且令人頭疼的!

某著名大佬曾說過:“但凡重複的工作,我都希望交給機器來做”,這種明顯重複的機械的工作,能否直接交給機器完成呢?

通過分析不難發現,在構建參數一致的情況下,module 產物的版本號和我們的代碼是一一對應的,即只要 module 代碼有修改那我們就應該更新這個module 的版本號。那我們有沒有什麼已有的東西符合這個屬性來當作版本號呢?有!Git commit ID 不就是嗎?

在我們的認知裏,commit ID 似乎是反映整個項目所有代碼的版本,但需要給每個 module 建立各自的版本記錄,commit ID 能滿足嗎?熟悉 git 的人應該知道,git 可以通過指定<pathspec>參數來獲取特定目錄的提交記錄。我們可以以此爲突破口,獲取每個 module 的 commit ID 作爲 module 的版本號:

圖片

這樣,只需要輸入 module 的代碼目錄,就可以推算出這個 module 代碼對應的預編譯產物版本號,那我們就無需自己來管理版本號了,交給 git 管理:

  • module 發佈時,根據 module 目錄得到 commit ID 作爲版本號上傳產物;
  • 本地拉取產物時,根據同樣的規則推算出 module 對應的版本號直接下載。

如此,就省掉了繁瑣的版本號維護流程。也正因爲 module 版本號是 commit ID,不同分支只要 module 的代碼沒有變,那 commit ID 也就不會變,不同分支間的 module 產物也就可以複用。

因此,我們可以只要在 master 或者 master&feature 分支觸發產物的構建,就能覆蓋絕大多數分支。

2.2 如何增量發佈產物

確定了使用 CI 來構建產物後,然後可以通過代碼提交來自動觸發 CI 啓動。但爲了避免浪費構建機資源,並不需要每次都構建發佈所有模塊,僅增量的發佈修改過的模塊即可。

那如何判斷模塊是否修改過呢?與獲取 module 版本號的方式類似,我們可以使用命令:git diff -- <pathspec>來找出本次構建有修改的模塊。

2.3 預編譯產物上傳到何處

CI 構建出來的產物,需要一個倉庫來保存,“善解人意”的騰訊軟件鏡像源爲我們提供了各種倉庫:Maven、Generic、CocoaPods。Android 有 Maven,上傳、下載、管理版本十分方便,可惜其他平臺並不支持。按照騰訊會議的一貫思路,就得考慮跨平臺的方式來上傳、下載產物。

最終我們選擇了原始的騰訊雲 Generic 倉庫。相較於其他鏡像倉庫,Generic 倉庫具有以下優勢:

  • 操作更自由,HTTP/HTTPS 協議上傳、下載、刪除
  • 支持自行管理上傳文件路徑
  • 存儲空間無限制

於是,我們自定義了一套產物打包、存儲的規範,將各端構建好的產物,自己造輪子實現上傳、下載、校驗、解壓安裝等功能。正所謂:“造輪子一時爽,一直造輪子一直爽”。

2.4 如何使用預編譯產物

爲了讓開發者無學習成本的使用預編譯產物,產物的匹配和編譯切換最好是無感的。即開發者並不需要主動配置,編譯時腳本會自動匹配可用的預編譯產物來構建 APP。

以 module A 爲例,自動匹配產物的大致流程如下:

1.通過 git diff 查詢當前的 changes list

2.有修改到 module A 的代碼,腳本就自動切換到 module A 的源碼編譯;

3.沒有修改 module A 的代碼,則自動選擇 module A 的產物構建。

大致方向確定了,接下來就是具體的實施細節了。

03、 發佈 Module 產物

首先,發佈產物需要確認的是預編譯產物結構是怎樣的。爲了腳本邏輯能夠跨平臺,我們將每個模塊輸出的產物統一命名規範爲:xx_module_output.zip,也就是各平臺將自己每個 module 的產物打包到一個 zip 包中。但是 zip 包並不能反映產物的具體信息,比如對應的版本號、時間等,因此還需要一個 manifest 文件來彙總所有產物的相關信息。那麼一個版本的代碼對應的產物有:

  • xx_module_output.zip:xx_module****的編譯產物,總共會有n個xx_module_output.zip(n爲模塊個數);

  • base_manifest.json:當前版本的所有 module 產物信息,包括 module 名字、版本號等。

而 base_manifest.json 裏保存的信息結構如下:

{
  "modules": [
    {
      "name": "account", // 模塊名稱
      "version": "7b2331b7e9", // 模塊版本號,即模塊commit ID
      "time": "1632624109"    // 模塊版本時間,即模塊commit時間戳
    },
    {
      "name": "audio",
      "version": "7b2331b7e9",
      "time": "1632624109"
    },
    … ],
  "appVersion": "7b2331b7e9", // 代碼庫git commit ID
  "appVersionTime": "1632624109"// 代碼庫commit時間戳}

然後,在有代碼提交時,自動觸發 CI 啓動 Module 發佈腳本,通過 git diff 找到提交的 change list,然後從 changes list 來判斷哪些 module 有變更,對變更的 module 重編髮布:

圖片

流程看起來並不複雜,但實際操作上確有很多問題值得推敲。比如:我們知道git diff 是一個對比命令,既然是對比就會有一個基準 commit ID 和目標commit ID,目標 commit 就取當前最新的 commit 就好了。但基準 commit應該取哪個呢?是上一個提交嗎?來看下面這個開發流程:

圖片

開發者從 master 拉取了一個分支修復 bug,本地產生了兩次 commit但沒有 push,最後走 MR 流程合入主幹。整個過程產生了三個 commit,如果直接使用最近一次的 commit 來 diff 產生結果,那麼 diff 的 commit 是最後的那次 merge commit,結果正好是這次 bugfix 的所有改動記錄。也就不用管前面的 commit a、commit b 了,這樣看起來使用最近一次 commit diff 似乎沒有問題。

但如果這次編譯被跳過或者失敗了,那麼下一次的 MR 還只關注本次 MR 的提交內容,中間跳過的代碼提交就很可能一直沒有對應構建產物了。

因此,我們是通過查找最近一次有 base_manifest.json 文件與之對應的merge commit 來作爲基準 commit。 即從當前 commit 開始,回溯之前所有的merge commit,如果能夠找到merge commit對應的base_manifest.json,那就說明這次 merge commit 是有發佈 module 的,那麼就以它爲基準計算 diff。當然,我們並不會無限制的往前回溯,在嘗試回溯了 n 次後仍然沒有找到,則認爲沒有發佈。

圖片

其次,要如何 diff 特定 module 代碼呢?

前面提到 git diff 可以通過<pathspec>參數指定目錄,根據這個特性,傳入特定的 module 目錄,就可以計算特定 module 的 change list 了:

git diff targetCommitId baseCommitId -- path/to/module/xxx_module  #獲取module的diff(v1)

那麼,只需包含這個 module 自身的代碼目錄路徑就可以了嗎?

答案是不夠的。因爲 module 還會依賴其他的接口代碼,如 module API 的,接口的改動也會影響到 module 的編譯結果,因此還需要包含 module API 的目錄纔行。於是獲取 module diff 就變成下面這樣:

git diff targetCommitId baseCommitId -- path/to/module/xxx_module path/to/module-api #獲取module的diff (v2)

另外,在 module 目錄中,有些無關的文件並不影響編譯結果(比如其他端的UI代碼),在計算 diff 時我們需要將其排除,如何做到呢?也是通過<pathspec>路徑,與之前不同的是需要在路徑前面加”:!“。比如以下命令以Android 爲例,我們需要將其他端的 UI 代碼排除掉,那麼獲取某個 module 的 diff 命令最終就變成了這樣:

git diff targetCommitId baseCommitId --name-only -- path/to/module/xxx_module path/to/module-api :!path/to/module/xxx_module/ui/Windows :!path/to/module/xxx_module/ui/Mac :!path/to/module/xxx_module/ui/iOS #獲取module的diff (final)

同樣的,在發佈 module 時,需要提供一個版本號,前面已經提到,可以使用module 的 commit ID 作爲版本號。那麼要如何獲取 module 的 commit ID呢?git 命令都支持傳入<pathspec>參數,那麼通過 git log – <pathspec>設置 module 相關目錄,即可得到這個 module 的 commit ID。

不難發現,git log 命令的<pathspec>應該和 diff 是一致的,那麼我們可以得出獲取 module version 的命令:

git diff targetCommitId baseCommitId --name-only -- path/to/module/xxx_module path/to/module-api :!path/to/module/xxx_module/ui/Windows :!path/to/module/xxx_module/ui/Mac :!path/to/module/xxx_module/ui/iOS #獲取module的diff (final)

確定了 diff 與獲取 module 版本號的算法,發佈流程基本就可以走通了,接下來就是如何使用產物。

04、使用Module產物

首先需要確認的是,當前的代碼要如何判斷 CI 是否有發佈與之匹配的 module產物。

4.1 匹配產物

前面我們提到在發佈產物時,是通過回溯查找每個commit對應的base_manifest.json 來確定最近一次發佈的 commit。那麼匹配當前可用的產物也是類似的邏輯,通過回溯來找到最近有發佈的 commit,整個 module 增量構建的流程如下:

  • 通過回溯 commit ID 找到最近一次發佈的 base_manifest.json。
  • 若最終沒有找到這個 base_manifest.json,則證明當前版本沒有 module 產物,所有 module 需要源碼編譯;
  • 若能夠找到此文件,文件中記錄了預編譯 module 的產物信息(版本、時間戳等)列表,如果能在產物列表中找到這個 module,那麼就能夠獲取這個module 對應的產物;
  • 得到 base_manifest.json 裏的產物信息後,還需要使用產物的版本號 diff 判斷出當前 module 是否有代碼修改,確認無修改的情況則使用產物打包 App,有修改則使用 module 源碼編譯。

圖片

這裏判斷 module 是否修改的 diff 算法與發佈產物時類似,以產物的版本號爲base commit、設置 module 的<pathspec>目錄執行 git diff 命令,來得到module 的 change list。

產物匹配下載成功後,就是使用預編譯產物來替換源碼編譯了。本着無使用成本的原則,我們希望替換過程能夠腳本自動化完成,不需要開發者關心和介入就能無縫切換。

4.2 CMake產物替換源碼編譯

會議的跨平臺層代碼使用 C++ 實現,並採用 CMake 來組織工程結構的,所以C++ 代碼的產物替換,需要從 CMake 文件入手。

首先,C++ 預編譯的產物是動態/靜態庫,這些對於 CMake 來講就是library,可以通過 add_library() 或者 link_directories() 函數將其作爲預編譯庫添加進來,以動態庫 xx_plugins 爲例,增量腳本會根據匹配的產物,會生成一個use_library_flag.cmake 文件,用來標記命中增量的庫:

# use_library_flag.cmake

set(lib_wemeet_plugins_bin "/Users/jay/Dev/Workspaces/wemeet/app/app/.productions/Android/libraries/wemeet_plugins")
set(lib_wemeet_sdk_bin "/Users/jay/Dev/Workspaces/wemeet/app/app/.productions/Android/libraries/wemeet_sdk")
...
set(lib_xxx patch/to/lib_xxx)

我們在使用 xx_plugins 的方式上做了改變:

  • 命中增量時,通過 add_library 導入這個預編譯的產物作爲 library,lib_app link 預編譯庫;
  • 未命中增量時,通過 add_subdirectory 添加 xx_plugins 的源碼目錄,lib_app link 源碼庫;

那麼,增量產物命中後要實現產物/源碼的切換,是不是隻需要重新生成use_library_flag.cmake 這個文件就可以了呢?

先來看看 CMake 的使用流程,主要分爲 generate 和 build 這兩個步驟:

  • generate - 根據 cmake 腳本中的配置確定需要編譯的源碼文件、鏈接庫等,生成適用於不同構建系統(makefile、ninja、xcode 等)的工程文件、編譯命令。
  • build - 使用 generate 生成的編譯命令執行編譯

對於 Android 來說,cmake 是屬於 gradle 管理的一個子編譯系統,在構建Android 的時候 gradle 會執行 cmake generate 和 build。

但對於 Xcode和 Visual Studio,cmake 修改之後是需要手動執行 generate的,原因是因爲點擊 IDE 的 build 按鈕後僅僅是執行 build 命令,IDE 不會自動執行 cmake generate。

那麼,增量命中的產物列表有更新時,需要開發者手動執行 generate 一次才能更新工程結構,切換到我們預期的編譯路徑。但這樣開發者就需要關心增量產物匹配情況是否有變更,增加了使用成本。

有沒有辦法將這個過程自動化呢?

4.3 自動Generate

技術前提:

https://cliutils.gitlab.io/modern-cmake/chapters/basics/programs.html

cmake 提供了運行其他程序的能力,既包含配置時,也包含構建時。對於Windows端我們可以插入一段腳本,在編譯前做自動 generate 檢測:

if(WIN32)
# https://cliutils.gitlab.io/modern-cmake/chapters/basics/programs.html
    add_custom_command(OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/running_command_at_build_time_generated.trickier"
      COMMAND node ${CMAKE_CURRENT_SOURCE_DIR}/build_project/auto_generate_proj.js
        ${LIB_OS_TYPE}
        ${windows_output_bin_dir}
        ...
        )

    add_custom_target(auto_generate_project ALL
    DEPENDS "${CMAKE_CURRENT_BINARY_DIR}/running_command_at_build_time_generated.trickier")
endif()

腳本會對比上一次構建的產物命中模塊,當命中模塊的列表有變更時,則啓動子進程調用cmd窗口執行 Windows 的 generate:

const cmd = `start cmd.exe /K call ${path.join(winGeneratePath, generateStr)}`;
child_process.exec(cmd, {cwd: winGeneratePath, stdio: 'inherit'});
process.exit(0);

注意: 使用 node 觸發 cmd 執行 generate 腳本時,需要使用 detached 進程的方式,並使需要進程 sleep 足夠時間以等待腳本執行結束。

4.4 半自動Generate

對於 iOS 和 OS X 平臺,也可以 在 xcode 的 Pre-actions 環節插入一段腳本,來檢測模塊的命中列表是否有變更:

圖片

但由於 xcode 本身檢測到工程結構改變會自動停止編譯,因此我們通過彈窗提示開發者,當檢測到命中產物的模塊已經更改時,需要手動 generate 更新工程結構。

圖片

4.5 IDE顯示源碼

產物/源碼切換編譯的問題解決了之後,我們也發現了了新的問題:在xx_plugins 命中增量產物時,發現 IDE 找不到 xx_plugins 的源碼了!

這是因爲前面改造 CMakeLists.txt 腳本時,命中增量的情況下,並不會去執行 add_subdirectory(xx_plugins),那 IDE 自然不會索引 xx_plugins 的源碼了,這顯然是十分影響開發體驗的。要解決這個問題就必須命中增量時也執行 add_subdirectory(xx_plugins) 添加源碼目錄,可添加了源碼目錄就會去編譯它,那麼可以讓它不編譯嗎?

答案是肯定的!我們來看看 cmake --build 的文檔:

Usage: cmake --build <dir> [options] [-- [native-options]]
Options:
  <dir>          = Project binary directory to be built.
  --parallel [<jobs>], -j [<jobs>]
                 = Build in parallel using the given number of jobs.
                   If <jobs> is omitted the native build tool's
                   default number is used.
                   The CMAKE_BUILD_PARALLEL_LEVEL environment variable
                   specifies a default parallel level when this option
                   is not given.
  --target <tgt>..., -t <tgt>...
                 = Build <tgt> instead of default targets.
  --config <cfg> = For multi-configuration tools, choose <cfg>.
  --clean-first = Build target 'clean' first, then build.
                   (To clean only, use --target 'clean'.)
  --verbose, -v = Enable verbose output - if supported - including
                   the build commands to be executed.
  -- = Pass remaining options to the native tool.

文檔中提到 cmake build 命令是可以通過–target 參數來設置需要編譯的target,而 target 則是通過 add_library 定義的代碼庫,默認情況下 cmake會 build 所有的 target。但如果我們指定了 target 後,那麼 cmake 就只會編譯該 target 及 target 依賴的庫。

在會議項目中 lib_app 依賴了其他所有的增量庫,屬於依賴關係中的頂層library,因此我們的 build 命令可以加上參數--target lib_app,那麼:

  • 當 xx_plugins 未命中增量時,由於 lib_app 依賴了 xx_plugins 源碼庫,cmake 會同時編譯 lib_app 與 xx_plugins;
  • 當 xx_plugins 命中增量時,lib_app 依賴 xx_plugins 的是預編譯庫,cmake就只會編譯 lib_app了。

因此,我們可以進一步改造CMakeLists.txt,讓add_subdirectory(xx_plugins)始終執行:

# CMakeLists.txt

include(use_library_flag.cmake)

...

# 引入wemeet_plugins源碼目錄
add_subdirectory(wemeet_plugins)


if(lib_wemeet_plugins_bin) # 命中增量
  # 則導入該lib
  add_library(prebuilt_wemeet_plugins SHARED IMPORTED)
  set_target_properties(${prebuilt_wemeet_plugins}
                        PROPERTIES IMPORTED_LOCATION
                        ${lib_wemeet_plugins_bin}/${LIB_PREFIX}wemeet_plugins.${DYNAMIC_POSFIX}) # DYNAMIC_POSFIX: so、dll、dylib
  set(shared_wemeet_plugins prebuilt_wemeet_plugins) # 設置爲預編譯庫
else() # 未命中增量
  set(shared_wemeet_plugins wemeet_plugins) # 設置爲源碼庫
endif()

...

# 引用wemeet_plugins
target_link_libraries(wemeet_app_sdk PRIVATE ${shared_wemeet_plugins})

這樣在 xx_plugins 命中增量的情況下,開發者也可以繼續用 IDE 愉快的閱讀、修改源碼了。

05、斷點調試

使用增量產物代替源碼編譯同時會帶來的另一個問題:lldb 的斷點調試失效了!

要解決這個問題,首先要知道 lldb 二進制匹配源碼斷點的規則:lldb 斷點匹配的是源碼文件在機器上的絕對路徑!(win 端沒有用 lldb 調試器沒有這個問題,只要 pdb 文件和二進制放在同級目錄就能夠自動匹配)

那麼,在機器 A 上編譯的二進制產物 bin_A 由於源碼文件路徑和本地機器B上的不一樣,在機器 B 上設置的斷點,lldb 就無法在二進制 bin_A 中找到與之對應位置。

那有辦法可以解決這個問題嗎?在 lldb 內容有限的文檔我們發現這樣一個命令:

settings set target.source-map /buildbot/path /my/path

其作用就是將本地源碼路徑與二進制中的代碼路徑做一個映射,這樣lldb就可以正確找到代碼對應的斷點位置了。那麼“藥”找到了,如何“服用”呢?

首先,我們會有多個庫分別編譯成二進制發佈,並且由於是增量發佈,各個庫的構建機器的路徑可能都不一樣,因此需要爲每個庫都設置一組映射關係。好在source-map是可以支持設置多組映射的,因此我們的映射命令演變成了:

settings set target.source-map /qci/workspace_A/path_to_lib1 /my_local/path_to_lib1
      /qci/workspace_B/path_to_lib2 /my_local/path_to_lib2
      /qci/workspace_C/path_to_lib3 /my_local/path_to_lib3
      ...
      /qci/workspace_X/path_to_libn /my_local/path_to_libn

命令有了,何時執行呢?需要手動執行嗎?依然還是無使用成本的原則,我們希望腳本能自動化完成這些繁瑣的事情。

瞭解 lldb 的開發者想必都知道“~/.lldbinit"這個配置文件,我們可以在執行增量腳本的時候,把 source-map 配置添加到“~/.lldbinit"中,這樣 lldb 啓動的時候就會自動加載,但是這裏的配置是在用戶目錄下,會對所有 lldb 進程生效。爲了避免對其他倉庫/項目代碼調試造成影響,我們應該縮小配置的作用範圍,xcode 是支持項目級別的 .lldbinit 配置,也就是可以將配置放到 xcode 的項目根目錄:

# Mac端的.lldbinit放到Mac的xcode項目根目錄:
app/Mac/Src/App/Application/WeMeetApp/.lldbinit

# iOS端的.lldbinit放到iOS的xcode項目根目錄:
app/iOS/Src/App/Application/WeMeetApp/.lldbinit

Android Studio(簡稱 AS)就沒有這麼人性化了,並不能自動讀取項目根目錄的 .lldbinit 配置,但可以在 AS 中手動配置一下 LLDB Startup Commands:

圖片

手動配置雖然造成了一定使用成本,但還好只需要配置一次。

5.1 Android產物替換

Android 中的子模塊由於包含了 Java 代碼和資源文件,預編譯的產物就不是動態庫/靜態庫了,產物替換得從 gradle 入手。

前面文章有提到,爲了更好的跨平臺,我們選擇了 Generic 倉庫來存儲增量構建的產物。 熟悉Android 的開發者都知道,Android 平臺集成預編譯產物的方式有兩種:

  • 本地文件集成,如 aar、jar 文件
  • maven 集成

如果選擇本地文件集成,那麼我們就需要將模塊源碼打包成 aar 文件,但會遇到一個問題:若模塊採用 maven 集成的方式依賴了三方庫,是不會包含在最終打包的 aar 文件中的,這就會導致產物集成該模塊時丟失了一部分代碼。而Google 推薦的集成方式都是 maven 集成,因爲 maven 產物中的 pom.xml文件會記錄模塊依賴的三方庫,方便管理版本衝突以及重複引入等問題。

那麼如何在 Generic 倉庫中使用 maven 集成呢?Generic 倉庫其實就是一個只提供上傳、下載的文件存儲服務器,文件上傳、下載均需自行實現,因此,每一個模塊產物的文件內容各端可以自行定義,最終打包成一個壓縮包上傳到倉庫即可。那麼對於 Android 端我們可以將每個模塊產物按照本地maven倉庫的文件格式進行打包發佈:

app/.productions/Android/repo/com/tencent/wemeet/module/chat/
├── f4d57a067d
│ ├── chat-f4d57a067d-prebuilt-info.json
│ ├── chat-f4d57a067d.aar
│ ├── chat-f4d57a067d.aar.md5
│ ├── chat-f4d57a067d.aar.sha1
│ ├── chat-f4d57a067d.aar.sha256
│ ├── …
│ ├── chat-f4d57a067d.pom
│ ├── chat-f4d57a067d.pom.md5
│ ├── chat-f4d57a067d.pom.sha1
│ ├── chat-f4d57a067d.pom.sha256
│ └── chat-f4d57a067d.pom.sha512
├── maven-metadata.xml
├── maven-metadata.xml.md5
├── maven-metadata.xml.sha1
├── maven-metadata.xml.sha256
└── maven-metadata.xml.sha512

以上就是模塊 chat 以 maven 格式進行發佈產物的文件列表,可以看到該倉庫中只有一個版本(版本號:f4d57a067d)的產物,也就是說每個版本的增量產物其實就是一個 maven 倉庫,我們將產物下載下來解壓後,通過引入本地maven倉庫的方式添加到項目中來:

// build.gradle

repositories {
    maven { url "file://path/to/local/maven" }
}

dependencies {
    implementation 'com.tencent.wemeet.module:chat:f4d57a067d'
}

集成方式敲定了,那要如何自動切換呢?gradle 本身就是腳本,那麼我們可以在增量腳本執行後,根據腳本的執行結果,命中產物的模塊則以 maven 方式依賴,未命中的則以源碼依賴。爲了簡化使用方法,我們定義了一個 projectWm函數:

// common.gradle

gradle.ext.prebuilts = [:]
// setup prebuilt result
...

ext.projectWm = { String name ->
    String projectName = name.substring(1) // remove ":"

  if (gradle.ext.prebuilts.containsKey(projectName)) { // 命中增量產物
    def prebuilt = gradle.ext.prebuilts[projectName]
    return "com.tencent.wemeet.module:${projectName}:${prebuilt.version}"
  } else { // 未命中增量產物
    return project(name)
  }
}

// build.gradle

apply from: 'common.gradle' // 引入通用配置
...

dependencies {
    implementation projectWm(':chat')
  ...
}

projectWm 裏面封裝了替換源碼編譯的邏輯,這樣我們只需要將所有的project(":xxx")改成projectWm(":xxx") 即可,使用方便簡單。

解決完替換的問題,就可以愉快的使用增量產物了?

5.2 成也Maven,敗也Maven

雖然 maven 的依賴管理給我們帶來了便利,但對於產物替換源碼編譯的場景,也帶了新的問題。看這樣一個 case,有 A、B、C 三個模塊,他們的依賴關係如下:

圖片

前面的 projectWm 方案,對於模塊A這種單一模塊可以很好的解決問題,但對於模塊 B 依賴模塊 C 這種複雜的依賴關係卻不適用。比如模塊 B 命中增量、模塊 C 未命中時,由於 B 使用 projectWm 替換成了 maven 依賴,而模塊 C 會因爲模塊的 maven 產物中 pom.mxl 定義的依賴關係給帶過來,也就是模塊 C也會是 maven 依賴,而無法變成源碼依賴。我們必須將模塊 B 附帶的 maven依賴中的模塊 C 再替換源碼!通過查閱 gradle 文檔,我們發現 gralde 提供了dependencySubstitution 功能可以將 maven 依賴替換成源碼,用法也十分簡單:

圖片

於是我們可以將爲命中的 module C 再替換成源碼編譯:

configurations.all {
    resolutionStrategy.dependencySubstitution.all { DependencySubstitution dependency ->
        if (dependency.requested instanceof ModuleComponentSelector) {
            def group = dependency.requested.group
            def moduleName = dependency.requested.module
            if (group == 'com.tencent.wemeet'|| (group.startsWith('application') && dependency.requested.version == 'unspecified')) {
                def prebuilt = gradle.ext.prebuilts[moduleName]
                if (prebuilt == null) { // module未命中
                    def targetProject = findProject(":${moduleName}")
                    if (targetProject != null) {
                        dependency.useTarget targetProject
                    }
                }
            }
        }
    }
}

替換好了本以爲萬事大吉了,但實際編譯運行時,隨着命中情況的變化,經常偶發的失敗:Could not resolve module_xx:

圖片

究其原因,還是上面的替換沒有起作用,替換的源碼模塊找不到,難道 gradle提供的API有問題?通過仔細閱讀文檔,發現這樣一段話:

圖片

意思就是Dependency Substitution 只是幫你把依賴關係轉變過來了,但實際上並不會將這個 project 添加到構建流程中。所以我們還得將源碼編譯的 project 手動添加到構建中:

// build.gradle

dependencies { implementation project(":xxx") }

那接下來的問題就是如何找到編譯 app 最終需要源碼編譯的 module,然後添加到 app 的 dependencies{}依 賴中。這就要求得拿到所有 module 的依賴關係圖,這個並不困難,在gradle configure之後就可以通過解析configurations 獲取。但問題是我們必須得在 gradle configure 之前獲取依賴關係,因爲在 dependencies{} 中添加依賴是在 gradle configure 階段生效的。

問題進入了陷入死循環,這樣一來,我們並不能通過 gradle的configure 結果獲取依賴關係,得另闢蹊徑。

前面提到過,maven 會將模塊產物依賴的子模塊寫到 pom.xml 文件中,雖然pom.xml 裏記錄的依賴並不全,但是我們可以將這些依賴關係拼湊起來:

圖片

假設我們有上圖的一個依賴關係,module A、B、C 都命中了增量,D、E 未命中,爲了避免“Could not resolve module_xx”的編譯錯誤,我們需要將module D、E 添加到 app 的 dependencies{} 中,那麼腳本中如何確定呢:

  • app 在 configure 前可以讀取 configurations 得倒 app 依賴了 module A、B;
  • 由於 module B 命中了增量,因此可以通過 B 的 pom.xml 文件找到 B 依賴了C、D;
  • 而 D 未命中增量,因此可以確定需要將 D 添加到 app 的的 dependencies{}中;
  • 同理,我們可以通過 B → C 依賴鏈,拿到 C 的 pom.xml中記錄的對E的依賴,從而確定E需添加到 app 的的 dependencies{} 中。

源碼替換的流程,到這裏大致就走通了,不過除此之外還有其他替換相關的細節問題(如版本號統一、本地 aar 文件的依賴替換等),這裏就不繼續展開講了。

5.3 Android Studio顯示產物源碼

與 cmake 類似,命中產物的模塊由於變成了 Maven 依賴,也會遇到 AS 無法正確索引源碼的問題。

首先,AS 中加載的源碼是在 Gradle sync 階段索引出來的,而我們用產物替換源碼編譯僅需要在 build 的時候生效。那是否可以在 sync 階段讓 AS 認爲所有模塊都未命中,去索引模塊的源碼,僅在真正 build才 做實際的替換呢?

答案是肯定的,但問題是如何判斷 AS 是在 sync 或 build 呢?gradle 並沒有提供 API,但通過分析 gradle.startParameter 參數可以發現,AS sync 其實是啓動了一個不帶任何 task參數的gradle命令,並且將systemPropertiesArgs中的“idea.sync.active“參數設置爲了 true,於是我們可以以此爲依據來區分 gradle 是否在 sync:

// settings.gradle 
gradle.ext.isGradleSync = gradle.startParameter.systemPropertiesArgs['idea.sync.active'] == 'true'
ext.projectWm = { String name ->
    String projectName = name.substring(1) // remove ":"

  if (gradle.ext.isGradleSync) { // Gradle Sync
    return project(name)
  }
    // 增量替換源碼...
}

06、萬物皆可增量

以上講的是業務 module 的增量流程,會議的業務 module 之間是沒有依賴關係的,結構比較清晰。那其他依賴關係更復雜的子工程呢?

通過總結 module 的增量規律我們發現,一個子工程要實現增量化編譯,需要解決的一個核心問題是判斷這個是否需要重編。而 module 是通過 git diff – <pathspec>來判斷,<pathspec>則是前面提到的 module 相關的代碼路徑:

  • 模塊內自身的代碼
  • 模塊依賴的的接口代碼

因此,這裏可以延伸一下,即確定了子工程的源碼及其依賴的接口路徑後,都可以通過這套流程來發布、匹配增量產物,完成增量化的接入。

07、構建參數

前面有說到,當構建參數一致的情況下,產物版本和代碼版本是一一對應的。但實際情況是,我們經常也需要修改構建參數,比如編譯 release、debug 版本編譯的結果往往會有很大差異。

那麼對於構建參數不一致的場景,增量構建的產物要如何匹配呢?

這裏引入 variant**(變體)**的概念,即編譯的產物會因構建參數不同有多種組合,每一種參數組合構建出來的產物我們稱之爲其中一種變體。雖然參數組合可能有n種,但是常用的組合可能就只有幾種。每一種組合都需要重新構建和存儲對應產物,成本成倍增加,要覆蓋所有的組合顯然不太現實。

既然常用的組合就那麼幾種,那麼只覆蓋這幾種組合命中率就基本達標了。不同構建參數組合的產物之間是不通用的,所以存儲路徑上也應該是相互隔離的:

圖片

上圖示例中,兼容了 package type(debug、release 等)和publish channel(app、private、oversea s等)兩種參數組合,實際場景可能更多,我們可以根據需要進行定製。

08、總結

到這裏,已經講述了騰訊會議使用增量編譯加速編譯的大致原理,其核心思想就是儘量少編譯、按需編譯。在本地能夠匹配到遠端預先編譯好的產物時,就取代本地的源碼編譯以節省時間。

圖片

接下來看下整體優化效果:

在全部命中增量產物的情況下,由於省去了大量的代碼編譯,全量編譯效率也大幅提升:

注:以上數據爲2022年3月本地實測數據,實際耗時可能因機器配置不同而不一致。

截止到現在,會議代碼全量編譯耗時已超30min+,但由於採用模塊化增量編譯,在命中增量的情況下效果是穩定的,即編譯耗時不會隨着代碼量的增長而持續增加。

增量編譯帶來的效率提升是顯著的,但現階段也有一些不足之處:

1.產物命中率優化:現階段產物命中率還不夠高,當修改了公共頭文件時容易導致命中率下降,但這種修改可以進一步細分,如當新增接口時,其實並不影響依賴它的模塊命中。

2.自動獲取依賴:目前工程依賴的關係是用配置文件人工維護的,因此會出現依賴關係更新滯後的情況。後續可以嘗試從cmake、gradle等工具中獲取依賴,自動更新配置。

以上是本次分享全部內容,歡迎大家在評論區分享交流。如果覺得內容有用,歡迎轉發~

-End-

原創作者|何傑、郭浩偉、吳超、杜美依、田林江

技術責編|何傑、郭浩偉、吳超、杜美依、田林江

圖片

隨着產品得到市場初步認可,業務開始規模化擴張。提速的壓力從業務傳導到研發團隊,後者開始快速加人,期望效率能跟上業務的腳步,乃至帶動業務發展。但不如人意的是,實際的研發效能反而可能與期望背道而馳——業務越發展,研發越跑不動。其實影響開發效率的因素遠遠不止代碼編譯耗時過長!

你在開發過程中經常遇到哪些複雜耗時的頭痛問題呢?

歡迎在公衆號評論區聊一聊你的問題。在4月13日前將你的評論記錄截圖,發送給騰訊雲開發者公衆號後臺,可領取騰訊雲「開發者春季限定紅包封面」一個,數量有限先到先得😄。我們還將選取點贊量最高的1位朋友,送出騰訊QQ公仔1個。4月13日中午12點開獎。快邀請你的開發者朋友們一起來參與吧!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章