关于程序员:腾讯会议10秒编译百万代码|鹅厂编译加速标杆案例公开

3次阅读

共计 18539 个字符,预计需要花费 47 分钟才能阅读完成。

👉腾小云导读

作为一个人造跨平台的产品,腾讯会议从第一行代码开始,团队就保持同源同构的思维,即同一套架构,同一套代码,服务所有场景。过来一年,腾讯会议,迭代优化了 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/progr…

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 点开奖。快邀请你的开发者敌人们一起来参加吧!

正文完
 0