ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Android NDK 开发实战:CMake 核心配置与避坑指南

Android NDK 开发实战:CMake 核心配置与避坑指南

1. 为什么现在还要啃 CMake 这块硬骨头

如果你最近两年才开始接触 Android 上的原生开发,可能会觉得 CMake 这东西有点"老派"——毕竟现在很多新项目直接上ndk-build的替代方案,或者干脆用 Rust 那套构建体系。但只要你真正做过需要跨平台复用 C/C++ 代码的项目,尤其是那种要在 Android、iOS、桌面端同时维护一套核心库的场景,CMake 依然是绕不开的基础设施。我在几个实际项目里反复折腾过 CMake 和 NDK 的配合,踩过的坑足够写满一个笔记本,今天就把这些经验系统性地梳理一遍。

先说清楚这篇文章适合谁看。如果你是完全没碰过 NDK 的新手,建议先补一下 JNI 的基本概念再回来,因为 CMake 在 Android 里的角色是"构建工具",它解决的是"怎么把一堆 C/C++ 源文件编译成 Android 能加载的 .so 库"这个问题,而不是"Java 怎么调 C++"的问题。如果你已经能用ndk-build跑通 Hello World,但一遇到多模块、第三方库依赖、ABI 过滤就头大,那这篇就是写给你的。如果你已经在用 CMake 但总是被各种链接错误、找不到头文件、运行时崩溃搞得焦头烂额,那更好,我会把那些最容易翻车的地方一个个拆开讲。

CMake 本质上是一个"构建系统生成器",它自己不编译代码,而是根据你写的CMakeLists.txt生成对应平台的构建文件(比如 Ninja 或 Makefile),然后再调用编译器去干活。这个间接层带来的好处是跨平台:同一份CMakeLists.txt,在 Android 上生成 NDK 工具链的构建脚本,在 Linux 上生成 GCC 的,在 Windows 上生成 MSVC 的。代价就是多了一层抽象,出问题的时候报错信息往往不那么直观。Android NDK 从 r18 开始正式把 CMake 作为默认推荐方案,到现在 r26、r27,CMake 的支持已经非常成熟,但成熟不代表简单,很多细节还是得自己趟。

接下来我会从整体设计思路讲起,然后逐个拆解核心配置项,再给出一套可以直接抄的实操流程,最后把那些年我遇到过的典型问题和排查方法整理成速查表。全程尽量说人话,该给参数给参数,该解释为什么就解释为什么。

2. CMake 在 Android NDK 中的整体设计思路

2.1 为什么 NDK 最终选择了 CMake 而不是继续推 ndk-build

早期 Android 原生开发基本是ndk-build+Android.mk的天下,这套东西是 NDK 自己的一套 Makefile 方言,写起来啰嗦,跨平台能力几乎为零。你写了一个Android.mk,想拿到 Linux 桌面端复用?对不起,得重写一份Makefile。CMake 的优势就在于它本身就是为跨平台设计的,NDK 团队从 r12 左右开始试验性支持,到 r18 正式转正,再到后来ndk-build虽然还在维护但官方文档里 CMake 的篇幅明显更重。

从工程角度看,选择 CMake 意味着你的原生代码可以更容易地和桌面端、服务端共享。我做过一个图像处理库,核心算法用 C++ 写,Android 端通过 JNI 调用,桌面端用 Qt 调用,服务端用命令行工具调用。如果当初用ndk-build,Android 这边就得单独维护一套构建脚本;用 CMake 之后,核心的CMakeLists.txt只写一份,各平台通过不同的 toolchain 文件来适配,维护成本直接砍半。

2.2 CMake 构建 Android 原生库的完整链路

理解这条链路对排查问题至关重要。当你在 Android Studio 里点下"Run"按钮,或者命令行执行./gradlew assembleDebug时,背后发生的事情大致是这样的:

Gradle 的 Android 插件读取你build.gradle里的externalNativeBuild配置,找到CMakeLists.txt的路径,然后调用 NDK 里自带的 CMake 可执行文件。这个 CMake 会加载 NDK 提供的 toolchain 文件(android.toolchain.cmake),这个文件里定义了目标平台是 Android、目标 ABI 是什么、使用哪个版本的 Clang 编译器、sysroot 在哪里等等。CMake 根据这些信息生成 Ninja 构建文件,然后调用 Ninja 去实际编译和链接,最终产出.so文件。这些.so会被打包进 APK 的lib/<abi>/目录下。

这条链路里任何一环出问题都会导致构建失败,而且报错信息可能来自 CMake、来自 Clang、来自 Ninja、甚至来自 Gradle 的封装层。我后面会专门讲怎么定位问题出在哪一环。

2.3 一个典型项目的目录结构应该怎么组织

在动手写CMakeLists.txt之前,先把目录结构定好,能省掉后面很多麻烦。我习惯的结构是这样的:

project-root/ ├── app/ │ ├── src/main/cpp/ │ │ ├── CMakeLists.txt # 主构建脚本 │ │ ├── native-lib.cpp # JNI 入口 │ │ └── core/ # 核心算法模块 │ │ ├── CMakeLists.txt │ │ ├── include/ │ │ └── src/ │ └── build.gradle ├── third_party/ # 第三方库源码 │ └── some-lib/ │ ├── CMakeLists.txt │ └── ... └── ...

把 JNI 入口和核心算法分开,核心算法单独成一个 CMake 子项目,好处是核心算法可以独立编译测试,不依赖 Android 环境。第三方库统一放third_party,通过add_subdirectory引入,而不是直接改它的源码。这个结构在项目变大之后优势非常明显。

3. CMakeLists.txt 核心配置逐项拆解

3.1 最低版本声明与项目定义

每个CMakeLists.txt开头都得有这两行:

cmake_minimum_required(VERSION 3.22.1) project("native-lib" LANGUAGES CXX C)

cmake_minimum_required这个版本号不是随便写的。Android Studio 不同版本捆绑的 CMake 版本不一样,NDK 不同版本要求的 CMake 最低版本也不一样。比如 NDK r26 要求 CMake 至少 3.22.1,如果你写了个更低的版本号,CMake 会以兼容模式运行,某些新特性就用不了;写太高了,如果本地装的 CMake 版本不够,直接报错。我的建议是查一下你当前 NDK 版本对应的推荐 CMake 版本,然后照着写。可以在$ANDROID_SDK/cmake/目录下看到实际安装了哪些版本。

project里的LANGUAGES参数指定项目涉及哪些语言。如果你的项目只有 C++,写CXX就行;如果还有纯 C 文件,得把C也加上。这个参数影响 CMake 会去检测哪些编译器,漏写会导致某些源文件被忽略,而且报错信息往往很隐晦。

3.2 设置 C++ 标准与编译选项

set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF)

这三行几乎是标配。CMAKE_CXX_STANDARD指定用哪个 C++ 标准,NDK 的 Clang 对 C++17 和 C++20 支持都很好,新项目建议直接上 17 或 20。CMAKE_CXX_STANDARD_REQUIRED ON表示如果编译器不支持这个标准就直接报错,而不是悄悄降级——这个很重要,否则你可能在本地用着 C++17 的特性,换台机器编译时编译器悄悄用了 C++14,然后一堆莫名其妙的错误。

CMAKE_CXX_EXTENSIONS OFF表示禁用编译器特有的扩展,强制使用标准 C++。这个在跨平台项目里尤其重要,因为 GCC 和 Clang 的扩展语法不完全一样,禁掉扩展能提前暴露兼容性问题。

编译选项方面,Debug 和 Release 要分开设置:

set(CMAKE_CXX_FLAGS_DEBUG "-O0 -g -DDEBUG") set(CMAKE_CXX_FLAGS_RELEASE "-O2 -DNDEBUG")

-O0关闭优化方便调试,-g生成调试符号。Release 用-O2是性能和编译时间的平衡点,-O3有时候会带来意外的代码膨胀。-DNDEBUG会禁用assert宏,这个在 Release 里是标准做法。

3.3 添加库目标:add_library 的三种形态

add_library是 CMake 里最核心的命令之一,它决定了你要产出什么类型的库。在 Android 场景下,主要有三种用法:

# 共享库,最终产出 .so 文件 add_library(native-lib SHARED native-lib.cpp) # 静态库,编译时链接进其他库 add_library(core-lib STATIC core/src/algorithm.cpp) # 接口库,只有头文件和依赖声明,不产出二进制 add_library(header-only-lib INTERFACE) target_include_directories(header-only-lib INTERFACE core/include)

SHARED库是 Android 上最常见的,因为 Java 层通过System.loadLibrary加载的就是.so。STATIC库适合把核心算法打包成静态库,再链接进最终的共享库,这样可以隐藏内部符号,减小最终.so的体积。INTERFACE库适合纯头文件的工具库,它不编译任何东西,只是把包含路径和依赖关系传递出去。

这里有个容易踩的坑:如果你有多个SHARED库,它们之间的符号默认是互相可见的,可能导致符号冲突。解决办法是用-fvisibility=hidden编译选项,只导出必要的符号:

set(CMAKE_CXX_VISIBILITY_PRESET hidden) set(CMAKE_VISIBILITY_INLINES_HIDDEN ON)

然后在需要导出的函数上加__attribute__((visibility("default")))或者用 JNI 的JNIEXPORT宏。

3.4 头文件路径与源文件收集

target_include_directories(native-lib PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/core/include ${CMAKE_CURRENT_SOURCE_DIR}/third_party/some-lib/include )

PRIVATE表示这些头文件路径只对当前目标可见,不会传递给依赖它的其他目标。对应的还有PUBLIC和INTERFACE。这个可见性控制是 CMake 现代用法(target-based)的核心,比老式的include_directories全局设置要清晰得多。

源文件收集方面,我强烈建议显式列出每个源文件,而不是用file(GLOB ...):

add_library(native-lib SHARED native-lib.cpp core/src/algorithm.cpp core/src/utils.cpp )

file(GLOB)看起来方便,但它有个致命问题:CMake 只在配置阶段执行 GLOB,如果你新增了一个源文件但没有重新运行 CMake 配置,新文件不会被编译进去。在 IDE 里可能感觉不到,但在 CI 环境里经常导致"本地能编译,CI 上找不到符号"的诡异问题。显式列出虽然麻烦,但可靠。

3.5 链接库与依赖传递

target_link_libraries(native-lib PRIVATE core-lib android log )

android和log是 NDK 提供的系统库,分别对应 Android 的 native 接口和日志接口。core-lib是我们自己定义的静态库。PRIVATE表示这些依赖不会传递给依赖native-lib的其他目标。

链接顺序在 CMake 里通常不需要手动关心,因为 CMake 会自动处理依赖关系。但如果你直接链接第三方预编译的.so,可能会遇到顺序问题。这时候可以用IMPORTED目标来包装:

add_library(prebuilt-lib SHARED IMPORTED) set_target_properties(prebuilt-lib PROPERTIES IMPORTED_LOCATION ${CMAKE_CURRENT_SOURCE_DIR}/libs/${ANDROID_ABI}/libprebuilt.so INTERFACE_INCLUDE_DIRECTORIES ${CMAKE_CURRENT_SOURCE_DIR}/libs/include )

用${ANDROID_ABI}变量来区分不同架构的预编译库,这是处理第三方二进制库的标准做法。

4. 与 Gradle 的集成配置详解

4.1 build.gradle 里的 externalNativeBuild 块

CMake 本身不关心 Android,是 Gradle 插件把它拉进来的。在app/build.gradle里:

android { defaultConfig { externalNativeBuild { cmake { cppFlags "-std=c++17 -fexceptions -frtti" arguments "-DANDROID_STL=c++_shared" } } ndk { abiFilters "arm64-v8a", "armeabi-v7a" } } externalNativeBuild { cmake { path file("src/main/cpp/CMakeLists.txt") version "3.22.1" } } }

cppFlags里的-fexceptions和-frtti需要特别注意。NDK 默认是关闭 C++ 异常和 RTTI 的,因为早期 Android 运行时对异常的支持不完善,而且开启这两个特性会增加二进制体积。但现在 Android 对异常的支持已经很好了,如果你的代码用了try/catch或者dynamic_cast,必须显式开启,否则编译能过但运行时行为异常。

arguments里的-DANDROID_STL=c++_shared指定使用共享的 C++ 标准库。默认是c++_static,每个.so都静态链接一份 STL,多个.so之间传递 STL 对象时会出问题。用c++_shared的话,所有.so共享同一个libc++_shared.so,但需要确保这个库被打包进 APK。Gradle 插件通常会自动处理,但如果你的项目结构比较特殊,可能需要手动确认。

4.2 ABI 过滤与产物路径

abiFilters决定了编译哪些架构的.so。现在主流是arm64-v8a和armeabi-v7a,x86和x86_64主要用于模拟器。全架构编译会显著增加构建时间,开发阶段建议只保留当前测试设备对应的架构。

编译产物默认在app/build/intermediates/cmake/<variant>/obj/<abi>/下,最终打包进 APK 的lib/<abi>/。如果你想在构建后自动拷贝.so到某个目录,可以用 Gradle 的 task 来做:

tasks.register('copyNativeLibs', Copy) { from layout.buildDirectory.dir("intermediates/cmake/debug/obj") into layout.projectDirectory.dir("libs") }

4.3 CMake 版本与 NDK 版本的匹配

这是新手最容易翻车的地方之一。Android Studio 里可以指定 CMake 版本,但如果你指定的版本本地没装,它会尝试下载,下载失败就报错。更稳妥的做法是让 Gradle 用 SDK 里已经装好的版本,或者干脆不指定version,让它用默认的。

NDK 版本同理,android.ndkVersion可以指定,不指定的话用 SDK 里最新的。但不同 NDK 版本对 CMake 的要求不一样,比如 NDK r23 之后移除了一些旧的 toolchain 行为,如果你的CMakeLists.txt依赖了那些旧行为,升级 NDK 就会炸。我的建议是在项目里显式锁定 NDK 和 CMake 版本,写在build.gradle和 CI 配置里,避免"我本地能编译"的经典问题。

5. 完整实操流程:从零搭一个可用的原生模块

5.1 环境准备与版本确认

先确认本地环境。打开终端:

# 查看已安装的 NDK 版本 ls $ANDROID_SDK/ndk/ # 查看已安装的 CMake 版本 ls $ANDROID_SDK/cmake/ # 查看 CMake 实际版本 $ANDROID_SDK/cmake/3.22.1/bin/cmake --version

假设你看到 NDK 有25.2.9519653和26.1.10909125,CMake 有3.22.1。那就在build.gradle里锁定:

android { ndkVersion "26.1.10909125" externalNativeBuild { cmake { version "3.22.1" } } }

锁定版本的好处是团队协作和 CI 环境一致,坏处是升级时需要手动改。但比起随机崩溃,这点麻烦完全值得。

5.2 编写第一个可运行的 CMakeLists.txt

从最简单的开始,确保能跑通再逐步加复杂度:

cmake_minimum_required(VERSION 3.22.1) project("native-lib" LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) add_library(native-lib SHARED native-lib.cpp ) target_link_libraries(native-lib PRIVATE android log )

对应的native-lib.cpp:

#include <jni.h> #include <android/log.h> #include <string> #define LOG_TAG "NativeLib" #define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) extern "C" JNIEXPORT jstring JNICALL Java_com_example_myapp_MainActivity_stringFromJNI(JNIEnv* env, jobject /* this */) { std::string hello = "Hello from C++"; LOGI("stringFromJNI called"); return env->NewStringUTF(hello.c_str()); }

Java 层对应:

public class MainActivity extends AppCompatActivity { static { System.loadLibrary("native-lib"); } public native String stringFromJNI(); }

这个最小可运行版本跑通之后,再往里加东西。很多人一上来就搞复杂结构,结果一个链接错误排查半天,不如先确保基础链路是通的。

5.3 引入第三方库的两种方式

第一种是源码引入,用add_subdirectory:

add_subdirectory(third_party/some-lib) target_link_libraries(native-lib PRIVATE some-lib)

前提是third_party/some-lib里有自己的CMakeLists.txt。这种方式的好处是第三方库也跟着你的编译选项走,ABI 和 STL 都一致。坏处是如果第三方库的CMakeLists.txt写得不规范,可能会污染你的构建环境。

第二种是预编译库引入,用IMPORTED目标:

add_library(prebuilt SHARED IMPORTED) set_target_properties(prebuilt PROPERTIES IMPORTED_LOCATION ${CMAKE_SOURCE_DIR}/libs/${ANDROID_ABI}/libprebuilt.so INTERFACE_INCLUDE_DIRECTORIES ${CMAKE_SOURCE_DIR}/libs/include ) target_link_libraries(native-lib PRIVATE prebuilt)

这种方式适合那些不提供源码或者编译很耗时的库。注意${ANDROID_ABI}变量在 CMake 配置阶段是可用的,NDK toolchain 会设置它。

5.4 编译、打包与验证

配置完成后,执行:

./gradlew assembleDebug

如果一切正常,在app/build/intermediates/cmake/debug/obj/arm64-v8a/下能看到libnative-lib.so。用readelf检查一下:

$ANDROID_NDK/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-readelf -d libnative-lib.so

看看NEEDED项里有没有libc++_shared.so,如果有,确认 APK 里也打包了这个库。可以用unzip -l app-debug.apk | grep libc++检查。

安装到设备后,用adb logcat看日志,确认stringFromJNI called打印出来,说明 JNI 调用链路是通的。

6. 常见问题与排查技巧实录

6.1 找不到头文件:target_include_directories 的可见性陷阱

最常见的报错就是fatal error: 'xxx.h' file not found。排查步骤:

先确认头文件路径是否正确,用message(STATUS "Include dir: ${CMAKE_CURRENT_SOURCE_DIR}/core/include")打印出来看看。然后确认target_include_directories是加在正确的目标上,而且可见性修饰符(PRIVATE/PUBLIC/INTERFACE)符合预期。如果 A 目标依赖 B 目标,B 的头文件路径是PRIVATE的,那 A 就看不到 B 的头文件,得改成PUBLIC或INTERFACE。

还有一种情况是头文件路径里包含了变量但变量为空,比如${ANDROID_ABI}在某些上下文里没定义,导致路径变成/libs//include,自然找不到。

6.2 链接错误:undefined reference 的几种典型原因

undefined reference to 'xxx'这个错误信息量很大。可能的原因:

一是源文件没被编译进去。检查add_library里是否列出了所有需要的源文件。二是链接顺序问题,虽然 CMake 通常能处理,但涉及循环依赖时可能需要手动调整。三是符号被隐藏了,如果开了-fvisibility=hidden,需要导出的符号必须显式标记。四是 C/C++ 混编时的extern "C"问题,C++ 代码调用 C 函数时,C 函数的声明必须包在extern "C"里,否则链接器找的是 C++ 修饰后的符号名。

排查方法是用nm或llvm-nm查看.so里的符号:

llvm-nm -D libnative-lib.so | grep xxx

-D只看动态符号。如果符号存在但类型是U(undefined),说明它依赖别的库;如果是t或T,说明定义在当前库里。

6.3 运行时崩溃:UnsatisfiedLinkError 的排查路径

java.lang.UnsatisfiedLinkError: dlopen failed: library "libxxx.so" not found这个错误说明.so没被打包进 APK,或者 ABI 不匹配。先检查 APK 里的lib/目录:

unzip -l app-debug.apk | grep "\.so"

如果某个 ABI 的.so缺失,检查abiFilters配置。如果.so存在但还是报错,可能是依赖的libc++_shared.so没打包,或者.so本身有未满足的依赖。用llvm-readelf -d看NEEDED项,逐个确认依赖库都在。

还有一种情况是设备 ABI 和 APK 里的 ABI 不匹配,比如设备是arm64-v8a但 APK 里只有armeabi-v7a,虽然理论上可以兼容运行,但如果armeabi-v7a的库依赖了arm64特有的东西就会崩。

6.4 构建缓存导致的诡异问题

CMake 和 Ninja 都有缓存机制,有时候改了CMakeLists.txt但构建结果没变化,或者报错信息指向已经删掉的文件。这时候清理缓存:

./gradlew clean rm -rf app/.cxx

app/.cxx目录是 CMake 配置和构建的缓存,删掉它强制重新配置。这个操作我几乎每周都要做几次,尤其是切换分支或者改构建脚本之后。

6.5 常见问题速查表

报错信息可能原因排查方法
fatal error: 'xxx.h' file not found头文件路径未配置或可见性不对检查target_include_directories的路径和修饰符
undefined reference to 'xxx'源文件未编译、链接顺序、符号隐藏、extern "C" 缺失用llvm-nm查符号,检查源文件列表
UnsatisfiedLinkError: library not found.so未打包、ABI 不匹配、依赖缺失解压 APK 检查lib/目录,用llvm-readelf -d查依赖
error: undefined symbol: __cxa_throw未开启 C++ 异常支持在cppFlags加-fexceptions
error: cannot use 'dynamic_cast'未开启 RTTI在cppFlags加-frtti
构建结果与预期不符CMake 缓存未更新删除app/.cxx目录重新构建
CMake Error: Could not find toolchain fileNDK 路径或版本配置错误检查ndkVersion和ANDROID_NDK环境变量

7. 进阶技巧与性能优化

7.1 减小 .so 体积的几种手段

原生库体积直接影响 APK 大小,尤其是多 ABI 打包时。几个有效的手段:

开启链接时优化(LTO):

set(CMAKE_INTERPROCEDURAL_OPTIMIZATION_RELEASE ON)

LTO 让编译器在链接阶段做跨编译单元的优化,能去掉未使用的代码,但会显著增加链接时间。Release 构建可以开,Debug 就别开了。

剥离调试符号:

set(CMAKE_CXX_FLAGS_RELEASE "${CMAKE_CXX_FLAGS_RELEASE} -s")

-s告诉链接器在最终产物里去掉符号表,能减小不少体积。但注意这会让你无法用addr2line解析崩溃堆栈,所以最好保留一份带符号的版本用于崩溃分析。

使用-ffunction-sections -fdata-sections配合-Wl,--gc-sections:

set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -ffunction-sections -fdata-sections") set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} -Wl,--gc-sections")

这组选项让每个函数和数据单独成段,链接器可以精确地回收未引用的段。实测在一个中等规模的项目里能减掉 15% 到 25% 的体积。

7.2 多模块项目的依赖管理策略

项目变大之后,CMakeLists.txt会变得难以维护。我的做法是每个模块一个CMakeLists.txt,顶层只负责add_subdirectory和全局配置。模块之间的依赖通过target_link_libraries显式声明,避免隐式的全局依赖。

对于跨模块的公共配置,比如编译选项、C++ 标准,可以定义一个INTERFACE库来统一管理:

add_library(project-defaults INTERFACE) target_compile_features(project-defaults INTERFACE cxx_std_17) target_compile_options(project-defaults INTERFACE -Wall -Wextra)

然后每个模块target_link_libraries(mymodule PRIVATE project-defaults),这样改一处就能全局生效。

7.3 构建速度优化实践

原生构建慢是常态,几个提速手段:

用 Ninja 而不是 Make。NDK 默认就用 Ninja,但如果你手动配置过,确认一下CMAKE_GENERATOR是Ninja。

开启并行编译。Gradle 里设置:

android { externalNativeBuild { cmake { arguments "-DCMAKE_BUILD_PARALLEL_LEVEL=8" } } }

数字根据机器 CPU 核心数调整,一般是核心数的 1 到 1.5 倍。

用ccache。如果团队里多人反复编译同一份代码,ccache能缓存编译结果,第二次编译快很多。配置方式是在CMakeLists.txt里:

find_program(CCACHE_PROGRAM ccache) if(CCACHE_PROGRAM) set(CMAKE_CXX_COMPILER_LAUNCHER ${CCACHE_PROGRAM}) endif()

不过 Android NDK 的交叉编译场景下ccache命中率不一定高,因为编译命令里包含大量路径信息,换台机器或换个目录就失效了。适合在 CI 环境里用固定路径构建。

8. 我踩过的那些坑和最后的经验

说几个印象深刻的翻车经历。有一次项目里同时用了两个第三方库,都静态链接了c++_static,结果运行时崩溃,堆栈指向 STL 内部。排查了半天才发现是两个库各自带了一份 STL,内存分配和释放跨了不同的 STL 实例。改成c++_shared之后问题消失。这个坑的教训是:只要项目里有多个.so,就老老实实用c++_shared,别省那点体积。

还有一次是 CI 上构建失败,报错说找不到某个头文件,但本地完全正常。查了半天发现是 CI 环境的 CMake 版本比本地低,不支持某个语法。从那以后我在项目根目录放了一个cmake-version.txt,CI 脚本里读这个文件来安装对应版本,确保环境一致。

另一个常见的坑是file(GLOB)导致的"新增文件不编译"。有个同事加了个新的.cpp文件,本地 IDE 里能跑,提交后 CI 构建出来的.so里就是没有那个文件的符号。原因就是 CI 是全新配置,GLOB 执行时文件已经存在所以能扫到,但本地是增量构建,CMake 没有重新配置。后来我们统一改成显式列出源文件,这个问题再也没出现过。

最后分享一个调试技巧:当你不确定 CMake 实际用了哪些编译选项时,在CMakeLists.txt里加:

set(CMAKE_EXPORT_COMPILE_COMMANDS ON)

这会在构建目录生成compile_commands.json,里面记录了每个源文件实际的编译命令。用这个文件可以精确知道编译器收到了什么参数,排查选项相关的问题非常高效。配合 IDE 的 clangd 插件,还能获得准确的代码补全和跳转。

CMake 这东西,入门容易精通难,但大部分项目用到的就是那些核心功能。把add_library、target_include_directories、target_link_libraries这三个命令吃透,再理解 toolchain 和 Gradle 的集成方式,基本就能应付绝大多数场景了。剩下的就是遇到问题查文档、看报错、动手试,踩的坑多了自然就熟了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进