前言
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.mk
和Application.mk
两个 Makefile 文件,这种构建方式在『Eclipse』中比较常见。Makefile 依赖于编译平台,在不同的平台上有不同的编译工具,遵循的规则也不相同,所以在不同的平台上编译时,需要重新配置 Makefile,工作量较大也容易出错。 - CMake:同样也是将 C/C++ 代码编译成原生库的编译工具,但它是一个跨平台的编译工具,很好地解决了 Makefile 的缺陷,只需要配置一个平台无关的
CMakeList.txt
文件来定制整个流程。同时它也是『Android Studio』中常用的编译工具。
了解这些基础含义,我们就可以开始项目的搭建工作。
搭建
了解 NDK 项目的搭建方式,最方便的莫过于直接利用『Android Studio』创建的模版来观察它与普通项目的异同。
它会自动帮我们下载及创建所需的文件,这样我们如果在现有项目中接入就可以参考对比。
SDK Tools
首先是需要 NDK 和 CMake 工具,在「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
:
大致内容如下:
# 设置此项目所需的最低 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
。
同时还看到上面指定了一个 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』会自动在 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 个,前面两个 JNIEnv
和 jobject
是每个方法都会有的,后面的才是我们定义的参数。
实现上面的方法并回到 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 来配置,如果后面有时间,再写写相关的笔记。