1. 为什么 CMake 在安卓 NDK 开发里值得单独拎出来讲
如果你做过一段时间的安卓 Native 开发,大概率会有这样的感受:写 C/C++ 业务代码本身不算最头疼的,真正让人反复查文档、反复试错的,往往是构建脚本那一层。安卓 NDK 从早期用Android.mk和Application.mk的 ndk-build 体系,逐步过渡到以 CMake 为主力,再到今天新工程默认就是 CMake 加externalNativeBuild,这个变化不是随便换了个工具,而是因为 CMake 在跨平台、依赖管理、IDE 集成上的表达能力强太多。
但能力强也意味着语法多。CMake 本身是一门脚本语言,加上 NDK 又给它套了一层工具链文件、ABI 过滤、STL 选择、平台版本这些安卓特有的概念,新手一上来看到CMakeLists.txt里一堆target_、find_、set、option,很容易懵。我见过不少同学,能写 C++,但一让他改构建脚本就发怵,遇到链接报错、找不到头文件、ABI 不匹配这类问题,只能靠搜索引擎碰运气。
这篇内容就是想把安卓 NDK 场景下 CMake 最常用、最容易踩坑的那部分语法系统梳理一遍。它不是什么官方文档的翻译,而是我在多个实际工程里反复用到的写法总结。适合两类人看:一类是刚接触安卓 Native 开发、想搞明白CMakeLists.txt到底在干什么的新手;另一类是有一定经验、但构建脚本基本靠复制粘贴、想真正理解每一行含义的开发者。读完你应该能做到:独立写出一份结构清晰、支持多 ABI、能正确链接第三方库的 CMake 构建脚本,并且在遇到常见报错时知道往哪个方向排查。
2. 安卓 NDK 下 CMake 的整体设计思路
2.1 为什么是 CMake,而不是继续用 ndk-build
先回答一个很多人心里的疑问:既然 ndk-build 也能用,为什么官方和社区都在往 CMake 推。核心原因有三个。
第一是跨平台一致性。CMake 本来就是为跨平台构建设计的,同一套CMakeLists.txt稍作调整就能在桌面端、安卓、甚至其他嵌入式平台上复用。而 ndk-build 的Android.mk是安卓专属的,换平台就得重写。对于一个既要出安卓库、又要在本地做单元测试的项目,CMake 的优势非常明显。
第二是依赖表达能力强。CMake 的target_link_libraries、target_include_directories这些以 target 为中心的指令,能把“谁依赖谁、依赖什么、是公开依赖还是私有依赖”表达得很清楚。ndk-build 在这方面相对粗糙,大型项目里依赖关系一复杂就容易乱。
第三是IDE 与生态集成。现在主流 IDE 对 CMake 的支持都很成熟,语法高亮、跳转、调试配置都能打通。第三方 C/C++ 库也大多优先提供 CMake 支持,你直接add_subdirectory或者find_package就能接进来,省去大量手工配置。
所以选 CMake 不是跟风,而是它在工程化和可维护性上确实更胜一筹。理解了这一点,后面学语法时你就知道,那些看起来繁琐的 target 指令,其实都是在为“清晰的依赖关系”服务。
2.2 一份 CMakeLists 的典型骨架长什么样
在深入细节之前,先看一份安卓 NDK 项目里最常见的CMakeLists.txt骨架,心里有个全局印象:
cmake_minimum_required(VERSION 3.22.1) project(mylibrary LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) add_library(mylibrary SHARED src/main.cpp src/utils.cpp) target_include_directories(mylibrary PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include) find_library(log-lib log) target_link_libraries(mylibrary PRIVATE ${log-lib})这短短十几行,其实已经覆盖了安卓 NDK 开发里 80% 的日常需求:指定版本、声明项目、设置 C++ 标准、定义库目标、配置头文件路径、链接系统库。后面所有章节,本质上都是在这个骨架上做扩展和细化。你可以把这段当作模板记下来,绝大多数场景都能从它出发改。
2.3 理解“目标(target)”这个核心概念
CMake 现代用法里最重要的一个概念就是target。你可以把它理解成“一个构建产物”,它可以是静态库、动态库、可执行文件,甚至是一个接口库(INTERFACE library,只传递配置不产出文件)。
为什么强调 target 思维?因为老式 CMake 喜欢用全局变量,比如include_directories、link_libraries,这些指令会影响之后所有的目标,项目一大就互相污染,排查起来极其痛苦。而 target 指令(target_include_directories、target_link_libraries、target_compile_options)只作用于指定目标,依赖关系清晰可控。
在安卓 NDK 场景下,这一点尤其重要。因为你的工程里往往同时有多个 native 库,还可能引入第三方库,如果都用全局指令,很容易出现“A 库的头文件被 B 库意外包含”这种诡异问题。养成用 target 指令的习惯,是从“能编译”到“好维护”的关键一步。
3. 核心语法细节与实操要点
3.1 版本声明与项目定义:别小看第一行
cmake_minimum_required(VERSION 3.22.1)这行看起来最不起眼,但它决定了 CMake 用哪套策略来解析你的脚本。版本写低了,一些新语法用不了;写高了,老版本 CMake 直接报错。安卓 NDK 不同版本内置的 CMake 版本不一样,比如较新的 NDK 通常带 3.22 左右,所以写3.22.1是比较稳妥的选择。
这里有个实操心得:不要盲目照抄别人的高版本号。如果你的团队里有人用老版本 NDK,你写了3.25,他那边直接构建失败。稳妥做法是先确认项目要支持的最低 NDK 版本,再对照它内置的 CMake 版本去写。可以在 NDK 目录下的build/cmake里找到实际版本。
project(mylibrary LANGUAGES CXX)这行定义了项目名和启用的语言。安卓 NDK 场景下,如果你只写 C++,就写CXX;如果还有 C 代码,就写C CXX。显式声明语言的好处是 CMake 不会去检测用不到的语言编译器,配置阶段能快一点。项目名本身会成为一个变量PROJECT_NAME,后面可以用。
3.2 设置 C++ 标准:三种写法与取舍
C++ 标准的设置有好几种写法,我按推荐程度排一下。
第一种,也是我最推荐的,是直接设置 target 属性:
set_target_properties(mylibrary PROPERTIES CXX_STANDARD 17 CXX_STANDARD_REQUIRED ON CXX_EXTENSIONS OFF)第二种是设置全局变量:
set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON)第三种是直接加编译选项-std=c++17,但这种方式不推荐,因为它绕过了 CMake 的标准管理机制,跨编译器时容易出问题。
为什么推荐第一种?因为它只作用于特定 target,不会影响第三方库。你想想,如果你全局设了 C++17,而某个第三方库只兼容 C++14,就可能编译失败。用 target 属性就能避免这种连带影响。CXX_EXTENSIONS OFF的作用是禁用编译器扩展,强制使用标准 C++,这样代码在不同编译器间行为更一致,是个好习惯。
3.3 add_library:静态库还是动态库
add_library是定义构建产物的核心指令,安卓场景下最常纠结的就是SHARED还是STATIC。
add_library(mylibrary SHARED src/main.cpp) add_library(myutils STATIC src/utils.cpp)SHARED生成.so动态库,STATIC生成.a静态库。安卓上给 Java/Kotlin 通过 JNI 调用的库,必须是SHARED,因为系统加载器只能加载动态库。而纯内部复用的工具代码,用STATIC更合适,链接时直接打进最终产物,减少运行时加载开销。
这里有个容易踩的坑:静态库之间的循环依赖。如果 A 静态库依赖 B,B 又依赖 A,链接顺序不对就会报未定义符号。解决办法是用target_link_libraries时注意顺序,或者干脆把循环依赖的部分合并。我个人的经验是,工具类代码尽量做成无依赖的静态库,业务逻辑做成动态库,层次清晰就不容易出循环。
另外,add_library还支持只写源文件列表,让 CMake 自动推断类型,但显式写SHARED/STATIC更清楚,建议别偷懒。
3.4 target_include_directories:PUBLIC、PRIVATE、INTERFACE 的区别
这是新手最容易搞混的一组关键字,但理解之后非常有用。
target_include_directories(mylibrary PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src)三个关键字的含义是这样的:
PRIVATE:这个头文件路径只在本 target 编译时用,不传递给依赖它的 target。PUBLIC:本 target 用,同时传递给依赖它的 target。INTERFACE:本 target 自己不用,只传递给依赖它的 target。
举个实际例子。假设mylibrary的include目录放的是对外暴露的公共头文件,那它应该是PUBLIC,因为别人用你的库时需要这些头文件。而src目录放的是内部实现头文件,不该暴露出去,就用PRIVATE。这样设计的好处是,依赖你的库不会意外看到你的内部实现,接口边界清晰。
我见过有人图省事全写PUBLIC,结果项目一大,头文件搜索路径爆炸,还容易出现同名头文件冲突。花点时间区分清楚,后期省心很多。
3.5 target_link_libraries:链接系统库与第三方库
安卓 NDK 提供了一批系统库,最常用的就是log(日志)、android(原生 API)、EGL、GLESv3等。链接系统库的标准写法是用find_library:
find_library(log-lib log) find_library(android-lib android) target_link_libraries(mylibrary PRIVATE ${log-lib} ${android-lib})为什么用find_library而不是直接写-llog?因为find_library会去 NDK 的工具链路径里查找,能拿到正确的绝对路径,跨平台和跨 NDK 版本时更稳。直接写-llog虽然也能过,但依赖链接器的搜索路径,不够可靠。
链接第三方库时,如果对方提供了 CMake 支持,优先用add_subdirectory或find_package。如果只有预编译的.so或.a,可以用IMPORTED目标:
add_library(prebuilt SHARED IMPORTED) set_target_properties(prebuilt PROPERTIES IMPORTED_LOCATION ${CMAKE_CURRENT_SOURCE_DIR}/libs/${ANDROID_ABI}/libprebuilt.so) target_link_libraries(mylibrary PRIVATE prebuilt)注意${ANDROID_ABI}这个变量,它会在构建时被替换成当前正在编译的 ABI,比如arm64-v8a。这样一份脚本就能适配多 ABI,不用为每个架构写一遍。
3.6 ABI 过滤与平台版本:控制产物范围
安卓 NDK 支持多种 ABI,常见的有arm64-v8a、armeabi-v7a、x86_64、x86。你可以在CMakeLists.txt里通过变量控制,但更推荐在 Gradle 的externalNativeBuild里配置,因为那里更直观:
# 在 CMakeLists 里可以这样读取 if(ANDROID_ABI STREQUAL "arm64-v8a") # 针对特定 ABI 的特殊配置 endif()平台版本用ANDROID_PLATFORM控制,比如android-21。这个值决定了你能调用哪些系统 API,设低了用不了新 API,设高了会丢掉老设备。一般建议根据实际用户设备分布来定,android-21是个比较常见的下限,覆盖设备足够广,又能用上大部分现代 API。
提示:ABI 和平台版本这两个参数,改一个就可能影响整个构建结果。改之前最好确认一下项目里有没有针对特定 ABI 写的汇编代码或预编译库,否则很容易出现某个 ABI 编译失败的情况。
4. 完整实操流程与关键环节实现
4.1 从零搭建一个支持多 ABI 的 Native 库
假设我们要做一个名为nativecore的动态库,对外暴露一个头文件,内部用到一个静态工具库,还要链接安卓日志库。完整流程如下。
第一步,规划目录结构:
nativecore/ ├── CMakeLists.txt ├── include/ │ └── nativecore.h ├── src/ │ ├── nativecore.cpp │ └── internal.h └── utils/ ├── CMakeLists.txt └── string_utils.cpp第二步,写顶层CMakeLists.txt:
cmake_minimum_required(VERSION 3.22.1) project(nativecore LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) add_subdirectory(utils) add_library(nativecore SHARED src/nativecore.cpp) target_include_directories(nativecore PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src) target_link_libraries(nativecore PRIVATE string_utils PRIVATE ${log-lib}) find_library(log-lib log)第三步,写utils/CMakeLists.txt:
add_library(string_utils STATIC string_utils.cpp) target_include_directories(string_utils PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})这里有个细节:find_library(log-lib log)我放在了target_link_libraries之后,虽然 CMake 通常能处理,但更规范的写法是放在使用之前。实际项目里建议把find_library统一放在文件靠前的位置,可读性更好。
4.2 参数计算:如何确定 C++ 标准与平台版本
选 C++ 标准不是越高越好。C++20 虽然新特性多,但安卓 NDK 对它的支持是逐步完善的,某些特性在旧版本 NDK 上可能缺失。我的经验是:新项目用 C++17,维护老项目保持和现有代码一致。C++17 在主流 NDK 版本上支持已经很完整,std::optional、std::string_view、结构化绑定这些都能用,性价比最高。
平台版本的计算稍微复杂点。假设你的目标用户设备分布是:安卓 8.0 及以上占 95%,那对应 API level 是 26。但如果你用了某个只在 API 24 才引入的系统函数,那下限就得设成 24。所以流程是:先看用户分布定一个候选值,再检查代码里用到的系统 API 的最低要求,取两者中较高的那个。
ABI 的选择也类似。arm64-v8a是现在的主流,armeabi-v7a覆盖老设备,x86_64主要用于模拟器。如果包体积不敏感,全带上最省事;如果在意体积,可以只保留arm64-v8a加armeabi-v7a,模拟器调试时临时加x86_64。
4.3 与 Gradle 的对接:externalNativeBuild 配置
CMake 脚本写好后,需要在 Gradle 里告诉构建系统去哪里找它:
android { defaultConfig { externalNativeBuild { cmake { cppFlags "-std=c++17" arguments "-DANDROID_STL=c++_shared" } } ndk { abiFilters "arm64-v8a", "armeabi-v7a" } } externalNativeBuild { cmake { path "src/main/cpp/CMakeLists.txt" version "3.22.1" } } }这里几个点值得说明。abiFilters决定了最终打包哪些 ABI,和 CMake 里的配置是配合关系。arguments可以往 CMake 里传变量,比如-DANDROID_STL=c++_shared指定用共享版 STL。version指定 CMake 版本,要和cmake_minimum_required兼容。
关于 STL 的选择,c++_shared和c++_static是个经典话题。c++_shared把 STL 做成独立的.so,多个 native 库可以共享,包体积小,但要求所有库用同一个 STL 版本。c++_static把 STL 打进每个库,互不干扰,但包体积大,且多个库之间传递 STL 对象可能出问题。我的建议是:单个库用c++_static省心,多个库共享 STL 对象时用c++_shared。
4.4 实操现场:一次完整的构建与验证
配置写好后,构建命令很简单:
./gradlew assembleDebug构建完成后,去build/intermediates/cmake/debug/obj/目录下能看到各 ABI 的产物:
obj/ ├── arm64-v8a/ │ └── libnativecore.so └── armeabi-v7a/ └── libnativecore.so验证库是否正确生成,可以用 NDK 里的llvm-readelf工具查看:
llvm-readelf -d libnativecore.so这个命令会列出动态库的依赖项,你能看到它依赖了liblog.so、libc++_shared.so等。如果发现依赖了不该依赖的库,或者缺少了该有的依赖,就说明链接配置有问题,需要回头检查target_link_libraries。
我个人的习惯是,每次改完构建脚本,都跑一次这个检查,确认依赖关系符合预期。这个习惯帮我提前发现过好几次“链接了错误版本的库”的问题,比等到运行时崩溃再排查高效得多。
5. 常见问题与排查技巧实录
5.1 找不到头文件:从搜索路径入手
最常见的报错就是fatal error: 'xxx.h' file not found。排查思路是分三步。
第一步,确认头文件确实存在,路径拼写没错。这一步听起来废话,但实际中因为路径拼错、大小写不一致导致的问题非常多,尤其在跨平台时,某些系统对大小写不敏感,换到安卓就暴露了。
第二步,确认target_include_directories里包含了该头文件所在目录。注意是包含目录,不是文件本身。比如头文件在include/foo/bar.h,那应该包含include,代码里写#include "foo/bar.h"。
第三步,确认依赖关系正确。如果头文件来自另一个 target,那个 target 必须通过PUBLIC或INTERFACE把路径传递过来。如果它写的是PRIVATE,你就看不到,需要改成PUBLIC。
5.2 未定义符号:链接顺序与依赖缺失
undefined reference to 'xxx'是另一类高频错误。原因通常有两个:要么没链接对应的库,要么链接顺序不对。
先检查target_link_libraries里有没有漏掉库。比如用了__android_log_print却没链接log,就会报这个错。补上find_library(log-lib log)和对应的链接即可。
链接顺序问题在静态库场景下更常见。链接器处理静态库时是从左到右扫描的,如果 A 依赖 B,那 A 要写在 B 前面。CMake 的target_link_libraries通常能自动处理依赖顺序,但如果你手动写库路径,就要注意了。用 target 方式链接基本能避免这个问题,这也是推荐 target 思维的原因之一。
5.3 ABI 不匹配:运行时崩溃的隐形杀手
有时候编译能过,但一运行就崩溃,日志里出现UnsatisfiedLinkError或者dlopen failed。这往往是 ABI 不匹配导致的。
排查方法是检查 APK 里各 ABI 目录下的.so是否齐全。如果某个 ABI 目录下缺了某个库,运行时就会找不到。常见原因是某个第三方库只提供了部分 ABI 的预编译版本,而你的abiFilters却包含了所有 ABI。
解决办法有两个:要么补齐缺失 ABI 的库,要么在abiFilters里去掉不支持的 ABI。我一般倾向于后者,先保证能跑起来,再考虑补齐。
5.4 常见问题速查表
| 报错信息 | 可能原因 | 排查方向 |
|---|---|---|
| file not found | 头文件路径未包含 | 检查 target_include_directories |
| undefined reference | 库未链接或顺序错 | 检查 target_link_libraries |
| UnsatisfiedLinkError | ABI 不匹配或库缺失 | 检查 abiFilters 与产物目录 |
| CMake version too low | 版本声明过高 | 对照 NDK 内置 CMake 版本 |
| STL 相关崩溃 | STL 版本不一致 | 统一 c++_shared 或 c++_static |
5.5 几个我踩过的坑
第一个坑是在 CMakeLists 里硬编码绝对路径。本地能跑,换台机器就挂。正确做法是用${CMAKE_CURRENT_SOURCE_DIR}、${CMAKE_SOURCE_DIR}这些变量拼相对路径。
第二个坑是忘记find_library就链接系统库。直接写log有时能过,有时不行,取决于链接器配置。老老实实用find_library最稳。
第三个坑是多个 native 库用了不同版本的 STL。表现是运行时随机崩溃,排查起来极其痛苦。解决办法是统一 STL 配置,要么全c++_shared,要么全c++_static,别混用。
第四个坑是改了 CMakeLists 但没重新配置。CMake 有缓存机制,有时候改了脚本但构建系统没重新跑配置阶段,导致改动不生效。遇到诡异情况时,先清理构建缓存再试,往往能解决。
6. 进阶技巧与工程化建议
6.1 用 option 做条件编译开关
实际项目里经常需要根据场景开关某些功能,比如调试日志、性能分析。用option定义开关:
option(ENABLE_DEBUG_LOG "Enable debug logging" OFF) if(ENABLE_DEBUG_LOG) target_compile_definitions(mylibrary PRIVATE ENABLE_DEBUG_LOG=1) endif()然后在 Gradle 里通过arguments "-DENABLE_DEBUG_LOG=ON"打开。这样同一份代码,debug 和 release 构建可以有不同的行为,不用改源码。
6.2 用 target_compile_options 精细控制编译
不同编译器支持的选项不一样,CMake 提供了按编译器区分的写法:
target_compile_options(mylibrary PRIVATE $<$<COMPILE_LANGUAGE:CXX>:-fno-exceptions> $<$<COMPILE_LANGUAGE:CXX>:-Wall>)$<...>是生成器表达式,COMPILE_LANGUAGE:CXX表示只在编译 C++ 时生效。这种写法比全局加选项精确得多,能避免影响 C 代码或其他语言。
6.3 依赖管理:优先用现代方式
引入第三方库时,优先级从高到低是:find_package(如果对方提供了 config 文件)、add_subdirectory(源码集成)、IMPORTED目标(预编译库)。前两种方式能自动处理依赖传递,最省心。IMPORTED方式需要手动配置路径和依赖,适合没有 CMake 支持的库。
我个人的经验是,能用源码集成就别用预编译库,因为源码集成能保证 ABI 和 STL 配置一致,减少运行时问题。预编译库虽然省编译时间,但配置不当容易埋雷。
6.4 构建性能优化
大型项目构建慢是常态,几个优化点:一是用ccache缓存编译结果,二是合理拆分 target,避免改一行代码全量重编,三是减少不必要的头文件包含,用前置声明替代。这些优化在 CMake 层面主要是通过合理的 target 划分来实现的,所以前面强调的 target 思维,在这里也能体现价值。
7. 我个人的一些实操体会
写了这么多,最后分享几点纯个人经验。CMake 这门语言,语法本身不难,难的是理解它背后的构建模型。很多人学的时候喜欢背指令,但真正管用的是理解“target 是核心、依赖要显式、作用域要清晰”这三条原则。抓住这三条,遇到没见过的指令也能猜出大概用法。
另外,构建脚本的调试成本比业务代码高,因为报错信息往往不直观。我的习惯是每加一段配置就构建一次,确认没问题再加下一段,而不是一口气写完再调。这样出问题时,范围小,好定位。
还有一点,别怕看 CMake 的官方文档。虽然它写得偏学术,但指令的每个参数都有详细说明,遇到不确定的地方查一下,比在网上翻零散的博客靠谱。第三方库的CMakeLists.txt也是很好的学习材料,看别人怎么组织依赖、怎么处理跨平台,收获很大。
安卓 NDK 加 CMake 这套组合,入门时确实有点陡,但一旦跨过那道坎,你会发现它带来的工程化收益是值得的。希望这些内容能帮你少走点弯路。