NDK 开发的 CMake 配置模板分享

NDK 开发的 CMake 配置模板分享

NDK 开发的 CMake 配置模板分享


NDK 开发的 CMake 配置模板分享


一个被 CMake 3.10 坑过的开场


去年维护一个老项目时,我把 build.gradle 里的 externalNativeBuild.cmake.version"3.10.2" 顺手改成了 "3.22.1",结果 CI 构建直接炸掉。错误日志很隐晦,CMake 在配置阶段就退出,提示某个 target_link_options 调用不合法。查了半小时才发现,那个选项是 CMake 3.13 才引入的,而 CI 环境用的是 Android Gradle Plugin 7.0 默认绑定的 CMake 3.18,但项目里另一个模块的 CMakeLists.txt 写死了 cmake_minimum_required(VERSION 3.10),导致 CMake 以兼容模式解析,新特性被静默忽略或报错。


这件事让我意识到,NDK 项目里的 CMake 配置不是"能跑就行"的胶水代码,而是一堆版本、路径、ABI、工具链细节纠缠在一起的脆弱系统。后来我把公司几个项目的 NDK 配置全部梳理了一遍,整理出一套模板,这篇文章就是分享这个模板的演进过程和里面的各种坑。


Android Studio 的 CMake 版本迷局


先说说 CMake 版本这个基础但混乱的话题。Android Studio 和 NDK 捆绑的 CMake 版本并不是跟着 Android Gradle Plugin 线性升级的,而是有自己的发布节奏。你可以通过 SDK Manager 安装多个 CMake 版本,路径通常在 sdk/cmake/3.x.y/bin/cmake。Gradle 里通过 version "3.22.1" 这样的语法指定,但这里有个坑:如果 SDK 里没有这个版本,构建会失败,不会自动下载。


我现在的做法是统一锁定 CMake 3.22.1。这个版本是 Android Studio 2021.x 系列开始稳定捆绑的,支持 target_link_optionsLINKER: 前缀,这对后续控制 linker 行为很重要。更老的 3.10.2 是 NDK r19 之前的默认版本,现在除非维护遗留项目,否则不建议用。CMake 3.25+ 开始有一些新的策略设置,但 Android 生态跟进较慢,为了避免团队环境不一致,3.22.1 是个稳妥的甜点。


模板里我会显式写死 cmake_minimum_required(VERSION 3.22.1),而不是写 3.10 然后祈祷。这会导致老环境直接报错,但报错比静默行为异常要好一百倍。


工具链文件的自动注入与手动覆盖


NDK 的 CMake 工具链文件路径在 $NDK/build/cmake/android.toolchain.cmake,这个文件是 Google 维护的,负责把 CMake 的抽象概念映射到 Android 的实际编译器、sysroot、STL 实现等。正常情况下,Android Gradle Plugin 会自动传递 -DCMAKE_TOOLCHAIN_FILE=... 给 CMake,但你如果在 build.gradle 里写 arguments "-DANDROID_STL=c++_shared" 这类参数,其实就是在覆盖工具链文件里的默认值。


我的模板里会把这些参数集中管理,而不是散落在 Gradle 和 CMake 两边。一个典型的 build.gradle 配置片段:


android {
    defaultConfig {
        externalNativeBuild {
            cmake {
                cppFlags "-O2 -fvisibility=hidden -fvisibility-inlines-hidden"
                arguments "-DANDROID_STL=c++_shared",
                          "-DANDROID_PLATFORM=android-24",
                          "-DCMAKE_BUILD_TYPE=Release"
            }
        }
        ndk {
            abiFilters 'arm64-v8a', 'armeabi-v7a', 'x86_64'
        }
    }
    externalNativeBuild {
        cmake {
            path "src/main/cpp/CMakeLists.txt"
            version "3.22.1"
        }
    }
}

这里 ANDROID_PLATFORM=android-24 是个关键选择。它决定了 CMake 会选用 NDK 里哪个 API level 的 sysroot 和头文件。注意这和 minSdk 不是一回事——minSdk 是 APK 安装限制,ANDROID_PLATFORM 是编译时的 API 级别。如果你设 android-21 但代码里调了 android-24 才有的 NDK API,编译能通过(因为链接时解析),但运行时会在低版本设备上崩溃。反过来,设太高的 ANDROID_PLATFORM 会失去对旧设备的兼容性。我通常让 ANDROID_PLATFORM 等于 minSdk,保持简单。


ANDROID_STL=c++_shared 意味着使用 libc++ 的动态链接版本。这是 NDK r18+ 后的默认推荐,因为静态链接 c++_static 会导致多个 so 库各自携带一份 STL 代码,体积膨胀且可能出现 ODR(One Definition Rule)违规。但动态链接也有代价:APK 里需要打包 libc++_shared.so,且所有 so 必须链接同一个 STL 版本。我的模板里会加一个 Gradle 任务检查,确保最终 APK 里只有一个 libc++_shared.so 实例。


CMakeLists.txt 的骨架结构


模板的核心是 CMakeLists.txt 的组织方式。我见过太多项目把这个文件写成一团浆糊,所有 target 的定义、源文件列表、编译选项、链接库全堆在一起。我的做法是用函数和宏做分层,大致结构如下:


cmake_minimum_required(VERSION 3.22.1)
project("myndk")

# 第一层:全局配置
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)  # 禁用 GNU 扩展,避免 clang 和 gcc 差异

# 第二层:工具函数
include(cmake/utils.cmake)
include(cmake/android_abi_check.cmake)

# 第三层:第三方依赖
add_subdirectory(third_party/oboe)
add_subdirectory(third_party/libpng)

# 第四层:主库
add_library(myengine SHARED ...)
target_sources(...)
target_compile_options(...)
target_link_libraries(...)

# 第五层:可选的测试和工具
if(BUILD_TESTING)
    add_subdirectory(tests)
endif()

这个分层不是形式主义。utils.cmake 里我放了一个 android_get_abi 函数,因为 CMake 在 Android 交叉编译时,CMAKE_SYSTEM_PROCESSOR 的值并不直接对应 Gradle 的 abiFilters,比如 arm64-v8a 在 CMake 里会显示为 aarch64。有些第三方库的构建脚本依赖这个值做条件编译,需要手动映射。


android_abi_check.cmake 更实际:它会在配置阶段检查当前 ABI 是否在白名单里,防止有人在 x86 模拟器上编译了一个只支持 arm64 的优化路径,然后疑惑为什么 CI 过不了。


编译选项的精细控制


NDK 的默认编译选项是保守的,为了兼容性牺牲了不少性能。我的模板里会显式覆盖一些关键选项。


-fvisibility=hidden-fvisibility-inlines-hidden 是必开的。这控制符号的默认可见性,只有显式标记 __attribute__((visibility("default"))) 的符号才会导出到动态符号表。对 Android 这种每个 so 都是独立链接单元的平台,这能显著减小 so 体积、加速动态链接器解析、还能避免符号冲突。代价是所有 JNI 函数和对外接口必须手动加 visibility 标记,漏掉的话 Java 层调用 System.loadLibrary 后会报 UnsatisfiedLinkError。我的模板里会配一个编译器警告 -Wmissing-attributes 来 catch 这种情况,虽然 clang 对这个警告的支持并不完整。


-O2 还是 -O3?我选 -O2 作为默认,Release 构建可以覆盖。-O3 在 NDK 的 clang 里有时会触发激进的向量化,导致 armv7 上的某些浮点运算和预期不一致。而且 -O3 增大的代码体积对热缓存不友好,实际性能未必更好。如果某个模块确实需要向量优化,我会在该 target 上单独加 -O3,而不是全局开。


-ffunction-sections -fdata-sections 和链接器的 --gc-sections 组合使用,可以剔除未使用的函数和数据段。这对依赖了大型第三方库(比如 OpenCV 或 FFmpeg)的项目特别重要。但注意这个组合和 -flto(链接时优化)有交互问题,某些版本的 NDK clang 会 LTO 后错误地 gc 掉还在用的 section。我遇到的是 NDK r23c 的问题,r25b 修复了。模板里会检测 NDK 版本,在已知有问题的版本组合里禁用 LTO。


-Wl,--no-undefined 强制链接器拒绝未定义符号。Android 的默认行为是允许未定义符号,因为运行时由动态链接器解析。但这会隐藏真正的链接错误,比如漏链接某个库,或者 C++ 符号因为名字修饰不匹配而找不到。开这个选项后,JNI 函数的未定义是预期的(因为由 JVM 动态绑定),所以要在链接选项里排除它们:target_link_options(myengine PRIVATE "-Wl,--unresolved-symbols=ignore-in-object-files")。这个细节很多项目没处理,导致要么不敢开 --no-undefined,要么开了之后 JNI 函数报错。


对 STL 和异常/RTTI 的务实选择


C++ 异常和 RTTI 在 Android NDK 里是可以禁用的,-fno-exceptions -fno-rtti 能减小体积。但我的模板默认保留它们,因为现代 C++ 代码很难完全避开异常,而且 NDK 的 libc++ 对异常的支持已经相当成熟。如果项目确实需要禁用,我会建议用 set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fno-exceptions") 在全局设置,而不是在每个 target 上重复。


真正要注意的是跨 so 的异常传播。从 so A 抛出异常,被 so B catch,这在理论上是可行的,但要求两个 so 使用同一个 libc++ 共享实例,且编译器版本一致。如果 so A 用 NDK r23 编译,so B 用 r25 编译,ABI 可能有细微差异,异常传播会崩溃。我的模板里会强制所有 so 在同一个 CMake 项目里构建,确保工具链一致。如果必须依赖预编译的第三方 so,我会把异常处理限制在单个 so 内部。


第三方库的集成策略


NDK 项目很少从零写,通常要集成 OpenCV、FFmpeg、Oboe、TensorFlow Lite 这类库。这些库的官方 CMake 支持程度参差不齐,是模板里坑最多的部分。


Oboe 是 Google 官方的低延迟音频库,GitHub 仓库 google/oboe 提供了 CMakeLists.txt,可以直接 add_subdirectory。但它的默认配置会尝试构建示例和测试,需要关 OBOE_BUILD_SHARED_LIBRARYOBOE_ENABLE_TESTS。我的模板里会这样集成:


set(OBOE_BUILD_SHARED_LIBRARY OFF CACHE BOOL "" FORCE)
set(OBOO_ENABLE_TESTS OFF CACHE BOOL "" FORCE)
add_subdirectory(third_party/oboe)
target_link_libraries(myengine PRIVATE oboe)

CACHE BOOL "" FORCE 是强制覆盖,防止被其他地方的 set 干扰。这是 CMake 的一个常见模式,但语法很丑,容易写错。


OpenCV 的 Android SDK 提供了预编译的 sdk/native/jni/OpenCV.mk 用于 ndk-build,CMake 支持是后来补的。它的 OpenCVConfig.cmake 假设了一些路径结构,如果你的项目目录层级和官方示例不一致,会找不到头文件。我的做法是手写 FindOpenCV.cmake,用 find_libraryfind_path 显式搜索,而不是依赖它自带的配置。代价是升级 OpenCV 版本时要检查路径是否变化,但换来了构建的稳定性。


FFmpeg 没有官方 CMake 支持,社区有几个维护的 CMake 封装,比如 https://github.com/tanersener/mobile-ffmpeg 或自己写 ExternalProject_Add。我用的是后者,因为 FFmpeg 的 configure 脚本选项太多,CMake 封装很难覆盖全。ExternalProject_Add 会在构建时下载、配置、编译 FFmpeg,然后把输出头文件和静态库引入主项目。坑在于:FFmpeg 的 configure 检测交叉编译环境很脆弱,需要手动传 --cross-prefix--sysroot,而这些值要从 NDK 的工具链文件里提取。我的模板里写了一个 get_ndk_toolchain_prefix 函数,解析 CMAKE_C_COMPILER 的路径来推断前缀,比如 /path/to/aarch64-linux-android24-clang 的前缀是 aarch64-linux-android-


调试和性能分析的基建配置


CMake 的 CMAKE_BUILD_TYPE 在 Android 里和 Gradle 的 buildTypes 有映射关系,但不是自动的。Gradle 的 debug 构建默认对应 CMake 的 Debugrelease 对应 Release,但你可以覆盖。我的模板里会显式对齐:


android {
    buildTypes {
        debug {
            externalNativeBuild {
                cmake {
                    arguments "-DCMAKE_BUILD_TYPE=RelWithDebInfo"
                }
            }
        }
        release {
            externalNativeBuild {
                cmake {
                    arguments "-DCMAKE_BUILD_TYPE=Release"
                }
            }
        }
    }
}

RelWithDebInfo 而不是 Debug,是因为 Debug 构建的 -O0 在 Android 设备上性能太差,很多实时相关的代码根本跑不起来。-O2 加调试信息是更实用的选择。但 RelWithDebInfo 的默认标志是 -O2 -g -DNDEBUG-DNDEBUG 会关掉 assert,如果你依赖 assert 做防御性编程,需要手动去掉。


对于性能分析,我集成 simpleperf 的 CMake 支持。NDK 自带 simpleperf 工具,路径在 $NDK/simpleperf。它的使用需要 unstripped 的 so 文件和对应的符号表。CMake 默认的 Release 构建会 strip 符号,需要在 CMakeLists.txt 里保留一份 unstripped 版本:


set_target_properties(myengine PROPERTIES
    CXX_VISIBILITY_PRESET hidden
    VISIBILITY_INLINES_HIDDEN ON
)
# 自定义 post-build 步骤,复制 unstripped so 到指定目录
add_custom_command(TARGET myengine POST_BUILD
    COMMAND ${CMAKE_COMMAND} -E copy
    $<TARGET_FILE:myengine>
    ${CMAKE_SOURCE_DIR}/../build/unstripped/${ANDROID_ABI}/libmyengine.so
)

这个路径 ../build/unstripped 要和 Gradle 的构建输出目录分开,因为 Gradle 的 mergeNativeLibs 任务会处理 strip,我们不能干扰那个流程。simpleperf 的脚本 report_html.py 需要指向这个 unstripped 目录来解析符号。


对 Prefab 的审慎态度


Prefab 是 Android Gradle Plugin 4.1+ 引入的机制,用于分发预编译的 native 库(AAR 格式),让依赖方通过 CMake 的 find_package 引入。听起来很美好,实际用起来限制很多。


Prefab 要求库作者提供精确的 Android.mkCMakeLists.txt 描述,包括 ABI、API level、STL 版本的组合。但社区里很多库没提供 Prefab 包,或者提供的版本滞后。Google 官方的 gamesdk(包含 Oboe 和性能调优库)有 Prefab 分发,但我在实际项目中遇到过版本不匹配:项目用 NDK r25,Prefab 包用 r23 编译,STL 的某些内部符号有差异,链接时报 undefined reference to __emutls_get_address 这类错误。


我的模板里保留了 Prefab 的支持路径,但默认关闭。只有在依赖的库确实提供了维护良好的 Prefab 包时才会启用,而且会检查 prefab 目录里的 module.json 和实际 NDK 版本是否兼容。


一个完整的模板片段


把上面的内容整合起来,模板的核心 CMakeLists.txt 大约长这样:


cmake_minimum_required(VERSION 3.22.1)
project("myengine")

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

# 工具函数
list(APPEND CMAKE_MODULE_PATH ${CMAKE_CURRENT_SOURCE_DIR}/cmake)
include(android_utils)

# 验证 ABI
android_check_abi()

# 全局编译选项
add_compile_options(
    -fvisibility=hidden
    -fvisibility-inlines-hidden
    -ffunction-sections
    -fdata-sections
)

# 全局链接选项
add_link_options(
    -Wl,--no-undefined
    -Wl,--gc-sections
    -Wl,--exclude-libs,ALL  # 隐藏静态库符号,避免冲突
)

# 第三方库
add_subdirectory(third_party/oboe)

# 主库
file(GLOB_RECURSE MYENGINE_SOURCES
    src/*.cpp
    src/*.c
)

add_library(myengine SHARED ${MYENGINE_SOURCES})

target_include_directories(myengine
    PUBLIC include
    PRIVATE src
)

target_compile_options(myengine PRIVATE
    -Wall -Wextra -Werror
    -Wmissing-attributes
)

target_link_libraries(myengine PRIVATE
    oboe
    log
    android
    EGL
    GLESv3
)

# 保留 unstripped 版本用于调试
set(UNSTRIPPED_DIR ${CMAKE_SOURCE_DIR}/../build/unstripped/${ANDROID_ABI})
file(MAKE_DIRECTORY ${UNSTRIPPED_DIR})
add_custom_command(TARGET myengine POST_BUILD
    COMMAND ${CMAKE_COMMAND} -E copy $<TARGET_FILE:myengine> ${UNSTRIPPED_DIR}/
)

--exclude-libs,ALL 是个不太常见的 linker 选项,它会强制隐藏所有静态库导出的符号,即使那些库没开 -fvisibility=hidden。这对防止第三方库的符号污染全局命名空间很有效,特别是当你静态链接了多个版本的同一个库(比如 OpenSSL 1.1 和 3.0 的迁移期)。


局限和还没解决的坑


这个模板不是银弹。有几个问题我至今没有完美方案。


CMake 的 file(GLOB_RECURSE ...) 被很多人诟病,因为新增源文件不会自动触发 CMake 重新配置,需要手动 touch CMakeLists.txt 或清理构建目录。我保留它是因为项目源文件结构变动不频繁,而且显式列出所有文件在大型项目里维护成本更高。如果追求严谨,可以用 configure_file 配合脚本生成文件列表。


NDK 的 lld 链接器在 r23 成为默认,但某些复杂项目会遇到 lld 特有的 bug,比如对重定位类型的处理和传统 bfd 链接器不一致。模板里可以通过 -fuse-ld=bfd 回退,但 NDK 已经不打包 bfd 了,这个选项在 r25+ 会失效。目前只能祈祷不触发 lld 的 corner case。


CMake 的 IMPORTED target 和 INTERFACE 属性在跨项目传递编译选项时行为复杂,特别是 INTERFACE_COMPILE_OPTIONS 会累积到所有依赖方。如果第三方库不规范地设置了全局选项,会污染你的主项目。我现在的防御是在 target_link_libraries 后用 target_compile_options(myengine PRIVATE ...) 覆盖关键选项,但这不是根本解决。


最后,CMake 本身的语法和调试体验依然糟糕。message(STATUS ...) 是主要的调试手段,但输出被 Gradle 的构建日志淹没,需要加 --info--debug 才能看到,而 --debug 的日志量巨大。我有时会直接用 cmake 命令行手动跑配置阶段,绕过 Gradle 的封装,来定位问题。


模板的使用方式


我把这个模板整理成了一个 GitHub 仓库,地址是 https://github.com/yourname/android-ndk-cmake-template(示例地址,实际发布时会替换)。不是做成一个"框架"或"库",因为 NDK 项目的差异太大,封装过度反而限制灵活性。它的用法是:复制 CMakeLists.txt 的骨架、cmake/ 目录的工具函数,然后根据项目调整 third_party/ 的集成部分。


Gradle 配置部分我提供了一个 ndk-config.gradle 文件,可以在主 build.gradleapply from: 'ndk-config.gradle' 来统一 NDK 版本、CMake 版本、ABI 过滤等设置。这适合多模块项目保持一致的 native 构建环境。


没有提供 Android.mk 的对应模板,因为 Google 已经明确 CMake 是首选,ndk-build 进入维护模式。维护老项目时偶尔还要碰 Android.mk,但新项目没必要双轨支持。


这套配置在我经手的三个商业项目和一个开源项目中运行,NDK 版本从 r23 到 r26 都验证过。最大的一个项目有 40+ 个 CMake target,链接后的 so 总大小控制在 15MB 以内(arm64),启动加载时间在 Pixel 6 上约 80ms。数据仅供参考,因为 so 大小和加载时间高度依赖具体代码和依赖库。

CopyOnWriteArrayList 的读写分离,更新开销有多大 2026-06-25
MAT 工具分析堆转储,定位内存泄漏 2026-06-25

评论区