杰瑞科技汇

Android JNI如何快速入门?

Android JNI 完整教程

目录

  1. 什么是 JNI?
  2. 为什么使用 JNI?
  3. 开发环境准备
  4. 创建第一个 JNI 项目
    • 步骤 1: 创建 Android 项目
    • 步骤 2: 编写 Java/Kotlin 代码并声明 native 方法
    • 步骤 3: 生成 JNI 头文件 (*.h)
    • 步骤 4: 编写 C/C++ 实现代码 (.cpp)
    • 步骤 5: 配置 CMake 构建脚本
    • 步骤 6: 加载并调用 native 方法
    • 步骤 7: 编译和运行
  5. JNI 核心数据类型与映射
  6. JNI 常用方法详解
    • 获取基本类型字段
    • 获取对象字段
    • 调用实例方法
    • 调用静态方法
    • 异常处理
  7. JNI 高级主题
    • 字符串处理
    • 数组处理
    • 全局引用与局部引用
    • 线程管理
  8. 调试 JNI 代码
  9. 现代替代方案:Kotlin/Native 与 Jetpack Compose
  10. 总结与最佳实践

什么是 JNI?

JNI (Java Native Interface),即 Java 本地接口,是 Java 平台的一部分,它允许 Java 代码和其他语言(主要是 C、C++ 和汇编)编写的代码进行交互。

Android JNI如何快速入门?-图1
(图片来源网络,侵删)

JNI 是一座桥梁,它让 Java 程序可以调用 C/C++ 编写的库,反之亦然,在 Android 开发中,我们主要用它来让 Java/Kotlin 代码调用 C/C++ 代码。

为什么使用 JNI?

在 Android 开发中,使用 JNI 并非首选,因为它增加了复杂性和维护成本,但在某些特定场景下,它是必要的:

  • 性能优化:对于计算密集型任务(如图像处理、物理模拟、游戏引擎),C/C++ 的执行效率远高于 Java。
  • 代码复用:利用现有的、成熟的 C/C++ 开源库(如 OpenCV、FFmpeg、SQLite),避免重复造轮子。
  • 访问硬件/系统底层:直接操作硬件或调用 Android NDK 提供的、Java API 无法访问的系统级功能。
  • 保护核心算法:将核心逻辑放在 C/C++ 中,比在 Java 中更难被反编译和窃取。

开发环境准备

在开始之前,请确保你的开发环境已经正确配置:

  1. Android Studio:推荐使用最新稳定版。
  2. NDK (Native Development Kit):Android SDK 的一部分,包含了用于编译 C/C++ 代码的工具链、库和头文件,在 Android Studio 的 SDK Manager 中安装。
  3. CMake:一个跨平台的自动化构建系统,用于编译 C/C++ 代码,同样在 SDK Manager 中安装。
  4. LLDB:用于调试 native 代码的调试器。

安装方法:打开 Android Studio -> Tools -> SDK Manager -> SDK Tools -> 勾选 NDK (Side by side)CMake,然后点击 Apply 安装。

Android JNI如何快速入门?-图2
(图片来源网络,侵删)

创建第一个 JNI 项目

我们将创建一个简单的项目,让 Java 调用一个 C++ 函数来计算两个整数的和。

步骤 1: 创建 Android 项目

  1. 打开 Android Studio,选择 File -> New -> New Project...
  2. 选择 Native C++ 模板(这会自动帮我们配置好所有 JNI 环境),然后点击 Next
  3. 填写项目名称(如 JniDemo)、包名等,点击 Next
  4. 选择语言(Java 或 Kotlin)、最低 API 级别等,点击 Finish

Android Studio 会自动为你创建一个包含 native 支持的项目结构。

步骤 2: 编写 Java/Kotlin 代码并声明 native 方法

打开 app/src/main/java/com/example/jnidemo/MainActivity.java (或 .kt)。

MainActivity 类中,声明一个 native 方法。native 关键字告诉 JVM 这个方法的实现在别处(即 C/C++ 中)。

Android JNI如何快速入门?-图3
(图片来源网络,侵删)
package com.example.jnidemo;
import androidx.appcompat.app.AppCompatActivity;
import android.os.Bundle;
import android.widget.TextView;
public class MainActivity extends AppCompatActivity {
    // 声明一个 native 方法
    // 注意:方法名必须遵循特定的规则:Java_全类名_方法名
    // Java_com_example_jnidemo_MainActivity_stringFromJNI
    // C++ 编译器会根据这个规则来查找对应的函数。
    public native String stringFromJNI();
    // 一个新的 native 方法,用于计算两数之和
    public native int add(int a, int b);
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        TextView tv = findViewById(R.id.sample_text);
        // 调用 native 方法
        String hello = stringFromJNI();
        tv.setText(hello);
        // 调用我们自己的 native 方法
        int sum = add(10, 20);
        tv.append("\n10 + 20 = " + sum);
    }
}

步骤 3: 生成 JNI 头文件 (*.h)

头文件定义了 C/C++ 函数的签名,告诉 C++ 编译器这个函数需要接收哪些参数。

  1. 在 Android Studio 的终端中,执行以下命令来编译 Java 代码并生成 .class 文件:
    ./gradlew assembleDebug
  2. 使用 javah 工具(或现代的 javac -h)从 .class 文件生成头文件,在较新的 Android Studio 和 JDK 版本中,推荐使用 javac -h
    # 假设你的 JDK 在 /usr/lib/jvm/java-11-openjdk-amd64/bin/
    # 你需要找到 javac 的路径
    /path/to/your/jdk/bin/javac -d . -h . app/build/intermediates/javac/debug/classes/com/example/jnidemo/MainActivity.class
    • -d .:将生成的 .class 文件输出到当前目录。
    • -h .:将生成的 C/C++ 头文件输出到当前目录。

执行后,你会在项目根目录下找到一个名为 com_example_jnidemo_MainActivity.h 的头文件。

步骤 4: 编写 C/C++ 实现代码 (.cpp)

我们来实现头文件中声明的函数。

  1. 在 Android Studio 中,右键点击 app/src/main/cpp 目录,选择 New -> C++ Source File,命名为 native-lib.cpp
  2. native-lib.cpp 的内容替换为:
#include <jni.h>
#include <string>
// 对应于 Java_com_example_jnidemo_MainActivity_stringFromJNI
extern "C" JNIEXPORT jstring JNICALL
Java_com_example_jnidemo_MainActivity_stringFromJNI(
        JNIEnv* env,
        jobject /* this */) {
    std::string hello = "Hello from C++";
    // 将 std::string 转换为 Java 的 jstring
    return env->NewStringUTF(hello.c_str());
}
// 对应于 Java_com_example_jnidemo_MainActivity_add
extern "C" JNIEXPORT jint JNICALL
Java_com_example_jnidemo_MainActivity_add(
        JNIEnv* env,
        jobject /* this */,
        jint a,
        jint b) {
    return a + b;
}

代码解释:

  • extern "C":告诉 C++ 编译器使用 C 语言的链接方式,这非常重要,因为 Java 查找函数时使用的是 C 风格的函数名。
  • JNIEXPORT, JNICALL:这是 JNI 规定的宏,用于声明函数的可见性和调用约定,必须加上。
  • Java_com_example_jnidemo_MainActivity_stringFromJNI:这是函数名,必须与 javac -h 生成的规则完全一致。
  • JNIEnv* env:指向 JNI 环境的指针,它是 JNI 的核心,通过它你可以调用所有 JNI 函数(如创建对象、访问字段等)。
  • jobject thisnative 方法是实例方法,this 就是调用该方法的对象实例,如果是静态方法,则传入 jclass
  • jstring, jint:JNI 的数据类型,分别对应 Java 的 Stringint
  • env->NewStringUTF(...):通过 JNIEnv 指针调用 JNI 函数来创建一个 Java 字符串。

步骤 5: 配置 CMake 构建脚本

Android Studio 已经为我们生成了一个 CMakeLists.txt 文件,位于 app/src/main/cpp/ 目录下,我们需要确保它包含了我们的源文件。

打开 app/src/main/cpp/CMakeLists.txt应该类似这样:

# Sets the minimum version of CMake required to build the native library.
cmake_minimum_required(VERSION 3.18.1)
# 项目名称,可以自定义
project("jnidemo")
# 添加 C++ 标准
set(CMAKE_CXX_STANDARD 17)
# 查找 log 库,用于在 C/C++ 中打印日志
find_library(log-lib log)
# 添加你的 native 库
# add_library(库名称 SHARED 源文件列表)
add_library(
        # Sets the name of the library.
        native-lib
        # Sets the library as a shared library.
        SHARED
        # Provides a relative path to your source file(s).
        native-lib.cpp)
# 指定要链接的库
target_link_libraries(
        # Specifies the target library.
        native-lib
        # Links the target library to the log library
        # Includes the log library in the native library.
        ${log-lib})

这个脚本告诉 CMake 如何编译 native-lib.cpp 并生成一个名为 libnative-lib.so 的共享库。

步骤 6: 加载并调用 native 方法

MainActivity.java 中,我们还需要加载这个 .so 库,在 onCreate 方法中,添加 System.loadLibrary()

public class MainActivity extends AppCompatActivity {
    // ... native 方法声明 ...
    static {
        // 加载 native 库
        // 库名是 "libnative-lib.so",这里只需要写 "native-lib"
        System.loadLibrary("native-lib");
    }
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        // ... 调用 native 方法 ...
    }
}

System.loadLibrary() 会在应用的 lib/<abi> 目录下查找 lib库名.so 文件并加载它。

步骤 7: 编译和运行

点击 Android Studio 工具栏上的绿色 "Run" 按钮 (或按 Shift + F10),应用会安装到模拟器或真机上,并在屏幕上显示从 C++ 返回的字符串和计算结果。


JNI 核心数据类型与映射

理解 Java 和 C/C++ 数据类型的对应关系是使用 JNI 的关键。

Java 类型 JNI 类型 C/C++ 类型 描述
byte jbyte signed char 8位有符号整数
short jshort short 16位有符号整数
int jint int 32位有符号整数
long jlong long long 64位有符号整数
float jfloat float 32位浮点数
double jdouble double 64位浮点数
char jchar unsigned short 16位Unicode字符
boolean jboolean unsigned char 8位布尔值 (true=1, false=0)
Object jobject _jobject* 任何 Java 对象的基类
Class jclass _jclass* Class 对象的引用
String jstring _jstring* String 对象的引用
Object[] jobjectArray _jobjectArray* 对象数组
boolean[] jbooleanArray _jbooleanArray* 布尔数组
byte[] jbyteArray _jbyteArray* 字节数组
... ... ... ...
void void void 无返回值

JNI 常用方法详解

JNIEnv 是一个结构体指针,它包含了大量的函数指针,这些函数就是 JNI API。JNIEnv 提供了丰富的操作。

获取基本类型字段

假设有一个 Java 类 Person:

public class Person {
    public int age;
}

在 C++ 中获取 age 字段:

jfieldID ageFieldID = env->GetFieldID(personClass, "age", "I"); // (类, 字段名, 字段签名)
jint age = env->GetIntField(personObject, ageFieldID);
  • GetFieldID: 获取字段ID,第三个参数是字段签名,I 代表 intD 代表 doubleLjava/lang/String; 代表 String
  • GetIntField: 根据字段ID获取 int 值。

获取对象字段

获取 Person 对象的 name 字段:

public class Person {
    public String name;
}
jfieldID nameFieldID = env->GetFieldID(personClass, "name", "Ljava/lang/String;");
jstring nameObj = (jstring)env->GetObjectField(personObject, nameFieldID);
const char* nameStr = env->GetStringUTFChars(nameObj, nullptr);
// ... 使用 nameStr ...
env->ReleaseStringUTFChars(nameObj, nameStr); // 记得释放

调用实例方法

调用 Person 对象的 toString() 方法:

jmethodID toStringMethodID = env->GetMethodID(personClass, "toString", "()Ljava/lang/String;");
jstring resultStr = (jstring)env->CallObjectMethod(personObject, toStringMethodID);
  • GetMethodID: 获取方法ID,第三个参数是方法签名,()Ljava/lang/String; 表示无参数,返回 String
  • CallObjectMethod: 调用返回对象的方法,还有 CallIntMethod, CallVoidMethod 等。

调用静态方法

调用 Math 类的 max 方法:

jclass mathClass = env->FindClass("java/lang/Math");
jmethodID maxMethodID = env->GetStaticMethodID(mathClass, "max", "(II)I");
jint maxVal = env->CallStaticIntMethod(mathClass, maxMethodID, 10, 20);
  • FindClass: 根据类全名查找 jclass
  • GetStaticMethodID: 获取静态方法ID。
  • CallStaticIntMethod: 调用返回 int 的静态方法。

异常处理

JNI 调用可能会抛出 Java 异常,C/C++ 代码不会自动捕获这些异常,必须手动检查并处理。

// 在调用可能抛出异常的 JNI 函数后
env->CallVoidMethod(...); // 假设这个调用可能抛出异常
if (env->ExceptionCheck()) {
    // 检查是否有异常发生
    jthrowable exception = env->ExceptionOccurred();
    env->ExceptionDescribe(); // 打印异常信息到 logcat
    env->ExceptionClear();   // 清除异常,否则后续 JNI 调用会失败
    // 你可以在这里决定如何处理异常,比如返回一个错误码
    return -1;
}

JNI 高级主题

字符串处理

Java 使用 UTF-16 编码,而 C/C++ 常用 UTF-8 或平台编码。JNIEnv 提供了转换函数。

  • Java -> C++:
    jstring javaStr = ...;
    const char* cStr = env->GetStringUTFChars(javaStr, nullptr); // 转换为 UTF-8
    // 使用 cStr
    env->ReleaseStringUTFChars(javaStr, cStr); // 必须释放!
  • C++ -> Java:
    const char* cStr = "Hello from C";
    jstring javaStr = env->NewStringUTF(cStr); // 从 UTF-8 创建 Java 字符串

数组处理

JNI 提供了专门处理数组的方法,以避免频繁的内存拷贝。

  • 获取数组元素指针:

    jintArray intArray = ...;
    jboolean isCopy;
    jint* elements = env->GetIntArrayElements(intArray, &isCopy);
    // 可以直接操作 elements 指针,它指向数组或其副本
    // 释放
    if (isCopy == JNI_TRUE) {
        // 操作的是副本,需要将副本写回原数组
        env->ReleaseIntArrayElements(intArray, elements, 0);
    } else {
        // 操作的是原数组,直接释放
        env->ReleaseIntArrayElements(intArray, elements, JNI_ABORT);
    }
  • 创建新数组:

    jintArray newArray = env->NewIntArray(10); // 创建一个长度为10的int数组

全局引用与局部引用

这是 JNI 中最常见也最容易出错的地方,与内存管理有关。

  • 局部引用 (Local Reference):

    • 生命周期:从创建它的 JNI 函数调用开始,到该函数返回结束。
    • 作用域:仅限于当前 JNI 函数调用栈。
    • 创建方式:绝大多数 JNI 函数(如 NewStringUTF, GetObjectClass, CallObjectMethod)返回的都是局部引用。
    • 问题:如果在循环中创建大量局部引用,可能会导致 JNI 引用表溢出,引发崩溃。
    • 手动释放:可以使用 env->DeleteLocalRef(ref) 在函数返回前手动释放,尤其是在循环中。
  • 全局引用 (Global Reference):

    • 生命周期:从创建开始,直到你手动调用 DeleteGlobalRef 释放它为止。
    • 作用域:可以在任何线程、任何 JNI 函数中使用。
    • 创建方式:通过 env->NewGlobalRef(localRef) 将一个局部引用转换为全局引用。
    • 用途:当你需要在 JNI 函数返回后仍然持有一个 Java 对象的引用时(在后台线程中使用)。

最佳实践

  1. 在大多数情况下,依赖局部引用的自动回收机制。
  2. 在循环中创建对象时,在循环结束后手动删除局部引用。
  3. 如果需要将一个 Java 对象的引用长期保存下来(在 C++ 类的成员变量中),务必将其转换为全局引用,并在不再需要时手动删除。

线程管理

  • 主线程:在主线程(UI线程)中调用 native 方法,JNIEnv 是有效的。

  • 子线程:在子线程中通过 JavaVM 来获取当前线程的 JNIEnv

    // JavaVM 是一个全局对象,可以通过 JNI_OnLoad 获取或通过 AttachCurrentThread 获取
    JavaVM* javaVM;
    // ... 在某个地方获取 javaVM ...
    JNIEnv* env;
    javaVM->AttachCurrentThread(&env, nullptr);
    // env 可以在这个子线程中使用了
    // ... 调用 JNI 函数 ...
    javaVM->DetachCurrentThread(); // 线程结束时分离
  • JNI_OnLoad:当 native 库被加载时,会自动调用 JNI_OnLoad 函数,你可以在这里做一些初始化工作,并保存 JavaVM 的指针。

JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) {
    javaVM = vm; // 保存 JavaVM 指针
    return JNI_VERSION_1_6;
}

调试 JNI 代码

调试 JNI 代码分为两部分:Java 代码和 C/C++ 代码。

  1. 设置断点:像调试普通 Java 代码一样,在 Java 代码或 native 代码(.cpp 文件)中设置断点。
  2. 配置 Debuggable:确保 app/build.gradle 中的 android 块里有 debuggable true
    android {
        ...
        buildTypes {
            release {
                ...
            }
            debug {
                debuggable true
            }
        }
    }
  3. 启动调试:点击 "Debug" 按钮 (或 Shift + F9)。
  4. 切换线程:当应用停在 Java 断点时,调试器在 Java 线程,当应用停在 C/C++ 断点时,调试器会切换到本地线程,你可能需要在调试窗口的线程列表中手动切换回主线程来查看 Java 调用栈。
  5. 查看变量:在 C/C++ 断点处,你可以查看 C/C++ 变量,要查看 Java 对象的内容,可以使用 Android Logcat 打印出来,或者在调试器的 "Evaluate Expression" 窗口中调用 JNI 函数(如 env->GetObjectClass(obj))来检查。

现代替代方案:Kotlin/Native 与 Jetpack Compose

虽然 JNI 是传统且强大的方案,但 Google 也在推广更现代的解决方案:

  • Kotlin/Native:Kotlin 团队开发的技术,可以直接将 Kotlin 代码编译成原生二进制文件(如 .so, .dylib, .dll),无需通过 JNI 桥接,它更安全,内存管理由编译器自动处理,但目前生态系统和工具链不如 NDK 成熟。
  • Jetpack Compose + Skia:对于 UI 渲染,Google 推荐使用 Jetpack Compose,它底层使用 Skia 图形库进行高性能渲染,很多时候可以避免直接操作 OpenGL 或 Vulkan。

对于新项目,如果可能,优先考虑纯 Kotlin/Java 解决方案,如果必须使用 C/C++ 库,JNI 仍然是目前最主流和稳定的选择。


总结与最佳实践

  • JNI 是最后的手段:只在性能、代码复用或访问底层等必要时使用。
  • 保持接口简洁:尽量让 native 层的接口(函数)简单,传递基本类型和简单的数据结构,避免在 Java 和 C/C++ 之间频繁传递复杂对象。
  • 管理内存和生命周期:深刻理解局部引用和全局引用的区别,避免内存泄漏和引用表溢出。DeleteLocalRefDeleteGlobalRef 是你的好朋友。
  • 错误处理:始终检查 JNI 调用是否抛出了 Java 异常。
  • 线程安全:注意 JNIEnv 的线程绑定特性,在子线程中正确使用 JavaVM
  • 利用工具:熟练使用 Android Studio 的 CMake 配置、LLDB 调试器和 Logcat。
  • 学习签名:熟悉 JNI 类型签名(如 I, D, Ljava/lang/String;),这是排查问题的关键。

这份教程涵盖了 JNI 开发的方方面面,希望能帮助你顺利地在 Android 项目中集成 C/C++ 代码。

分享:
扫描分享到社交APP
上一篇
下一篇