3. Native代码编译与构建优化
Native 层的性能问题,很多时候并不是算法写错了,而是编译与构建阶段的选择把优化空间提前锁死了。同一份 C/C++ 源码,用不同的优化级别、不同的 ABI、不同的链接方式,最终生成的 .so 在指令数、代码体积、启动耗时和缓存命中率上可能相差数倍。本节围绕编译选项、ABI 选择、链接方式、符号裁剪和构建缓存五条主线,说明它们各自影响什么、如何配置,以及怎样判断优化是否真正生效。
编译选项:从 -O2 到 LTO、PGO
优化级别决定编译器在多大程度上重排、内联和向量化代码。-O0 保留完整调试信息、不做优化,适合定位崩溃;-O2 是性能与体积的平衡点,开启指令调度、循环优化和函数内联,是 Native 库的常用默认值;-Os 在 -O2 基础上优先压缩代码体积,会关闭部分膨胀型优化,适合对包体和指令缓存敏感的场景;-O3 更激进地展开循环和向量化,对计算密集型内核可能更快,但也可能因代码膨胀导致 i-cache 抖动而变慢。选择原则是:先以 -O2 为基线,用真实负载测量,再决定是否升级到 -O3 或退回 -Os。
LTO(Link Time Optimization,链接时优化)把优化推迟到链接阶段,使编译器能看到跨编译单元的调用关系,从而做跨模块内联、死代码消除和更精确的别名分析。开启方式是在编译和链接阶段同时加入 -flto,并配合 -ffunction-sections -fdata-sections 与链接器的 --gc-sections。LTO 的代价是链接时间显著变长,且对构建缓存不友好,因此通常只在 Release 构建中启用。
PGO(Profile Guided Optimization,基于性能剖析的优化)先用插桩版本跑一遍代表性负载,采集分支和调用频率,再让编译器按真实热点布局代码。典型流程是:-fprofile-generate 编译并运行,产生 .profraw/.profdata;再用 -fprofile-use 重新编译。PGO 对分支密集、热点集中的代码收益明显,但要求负载具有代表性,否则可能把冷路径误判为热路径。
# Release 构建示例(CMake 片段)
set(CMAKE_C_FLAGS_RELEASE "-O2 -flto -ffunction-sections -fdata-sections")
set(CMAKE_CXX_FLAGS_RELEASE "-O2 -flto -ffunction-sections -fdata-sections")
set(CMAKE_SHARED_LINKER_FLAGS_RELEASE "-flto -Wl,--gc-sections -Wl,-O1")
# PGO 两阶段
# 阶段一:-O2 -fprofile-generate=/tmp/pgo
# 阶段二:-O2 -fprofile-use=/tmp/pgo -fprofile-correction
ABI 选择:arm64-v8a 与 armeabi-v7a 的取舍
ABI 决定指令集、寄存器宽度和浮点调用约定。当前主流是 arm64-v8a(AArch64)与 armeabi-v7a(ARM32)。AArch64 拥有 31 个通用寄存器、更规整的浮点/NEON 支持,函数调用参数传递更高效,通常比 ARM32 快 10%~30%,且是 64 位设备的唯一选择。若应用仍需覆盖老旧 32 位设备,可同时打包两种 ABI,但要注意:APK 体积会因多份 .so 而增大,且 32 位进程受 4GB 虚拟地址空间限制,大内存场景容易触发 OOM。
选择策略上,优先只保留 arm64-v8a;确有 32 位存量用户时再补 armeabi-v7a,并通过 abiFilters 显式声明,避免误打包 x86 等模拟器专用 ABI 拖大包体。同时应确认第三方预编译库提供了对应 ABI,否则链接阶段会直接失败。
静态链接与动态链接
静态链接把库代码直接并入最终产物,调用无 PLT/GOT 间接跳转,启动时无需动态符号解析,适合体积小、调用频繁的基础库;缺点是每个使用方各持一份副本,升级需重新编译,且多个 .so 同时静态链接同一库可能造成符号重复和体积膨胀。动态链接通过 libxxx.so 共享代码,减小单模块体积、便于独立升级,但首次调用需符号查找,跨 .so 调用有额外开销。
实践中的折中做法是:对性能敏感的底层库(如数学、编解码内核)静态链接进主 .so,减少跨模块调用;对体积大、更新频繁或需被多方共享的库保持动态链接。无论哪种方式,都应避免在多个 .so 中重复静态链接同一份代码。
符号裁剪与可见性
默认情况下,共享库会导出大量符号,既增大体积,也增加动态链接器解析负担,还可能因符号冲突引发难以排查的问题。控制手段有三层:一是用 -fvisibility=hidden 让符号默认隐藏,仅对需要导出的接口加 __attribute__((visibility("default")));二是用版本脚本 --version-script 精确限定导出集合;三是配合 -ffunction-sections -fdata-sections 与 --gc-sections 删除未被引用的函数和数据。
# 版本脚本 libfoo.map
{
global:
Java_com_example_Native_init;
foo_process;
local:
*;
};
裁剪后可用 nm -D --defined-only libfoo.so | wc -l 对比导出符号数量,或用 readelf -d 查看依赖,确认没有意外导出内部实现符号。
构建缓存与增量编译
构建速度直接影响迭代效率。增量编译依赖时间戳或内容哈希判断哪些目标需要重建,但头文件被广泛包含时,改一个头文件可能触发大面积重编。缓解手段包括:使用 ccache 缓存编译结果,命中时直接复用目标文件;使用 Ninja 替代 Make 提升依赖图调度效率;用 ninja -d explain 或构建日志定位“为什么这个文件被重编”。需要注意的是,开启 LTO 或 PGO 后,缓存命中率会下降,因为链接阶段和插桩数据参与了最终产物,此时应把缓存粒度调整到阶段级别。
把上述环节串起来看,编译构建优化的本质是在“代码体积、运行速度、构建速度、可维护性”之间做取舍。一个可复用的判断顺序是:先用 -O2 建立基线并测量;确认热点后考虑 LTO 或 PGO;ABI 优先 arm64-v8a;对性能敏感的底层库静态链接、其余动态链接;用可见性和版本脚本把导出符号压到最小;最后用 ccache 与 Ninja 把日常迭代的构建时间控制在可接受范围。每一步都以指标对比为准,而不是凭感觉开启选项。