ARTICLE DETAIL

资讯详情

深耕网站SEO优化与搜索引擎排名提升的一线实战洞察。

Android NDK与JNI开发实战:从零构建Java与C++的桥梁

Android NDK与JNI开发实战:从零构建Java与C++的桥梁 1. 项目概述为什么要在Android里调用C/C如果你是一个Android开发者可能大部分时间都在和Java或Kotlin打交道。但当你遇到一些对性能要求极高的场景时比如图像处理、音视频编解码、物理引擎计算或者需要复用大量成熟的C/C库时纯Java/Kotlin就显得力不从心了。这时你就需要请出Android NDKNative Development Kit和JNIJava Native Interface这两位“老将”。简单来说这个过程就是让运行在Java虚拟机JVM上的Java/Kotlin代码能够跨越语言的边界去调用和执行由C/C编写的、直接运行在操作系统上的原生Native代码。这听起来有点复杂像是让两个说不同语言的人合作完成一项精密工作。但别担心只要工具和步骤对了这件事就像搭积木一样清晰。我见过很多新手卡在环境配置和第一个“Hello World”上被CMakeLists.txt、NDK路径、头文件生成这些概念绕晕。所以这篇内容的目标就是充当你的“贴身向导”我会用最详细的截图和讲解带你从零开始在Android Studio中完成一次完整的、可运行的C/C代码调用。我们不仅会跑通流程更会拆解每一步背后的原理让你彻底明白“为什么这么做”以及踩坑时“该怎么解决”。2. 环境准备与核心概念扫盲在动手之前我们必须把“工具箱”准备好并理解几个关键名词。这能避免你后续操作时一头雾水。2.1 必备工具安装与配置1. Android Studio这是我们的主战场。确保你安装的是较新版本如Hedgehog或Iguana以上因为它们对NDK和CMake的支持更完善、界面更友好。安装过程就是一路“Next”这里不赘述。安装完成后首次启动可能会提示安装Android SDK同意即可。2. Android NDKNative Development Kit这是谷歌官方提供的工具集包含了允许你在Android应用中使用C/C代码所需的编译器、库和头文件。没有它你的Java代码和C代码就无法“对话”。安装方式推荐在Android Studio内安装打开Android Studio点击File-Settings(Windows/Linux) 或Android Studio-Preferences(macOS)。 在左侧找到Appearance Behavior-System Settings-Android SDK。 切换到SDK Tools标签页。你会看到一个列表找到NDK (Side by side)并勾选它。同时确保CMake和LLDB也被勾选上。CMake是我们的构建脚本工具LLDB是用于调试Native代码的调试器。 点击Apply等待下载安装完成。(此处为示意图位置实际行文时需描述在SDK Tools选项卡中勾选NDK、CMake和LLDB)验证安装安装完成后NDK会默认放在Android SDK目录下的ndk文件夹里例如C:\Users\YourName\AppData\Local\Android\Sdk\ndk\25.2.9519653。这个长数字是版本号。记住这个路径有时需要手动指定。3. CMake你可以把它理解为一个高级的、跨平台的“项目构建说明书”生成器。我们不再直接编写复杂的Android.mk或Application.mk文件旧方式而是编写一个更简洁的CMakeLists.txt文件。CMake会根据这个文件为你的C/C代码生成对应平台如x86, arm64-v8a的构建脚本如Makefile或Ninja文件。Android Studio就是通过CMake来管理和编译你的Native代码的。注意在SDK Tools中安装的CMake是一个特定版本如3.22.1。如果你的项目需要其他版本可以在这里勾选多个版本然后在项目配置中指定。2.2 核心概念解析JNI与CMakeJNI (Java Native Interface)这是Java平台定义的一套标准编程接口是Java世界和Native世界C/C之间的“桥梁协议”或“翻译官”。它规定了如何声明在Java类中你需要用native关键字声明一个方法告诉JVM“这个方法的具体实现不在我这里在Native代码里”。如何命名Native层对应的C/C函数必须遵循一套严格的命名规则Java_包名_类名_方法名这样JVM才能准确地找到它。如何交互JNI提供了一大套函数JNIEnv指针让你在C/C代码里能够创建Java对象、调用Java方法、访问Java字段反之亦然。CMake如前所述它是构建系统的构建系统。在Android项目中CMakeLists.txt文件是核心它告诉CMake需要编译哪些C/C源文件.cpp,.c。这些源文件生成什么样的库动态库.so还是静态库.a。需要链接哪些其他的库比如OpenCV、FFmpeg。头文件.h的搜索路径在哪里。Android Studio的Gradle插件会读取你的模块级build.gradle文件中的CMake配置然后调用CMake去执行CMakeLists.txt最终编译出.so文件并打包进APK。关系梳理Java代码(声明native方法)--(通过JNI规则)-- C/C代码(实现函数)--(通过CMake编译)-- 原生库.so--(打包进APK)-- 在设备上Java代码通过System.loadLibrary加载.so库最终调用到Native函数。3. 实战创建并配置一个支持C/C的Android项目理论说再多不如动手做一遍。我们从一个新项目开始。3.1 创建新项目打开Android Studio选择New Project。在模板选择界面为了简单起见我们选择Empty Views ActivityEmpty Activity也可以。点击Next。配置项目信息Name:NativeDemoPackage name:com.example.nativedemoSave location: 按你的习惯选择Language: 选择Java(为了更直观地展示JNIKotlin原理相同但语法稍有差异)Minimum SDK: 选择一个足够低的版本以覆盖更多设备例如API 24: Android 7.0。在底部你会看到一个Use legacy android.support libraries的选项不要勾选。关键一步在配置页面的最下方找到Configuration折叠菜单点击展开。这里有一个C Standard的下拉菜单。这就是创建支持Native项目的关键从下拉菜单中选择C17或Toolchain Default。这决定了编译你的C代码时使用的语言标准。下方的Exceptions Support和Runtime Type Information保持默认勾选即可它们允许你使用C异常和RTTI特性。点击Finish。Android Studio会自动创建一个包含C支持的基本项目结构这会比普通项目多花一点时间。3.2 项目结构解析创建完成后观察你的项目Android视图app/src/main/java/: 你的Java代码在这里。app/src/main/cpp/:这是一个新文件夹里面存放了你的C源文件。默认会有一个native-lib.cpp文件和一个CMakeLists.txt文件。这就是项目的Native部分。app/build.gradle (Module :app): Gradle构建脚本已经自动添加了CMake的配置。我们重点看一下自动生成的两个核心文件1.app/src/main/cpp/native-lib.cpp#include jni.h #include string extern C JNIEXPORT jstring JNICALL Java_com_example_nativedemo_MainActivity_stringFromJNI( JNIEnv* env, jobject /* this */) { std::string hello Hello from C; return env-NewStringUTF(hello.c_str()); }#include jni.h: 引入JNI头文件这是必须的。extern C: 告诉C编译器以C语言的方式编译这个函数。因为JVM是C语言实现的它需要按照C语言的函数名修饰规则来查找函数。没有这个C的命名修饰mangling会导致JVM找不到函数。JNIEXPORT和JNICALL: 这是JNI规范定义的宏确保函数在动态库中可见且调用约定正确。jstring: JNI数据类型对应Java中的String。Java_com_example_nativedemo_MainActivity_stringFromJNI: 这就是JNI函数命名规则Java_包名类名方法名。包名中的点用下划线代替。JNIEnv* env: 指向JNI环境的指针是所有JNI操作的入口比如创建Java字符串 (NewStringUTF)。jobject this: 对应调用这个native方法的Java对象实例如果是静态方法则是jclass类型。这里参数名被注释掉了因为没用到。2.app/src/main/cpp/CMakeLists.txtcmake_minimum_required(VERSION 3.22.1) # 指定CMake最低版本 project(nativedemo) # 项目名称 add_library( # 添加一个库 native-lib # 库的名称稍后在Java中加载库时要用这个名字 SHARED # 库的类型SHARED 表示动态库 (.so)STATIC 表示静态库 (.a) native-lib.cpp # 库的源文件可以列出多个 ) find_library( # 查找一个系统预构建库 log-lib # 给找到的库起个别名 log # 要查找的库的实际名称这里是Android的日志库liblog.so ) target_link_libraries( # 将目标库与其它库链接起来 native-lib # 目标库就是我们上面定义的 native-lib ${log-lib} # 要链接的库这里链接了日志库这样我们才能在C中使用__android_log_print )这个文件逻辑很清晰定义项目、创建库、查找依赖库、链接依赖库。3.app/build.gradle (Module :app)中的CMake配置android { ... defaultConfig { ... externalNativeBuild { cmake { cppFlags // 可以在这里传递C编译标志如-stdc17 // 指定ABI过滤器减少APK体积。例如只打包arm64-v8a和armeabi-v7a // abiFilters arm64-v8a, armeabi-v7a, x86, x86_64 } } } buildTypes { ... } externalNativeBuild { cmake { path file(src/main/cpp/CMakeLists.txt) // 指定CMakeLists.txt的路径 version 3.22.1 // 指定CMake版本与CMakeLists.txt中声明的一致 } } }Gradle通过这段配置知道去哪里找CMake构建脚本以及传递哪些参数给CMake。3.3 编写Java层代码调用Native方法现在看MainActivity.javapackage com.example.nativedemo; import androidx.appcompat.app.AppCompatActivity; import android.os.Bundle; import android.widget.TextView; public class MainActivity extends AppCompatActivity { // 1. 加载原生库。参数“native-lib”必须与CMakeLists.txt中add_library定义的库名一致 static { System.loadLibrary(native-lib); } // 2. 声明一个native方法。这个方法没有方法体其实现将在C中完成 public native String stringFromJNI(); Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); TextView tv findViewById(R.id.sample_text); // 3. 调用native方法并将返回的字符串设置到TextView上 tv.setText(stringFromJNI()); } }流程非常清晰静态代码块加载库 - 声明native方法 - 像调用普通Java方法一样使用它。3.4 构建与运行连接你的Android设备或启动模拟器确保是64位系统镜像因为现代NDK默认编译64位库。点击Android Studio工具栏上的绿色运行按钮或ShiftF10。应用安装并启动后你应该看到屏幕上显示“Hello from C”。恭喜你已经完成了Android调用C代码的“Hello World”。但这只是开始我们还需要深入更多细节。4. 深入JNI数据类型映射与复杂操作在实际项目中我们不可能只传递一个字符串。JNI定义了Java类型和Native类型C/C类型之间完整的映射关系。4.1 基本数据类型映射Java 类型JNI 类型C/C 类型大小/范围booleanjbooleanunsigned char8位bytejbytesigned char8位charjcharunsigned short16位shortjshortsigned short16位intjintsigned int32位longjlongsigned long long64位floatjfloatfloat32位doublejdoubledouble64位voidvoidvoidN/A在Native函数中你可以直接使用这些JNI类型如jint,jdouble进行计算。4.2 引用类型与字符串操作对于对象如String、数组、自定义类JNI使用不透明的“引用”jobject,jstring,jarray等。你不能直接操作它们的内存必须通过JNIEnv*指针提供的函数来操作。字符串示例// Java public native String manipulateString(String input);// C extern C JNIEXPORT jstring JNICALL Java_com_example_nativedemo_MainActivity_manipulateString( JNIEnv* env, jobject thiz, jstring input) { // 1. 将jstring转换为C风格的字符串UTF-8编码。这是一个必须释放的指针 const char* c_str env-GetStringUTFChars(input, nullptr); if (c_str nullptr) { return nullptr; // 内存不足异常已抛出 } // 2. 对c_str进行操作例如转换为大写 std::string cpp_str(c_str); std::transform(cpp_str.begin(), cpp_str.end(), cpp_str.begin(), ::toupper); // 3. 释放从Java字符串获取的字符数组非常重要否则内存泄漏。 env-ReleaseStringUTFChars(input, c_str); // 4. 将C字符串转换回jstring并返回 return env-NewStringUTF(cpp_str.c_str()); }重要提示GetStringUTFChars和ReleaseStringUTFChars必须成对调用。对于GetTypeArrayElements这类函数也是如此。忘记释放是Native内存泄漏的常见原因。4.3 数组操作数组操作同样需要通过JNIEnv函数。// Java public native int sumArray(int[] arr);// C extern C JNIEXPORT jint JNICALL Java_com_example_nativedemo_MainActivity_sumArray( JNIEnv* env, jobject thiz, jintArray arr) { // 1. 获取数组指针和长度 jint* c_array env-GetIntArrayElements(arr, nullptr); jsize length env-GetArrayLength(arr); // 2. 执行计算 jint sum 0; for (jsize i 0; i length; i) { sum c_array[i]; } // 3. 释放数组元素。第三个参数是模式 // 0 复制回内容并释放原生数组 // JNI_COMMIT 复制回内容但不释放原生数组 // JNI_ABORT 不复制回内容直接释放原生数组 env-ReleaseIntArrayElements(arr, c_array, 0); return sum; }4.4 调用Java方法与访问字段有时需要在C中回调Java方法或修改Java对象的字段。这需要先获取类引用、方法ID或字段ID。// 假设我们想在C中调用一个Java对象的实例方法 extern C JNIEXPORT void JNICALL Java_com_example_nativedemo_MainActivity_callBackToJava( JNIEnv* env, jobject thiz) { // thiz就是MainActivity的实例 // 1. 获取对象的类 jclass clazz env-GetObjectClass(thiz); // 2. 获取方法ID。参数(类引用, 方法名, 方法签名) // 方法签名格式(参数类型)返回类型。例如 ()V 表示无参无返回。 // 可以使用 javap -s -p com.example.nativedemo.MainActivity 命令查看类中所有方法的签名。 jmethodID methodId env-GetMethodID(clazz, showToast, (Ljava/lang/String;)V); if (methodId nullptr) { // 方法没找到可能签名错误或方法不存在 return; } // 3. 创建要传递的字符串参数 jstring msg env-NewStringUTF(Called from C!); // 4. 调用Java实例方法 // 参数(对象实例, 方法ID, 实际参数...) env-CallVoidMethod(thiz, methodId, msg); // 5. 局部引用管理可选但建议clazz和msg是局部引用函数返回后会自动释放。 // 但在长时间运行的Native函数或大量创建引用时应使用env-DeleteLocalRef()主动管理防止局部引用表溢出。 }5. CMakeLists.txt 进阶配置随着项目复杂你的CMakeLists.txt也需要变得更强大。5.1 添加多个源文件与头文件# 添加多个源文件 add_library( native-lib SHARED native-lib.cpp another-lib.cpp utils.cpp ) # 添加头文件搜索目录。这样#include utils.h时CMake就知道去哪里找 include_directories( src/main/cpp/include ) # 或者更现代的方式使用target_include_directories作用域更清晰 target_include_directories( native-lib PRIVATE src/main/cpp/include )5.2 添加预构建库第三方.so或.a假设你有一个预编译好的第三方库libawesome.so和它的头文件awesome.h。# 1. 将第三方库导入为一个IMPORTED库目标 add_library( awesome-lib SHARED IMPORTED ) # 2. 设置这个导入库的路径。${CMAKE_SOURCE_DIR} 是CMakeLists.txt所在目录 set_target_properties( awesome-lib PROPERTIES IMPORTED_LOCATION ${CMAKE_SOURCE_DIR}/libs/${ANDROID_ABI}/libawesome.so ) # 注意按ABI分目录存放 # 3. 指定头文件目录 target_include_directories( native-lib PRIVATE ${CMAKE_SOURCE_DIR}/third_party/include ) # 4. 链接这个第三方库到你的库 target_link_libraries( native-lib awesome-lib ${log-lib} )5.3 条件编译与变量# 根据构建类型Debug/Release设置不同的编译选项 if (${CMAKE_BUILD_TYPE} STREQUAL Debug) target_compile_options( native-lib PRIVATE -O0 -g ) # 关闭优化包含调试信息 else() target_compile_options( native-lib PRIVATE -O3 ) # 开启优化 endif() # 定义宏可以在C代码中使用 #ifdef ENABLE_LOG target_compile_definitions( native-lib PRIVATE -DENABLE_LOG1 )6. 调试Native代码与性能分析6.1 使用LLDB进行Native调试Android Studio集成了LLDB调试器。调试Native代码和调试Java代码类似。在C代码行号左侧点击设置断点红色圆点。以Debug模式运行应用点击虫子图标或ShiftF9。当执行到断点时程序会暂停。你可以在Debug窗口查看变量值、调用栈并可以单步执行Step Over/Into。在Debug窗口的LLDB标签页你还可以直接输入LLDB命令进行更底层的检查。实操心得有时LLDB可能无法正确加载符号显示??。确保你的build.gradle中debug构建类型没有剥离调试符号默认不会。如果问题依旧尝试在CMakeLists.txt的Debug配置中显式添加-g标志。6.2 使用Android Logcat输出日志在C中使用__android_log_print函数输出日志到Logcat比printf更可靠。#include android/log.h // 需要包含这个头文件 // 定义日志标签 #define LOG_TAG NativeDemo // 定义日志宏方便使用 #define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__) #define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) extern C JNIEXPORT void JNICALL Java_com_example_nativedemo_MainActivity_logFromNative( JNIEnv* env, jobject thiz) { int value 42; LOGD(This is a debug log from native, value %d, value); LOGE(This is an error log!); }记得在CMakeLists.txt中链接log库 (target_link_libraries(native-lib ${log-lib}))。6.3 简单的性能考量JNI调用开销跨越Java-Native边界是有成本的。避免在紧凑循环中进行大量的、细粒度的JNI调用。应该尽量在一次JNI调用中完成更多工作或者将循环移到Native侧执行。局部引用与全局引用在Native函数中创建的jobject,jstring,jclass等默认都是局部引用函数返回后会被自动释放。如果你需要长时间持有比如保存在一个全局变量中必须使用env-NewGlobalRef()创建全局引用并在不再需要时用env-DeleteGlobalRef()释放否则会导致内存泄漏和引用混乱。异常处理JNI函数调用可能会抛出Java异常如NullPointerException。在调用可能抛出异常的JNI函数后应使用env-ExceptionCheck()或env-ExceptionOccurred()检查异常并妥善处理打印日志、清理资源、返回安全值。Native代码中的异常不会自动传播到Java层。7. 常见问题排查与解决实录在实际开发中你几乎一定会遇到下面这些问题。7.1UnsatisfiedLinkError或java.lang.UnsatisfiedLinkError这是最常见的错误意思是Java运行时找不到对应的Native函数实现。可能原因及排查步骤库名不匹配System.loadLibrary(“mylib”)中的“mylib”必须与CMakeLists.txt中add_library(mylib ...)定义的名称完全一致且不包含lib前缀和.so后缀。函数签名错误JNI函数名必须严格遵守Java_包名_类名_方法名的格式。包名中的点.必须替换为下划线_。使用javah旧或javac -h新命令自动生成头文件可以避免手写错误。# 在项目根目录或app模块目录下执行 javac -h ./jni src/main/java/com/example/nativedemo/MainActivity.java这会生成一个com_example_nativedemo_MainActivity.h头文件里面包含了正确的函数签名。ABI不兼容你的设备CPU架构如arm64-v8a与编译的.so库的ABI不匹配。检查build.gradle中的abiFilters是否包含了目标设备的ABI以及APK中是否包含了对应ABI的库文件。C命名修饰Name Mangling确保你的C函数用extern “C”包裹以防止编译器进行名称修饰。库未成功打包构建完成后检查app/build/intermediates/cmake/debug/obj/或app/build/intermediates/stripped_native_libs/目录下是否有对应ABI的.so文件。也可以解压APK查看lib/目录下是否有.so文件。7.2CMake Error或构建失败CMake版本不匹配检查build.gradle中cmake.version和本地安装的版本以及CMakeLists.txt中cmake_minimum_required指定的版本确保它们兼容。路径错误CMakeLists.txt中add_library指定的源文件路径、IMPORTED_LOCATION指定的第三方库路径是否正确。使用${CMAKE_CURRENT_SOURCE_DIR}来表示当前CMakeLists.txt所在目录是更安全的方式。缺少依赖如果使用了find_library或find_package确保对应的库已正确安装且路径已配置。7.3 运行时崩溃SIGSEGV, SIGABRT这通常是Native代码本身的bug比如空指针解引用、数组越界、内存泄漏、使用已释放的内存等。排查方法查看Logcat崩溃时Android系统会输出详细的墓碑tombstone信息到Logcat包含错误信号、寄存器状态、调用栈回溯backtrace。仔细阅读这些信息它们能精确定位到崩溃的代码行如果调试符号已包含。使用AddressSanitizer (ASan)这是一个强大的内存错误检测工具。在build.gradle中启用它android { defaultConfig { externalNativeBuild { cmake { // 其他标志... arguments “-DANDROID_ARM_MODEarm”, “-DANDROID_STLc_shared” // 启用ASan cppFlags “-fsanitizeaddress -fno-omit-frame-pointer” } } packagingOptions { // 对于使用c_shared STL的情况 doNotStrip ‘**/*.so’ } } }启用后内存错误会被更早、更清晰地检测出来。逐步注释代码如果崩溃点不明确可以尝试逐步注释掉可疑的Native代码块定位问题区域。7.4 中文乱码问题在C中处理Java传过来的中文字符串时有时会出现乱码。原因与解决Java内部使用UTF-16编码字符串。GetStringUTFChars获取的是修改过的UTF-8MUTF-8编码对于大部分字符是兼容标准UTF-8的但处理所有Unicode字符更安全的方式是使用GetStringChars获取UTF-16或GetStringCritical。确保你的C源文件保存的编码与编译环境期望的编码一致通常为UTF-8。在Android Studio中可以通过File-Settings-Editor-File Encodings全局设置为UTF-8。在Logcat中输出时确保Logcat的编码设置正确默认通常是UTF-8。从第一次配置环境时对着满屏英文错误日志发呆到如今能相对从容地搭建JNI桥梁、调试Native崩溃这个过程里最大的体会就是细节决定成败。一个拼写错误的方法名、一个忘记释放的局部引用、一个ABI过滤器的疏忽都可能导致应用崩溃或行为异常。我的建议是建立一个自己的“检查清单”在每次创建新的Native方法或引入第三方库时逐项核对函数签名对了没库加载了吗内存管理妥当吗ABI兼容吗多用自动生成工具如javac -h善用调试工具LLDB, ASan并把关键的操作步骤和配置写成脚本或模板。这样Android的Native开发就不再是令人畏惧的黑魔法而是一个强大且可控的性能利器。
返回列表