15、增量编译与缓存:增量编译原理、ccache配置、编译缓存优化、ninja文件监控
聊到Android构建,有个话题绕不开——增量编译。说白了,就是只编译改动的部分,不动那些没变过的代码。我刚开始接触AOSP时,第一次全编花了快两个小时,当时就懵了。后来才明白,搞懂增量编译,才是真正入门的开始。
增量编译原理:为什么不是每次都全编?
你想想看,一个几千万行代码的项目,每次改一行就全量重编,那得等到猴年马月?增量编译的核心思路很简单:只重新构建那些输入发生变化的编译目标。
在AOSP里,构建系统通过ninja来管理任务依赖。每个编译动作(比如编译一个C++文件)都被抽象成一个rule,输入是源文件,输出是目标文件。Ninja会记录每个输出的构建命令和输入文件的哈希值。下次构建时,如果输入没变,ninja就直接跳过。
嗯,这里要注意:增量编译不是万能的。比如你改了某个头文件,所有包含它的源文件都得重新编译。这就是所谓的“头文件依赖爆炸”。我在项目中遇到过,一个底层头文件被几千个源文件引用,改一行就触发半小时的重编。那滋味,真不好受。
核心要点:增量编译依赖的是ninja的restat机制和.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里还有几层缓存机制。我整理了一张图,帮你理清它们的关系:
从这张图可以看出,缓存是分层的。ccache在最底层,缓存的是单个编译单元的产物。ninja日志在中间层,记录的是构建依赖关系。Soong缓存则更上层,缓存的是模块级别的构建结果。
优化建议:
- 优先保证ccache命中率:如果ccache命中率低于80%,检查是不是编译参数经常变化(比如
-DDEBUG开关)。 - 定期清理ninja日志:
out/build-aosp_arm.ninja文件会越来越大,我一般每两周清一次out/目录。 - 利用
soong_build的缓存:在Android.bp里合理设置dist和install,避免不必要的重新打包。
注意:不要同时使用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目录。别心疼这点磁盘,省下来的时间,够你喝好几杯咖啡了。