简介:Android NDK r26b Windows版面向在Windows平台进行Android原生C/C++开发的工程师,旨在解决Java层性能瓶颈、复用现有C/C++库以及开展JNI交互等需求。包内共2000个文件,包含1969个头文件、13个Python脚本、10个Markdown文档、7个txt说明与1份PDF;头文件覆盖图形处理、神经网络、OpenMAXAL等模块接口,脚本与文档分别用于构建辅助和版本特性查阅,整体约630.97MB。压缩包目录贴近官方NDK发布形态,便于按模块定位所需头文件与工具链。目前已有731人学习下载,适合需要搭建NDK环境、研究交叉编译工具链或接入预编译库的Android开发者。通过该资源可完整获得NDK r26b原生接口定义,支撑高频计算模块开发、静态链接库集成、算法加密等实战场景,也有助于理解不同ABI架构下的构建差异。NDK使用中常涉及调试难度与兼容性处理,这套资源为此提供了基础头文件和对照文档。
1. android-ndk-r26b-windows.zip 到底能干什么
第一次拿到 android-ndk-r26b-windows.zip 这个压缩包的人,十有八九会愣一下:解压出来既没有安装向导,也没有一键脚本,倒是有一堆以.h结尾的头文件和看起来像命令行的工具目录。这其实才是 NDK 的本来面目——它不是 IDE 插件,而是一整套用 C/C++ 写 Android 模块的交叉编译工具集。r26b 这个版本专门服务于 Windows 开发者,解决的是"算法跑得太慢、想复用现成的 C/C++ 库、或者某些底层能力 Java 层够不着"这一类问题。搞图形渲染、音视频处理、自研推理库的人,以及想把老代码移植到 Android 的从业者,都会用到它。
2. NDK r26b 的组件构成与 Windows 环境接入
2.1 解压之后要先认识谁:工具链、sysroot 与头文件
拿到压缩包后,先别急着往 Android Studio 里塞。你要知道 NDK 干活靠的是三个部分:编译工具链(clang、lld、llvm-strip 这些)、sysroot(Android 平台的头文件与库文件)、以及 CMake 或 ndk-build 这两套构建框架。
工具链的主目录在toolchains/llvm/prebuilt/windows-x86_64/bin,里面全是.cmd包装的 clang 编译器。打开sysroot/usr/include能发现一批很有意思的头文件,这些才是 NDK 区别于普通 Linux 工具链的关键。
| 头文件 | 归属模块 | 实际用途 |
|---|---|---|
| NeuralNetworks.h / NeuralNetworksTypes.h | NNAPI(神经网络 API) | 在 C/C++ 层直接调用 NPU/DSP/GPU 加速的推理 |
| gl2ext.h / gl31.h / gl32.h | OpenGL ES 扩展与高版本入口 | 写自定义渲染管线、Tessellation、Compute Shader |
| OpenMAXAL.h | OpenMAX AL 多媒体接口 | 低延迟音频播放与录制 |
| uchar.h | C11 标准头 | 支持char16_t、char32_t,做文本处理时需要 |
| ubidi.h | ICU 双向文本算法 | 处理阿拉伯语、希伯来语这类从右往左书写的文本 |
| urename.h | ICU 符号重命名机制 | 防止 ICU 符号与系统库冲突 |
新手最容易犯的错是把这些头文件当摆设。实际上很多性能问题在 Java/Kotlin 层死活解决不了,就是因为 NNAPI 和 OpenGL ES 的入口在 NDK 里。建议动手前先把NeuralNetworks.h翻一翻,至少知道ANeuralNetworksMemory和ANeuralNetworksModel这两个核心类型长什么样。
2.2 环境变量与 Android Studio 工程接入
Windows 上装 NDK 不需要安装器,解压即用。但你得让系统找到它。
我一般会把压缩包解压到固定目录,比如D:\Android\Sdk\ndk\26.2.11394342(r26b 的内部版本号),然后手动写环境变量。注意在 Windows 上,ANDROID_NDK_HOME比ANDROID_HOME更直观,Android Studio 的 Gradle 插件未必会自动带上这变量,所以要在local.properties里兜底。
# cmd 里临时设置当前终端的环境变量,适合命令行验证 set ANDROID_NDK_HOME=D:\Android\Sdk\ndk\26.2.11394342 set PATH=%ANDROID_NDK_HOME%;%PATH% # 永久写入用户环境变量(PowerShell) [Environment]::SetEnvironmentVariable("ANDROID_NDK_HOME", "D:\Android\Sdk\ndk\26.2.11394342", "User")这段命令的逻辑是:先让当前终端认得ANDROID_NDK_HOME,再把它追加进 PATH,这样在 cmd 里直接敲clang就能定位到编译器;PowerShell 那行是把变量写进用户级注册表,重开终端也生效。参数上没必要把toolchains/llvm/prebuilt/windows-x86_64/bin完整写进 PATH,NDK 里的.cmd脚本会自动拼接前缀。
工程接入时,我更习惯在模块级build.gradle里直接声明ndkVersion,而不是依赖全局 SDK Manager 里的版本,避免同事机器上装错版本。
android { ndkVersion "26.2.11394342" // 与 android-ndk-r26b-windows.zip 解压目录对齐 defaultConfig { externalNativeBuild { cmake { arguments "-DANDROID_STL=c++_shared" cppFlags "-std=c++17" } } } externalNativeBuild { cmake { path "src/main/cpp/CMakeLists.txt" } } }这段配置的作用是把 NDK 版本钉死、把 CMake 指向cpp目录下的构建脚本。arguments里我故意写了ANDROID_STL=c++_shared,这意味着最终产物会依赖libc++_shared.so,后面第 5 章会专门讲这个坑。cppFlags指定 C++17,r26b 的 clang 版本完全支持。
2.3 管理 NDK 组件多版本的后悔药
如果某个老项目还在用 r21 的armeabi,而你新项目已经在用 r26b,两个版本会同时躺在 SDK 目录里。靠环境变量切换版本属于玄学操作,最稳妥的办法是让每个工程各自指定ndkVersion,并在local.properties里写:
ndk.dir=D\:\\Android\\Sdk\\ndk\\26.2.11394342Android Studio 会优先读这个路径,其次是ANDROID_NDK_HOME。我从 r21 升到 r26b 时被这个顺序坑过一次,血泪经验:如果你同时配了环境变量和local.properties,又以 Android Studio 打开工程,IDE 优先用local.properties里的路径,命令行构建反而优先环境变量,所以两边必须指向同一个目录,不然会出现"IDE 能编、命令行翻车"的诡异局面。
3. JNI 接口开发:从 Java 声明到 C++ 实现
3.1 静态注册还是动态注册:一张表看懂取舍
NDK 开发的入口必然是 Java 与 C/C++ 的桥接,也就是 JNI。所谓静态注册,是指 C++ 函数名严格按Java_包名_类名_方法名来命名,JVM 启动时按名字去找符号;动态注册则是用RegisterNatives在JNI_OnLoad里把 Java 方法和 C++ 函数指针绑定起来。
| 注册方式 | 命名约束 | 运行时开销 | 典型场景 |
|---|---|---|---|
| 静态注册 | 函数名必须包含完整类名路径 | 首次调用时按名字查符号略慢 | 方法少、Demo 级项目 |
| 动态注册 | 只需保证JNI_OnLoad存在 | 启动时一次性查表 | 生产级 SDK、方法多的模块 |
r26b 自带的jni.h对两种方式都支持,但我个人强烈建议动态注册。原因很现实:静态注册的函数名里如果包名带下划线,或者类名带$内部类,映射规则会让人头大;方法一多,改一个 Java 方法名就得同步改一长串 C++ 符号,纯属自找麻烦。
3.2 一个最小可跑的动态注册示例
我以一个常规的加解密模块为例,Java 层声明 native 方法,C++ 层实现后动态注册。先把 Java 侧写好:
package com.example.nativecrypto; public class NativeCrypto { // 在 C++ 里实现 SHA256 摘要,返回 hex 字符串 public static native String sha256Hex(byte[] input); static { // 这里加载的是 CMake 生成的 so 库 System.loadLibrary("nativecrypto"); } }System.loadLibrary的参数是 CMake 配置里add_library指定的名字,不加lib前缀,不加.so后缀。接下来是 C++ 侧:
#include <jni.h> #include <string> #include <vector> #include <openssl/sha.h> static jstring NativeCrypto_sha256Hex(JNIEnv* env, jclass clazz, jbyteArray input) { // 把 Java 的 byte[] 拷贝到 C++ 的 vector,避免直接持有 JNI 引用 jsize len = env->GetArrayLength(input); std::vector<jbyte> buf(len); env->GetByteArrayRegion(input, 0, len, buf.data()); unsigned char hash[SHA256_DIGEST_LENGTH] = {0}; SHA256(reinterpret_cast<const unsigned char*>(buf.data()), len, hash); // 将二进制摘要转成 hex 字符串 static const char hexDigits[] = "0123456789abcdef"; std::string out; out.reserve(SHA256_DIGEST_LENGTH * 2); for (unsigned char byte : hash) { out.push_back(hexDigits[byte >> 4]); out.push_back(hexDigits[byte & 0x0F]); } return env->NewStringUTF(out.c_str()); } static const JNINativeMethod kMethods[] = { {"sha256Hex", "([B)Ljava/lang/String;", reinterpret_cast<void*>(NativeCrypto_sha256Hex)} }; extern "C" JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) { JNIEnv* env = nullptr; if (vm->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_6) != JNI_OK) { return JNI_ERR; } jclass clazz = env->FindClass("com/example/nativecrypto/NativeCrypto"); if (clazz == nullptr) { return JNI_ERR; } // 第二个参数:方法个数。注册失败返回负数,JVM 会直接 abort if (env->RegisterNatives(clazz, kMethods, 1) < 0) { return JNI_ERR; } return JNI_VERSION_1_6; }代码逻辑分四层:第一段是 Java 声明与库加载;第二段把jbyteArray安全地拷贝成 C++ 容器的数据,原因是GetByteArrayElements拿到的指针在 GC 移动后可能失效,除非显式Release,所以我宁可用GetByteArrayRegion多拷贝一次换稳定;第三段调用 OpenSSL 算摘要;第四段JNI_OnLoad里先FindClass再RegisterNatives,把 Java 的sha256Hex和 C++ 函数绑定。
参数说明上,([B)Ljava/lang/String;是 JNI 描述符,[B表示byte[],括号内是入参、括号外是返回值类型。这个描述符一旦写错,注册会静默失败,调用时报UnsatisfiedLinkError。另外GetEnv第二个参数建议填JNI_VERSION_1_6,它能兼容从 Android 4.0 到现在所有版本,不会触碰到 API 等级检查。
3.3 r26b 头文件里值得直接开箱的 API:NeuralNetworks.h
同样在这个压缩包里,NeuralNetworks.h是 NNAPI 的 C 接口。很多人不知道,Android 8 以后设备上通常有 NPU 或 DSP,但 Java 层的NeuralNetworkAPI 并不是所有设备都暴露得干净。用 NDK 的ANeuralNetworksCompilation可以更底层地控制模型编译选项,比如指定优先用 GPU 还是 CPU。
我一般不会直接手写 NNAPI 的模型构建流程,太琐碎;但如果你的 so 已经内置了一个 Caffe/TensorFlow Lite 转换出来的模型文件,NDK 这套接口能让你跳过 Java 层,直接完成内存映射和推理。用法要点是:先把模型二进制映射成ANeuralNetworksMemory,再ANeuralNetworksModel_addOperand逐个声明输入输出张量。这套流程适合做推理引擎封装的人去啃,普通应用开发可以先用现成的推理框架。
3.4 gl2ext.h 与 gl31.h 的实际价值
gl2ext.h里声明了glEGLImageTargetTexture2DOES,这是从相机预览纹理直接生成 GL 纹理的关键入口。gl31.h则引入了 compute shader 相关的接口,比如glDispatchCompute。在 r26b 里,这些头文件对应的 GLES 版本是 3.1/3.2,sysroot/usr/lib下有对应的 so 可以直接链接。写视频特效、滤镜之类的模块,绕不开这几个头文件。
#include <GLES3/gl31.h> // 提供 glDispatchCompute 等入口 #include <GLES2/gl2ext.h> // 提供 EGLImage 扩展 // 典型调用:把外部纹理对象绑定到 sampler glActiveTexture(GL_TEXTURE0); glBindTexture(GL_TEXTURE_EXTERNAL_OES, externalTexId);GL_TEXTURE_EXTERNAL_OES只能在扩展头文件里拿到定义,不引入gl2ext.h直接编译会报未声明标识符。这是新手第一次做相机滤镜最容易翻车的地方。
4. 交叉编译与多 ABI 适配:从源码到 .so
4.1 r26b 的 ABI 视野与最低 API 等级
NDK r26b 带来的一个重要变化是正式移除 32 位 ARMv5(armeabi)支持,最低 API 等级推到了 21(Android 5.0)。这意味着如果你的业务还要覆盖 Android 4.4 的存量设备,这个版本直接编不过,得继续用旧的 r21/r22。
| ABI | 架构 | 支持 API 等级 | 设备覆盖 | 体积影响 |
|---|---|---|---|---|
| armeabi-v7a | 32 位 ARM | 21+ | 存量中低端手机 | 中等 |
| arm64-v8a | 64 位 ARM | 21+ | 2017 年后绝大多数设备 | 较大 |
| x86 | 32 位 Intel | 21+ | 老旧模拟器 | 中等 |
| x86_64 | 64 位 Intel | 21+ | 主流模拟器 | 较大 |
对 r26b 来说,armeabi-v7a 其实是用 32 位兼容模式跑的,真机为了效率和生态,我建议默认只保留 arm64-v8a 和 armeabi-v7a。x86_64 留给模拟器调试,单独开一个 debug 变体即可。
4.2 手工交叉编译:不依赖 Gradle 的一次性构建
大多数项目走 Gradle + CMake 就够了,但有一种场景必须手工交叉编译:你要给某个开源库打补丁,只想快速验证它在 Android 上能不能编过。这时候直接调 NDK 的 clang 最省事。
set CC=D:\Android\Sdk\ndk\26.2.11394342\toolchains\llvm\prebuilt\windows-x86_64\bin\aarch64-linux-android21-clang.cmd set AR=D:\Android\Sdk\ndk\26.2.11394342\toolchains\llvm\prebuilt\windows-x86_64\bin\llvm-ar.exe set STRIP=D:\Android\Sdk\ndk\26.2.11394342\toolchains\llvm\prebuilt\windows-x86_64\bin\llvm-strip.exe %CC% -shared -o libdemo.so demo.c ^ -I%ANDROID_NDK_HOME%\sysroot\usr\include ^ -I%ANDROID_NDK_HOME%\sysroot\usr\include\aarch64-linux-androideabi这段命令做了三件事:指定aarch64-linux-android21-clang作为交叉编译器,表示目标平台是 64 位 ARM、最低 API 21;用-shared生成动态库;手动给出 sysroot 头文件路径。参数上-I必须指到sysroot/usr/include的两个层级,因为 Android 的 C++ 头文件有一部分按 ABI 子目录存放,漏了aarch64-linux-androideabi这一级,cstdint之类的头会直接找不到。
用llvm-strip可以压缩 so 体积。我通常会在发布前跑一遍:
%STRIP% --strip-unneeded libdemo.so--strip-unneeded会移除所有不会被动态链接引用的符号,和-s的区别是它保留导出符号表,避免 JNI 函数在dlsym时反查不到。
4.3 多 ABI 打包策略:别为了强迫症把包撑爆
每个 ABI 一套 so,意味着包体量翻倍甚至翻三倍。我的习惯是abiFilters只保留两个:
android { defaultConfig { ndk { abiFilters "arm64-v8a", "armeabi-v7a" } } }如果想连模拟器一起覆盖,可以加一个debug构建类型单独放开 x86_64,发布时仍以真机 ABI 为准。这个策略在 r26b 下编译速度也更快——每少一个 ABI,C++ 编译时间能省掉近三分之一。
5. 常见问题与排查:Windows 上集成 NDK 的五次翻车
5.1 现象:运行时报 UnsatisfiedLinkError,明明 so 已经在 libs 里
原因:大概率是 Java 的System.loadLibrary名字和 so 实际文件名对不上,或者 ABI 目录没对齐。比如add_library(foo)生成的是libfoo.so,但你写loadLibrary("foo")是对的;如果你写loadLibrary("libfoo")就找不到了。另外,默认打包只带主 ABI 所需的 so,如果模拟器是 x86_64 而工程只编了 arm64-v8a,同样报这个错。
解决:确认abiFilters包含当前运行设备的 ABI;在build.gradle里临时加上:
packagingOptions { jniLibs { useLegacyPackaging true } }这不是根治办法,只用于排查:当useLegacyPackaging打开后,so 会以未压缩方式放进 APK,方便直接看 APK 里的文件路径是否跟设备 ABI 匹配。
5.2 现象:NDK 编译时提示找不到 sysroot 下的标准库头文件
原因:ANDROID_NDK_HOME指向了解压包里的android-ndk-r26b,但你的 CMake 配置里android.toolchain.cmake路径写到了上一级目录,导致 sysroot 拼接错误。另一个常见原因是 Windows 路径里的反斜杠没被 CMake 正确转义。
解决:先用绝对路径验证一遍配置:
externalNativeBuild { cmake { // 必须精确到 toolchain 文件本身,不能写到 NDK 根目录 path "C:/Users/A/StudioProjects/Demo/src/main/cpp/CMakeLists.txt" } }在CMakeLists.txt头部不要手动set(CMAKE_SYSROOT ...),让 Gradle 传入的 toolchain 文件接管。手动设置往往会覆盖 NDK 的默认 sysroot,造成头文件错乱。
5.3 现象:同一个 so 在 arm64 手机正常,在 armeabi-v7a 上启动崩溃
原因:典型的 NEON 优化没做运行时检测。编译时用了-mfpu=neon,但目标设备是低端 Cortex-A53,它没有完整的 NEON 单元(或者实现有 bug),指令直接触发 SIGILL。
解决:不要全局开 NEON,只针对单独的源文件控制:
set_source_files_properties( native_filter_neon.cpp PROPERTIES COMPILE_FLAGS "-mfpu=neon -mfloat-abi=softfp" )或者干脆在 C++ 里用android_getCpuFeatures()做运行时判断,支持再走 NEON 分支。
5.4 现象:多个 so 依赖同一个 libc++_shared.so,启动时 so 找不到依赖
原因:如果你用了ANDROID_STL=c++_shared,每个 so 都会在运行时依赖libc++_shared.so,但它不是系统库,必须打包进 APK。Gradle 默认会把这个依赖 so 放进jniLibs,但如果你用了自定义的打包流程(比如拷 so 到src/main/jniLibs),很容易漏掉它。
解决:检查 APK 里是否同时存在业务 so 和libc++_shared.so。如果不想每次打包都盯着依赖,直接改用ANDROID_STL=c++_static,这样业务 so 自身完整承载 C++ 运行时,缺点是每个 so 都大几百 KB。多 so 场景建议保留 shared,但千万别漏拷。
5.5 现象:编译时uchar.h与项目自带头文件冲突,报 redefinition 错误
原因:r26b 的 sysroot 里自带 C11 的uchar.h,有些老项目为了兼容低版本 Android,自己塞了一份功能相近的uchar.h到源码目录,两个头文件同时进入 include 路径,编译器就会报宏重复定义。
解决:优先删除项目里自造的uchar.h,用 NDK 自带的。如果第三方库强制要求自己的头文件,可以用 CMake 的include_directories(BEFORE ...)调整搜索顺序,让第三方头文件保持在系统头之前,但这样做有风险,BEFORE会把整个路径提前,可能连带影响其他标准头。我一般直接改第三方库的构建脚本,禁用它的内置头文件——改动成本远小于排查成本。
5.6 现象:cmake 增量构建后模块行为异常,改半天发现没重新编译 C++
原因:Windows 上对 so 的文件时间戳处理有延迟,Gradle 有时以为 so 没变化,跳过了externalNativeBuild任务。这是典型的 Windows 玄学问题。
解决:触发强制重编,执行以下任一命令:
# 删除 CMake 缓存和构建产物 gradlew clean del CMakeCache.txt gradlew :app:externalNativeBuildClean gradlew :app:externalNativeBuildDebug更彻底的方案是把build目录里的intermediates/cxx整个删掉再编。从那以后我每次改完 C++ 代码都会看一眼build/intermediates/cxx/Debug下的 so 时间戳,确认它真的更新了,再往真机上装。
6. 用 ndk-stack 解析崩溃现场,替原生代码留一条后悔药
原生代码崩了以后,adb logcat里只给一段内存地址,比如pc 0000000000012345,光看这串数字谁也定位不到是哪一行。r26b 的工具链里带了ndk-stack.py,它能把地址还原成函数名和源码行号。
# 先抓取 logcat 崩溃日志到文件 adb logcat > crash.log # 用 ndk-stack 解析,注意传入带符号的 so D:\Android\Sdk\ndk\26.2.11394342\ndk-stack.py ^ -sym build\intermediates\cxx\Debug\6s4x2z1a\obj\arm64-v8a ^ -dump crash.log-sym参数指向一个包含未 strip 的 so 的目录,Gradle 在编 debug 包时会在obj/<ABI>下留一份带符号表的文件,它和 APK 里的 so 是同一份源码编出来的,但体积大很多。ndk-stack输出会变成类似:
#00 pc 0000000000012345 libnativecrypto.so NativeCrypto_sha256Hex NativeCrypto.cpp:52解析完的栈能精准指出是哪一行触发了空指针或数组越界。我常用的验证流程是这样:每次接入一个新的 C/C++ 模块,都会在母函数入口加一个__android_log_print标记,然后故意写一段必崩的代码,确认 ndk-stack 能还原到正确行号,再把它删掉。整个过程走一遍之后,后面再遇到闪退就不会对着地址傻瞪眼了。
#include <android/log.h> #define LOG_TAG "NativeCrypto" #define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__) LOGD("sha256 entry, len=%d", static_cast<int>(len));LOG_TAG建议和 so 模块名保持一致,这样logcat过滤时能直接按标签收窄。原生产品的崩溃往往集中在 JNI 边界处,比如GetByteArrayRegion传入的 length 为负数,这类问题靠人工目光扫一遍很难发现,靠栈回溯是最靠谱的后悔药。从那以后我每次发布原生模块前,都强制走一遍"crash.log → ndk-stack → 确认行号"的例行检查,确定没有未捕获的异常才敢出包。希望这套流程也能帮你少折腾几个深夜。
本文还有配套的精品资源,点击获取