腾讯会议10秒编译百万代码|鹅厂编译加速标杆案例公开

Written by

in

文章目录

e434e8602b8f436f0fbc3a91db02bb0f.png

e83c792dc9d60f4c215d771de73168aa.gif

👉腾小云导读

作为一个天然跨平台的产品,腾讯会议从第一行代码开始,团队就坚持同源同构的思想,即同一套架构,同一套代码,服务所有场景。过去一年,腾讯会议,迭代优化了 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  总结

 编译加速有哪些方向?

大家知道,编译是将源代码经过编译器的预处理、编译、汇编等步骤,生成能够被计算机直接执行的机器码的过程,而这个过程是十分耗时的。

常规的开发工具如 xcode、gradle 为了提高效率都会自带编译缓存的功能,即将上一次编译的结果缓存起来,对于没有修改的代码再次编译就直接使用缓存。

但此这些缓存文件一般存在于本地,更新代码后难免需要一次重编,生成新的编译缓存。在会议这样一个上百人的团队里,修改提交十分频繁,更新一次代码所需要重编的代码量往往是十分巨大的。特别是一些被深度依赖的头文件被修改,往往等价于需要全量编译了。

虽说也有一些工具能够支持云端共享编译缓存,如 gradle 的 remote build cache,但是对 C++ 部分并没有 cache,而且方案也不能跨平台通用。

腾讯会议经历了框架 3.0 的模块化改造后,原本一整块代码按照业务拆分出了若干个小模块,开发需求修改代码逐渐集中在模块内部。这为我们的编译加速提供了新思路:每个业务模块之间是不存在依赖关系的,那么开发没有修改的模块是否可以免编译呢?

7ccc801acd619a204fb5592de78d7ec9.png

那么想要模块免编译,可行的方案有两种:

  • Module 独立运行,即不依赖其他 module 业务代码,任意一个 module 能够单独调试运行,这样就不需要编译其他 module 了;

  • Module 预编译,将所有 module 预先编译好,本地开发直接下载预先编译的 module 产物,在编译完整app的时候直接组装即可,类似云端缓存的概念。

从长远来看,如果 Module 独立运行肯定是最优的,但是现阶段比较难实现,虽然会议的模块代码没有相互依赖,但业务功能间的相互依赖还是较高,模块要独立运行很难跑通完整功能;而 Module 预编译方案在会议项目中的可行性更高,不需要改动业务逻辑。

 如何优雅的预编译 Module 产物?

那么既然要预编译所有的 module,就需要有一个机器自动构建 module 产物,并上传构建产物到云端。本地编译时从云端拉取预先编译好的产物来加速APP 编译。

那么,这里有几个问题需要确定:

1.构建在哪里执行;

2.如何增量发布产物;

3.预编译产物上传到何处;

4.如何使用预编译产物

首先,产物构建需要一台机器自动触发,很自然会想到持续集成(Continuous Integration,简称 CI)机器,这里我们选择了 Coding CI 来自动触发构建。首先来看看会议的开发模式:

4d5e1181f704855ef9b61864d1f54ff8.png

会议的开发模式是从主分支拉子分支开发新需求,开发完成后再合入主干。那么 CI 应该在哪条流水线构建 module 产物呢?需要为每条流水线都构建吗?

如果在 master 流水线构建:那么开发分支一旦 module 代码有修改,module 缓存就失效了。只改动一两个 module 还好,但如果是 module 全部失效(比如 module 依赖的公共接口有变更),那增量构建的逻辑就形同虚设了。

如果在 feature/bugfix 流水线构建:开发分支那么多,每个分支流水线都跑一次 module 构建,时间、仓库的存储成本都激增。而且,每个分支的构建产物相互独立,本地如果想要用产物加速编译,就必须得先启动流水线跑一次,等预编译产物构建完成了才可以使用。这对于拉一个 bugfix 分支修改两行代码就修复一个 bug 的场景来说是不可接受的。

这里首先分析一下 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 的时候就会发生版本冲突:

7b36b845afe69c0124c09e0b401d0be9.png

这时就必须是 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 的版本号:

ffb2ddf05b81d339b7919ca469b376e4.png

这样,只需要输入 module 的代码目录,就可以推算出这个 module 代码对应的预编译产物版本号,那我们就无需自己来管理版本号了,交给 git 管理:

  • module 发布时,根据 module 目录得到 commit ID 作为版本号上传产物;

  • 本地拉取产物时,根据同样的规则推算出 module 对应的版本号直接下载。

如此,就省掉了繁琐的版本号维护流程。也正因为 module 版本号是 commit ID,不同分支只要 module 的代码没有变,那 commit ID 也就不会变,不同分支间的 module 产物也就可以复用。

因此,我们可以只要在 master 或者 master&feature 分支触发产物的构建,就能覆盖绝大多数分支。

确定了使用 CI 来构建产物后,然后可以通过代码提交来自动触发 CI 启动。但为了避免浪费构建机资源,并不需要每次都构建发布所有模块,仅增量的发布修改过的模块即可。

那如何判断模块是否修改过呢?与获取 module 版本号的方式类似,我们可以使用命令:git diff — <pathspec>来找出本次构建有修改的模块。

CI 构建出来的产物,需要一个仓库来保存,“善解人意”的腾讯软件镜像源为我们提供了各种仓库:Maven、Generic、CocoaPods。Android 有 Maven,上传、下载、管理版本十分方便,可惜其他平台并不支持。按照腾讯会议的一贯思路,就得考虑跨平台的方式来上传、下载产物。

最终我们选择了原始的腾讯云 Generic 仓库。相较于其他镜像仓库,Generic 仓库具有以下优势:

  • 操作更自由,HTTP/HTTPS 协议上传、下载、删除

  • 支持自行管理上传文件路径

  • 存储空间无限制

于是,我们自定义了一套产物打包、存储的规范,将各端构建好的产物,自己造轮子实现上传、下载、校验、解压安装等功能。正所谓:“造轮子一时爽,一直造轮子一直爽”。

为了让开发者无学习成本的使用预编译产物,产物的匹配和编译切换最好是无感的。即开发者并不需要主动配置,编译时脚本会自动匹配可用的预编译产物来构建 APP。

以 module A 为例,自动匹配产物的大致流程如下:

1.通过 git diff 查询当前的 changes list

2.有修改到 module A 的代码,脚本就自动切换到 module A 的源码编译;

3.没有修改 module A 的代码,则自动选择 module A 的产物构建。

大致方向确定了,接下来就是具体的实施细节了。

 发布 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 重编发布:

d09ceb37bf5f3729844197a660a78489.png

流程看起来并不复杂,但实际操作上确有很多问题值得推敲。比如:我们知道git diff 是一个对比命令,既然是对比就会有一个基准 commit ID 和目标commit ID,目标 commit 就取当前最新的 commit 就好了。但基准 commit应该取哪个呢?是上一个提交吗?来看下面这个开发流程:

09f9a92eddafad8ff6145b079494b7a7.png

开发者从 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 次后仍然没有找到,则认为没有发布。

7debb4a2ca4c0fe10444dd922e2b1e43.png

其次,要如何 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 版本号的算法,发布流程基本就可以走通了,接下来就是如何使用产物。

 使用Module产物

首先需要确认的是,当前的代码要如何判断 CI 是否有发布与之匹配的 module产物。

前面我们提到在发布产物时,是通过回溯查找每个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 源码编译。

9a155a2fbee6b8f4b111624736cd1906.png

这里判断 module 是否修改的 diff 算法与发布产物时类似,以产物的版本号为base commit、设置 module 的<pathspec>目录执行 git diff 命令,来得到module 的 change list。

产物匹配下载成功后,就是使用预编译产物来替换源码编译了。本着无使用成本的原则,我们希望替换过程能够脚本自动化完成,不需要开发者关心和介入就能无缝切换。

会议的跨平台层代码使用 C++ 实现,并采用 CMake 来组织工程结构的,所以C++ 代码的产物替换,需要从 CMake 文件入手。

首先,C++ 预编译的产物是动态/静态库,这些对于 CMake 来讲就是library,可以通过 add_library() 或者 link_directories() 函数将其作为预编译库添加进来,以动态库 xx_plugins 为例,增量脚本会根据匹配的产物,会生成一个use_library_flag.cmake 文件,用来标记命中增量的库:

# use_library_flag.cmakeset(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 一次才能更新工程结构,切换到我们预期的编译路径。但这样开发者就需要关心增量产物匹配情况是否有变更,增加了使用成本。

有没有办法将这个过程自动化呢?

技术前提:

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

cmake 提供了运行其他程序的能力,既包含配置时,也包含构建时。对于Windows端我们可以插入一段脚本,在编译前做自动 generate 检测:

if(WIN32)
# https://cliutils.gitlab.io/modern-cmake/chapters/basics/programs.htmladd_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 ALLDEPENDS "${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 足够时间以等待脚本执行结束。

对于 iOS 和 OS X 平台,也可以 在 xcode 的 Pre-actions 环节插入一段脚本,来检测模块的命中列表是否有变更:

6cb1c2a97b03d4808a5646ddb6cec849.png

但由于 xcode 本身检测到工程结构改变会自动停止编译,因此我们通过弹窗提示开发者,当检测到命中产物的模块已经更改时,需要手动 generate 更新工程结构。

fb60d1ad7e8c51975873820b29394914.png

产物/源码切换编译的问题解决了之后,我们也发现了了新的问题:在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 variablespecifies a default parallel level when this optionis 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 - includingthe 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.txtinclude(use_library_flag.cmake)...# 引入wemeet_plugins源码目录
add_subdirectory(wemeet_plugins)if(lib_wemeet_plugins_bin) # 命中增量# 则导入该libadd_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、dylibset(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 愉快的阅读、修改源码了。

 断点调试

使用增量产物代替源码编译同时会带来的另一个问题: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:

ecd81f9875870af3b1f2e8a03e70d10f.png

手动配置虽然造成了一定使用成本,但还好只需要配置一次。

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.gradlerepositories {maven { url "file://path/to/local/maven" }
}dependencies {implementation 'com.tencent.wemeet.module:chat:f4d57a067d'
}

集成方式敲定了,那要如何自动切换呢?gradle 本身就是脚本,那么我们可以在增量脚本执行后,根据脚本的执行结果,命中产物的模块则以 maven 方式依赖,未命中的则以源码依赖。为了简化使用方法,我们定义了一个 projectWm函数:

// common.gradlegradle.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.gradleapply from: 'common.gradle' // 引入通用配置
...dependencies {implementation projectWm(':chat')...
}

projectWm 里面封装了替换源码编译的逻辑,这样我们只需要将所有的project(":xxx")改成projectWm(":xxx") 即可,使用方便简单。

解决完替换的问题,就可以愉快的使用增量产物了?

虽然 maven 的依赖管理给我们带来了便利,但对于产物替换源码编译的场景,也带了新的问题。看这样一个 case,有 A、B、C 三个模块,他们的依赖关系如下:

acb41c2835864628af61efb4d63e5956.png

前面的 projectWm 方案,对于模块A这种单一模块可以很好的解决问题,但对于模块 B 依赖模块 C 这种复杂的依赖关系却不适用。比如模块 B 命中增量、模块 C 未命中时,由于 B 使用 projectWm 替换成了 maven 依赖,而模块 C 会因为模块的 maven 产物中 pom.mxl 定义的依赖关系给带过来,也就是模块 C也会是 maven 依赖,而无法变成源码依赖。我们必须将模块 B 附带的 maven依赖中的模块 C 再替换源码!通过查阅 gradle 文档,我们发现 gralde 提供了dependencySubstitution 功能可以将 maven 依赖替换成源码,用法也十分简单:

caf1c6d30def49dd33b3e8be8cc0e285.png

于是我们可以将为命中的 module C 再替换成源码编译:

configurations.all {resolutionStrategy.dependencySubstitution.all { DependencySubstitution dependency ->if (dependency.requested instanceof ModuleComponentSelector) {def group = dependency.requested.groupdef moduleName = dependency.requested.moduleif (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:

00fefaf55526e017397d1bfca128bbab.png

究其原因,还是上面的替换没有起作用,替换的源码模块找不到,难道 gradle提供的API有问题?通过仔细阅读文档,发现这样一段话:

5f5cdc30e69fdd5e1005ebdebf0953b2.png

意思就是Dependency Substitution 只是帮你把依赖关系转变过来了,但实际上并不会将这个 project 添加到构建流程中。所以我们还得将源码编译的 project 手动添加到构建中:

// build.gradledependencies { implementation project(":xxx") }

那接下来的问题就是如何找到编译 app 最终需要源码编译的 module,然后添加到 app 的 dependencies{}依 赖中。这就要求得拿到所有 module 的依赖关系图,这个并不困难,在gradle configure之后就可以通过解析configurations 获取。但问题是我们必须得在 gradle configure 之前获取依赖关系,因为在 dependencies{} 中添加依赖是在 gradle configure 阶段生效的。

问题进入了陷入死循环,这样一来,我们并不能通过 gradle的configure 结果获取依赖关系,得另辟蹊径。

前面提到过,maven 会将模块产物依赖的子模块写到 pom.xml 文件中,虽然pom.xml 里记录的依赖并不全,但是我们可以将这些依赖关系拼凑起来:

2d4dee699f896a80b37e0d8897d25283.png

假设我们有上图的一个依赖关系,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 文件的依赖替换等),这里就不继续展开讲了。

与 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 Syncreturn project(name)} // 增量替换源码...
}

 万物皆可增量

以上讲的是业务 module 的增量流程,会议的业务 module 之间是没有依赖关系的,结构比较清晰。那其他依赖关系更复杂的子工程呢?

通过总结 module 的增量规律我们发现,一个子工程要实现增量化编译,需要解决的一个核心问题是判断这个是否需要重编。而 module 是通过 git diff – <pathspec>来判断,<pathspec>则是前面提到的 module 相关的代码路径:

  • 模块内自身的代码

  • 模块依赖的的接口代码

因此,这里可以延伸一下,即确定了子工程的源码及其依赖的接口路径后,都可以通过这套流程来发布、匹配增量产物,完成增量化的接入。

 构建参数

前面有说到,当构建参数一致的情况下,产物版本和代码版本是一一对应的。但实际情况是,我们经常也需要修改构建参数,比如编译 release、debug 版本编译的结果往往会有很大差异。

那么对于构建参数不一致的场景,增量构建的产物要如何匹配呢?

这里引入 variant(变体)的概念,即编译的产物会因构建参数不同有多种组合,每一种参数组合构建出来的产物我们称之为其中一种变体。虽然参数组合可能有n种,但是常用的组合可能就只有几种。每一种组合都需要重新构建和存储对应产物,成本成倍增加,要覆盖所有的组合显然不太现实。

既然常用的组合就那么几种,那么只覆盖这几种组合命中率就基本达标了。不同构建参数组合的产物之间是不通用的,所以存储路径上也应该是相互隔离的:

0be974edcf0fc99d2778f42d6e486f89.png

上图示例中,兼容了 package type(debug、release 等)和publish channel(app、private、oversea s等)两种参数组合,实际场景可能更多,我们可以根据需要进行定制。

 总结

到这里,已经讲述了腾讯会议使用增量编译加速编译的大致原理,其核心思想就是尽量少编译、按需编译。在本地能够匹配到远端预先编译好的产物时,就取代本地的源码编译以节省时间。

319671678d55439238da6572495d9655.png

接下来看下整体优化效果:

在全部命中增量产物的情况下,由于省去了大量的代码编译,全量编译效率也大幅提升:

平台

优化前

优化后

效率提升

Android

17min

3min

82%

 Windows  20min 10s 99%

iOS

12min

3min

75%

OSX

16min

4min

75%

注:以上数据为2022年3月本地实测数据,实际耗时可能因机器配置不同而不一致。

截止到现在,会议代码全量编译耗时已超30min+,但由于采用模块化增量编译,在命中增量的情况下效果是稳定的,即编译耗时不会随着代码量的增长而持续增加。

增量编译带来的效率提升是显著的,但现阶段也有一些不足之处:

1.产物命中率优化:现阶段产物命中率还不够高,当修改了公共头文件时容易导致命中率下降,但这种修改可以进一步细分,如当新增接口时,其实并不影响依赖它的模块命中。

2.自动获取依赖:目前工程依赖的关系是用配置文件人工维护的,因此会出现依赖关系更新滞后的情况。后续可以尝试从cmake、gradle等工具中获取依赖,自动更新配置。

以上是本次分享全部内容,欢迎大家在评论区分享交流。如果觉得内容有用,欢迎转发~

-End-

原创作者|何杰、郭浩伟、吴超、杜美依、田林江

技术责编|何杰、郭浩伟、吴超、杜美依、田林江

3a11fd0589fd66a40c11c8d42de0e534.png

869b513da4968dddb5d9585bcecb640a.png

97cb432bc34f2d07fd71044f7758bcf1.png

f2a0d5998688f89401a5b61c417bb00e.png

b822b6ab808b3da03f2e9c2dd69e326d.png

随着产品得到市场初步认可,业务开始规模化扩张。提速的压力从业务传导到研发团队,后者开始快速加人,期望效率能跟上业务的脚步,乃至带动业务发展。但不如人意的是,实际的研发效能反而可能与期望背道而驰——业务越发展,研发越跑不动。其实影响开发效率的因素远远不止代码编译耗时过长!

你在开发过程中经常遇到哪些复杂耗时的头痛问题呢?

欢迎在评论区聊一聊你的问题。在4月13日前将你的评论记录截图,发送给腾讯云开发者公众号后台,可领取腾讯云「开发者春季限定红包封面」一个,数量有限先到先得😄。我们还将选取点赞量最高的1位朋友,送出腾讯QQ公仔1个。4月13日中午12点开奖。快邀请你的开发者朋友们一起来参与吧!

下方关注腾讯云开发者并点亮星标

第一时间看腾讯方案和学习资源

fc11773f989548a4f780305418eaf6d4.gif

查看全文

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.dgrt.cn/a/2225820.html

如若内容造成侵权/违法违规/事实不符,请联系一条长河网进行投诉反馈,一经查实,立即删除!

fc11773f989548a4f780305418eaf6d4.gif

腾讯会议10秒编译百万代码|鹅厂编译加速标杆案例公开

👉腾小云导读作为一个天然跨平台的产品,腾讯会议从第一行代码开始,团队就坚持同源同构的思想,即同一套架构,同一套代码,服务所有场景。过去一年,腾讯会议,迭代优化了 20000 个功能&a……

TS2023年面试题汇总~~~~持续更新中!!!!

文章目录一、typescript 的数据类型有哪些二、TypeScript 中枚举类型的理解三、TypeScript 中接口的理解四, TypeScript 中类的理解五,TypeScript 中泛型的理解?六,TypeScript 中高级类型的理解?一、typescript 的数据……

java代码新增或修改时候的验证字段唯一

java代码新增或修改时候的验证字段唯一
String PLOT_CODE_MSG "地块编码已存在";
//验证plotCode是否唯一,获取id
String plotId plotService.getIdByPlotCode(plot.getPlotCode());//新增的时候判断是否存在if (Func.isEmpty(plot.getId())) {if (Fun……

JavaSwing实现动态时钟【风格2】

关于时钟的Java实现一共两篇,相比较上篇,这次的比较简单,因为只是动态的显示数字时间,而不需要秒针、分针、时针和时钟的绘制以及动作设置。
上一篇在这 带指针的时钟
代码 import javax.swing.*;
import java.awt.*;
import ja……

长江流域土地利用与土地覆盖数据(1985-2020)

长江流域土地利用与土地覆被数据(1985-2020) 简介 本文利用1985-2020年Landsat数据,获取了长江流域每年的LULC地图,利用改进的随机森林方法将LULC的总体平均准确率提高到91.75%。
数据大小:10.08GB
数据时间范围&amp……

如何低成本实现微前端架构?

微前端架构是一种前端架构模式,旨在将一个大型的Web应用程序拆分为更小、更独立的部分,每个部分可以由不同的团队开发、部署和维护。在微前端架构中,每个子应用程序可以独立开发、构建、测试和部署,同时也可以独立于其他子应用程序……

Go 性能加速器:你需要知道的 5 个诀窍和技巧

👇我在这儿 通过这 5个诀窍和技巧来将那些运行缓慢,低效的 go 代码变成精简,高效,快速的机器代码。各位 Go 大师和初学者们,你们是否已经厌倦了那些慢得让你想要抓狂的 Go 应用程序?别担心,我们……

【AIGC提示词工程师、AI提示词工程师、Prompts工程师、Midjourney培训】电商行业AIGC图像生成与内容创作学习路线图

导言 关注【元壤教育】公众号进入平台开始系统学习之路。 AIGC(Stable Diffusion、DALL-E 和 Midjourney)助力电商行业降本增效、提升10倍生产力 一门深入全面的课程,专为对AI图像生成在电商行业应用感兴趣的人士打造,旨在帮助他们……

记录一个问题,内存泄漏,还没彻底解决

记录一个问题,内存泄漏,还没彻底解决…

IO流中「线程」模型总结

IO流模块:经常看、经常用、经常忘; 一、基础简介
在IO流的网络模型中,以常见的「客户端-服务端」交互场景为例; 客户端与服务端进行通信「交互」,可能是同步或者异步,服务端进行「流」处理时,可……

学会这些终端快捷键,让你在Linux上的操作快100倍

🪶 简述 Linux命令行的许多快捷键与GNU/Emacs编辑器非常像,因此我十分建议可以学习学习emacs编辑器,来了解或发现更多的命令行快捷键。 点此访问emacs官网 点此访问emacs中国(论坛) 简述一下Emacs:Emacs’一切皆快捷键……

手把手教你Temporal Fusion Transformer——Pytorch实战

建立了一个关于能源需求预测的端到端项目: 如何为 TFT 格式准备我们的数据。 如何构建、训练和评估 TFT 模型。 如何获取对验证数据和样本外预测的预测。 如何使用built-in model的可解释注意力机制计算特征重要性、季节性模式和极端事件鲁棒性。
什么是Temporal F……

【Java开发】设计模式 12:解释器模式

1 解释器模式介绍
解释器模式是一种行为型设计模式,它提供了一种方法来解释语言、表达式或符号。
在该模式中,定义了一个表达式接口,并实现了对应的表达式类,这些类可以解释不同的符号组成的表达式,从而实现对语言的……

反序列化渗透与攻防(五)之shiro反序列化漏洞

Shiro反序列化漏洞
Shiro介绍
Apache Shiro是一款开源安全框架,提供身份验证、授权、密码学和会话管理。Shiro框架直观、易用,同时也能提供健壮的安全性
Apache Shiro 1.2.4及以前版本中,加密的用户信息序列化后存储在名为remember-me的Cookie中。攻击者可以使用Shiro的默……

vue2+vue3

vue2vue3尚硅谷vue2vue2 课程简介【02:24】vue2 Vue简介【17:59】vue2 Vue官网使用指南【14:07】vue2 搭建Vue开发环境【13:54】vue2 Hello小案例【22:25】了解: 不常用常用:id 更常用 简单class差值总结vue 实例vue 模板 : 先 取 &#xff0……

【hello Linux】环境变量

目录 1. 环境变量的概念 2. 常见的环境变量 3. 查看环境变量 4. 和环境变量相关的命令 5. 环境变量的组织方式 6. 通过代码获取环境变量 7. 通过系统调用获取环境变量 Linux🌷 在开始今天的内容之前,先来看一幅图片吧! 不知道你们是否和我一……

【Linux基础】常用命令整理

ls命令
-a选项,可以展示隐藏的文件和文件夹-l选项,以列表形式展示内容-h,需要和-l搭配使用,可以展示文件的大小单位ls -lah等同于la -a -l -h
cd命令(change directory)
语法:cd [Linux路径]……

客快物流大数据项目(一百一十二):初识Spring Cloud

文章目录
初识Spring Cloud
一、Spring Cloud简介
二、SpringCloud 基础架构图…

C和C++中的struct有什么区别

区别一: C语言中: Struct是用户自定义数据类型(UDT)。 C语言中: Struct是抽象数据类型(ADT),支持成员函数的定义。
区别二:
C中的struct是没有权限设置的&#xff0c……

docker的数据卷详解

数据卷 数据卷是宿主机中的一个目录或文件,当容器目录和数据卷目录绑定后,对方修改会立即同步
一个数据卷可以同时被多个容器同时挂载,一个容器也可以被挂载多个数据卷
数据卷作用:容器数据持久化 /外部机器和容器间接通信 /容器……

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

站内搜索