Android - NDK

Prologue

  这篇博客主要是用于团队内的第一次分享,关于主题的话思考了好久。一开始想分享数据结构与算法,感觉好像又不太合适,后面决定还是来学习 + 分享安卓 NDK 相关的一些知识了。因为自己也是第一次接触,如果有表述有问题还请见谅。下面正式开始进入NDK的世界吧。(可以把这篇文章当成是google官方文档的翻译)
  注: 本文假设读者有一定的 Android 基础,能使用 Android Studio 写一些简单的 app。以及如何用 c/c++ 写 Hello World 文件。

Before Start

Main Components

  正式开始前,我们需要理解构建 app 的基本概念。

  1. NDK(Native Development Kit),包含 Native 层开发所需的基本工具
  2. Native shared libraries: NDK 构建的库,或者 .so 文件,从 c/c++ 源码编译得到
  3. JNI(Java Native Interface): Java 和 C++ 层的交流的”桥梁”

  Native Activity的开发流程:

  1. 设计 app 的基本架构, 将 native 层的需要实现的功能与 Java 层区分开来。
  2. 创建 app 项目。在 AndroidManifest.xml 中声明 NativeActivity 类
  3. 创建 Android.mk 文件,描述 Native 层的库文件信息
  4. (可选项)创建 Application.mk 文件,配置目标 ABIs,工具链等
  5. 将 Native 代码放在项目的 jni 目录下
  6. 使用 ndk-build 编译 native 库
  7. 构建 Java 组件,产生可执行的 .dex 文件
  8. 将所有东西打包到 apk 文件中,可以安装并运行

  值得一提的是,现在更加推荐的构建方式是使用 CMake

Native Activities and Applications

  安卓SDK提供了一个帮助类 NativeActivity 去处理 framework 层和 native 层的交互,因此我们可以不需要去继承这个类或者去调用它的方法。只需要在 Manifest 文件中声明文件是 Native 的即可。需要注意的是,使用 NativeActivity 的安卓应用还是跑在虚拟机上,和其他应用相隔离,因此我们也可一使用 JNI 去访问 framework 层的 api。
  Androi NDK 提供了两种实现 native activity 的方式:

  1. native_activity.h 头文件定义了 NativeActivity 类的 native 版本,包含了回调接口和许多用于创建 activity 的数据结构。但是,在这种实现中,回调函数是跑在主线程当中的,因此不能阻塞,否则会触发 ANR(Application no response) 导致错误。
  2. android_native_app_glue.h 头文件定义了一个在 native_activity.h 接口上的一个帮助类的库。它使用了另外一个线程去执行我们的代码。因此可以避免阻塞主线程。

  google 官方给出了一个 NativeActivity 的实现样例,这个样例使用了 Cmake 编译.so 文件(这也是 google 官方推荐的方式),用gles渲染app,且不需要写任何的java代码。

CMake

  如今市场上可以用于构建项目的软件(应用程序)有很多,如 unix 系统上常见的 make。但是,它们有一个很大的缺点,就是这些应用是和平台相关的,这就使得泛用性不高。而 CMake 就很好的解决了这个问题。Cmake 是 “Cross Platform Make” 的缩写,wiki 上是这样介绍 CMake 的:

CMake is a cross-platform free and open-source software tool for managing the build process of software using a compiler-independent method. It supports directory hierarchies and applications that depend on multiple libraries.

  CMake 的特点使得它能很多 IDE 兼容,其中一个就是 Android Studio. 下面让我们先脱离安卓平台,一起来简单地学习如何使用 CMake。
  首先,我们创建一个文件夹用来学习 CMake。然后,这里需要一个用来测试的文件 test.cpp。这里给的样例如下,简单地打印出了 “Hello World!”:

1
2
3
4
5
6
#include <iostream>
using namespace std;
int main() {
cout << "Hello World" <<endl;
return 0;
}

  然后,我们新建一个文本文件,CMakeLists(注意名字不要写错)。在里面写上这些内容:

1
2
3
cmake_minimum_required(VERSION 3.4.1)
project (hello)
add_executable(hello test.cpp)

  第一行 cmake_minimum_required() 命令设置了这个项目要求的 CMake 的最小版本。这里我们设置为 3.4.1,你也可以根据自己的实际情况更改这个参数。
  第二行声明了项目的名称,这里我们简单地称之为 hello。
  最下面一行是添加可执行文件,第一个参数是可执行文件的名字,第二个参数是源文件,即把 test.cpp 作为源文件,编译成一个名为hello的可执行文件。当然,如果还有用到其他的源文件,我们还可以写成”add_executable(hello test1.cpp test2.cpp, …)”
  文件写好后,保存。根目录下应该有两个文件: CMakeLists.txt 和 test.cpp。这个时候在根目录下调用以下命令:

cmake .
make

  第一行代码用于 CMake,在 linux 下,它生成了许多项目文件,以及一个 Makefile 文件。第二行调用 make,即使用 Makefile 文件中的信息将项目”打包”为可执行文件。
  不过,我们会发现,生成的 Makefile 文件和可执行文件等都放在了当前的目录下,显得十分不美观。我们可以让它显得更有结构一些。在 CMakeLists.txt 文件的 add_executable 命令前中添加语句:

1
2
3
set(EXECUTABLE_OUTPUT_PATH ${CMAKE_SOURCE_DIR}/bin)
set(LIBRARY_OUTPUT_PATH ${CMAKE_SOURCE_DIR}/lib)
add_library(ano SHARED ${CMAKE_SOURCE_DIR}/test.cpp)

  其中,set 命令用于设置变量的值,第一个参数表示需要被设置的变量,第二个参数表示变量的值。CMAKE_SOURCE_DIR 是 CMake 内置的变量,表示当前源文件的目录,我们将 EXECUTABLE_OUTPUT_PATH 和 LIBRARY_OUTPUT_PATH 分别设置在 bin 和 lib 文件夹下,那么构建后所有的可执行文件都会放在 bin 文件夹中,所有的库文件都会放在 lib 文件夹中。
  add_library 用于打包成库文件,第一个参数表示生成的库的名字,第二个参数可选,”SHARED” 表示生成共享库(.so)文件,没有加的话默认生成 .a 文件。最后一个参数是源文件。生成的文件的名字为 lib+库名字+后缀名,如这里生成的文件名应该为 libano.so。
  添加完这几行后,保存。在命令行中键入:

cmake -B build
// 这下面应该是两个-,但好像显示有点问题
cmake –build bulid

  第一行 -B 表示构建过程生成的文件的存放位置,放在 /build 文件夹下。第二行则是相当与在 /build 文件夹下调用 make 命令。如果顺利的话,最后我们就能得到这样的目录结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|-- CMakeLists.txt
|-- bin
| `-- hello
|-- build
| |-- CMakeCache.txt
| |-- CMakeFiles
| | |-- 3.15.2
| | |-- CMakeDirectoryInformation.cmake
| | |-- CMakeOutput.log
| | |-- CMakeTmp
| | |-- Makefile.cmake
| | |-- Makefile2
| | |-- TargetDirectories.txt
| | |-- ano.dir
| | |-- cmake.check_cache
| | |-- hello.dir
| | `-- progress.marks
| |-- Makefile
| `-- cmake_install.cmake
|-- lib
| `-- libano.so
`-- test.cpp

  怎么样?看起来是不是有一点”正式项目”的感觉呢?那么我们来试着用目前学到的知识来实现在 Android Studio 中调用 C 的代码吧。先创建一个空的项目(创建时选择 Empty Activity 即可),并命名为 Test(你也可以用其他的你想要起的名字)。进入之后,我们应该就得到了一个 Android 版的 HelloWorld 程序。
  接下来,在模块的 src/main 文件夹下创建一个新的文件夹,命名为 app,用于存放我们的CMakeLists 文件以及 C/C++ 文件。然后,我们再创建一个 CMakeLists.txt 文件,在文件中写上:

1
2
3
cmake_minimum_required(VERSION 3.4.1)
add_library(my-sdk SHARED main.cpp)
target_link_libraries(my-sdk android log)

  和之前类似。第一行声明了最低的版本。第二行将 main.cpp 添加到库名字为 my-sdk 的 .so 文件,我们得到了 libmy-sdk.so。第三行将 android 库和 log 库与我们的 my-sdk 链接在一起。这样,我们的 CMakeLists 文件就写好了。接下来,我们再来写一下 main.cpp 文件。这里我写的代码如下:
  Warning: 这里的代码不能直接复制,如果你足够细心的话,你会发现,我们的函数名字是有规定的。它的格式是 Java 开头,接下来是包名,文件夹之间以’_’隔开,再接下来是类名,默认是 MainActivity,最后才是方法的名字,这里我方法命名为 getStringFromJNI。参数不能省略。

1
2
3
4
5
6
#include "jni.h"
extern "C" JNIEXPORT jstring JNICALL
Java_com_example_littlecsd_test_MainActivity_getStringFromJNI(
JNIEnv* env, jobject thiz) {
return env->NewStringUTF("Receive String From JNI!");
}

  具体为什么要这样写,我们可以先暂时不管,等会我们再回来看。
  紧接着,我们可以在 Java 层写调用的代码了。在 MainActivity 中,我用了一个 id 为 show 的 TextView 来展示。这里我用的是 Kotlin,Java 的写法也是一样的。

1
2
3
4
5
6
7
8
9
10
11
12
13
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
show.text = getStringFromJNI()
}
// 声明Native层的方法,Java的话是使用native关键字
private external fun getStringFromJNI(): String
companion object {
init {
// 初始化,调用系统函数,载入my-sdk库
System.loadLibrary("my-sdk")
}
}

  那么,现在我们Java和C层的代码都写好了,CMake 文件也写好了,我们可以试着点击 Run 运行试一下结果是什么样的了。不出意外的话,我们的 Activity 会闪退。通过排查,我们会发现,有一个重要的步骤被我们略去了。我们虽然这些文件都写好了,可是谁去调用 CMake 命令生成动态库文件呢?
  这个时候就要用到我们神奇的 build.gradle 文件了,gradle 本身对 CMake 是有支持的(官方亲儿子~~)。我们只需要在 module 下的 build.gradle 文件(注意有两个 build.gradle,不要搞错)中添加命令,告诉 gradle,需要去调用 cmake 即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
android {
compileSdkVersion 29
buildToolsVersion "29.0.2"
defaultConfig { ... }
buildTypes { ... }
// 添加下面的命令
externalNativeBuild {
cmake {
version '3.10.2'
path 'src/main/app/CMakeLists.txt'
}
}
}

  现在,你可以再次尝试一下,不出意外的话,我们应该可以看到界面中间显示这一个字符串:

Receive String From JNI!

  Congratulations! 我们成功地调用了C层的代码了。是不是其实挺简单的呢?如果你还是出现错误的话,最好仔细检查一下之前的代码是否有出现什么问题,有可能有一些单词打错之类的。

JNI

  前面我们能够简单地实现 Java 层调用 C 的函数了。那么,接下来我们需要考虑一下,C 层应该如何调用 Java 的函数呢? C 和 Java 究竟是怎样实现数据的交流的?答案都在 JNI 当中。JNI 定义了 java 字节码与本地代码交互的方式,它像一个中间商,并且还可以读取动态库文件,尽管有时比较麻烦,却还是比较高效的。

General Tips

  在使用 JNI 的时候,我们需要尽量最小化JNI层所占用的空间。有以下几点需要考虑的:

  1. 尽量减少跨 JNI 层的数据转换,这种跨 JNI 层的操作对时间的消耗是很明显的。
  2. 尽量避免在代码中进行跨 JNI 的异步交流。在需要交互的时候,可以使用两个线程,UI 线程负责 Java层,另一个线程负责进行 C++ 层的阻塞调用,当调用完成后通知 UI 线程对 UI 进行更新即可。
  3. 最小化需要和 JNI 接触的线程数量。
  4. 将接口代码保存在少量的易于识别的 C++ 和 Java 源代码位置,有利于后续的维护/重构

JavaVM and JNIEnv

  JNI 定义了两种关键的数据结构,”JavaVM” 和 “JNIEnv”。两者都是十分重要的指针,指向函数表。JavaVM 提供了一个”调用接口”函数,使得我们可以创建或销毁一个 JavaVM。理论上一个进程可以有多个JavaVM,但安卓只允许有一个。在 jni.h 中,定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct _JavaVM {
const struct JNIInvokeInterface* functions;
#if defined(__cplusplus)
jint DestroyJavaVM()
{ return functions->DestroyJavaVM(this); }
jint AttachCurrentThread(JNIEnv** p_env, void* thr_args)
{ return functions->AttachCurrentThread(this, p_env, thr_args); }
jint DetachCurrentThread()
{ return functions->DetachCurrentThread(this); }
jint GetEnv(void** env, jint version)
{ return functions->GetEnv(this, env, version); }
jint AttachCurrentThreadAsDaemon(JNIEnv** p_env, void* thr_args)
{ return functions->AttachCurrentThreadAsDaemon(this, p_env, thr_args); }
#endif /*__cplusplus*/
};

  JNIEnv 则提供了大多数的 JNI 函数,所有的 native 函数都接收一个 JNIEnv 变量作为第一个参数。JNIEnv 用于线程的本地存储,因此,不同线程之间不允许共享 JNIEnv。如果一块代码没有其它方式可以获得 JNIEnv,那么可以使用 JavaVM 变量,使用 GetEnv 函数去找当前线程的 JNIEnv (如果有的话)。我们可以查看jni.h文件以获得更加详细的信息。
  细心的同学可能会发现,javaVM 翻译过来不就是 java virtual machind —— jvm 吗? 是的,我们在 jni 中用到的 javaVM 变量事实上就代表这 java 虚拟机,在加载动态库链接的时候,如果我们定义了 JNI_OnLoad 方法的话,就会将 javaVM 变量传进来,我们可以将其保存起来进行使用。你甚至可以使用 jni.h 库提供的函数,直接在本地的一个 cpp 文件中创建一个 java 虚拟机,并使用该虚拟机去执行 java 代码!

Threads

  Android 中的线程都是 Linux 线程,遵循 POSIX 规范,由内核进行调度。他们通常是从托管代码开始(使用T hread.start()),但是他们也可以在其他地方进行创建,并关联到 JavaVM 中。比如我们可以在 Native 层中调用 pthread_create 方法创建线程,并调用 JNI 的 AttachCurrentThread 或者AttachCurrentThreadAsDaemon 函数绑定 JavaVM。在线程 attach 之前,它不包含 JNIEnv,因此不能进行 JNI 调用。
  当一个 native 线程 attach 的时候,会有一个对应的 java.lang.Thread 对象被创建并加入到主线程组当中,使得它对调试器可见。
  另一个值得注意的点是,安卓并不会暂停正在执行 native 代码的线程。当垃圾回收器在处理中或者调试器发起了暂停请求时,该线程会下次进行 JNI 调用的时候被暂停。
  此外,native 线程 attach 后,在退出之前需要调用 JNI 的 DetachCurrentThread 函数。但是直接进行这样的调用过于笨拙,在安卓2.0或者更高的版本后,可以使用 pthread_key_create 函数定义一个析构函数(当线程退出前被调用)。另外,如果读者使用 java 做过一定的开发的话,会发现 Thread 类中的好几个方法(比如 start)最后都会转到一个 native 方法,这说明 java 在新建一个线程时,会使用操作系统提供的线程接口,更进一步说,java 的线程和 native 线程是有一定的对应关系的 (不一定是一一对应)。不过,如果操作系统没有提供多线程呢? (那就自己造呗。

jclass, jmethodID, and jfieldID

  在 native 层,如果想访问一个 Java 对象,那就需要做以下的操作

  1. 使用 FindClass 获得类的一个实例的引用
  2. 使用 GetFieldID 获得域的 ID
  3. 使用 ID 以及其他需要的参数获得 Java 对象,比如 GetIntField

  类似的,如果想要调用一个方法,也需要先获得一个类的对象的引用然后就是一个方法 ID。第一次调用的话是比较耗时的,但获得了域后进行调用就很快了。如果比较追求效率的话,最好在 native 层中进行缓存。可以在类中放置一个静态代码块,当类被加载的时候,调用 native 函数对一些值进行缓存。
  等等… findClass, GetID ??? 这样的写法不禁让人想到了 java 的一个重要特性 —— reflection. 没错,你的直觉是对的,这种写法和 java 中的反射是及其类似的,包括反射的实现,最终也依赖于一个 native 方法。

Local and global reference

  每一个传递给 native 方法的参数以及每一个 JNI 方法返回的对象都是局部变量。这意味着在当钱县城的当前方法中使用是合法的。但是,一旦本地方法返回,引用就失效了,即使引用的对象本身还继续存活着。(猜测应该和 GC 有关)
  获得非局部变量的唯一方法是使用 NewGlobalRef 或者 NewWeakGlobalRef 方法。如果想要长期使用某个引用,就要把它设置为全局的。全局变量直到使用 DeleteGlobalRef 将其删除前都是合法的。
  所有的 JNI 方法接受局部和全局引用作为参数。并且,对于同一个对象,有可能会有多个值不同的引用。比如当连续调用 NewGlobalRef 方法得到的返回值可能是不同的。因此我们不能简单地使用’==’去判断两个对象是否相同,而是应该调用 IsSameObject 方法。这就造成了一个问题 —— 我们不能假定对象的引用是一个固定不变的值。
  另一个值得一提的点是,如果一个 native 线程已经调用了 AttachCurrentThread 方法,代码中的局部变量不会自动释放(直到线程 detach 之前)。所有创建的局部变量都需要自己进行删除。

Primitive arrays

  JNI提供了访问数组对象的函数。并且,我们可以像声明一个C语言的数组一样使用它。Get\<type>ArrayElement 系列的函数可以帮助我们实现对数组的访问。如 GetIntArrayElements(jintArray array, jboolean* isCopy)。第一个参数传入 jintArray 变量,可以使用 FindClass 方法获得。第二个参数传入一个 bool 值,表示返回的是实际的位于 Java 层的数组的指针还是重新分配一块区域,并把数组元素复制进去。另外,通过这个方法获得的数组指针在被释放前一直是合法的,这就意味着我们自行使用 Release 方法将其释放。注意到如果我们获得的是实际的数组的指针,用完后一定要将其释放,否则由于存在引用,这个数组无法被垃圾回收器移动/回收,这将导致内存泄漏。
  Release函数声明如下:

1
void ReleaseIntArrayElements(jintArray array, jint* elems, jint mode)

  其中,第三个变量表示释放的模式,共有三种输入:

  1. 0: 如果是实际的数组指针,则消除对原数组的固定,使其能被回收。如果是复制的数组,将更改复制回去,并将数组释放。
  2. JNI_COMMIT: 如果是实际的数组指针,则不做任何处理。如果是复制的数组,则将修改复制回去,不释放数组。
  3. JNI_ABORT: 如果是实际的数组指针,取消对原数组的固定,但早期的更改不会丢弃。如果是复制的数组,则释放数组,但所有的更改都会丢弃。

Signature

  在使用 GetMethodID 一类的方法时,我们需要传入函数的签名的字符串。我们知道,函数的签名是由传入的变量类型以及返回值组成的,签名有一定的转换规则,下面我们可以看下几个例子:

1
2
3
void test1();                   "()V"
int test2(String s); "(Ljava/lang/String;)I"
long test3(char[] c, String s) "([CLjava/lang/String;)J"

  对应的表格如下。注意参数直接连在一起,中间不用空格

Java类型 对应的串
boolean Z
byte B
char C
short S
int I
long J
float F
double D
void V
L + 类名(包括包名) + ;
数组 [ + 元素类型
方法 (paras-type)res-type

Epilogue

  在这篇博客中,我们简单地介绍了 JNI 的使用,通过阅读 google 的官方文档,我们基本学会了如何在 C/C++ 和 Java 层之间进行简单的交互。NDK 作为 Android 开发中的一个重要部分,尝试着去了解一下之后还是会发现,里面有很多比较有意思的东西,尤其用到了 C 语言的话,就会涉及到一些 Java 虚拟机,或者是操作系统方向的知识了。不过,如果可以的话,其实并不会特别推荐使用 ndk 进行开发,因为 C/C++ 层的管理以及和 java 层之间的交互是很容易出问题的,除非需要用到一些 java 所无法实现的需求或者 C/C++ 的库难以迁移到 java。另外,其实现在本身 java 已经做了许多的优化,比如 JIT 技术,这使得现在 java 应用程序的执行效率事实上并不会和 C/C++ 程序有过大的差距。如果时间充足的话,建议后续再进行深入的了解。

------------- The artical is over Thanks for your reading -------------