前言

Android 开发在接入一些第三方库时,你可能会发现它们会带有 SO 库,这些 SO 库是通过 C/C++、Rust 等语言编写生成的。

使用这种方式开发,有以下几个优点:

  • 性能快。C/C++ 编译过程是可执行文件到机器码,而 Java 则是字节码到 JVM 再到机器码。虽然 C/C++ 代码高效,但 Java 和 C/C++ 互相调用时却增大了开销。
  • 安全性高APK 中的 Java 代码很容易被反编译,而 C/C++ 库逆向难度较高。
  • 便于平台间的移植。通过 C/C++ 实现的动态库可以很方便地在其他平台上使用。
  • 可调用系统底层功能。这在硬件交互上很常见,比如驱动开发、音视频处理、人脸识别等等。

当接触到这种开发方式时,有几个名词会不断出现:

  • NDK:即 Native Development Kit。它是原生开发工具包,这套工具允许 Android 使用 C/C++ 代码,帮助开发者快速开发 C/C++ 的动态库。
  • JNI:即 Java Native Interface。它是 Java 平台的一部分,也就是 Java 与 C/C++ 相互通信的接口。
  • Makefile:将 C/C++ 代码编译成原生库的编译工具。在 Android 中,Makefile 依靠 ndk-build 脚本编译工具来完成配置编译,通常需要编写 Android.mkApplication.mk 两个 Makefile 文件,这种构建方式在『Eclipse』中比较常见。Makefile 依赖于编译平台,在不同的平台上有不同的编译工具,遵循的规则也不相同,所以在不同的平台上编译时,需要重新配置 Makefile,工作量较大也容易出错。
  • CMake:同样也是将 C/C++ 代码编译成原生库的编译工具,但它是一个跨平台的编译工具,很好地解决了 Makefile 的缺陷,只需要配置一个平台无关的 CMakeList.txt 文件来定制整个流程。同时它也是『Android Studio』中常用的编译工具。

了解这些基础含义,我们就可以开始项目的搭建工作。

搭建

了解 NDK 项目的搭建方式,最方便的莫过于直接利用『Android Studio』创建的模版来观察它与普通项目的异同。

新建 Native C++ 项目

它会自动帮我们下载及创建所需的文件,这样我们如果在现有项目中接入就可以参考对比。

SDK Tools

首先是需要 NDK 和 CMake 工具,在「SDK Tools」中下载即可:

SDK Tools

CMake

在 Module 的 build.gradle.kts 文件中指定 CMake 的目录和版本:

android {
    ...
    externalNativeBuild {
        cmake {
            path = file("src/main/cpp/CMakeLists.txt")
            version = "3.22.1"
        }
    }
}

接着在这个指定的目录下创建文件 src/main/cpp/CMakeLists.txt

创建 CMakeLists.txt

大致内容如下:

# 设置此项目所需的最低 CMake 版本
cmake_minimum_required(VERSION 3.22.1)
# 声明项目名称
project("hellondk")
# 创建并命名库,将其设置为 STATIC 或 SHARED,并提供其源代码的相对路径
add_library(${CMAKE_PROJECT_NAME} SHARED 
        native-lib.cpp)
# 声明 CMake 需要链接的库
target_link_libraries(${CMAKE_PROJECT_NAME} library
        android
        log)

add_library() 这一步,我们创建了一个以 ${CMAKE_PROJECT_NAME} 命名的库,实际上它就是 project() 中指定的项目名称。最终生成的 SO 文件将会以 lib${CMAKE_PROJECT_NAME}.so 命名,在这里就是 libhellondk.so

SO 文件名称

同时还看到上面指定了一个 C++ 文件,那么继续创建它:

创建 C++ 文件

至此,开发环境已搭建完成,同步下 Gradle,即可开始编码。

开发

首先需要加载对应的库:

class MainActivity : AppCompatActivity() {
    ...
    companion object{
        init {
            System.loadLibrary("hellondk")
        }
    }
}

这里 Kotlin 的写法有点繁琐,这是因为 Kotlin 中没有 static 关键字,只能依靠伴生对象来实现,Java 中的写法则更加明了:

public class MainActivity extends AppCompatActivity {
    ...
    static {
        System.loadLibrary("hellondk");
    }
}

这里需要跟上一节中配置的名字相同,这样我们就可以在类中加载这个库了。

Java/Kotlin 调用 C/C++ 代码

首先定义一个 Native 方法:

class MainActivity : AppCompatActivity() {
    ...
    private external fun sumByJNI(a: Int, b: Int): Int
}

这时候『Android Studio』会给出提示说找不到 JNI 方法:

Android Studio 提示找不到 JNI 方法

直接使用快捷键修复,『Android Studio』会自动在 src/main/cpp/native-lib.cpp 中创建对应的方法:

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

extern "C"
JNIEXPORT jint JNICALL
Java_com_example_hellondk_MainActivity_sumByJNI(JNIEnv *env, jobject thiz, jint a, jint b) {
    // TODO: implement sumByJNI()
}

我们观察该方法,其中 JNIEXPORT 后面跟的是返回值,jint 表示的就是 Java 的整型,JNICALL 后面的方法名格式为 Java_$package_$class_$method(),参数的话可以看到我们本来是定义了 2 个,但这里生成了 4 个,前面两个 JNIEnvjobject 是每个方法都会有的,后面的才是我们定义的参数。

实现上面的方法并回到 Java/Kotlin 中调用即可:

class MainActivity : AppCompatActivity() {
    ...
    override fun onCreate(savedInstanceState: Bundle?) {
        ...
        val i: Int = sumByJNI(1, 2)
    }
}

C/C++ 调用 Java/Kotlin 代码

C/C++ 调用 Java/Kotlin 方法是通过反射来实现的,这也是其为什么会带来性能损耗。

这里我直接在上一小节的例子中扩展,上一小节中我们将两数相加的操作交给了 C/C++,这里我们从 C/C++ 中将其再交给 Java/Kotlin,相当于绕了一圈,实际开发并不会这样操作,作为示例理解即可。

在 Java/Kotlin 中定义方法:

class MainActivity : AppCompatActivity() {
    ...
    private external fun sumByJNI(a: Int, b: Int): Int
    private fun sum(a: Int, b: Int) = a + b
}

修改 C/C++ 中的实现:

extern "C"
JNIEXPORT jint JNICALL
Java_com_example_hellondk_MainActivity_sumByJNI(JNIEnv *env, jobject thiz, jint a, jint b) {
    const char *className = "com/example/hellondk/MainActivity";
    const char *methodName = "sum";
    jclass jc = env->FindClass(className);
    jobject jo = env->AllocObject(jc);
    jmethodID methodId = env->GetMethodID(jc, methodName, "(II)I");
    jint value = env->CallIntMethod(jo, methodId, a, b);
    return value;
}

这里解释一下 GetMethodID() 方法的最后一个参数,它指要调用的 Java/Kotlin 方法的方法签名,即描述了该方法的参数和返回值,使用的是 CLASS 文件的字段描述符,即使你不熟悉但做应该也有见过,你可以在网上很轻松地查到相关的介绍,我们也可以直接使用命令对 CLASS 文件查看:

➜   javap -s MainActivity

反射的步骤其实与 Java/Kotlin 中差别不大,注意所有类都是以 j 开头,即代表 Java 对象。

后记

『Android Studio』推荐我们使用 CMake 配置 NDK 项目,整个流程走下来还是比较简单的,最难的地方也许是掌握 C/C++。

很多老项目还在使用 Makefile 来配置,如果后面有时间,再写写相关的笔记。