2. NDK工具链与工程构建

上一章我们理清了 JNI 的调用边界与稳定性风险来源,这一章要把视角落到工程构建层面:一个可复用的 JNI 模块骨架,究竟由哪些构建要素组成,它们之间如何协作,又该如何在 Gradle 中稳定地产出可交付的 .so。很多线上崩溃并非代码逻辑错误,而是构建配置不一致导致的 ABI 缺失、符号被裁剪、STL 运行时不匹配或 so 版本错配。因此,构建骨架本身就是稳定性的一部分。

一、先确定构建系统的分工

Android NDK 提供两套主流构建方式:CMake 与 ndk-build。CMake 是当前 Android Studio 默认推荐方案,跨平台、可读性好,适合新工程;ndk-build 基于 Android.mk 与 Application.mk,在存量工程和部分第三方库中仍大量存在。二者最终都调用同一套 NDK 工具链(clang、lld、ar 等),差别主要在描述文件与 Gradle 集成方式。

一个可复用骨架建议采用如下目录结构,把“源码、构建脚本、产物、版本信息”分离:

jni-core/
├── CMakeLists.txt
├── src/main/cpp/
│   ├── native-lib.cpp
│   ├── jni_bridge.cpp
│   └── include/jni_core.h
├── src/main/java/com/example/jnicore/
│   └── NativeBridge.kt
└── build.gradle.kts

其中 jni_bridge.cpp 只负责 JNI 层参数校验与异常转换,native-lib.cpp 承载真正的业务逻辑,jni_core.h 暴露稳定 C 接口。这样分层后,JNI 层可被替换,核心逻辑可被单元测试或复用到其他平台。

二、CMake 骨架的关键配置

CMakeLists 中需要显式声明最低版本、库类型、C++ 标准、STL 选择与导出符号策略。以下是一份可直接参考的骨架:

cmake_minimum_required(VERSION 3.22.1)
project(jnicore LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

add_library(jnicore SHARED
    src/main/cpp/native-lib.cpp
    src/main/cpp/jni_bridge.cpp)

target_include_directories(jnicore PRIVATE
    src/main/cpp/include)

find_library(log-lib log)
target_link_libraries(jnicore ${log-lib} android)

# 只导出 JNI 入口,隐藏其余符号
set_target_properties(jnicore PROPERTIES
    CXX_VISIBILITY_PRESET hidden
    VISIBILITY_INLINES_HIDDEN ON)

这里有两个容易被忽略的点。第一,CXX_VISIBILITY_PRESET hidden 会把非 JNI 符号隐藏,减小 so 体积并降低符号冲突概率,但要求 JNI 函数必须用 JNIEXPORT 或 __attribute__((visibility("default"))) 显式导出。第二,STL 选择要与 Gradle 中的 ANDROID_STL 保持一致,否则会出现 std::__ndk1 符号找不到的运行时错误。

三、ABI 过滤与 so 打包

ABI 过滤决定最终打进 APK 的 so 架构。常见取值有 armeabi-v7a、arm64-v8a、x86、x86_64。生产环境通常只保留 arm64-v8a 与 armeabi-v7a,模拟器调试时再临时加入 x86_64。过滤配置写在 Gradle 的 ndk { abiFilters } 中,同时 CMake 侧无需重复声明。

so 打包有两种策略:一是随 APK 一起打包,二是通过 android:extractNativeLibs 与 useLegacyPackaging 控制是否压缩。若开启 extractNativeLibs=false,so 会以未压缩形式直接映射,启动更快但 APK 体积略增。对于稳定性要求高的模块,建议保持默认压缩并配合 abiFilters 精确控制,避免把调试用架构带入线上包。

JNI 模块构建骨架:从源码到 so 打包 C/C++ 源码 native-lib.cpp jni_bridge.cpp include/jni_core.h 构建脚本 CMakeLists.txt 或 Android.mk Application.mk Gradle 集成 externalNativeBuild ndk { abiFilters } ANDROID_STL NDK 工具链(clang / lld / ar) 按 ABI 分别编译:arm64-v8a、armeabi-v7a、x86_64 libjnicore.so arm64-v8a/ armeabi-v7a/ APK / AAB jniLibs 目录 按 ABI 分包 版本管理 versionCode / versionName so 哈希与符号表 关键一致性:CMake 的 STL 与 Gradle 的 ANDROID_STL 必须一致;abiFilters 决定最终打包架构

四、Gradle 集成与 so 版本管理

在模块级 build.gradle.kts 中,通过 externalNativeBuild 指向 CMakeLists,并在 defaultConfig 中声明 ABI 与 STL:

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

若使用 c++_shared,必须确保 libc++_shared.so 随包一起打入,否则在部分设备上会因找不到 STL 运行时崩溃。若模块不依赖其他 so,可改用 c++_static 减少一个依赖文件,但要注意多个 so 各自静态链接 STL 会带来符号重复与体积膨胀。

版本管理方面,建议把 so 的版本与模块版本绑定,并在构建时记录 ABI、STL 类型、Git commit 与符号表哈希。一个实用的做法是在 CMake 中注入版本宏:

target_compile_definitions(jnicore PRIVATE
    JNICORE_VERSION="${JNICORE_VERSION}"
    JNICORE_GIT_COMMIT="${GIT_COMMIT}")

这样在运行时可通过 JNI 暴露 getBuildInfo(),把版本信息写入日志或上报,便于线上问题定位。当出现 so 加载失败时,先核对 APK 内 lib/<abi>/ 是否包含目标架构,再用 readelf -d 检查依赖的 libc++_shared.so 是否存在,最后确认符号是否被 visibility 隐藏误伤。

骨架落地检查清单:① CMake 与 Gradle 的 STL 一致;② abiFilters 与线上分发架构一致;③ JNI 入口符号已导出;④ c++_shared 已随包;⑤ 版本宏可查询。五项全部通过,才算一个可复用的 JNI 模块骨架。