Android NDK 开发实践

694 阅读7分钟

NDK(Native Development Kit)开发是让开发者能够在 Android 平台上使用 C/C++ 语言进行编程的工具集,它是一项强大而又具有挑战性的技术。

NDK 开发的作用

  • 性能优化:对于一些计算密集型或对性能要求苛刻的任务,比如图像处理,音频处理,物理模拟等,使用 C/C++ 编写可以充分发挥底层硬件的性能,运行效率更高。
  • 代码复用:如果已经有现有的 C/C++ 代码库,例如一些成熟的算法库,游戏引擎等,可以通过 NDK 将其集成到 Android 应用中,避免重新开发。
  • 加密保护:某些关键的加密算法或安全相关的代码,使用 C/C++ 编写相对更难被反编译和破解,增加代码的保密性和安全性。在 Java/Kotlin 代码中,由于其字节码的特性,反编译相对容易,而 C/C++ 编译后的二进制代码更难被直接理解和逆向工程。
  • 跨平台开发:如果需要同时支持多个平台,将核心逻辑用 C/C++ 编写,然后通过 NDK 集成到 Android 中,可以减少在不同平台上的重复开发工作。
  • 深入系统交互:某些情况下,需要直接与底层系统进行交互,比如访问特定的硬件设备,实现底层网络协议等,NDK 可以提供这种能力。

NDK 与 JNI 的关系

JNI 的全称为 Java Native Interface,即 Java 的本地接口,可以实现 Java 与 C/C++ 语言的交互。NDK 和 JNI 就像是一对好搭档,NDK 就像是一个装满各种工具和资源的大箱子,能让我们用 C/C++ 语言来编写代码,然后把这些代码整合到 Android 应用中,而 JNI 呢,更像是一座连接 Java 世界和 C/C++ 语言世界的桥梁,它让 Java 代码能够和用 C/C++ 写的代码相互交流,传递数据。没有 JNI,Java 代码和 C/C++ 代码就像是两个语言不通的小伙伴,没法一起愉快地玩耍。

简单来说,NDK 提供了开发的环境和工具,而 JNI 负责在 Java 和原生代码之间牵线搭桥,让它们能够协同工作。

创建 Native C++ 项目

创建 NDK Project 之前,我们得先确保本地有 NDK 环境。如果没有,则需要先下载并配置好环境变量。

image.png

环境变量配置好之后,用 Android Studio 创建一个 Native C++ Project

image.png

Finish 之后,Android Studio 会为我们创建一些相关的代码文件,我们先来看看 build.gradle

plugins {
    id 'com.android.application'
    id 'org.jetbrains.kotlin.android'
}

android {
    compileSdk 33

    defaultConfig {
        applicationId "com.jian.nativelib"
        minSdk 26
        targetSdk 33
        versionCode 1
        versionName "1.0"

        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
        // 用于配置 cmake 命令参数
        externalNativeBuild {
            cmake {
                cppFlags ''
            }
        }
    }

    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }
    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }
    kotlinOptions {
        jvmTarget = '1.8'
    }
    // 定义 cmake 版本和构建脚本 CMakeLists 的路径
    externalNativeBuild {
        cmake {
            path file('src/main/cpp/CMakeLists.txt')
            version '3.18.1'
        }
    }
    buildFeatures {
        viewBinding true
    }
    ndkVersion '25.1.8937393'
}

dependencies {

    implementation 'androidx.core:core-ktx:1.7.0'
    implementation 'androidx.appcompat:appcompat:1.3.0'
    implementation 'com.google.android.material:material:1.4.0'
    implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
    testImplementation 'junit:junit:4.13.2'
    androidTestImplementation 'androidx.test.ext:junit:1.1.3'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
}

build 文件里会有 externalNativeBuild 配置项,我们再看下官方提供的简单 JNI 交互示例,Android Studio 会为我们创建如下两个文件。

image.png

CMakeLists 是 cmake 的构建脚本,具体看注释,如下所示:


# 设置 cmake 最小版本

cmake_minimum_required(VERSION 3.18.1)

# 声明和命名项目

project("nativelib")

# 编译 library

add_library( # 设置 library 名称
        nativelib

        # 设置 library 模式,SHARED 会编译 so 文件,STATIC 则不会
        SHARED

        # 设置原生代码路径
        native-lib.cpp)

# 搜索 library

find_library( # 设置路径变量的名称
        log-lib

        # 指定希望 cmake 查找的 NDK 库的名称
        log)

# 关联 library

target_link_libraries( # 指定目标库
        nativelib

        # 将目标库链接到 NDK 中包含的日志库
        ${log-lib})

native-lib 就是需要实现的原生代码,方法名的格式是:Java_包名_类名_方法名

#include <jni.h>
#include <string>

extern "C" JNIEXPORT jstring JNICALL
Java_com_jian_nativelib_MainActivity_stringFromJNI(
        JNIEnv* env,
        jobject /* this */) {
    std::string hello = "Hello from C++";
    return env->NewStringUTF(hello.c_str());
}

JNIEnv 是指向 JNI 环境的指针,可以用它来访问 JNI 提供的接口方法,jobject 表示对象中的 this。

对应的 MainActivity 调用代码如下:

class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        binding.sampleText.text = stringFromJNI()
    }

    // external 用于声明某个方法不由 Kotlin 实现,与 Java 的 native 相似
    external fun stringFromJNI(): String

    companion object {
        init {
            System.loadLibrary("nativelib")
        }
    }
}

对应的数据类型

两边对应的数据类型,Android Studio 会帮我们生成,我们只需定义好方法,比如,我们 MainActivity 中添加一个方法。

截屏2023-01-31 17.08.47.png

点击 Create JNI function for add,就会自动生成对应的数据类型代码。

extern "C"
JNIEXPORT jint JNICALL
Java_com_jian_nativelib_MainActivity_add(JNIEnv *env, jobject thiz, jint x, jint y) {
    // TODO: implement add()
}

再比如,在 MainActivity 中添加一个带引用类型的方法。

external fun getArraySize(a: IntArray, b: FloatArray): Int

生成对应的数据类型如下,其实大多就是在类型前面加个 “j”。

extern "C"
JNIEXPORT jint JNICALL
Java_com_jian_nativelib_MainActivity_getArraySize(JNIEnv *env, jobject thiz, jintArray a,
                                                  jfloatArray b) {
    // TODO: implement getArraySize()
}

处理对象

JNI 会把所有对象当作一个 C 指针传递到本地方法中,这个指针指向 JVM 内部数据结构,而内部数据结构在内存中的存储方式是不可见的,所以只能通过 JNIEnv 指针指向的函数来操作 JVM 的数据结构。

比如,我们有一个方法,操作一个字符串。

external fun handleString(str: String): String

由于字符串属于引用类型,所以不能像访问基本数据类型那样使用,native 只能通过 JNI 函数来访问字符串内容,代码如下:

extern "C"
JNIEXPORT jstring JNICALL
Java_com_jian_nativelib_MainActivity_handleString(JNIEnv *env, jobject thiz, jstring str) {
    // 从内存中拷贝出来供 native 使用
    const char *copyStr = (char *) env->GetStringUTFChars(str, NULL);
    if (copyStr == NULL) {
        return NULL;
    }
    char newStr[128] = {0};
    strcpy(newStr, copyStr);
    // 附加字符串
    strcat(newStr, "NDK");
    // 需要手动释放资源
    env->ReleaseStringUTFChars(str, copyStr);
    // 编码转换,因为 C/C++ 默认使用 UTF 编码,而 Kotlin 是 Unicode 编码。
    return env->NewStringUTF(newStr);

}

对于基本数据类型的数组,可以直接访问,举个例子,有个处理数组的方法,如下所示:

external fun handleArray(a: IntArray)

对应的 native 方法为

extern "C"
JNIEXPORT void JNICALL
Java_com_jian_nativelib_MainActivity_handleArray(JNIEnv *env, jobject thiz, jintArray a) {
    // 获取数组长度
    jint length = env->GetArrayLength(a);
    // 转化成本地数组
    jint *array = env->GetIntArrayElements(a, nullptr);
    // 遍历改变数组的值
    for (int i = 0; i < length; i++) {
        array[i] = i;
    }
    // 更新并释放数组
    env->ReleaseIntArrayElements(a, array, 0);
}

而数组元素为对象时,则不能直接访问,比如需要处理一个字符串数组

external fun handleObjArray(strArray: Array<String>)
extern "C"
JNIEXPORT void JNICALL
Java_com_jian_nativelib_MainActivity_handleObjArray(JNIEnv *env, jobject thiz,
                                                    jobjectArray str_array) {
    // 获取数组长度
    jint length = env->GetArrayLength(str_array);
    for (int i = 0; i < length; i++) {
        // 获取元素
        jstring element = (jstring) env->GetObjectArrayElement(str_array, i);
        // 设置元素
        env->SetObjectArrayElement(str_array, i, env->NewStringUTF("NDK"));
    }

}

so 库

为了方便使用,NDK 提供了一些脚本,使得更容易编译 C/C++ 代码,这个编译文件为 so 文件,就是 C/C++ 库。so 文件在程序运行时就会加载,要调用 so 文件,必有某个 kotlin 类运行时加载 native 库,并通过 JNI 调用了它的方法,所以一般情况下,都是 jar 包和 so 库一起提供出去,下面就简单整一个吧!

我们新建一个 NDK 项目,然后再创建一个 Module,在这个 Module 中创建一个类,用于调用 JNI 方法。

class JniMethod {
    companion object {
        init {
            System.loadLibrary("jniso")
        }

        external fun stringFromJNI(): String
    }
}

对应的 native 方法为

extern "C"
JNIEXPORT jstring JNICALL
Java_com_jian_mylibrary_JniMethod_00024Companion_stringFromJNI(JNIEnv *env, jobject thiz) {
    std::string hello = "Hello from C++";
    return env->NewStringUTF(hello.c_str());
}

然后我们运行这个项目,然后 so 库在这里。

image.png

默认支持 x86,如果想支持多种库架构,则可在 build.gradle 中配置一下,配置后记得 Clean 和同步一下。

defaultConfig {
    ...
    externalNativeBuild {
        cmake {
            cppFlags ''
        }
    }
    ndk {
        // 设置支持的 so 库架构
        abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64'
    }

}

对应的调用方法在 kotlin 文件中,所以,我们需要将这个 module 打包成 jar 包。执行命令

image.png

然后就会在该路径下生成 jar 包

image.png

现在 so 库和 jar 包都有了,就可以提供出去啦,将 so 库放到 jniLibs 目录下,jar 包放在 libs 目录下,然后项目中引入就行啦 ~