15、增量编译与缓存:增量编译原理、ccache配置、编译缓存优化、ninja文件监控

聊到Android构建,有个话题绕不开——增量编译。说白了,就是只编译改动的部分,不动那些没变过的代码。我刚开始接触AOSP时,第一次全编花了快两个小时,当时就懵了。后来才明白,搞懂增量编译,才是真正入门的开始。

增量编译原理:为什么不是每次都全编?

你想想看,一个几千万行代码的项目,每次改一行就全量重编,那得等到猴年马月?增量编译的核心思路很简单:只重新构建那些输入发生变化的编译目标

在AOSP里,构建系统通过ninja来管理任务依赖。每个编译动作(比如编译一个C++文件)都被抽象成一个rule,输入是源文件,输出是目标文件。Ninja会记录每个输出的构建命令和输入文件的哈希值。下次构建时,如果输入没变,ninja就直接跳过。

嗯,这里要注意:增量编译不是万能的。比如你改了某个头文件,所有包含它的源文件都得重新编译。这就是所谓的“头文件依赖爆炸”。我在项目中遇到过,一个底层头文件被几千个源文件引用,改一行就触发半小时的重编。那滋味,真不好受。

核心要点:增量编译依赖的是ninjarestat机制和.d依赖文件。每个编译步骤都会生成一个.d文件,里面记录了该目标依赖的所有头文件。Ninja通过比对时间戳或内容哈希来决定是否重新执行命令。

ccache配置:给编译加个“缓存加速器”

ccache,全称是“Compiler Cache”。它会把编译器的中间结果缓存起来。说白了,就是同一个源文件,同样的编译参数,第二次编译时直接从缓存拿结果,不用真的再跑一遍编译器。

我个人习惯在AOSP里这样配置ccache:

# 设置ccache缓存目录,建议放在SSD上
export CCACHE_DIR=/home/user/.ccache
# 设置缓存大小,我一般给50GB
export CCACHE_SIZE="50G"
# 启用ccache
export USE_CCACHE=1
# 对于AOSP,还需要设置
export CCACHE_EXEC=/usr/bin/ccache

然后在源码根目录执行:

prebuilts/misc/linux-x86/ccache/ccache -M 50G

这个-M参数就是设置最大缓存容量。我建议至少给30GB,如果经常全编,50GB起步比较稳妥。

小技巧:ccache默认只缓存C/C++编译。如果你用Rust或Kotlin,需要额外配置。另外,ccache对-g调试符号的压缩效果一般,建议在正式发布版本中开启。

我曾经踩过一个坑:ccache缓存目录放在了机械硬盘上,结果编译速度反而变慢了。因为缓存读写成了瓶颈。后来我把CCACHE_DIR指向了NVMe SSD,效果立竿见影。

编译缓存优化:不止是ccache

除了ccache,AOSP里还有几层缓存机制。我整理了一张图,帮你理清它们的关系:

AOSP 编译缓存层次结构 ccache 缓存编译器输出(.o文件),基于源文件内容+编译参数哈希 ninja 构建日志 记录每个目标的构建命令、输入文件哈希、输出文件时间戳 Soong 构建缓存 缓存模块的构建结果(如 .so、.dex),基于模块依赖图变化 dist 输出缓存 最终镜像文件(system.img等),通过 make 的 dist 目标管理

从这张图可以看出,缓存是分层的。ccache在最底层,缓存的是单个编译单元的产物。ninja日志在中间层,记录的是构建依赖关系。Soong缓存则更上层,缓存的是模块级别的构建结果。

优化建议:

  • 优先保证ccache命中率:如果ccache命中率低于80%,检查是不是编译参数经常变化(比如-DDEBUG开关)。
  • 定期清理ninja日志out/build-aosp_arm.ninja文件会越来越大,我一般每两周清一次out/目录。
  • 利用soong_build的缓存:在Android.bp里合理设置distinstall,避免不必要的重新打包。

注意:不要同时使用ccache和ccache--cache-dir指向同一个目录。另外,如果切换了编译器版本(比如从clang 12升到14),建议清空ccache缓存,否则可能产生二进制不兼容的问题。

ninja文件监控:谁动了我的构建?

Ninja本身不提供文件监控功能,但它依赖.ninja_log.ninja_deps文件来追踪变化。你可以通过以下方式监控构建过程:

# 查看ninja构建统计
ninja -f out/build-aosp_arm.ninja -d stats

# 查看构建依赖图(输出为dot格式)
ninja -f out/build-aosp_arm.ninja -t graph

# 查看哪些目标被重新构建了
ninja -f out/build-aosp_arm.ninja -d explain

-d explain这个参数特别有用。它会告诉你为什么某个目标被重新编译了。比如:

ninja explain: output out/target/product/generic/obj/SHARED_LIBRARIES/libfoo_intermediates/foo.o older than input frameworks/base/libs/foo/foo.cpp
ninja explain: frameworks/base/libs/foo/foo.cpp is dirty

看到这种输出,你就知道是源文件被修改了。如果看到莫名其妙的重新编译,可以用这个命令排查。

我曾经遇到过一个诡异的问题:每次构建都会重新编译整个libui库,明明代码没改。后来用-d explain一查,发现是一个头文件的#include路径里有个软链接,每次构建时软链接的时间戳都会变。嗯,这种坑,没有监控工具根本发现不了。

实战:配置一个高效的增量编译环境

说了这么多,我总结一套我自己的配置方案:

配置项 推荐值 说明
CCACHE_DIR /ssd/ccache 放在SSD上,不要用机械盘
CCACHE_SIZE 50G 根据项目大小调整,AOSP建议50G+
CCACHE_COMPRESS 1 启用压缩,节省空间但略慢
CCACHE_SLOPPINESS file_macro,time_macros 放宽一些检查,提高命中率
USE_CCACHE 1 在AOSP中启用ccache

~/.bashrc里加上这些,然后source一下,就能生效了。我个人习惯在每次全编前先执行ccache -z清零统计,然后全编结束后用ccache -s查看命中率。如果命中率低于70%,我会检查是不是编译参数有变化。

避坑指南:我曾经在CI服务器上配置了ccache,结果发现每次构建的缓存命中率都接近0%。排查了半天,发现是CI每次都用不同的Docker容器,容器里的CCACHE_DIR指向了临时目录,构建完就销毁了。后来我把缓存目录挂载到持久化卷上,问题才解决。

最后说一句,增量编译和缓存优化,本质上是在“空间换时间”。你愿意为缓存分配多少磁盘空间,决定了你能省下多少编译时间。对于AOSP这种级别的项目,我建议至少准备100GB的缓存空间,包括ccache和out目录。别心疼这点磁盘,省下来的时间,够你喝好几杯咖啡了。


公众号:蓝海资料掘金营,微信deep3321