NDK 与 JNI
引言
Android 开发应用程序, Google 提供了两种开发包:SDK 与 NDK。 相较于 SDK 拥有了很多的参考资料和文章,NDK 的资料要少很多。
Android 系统在一开始就支持使用 C/C++
进行开发,因为 Android 的 SDK 主要是基于 Java 的,所以导致了在用 Android SDK 进行开发的工程师都必须使用 Java 进行开发。但是 Android 一开始就支持使用 JNI 的编程方式,也就是第三方应用可以通过 JNI 调用自己的 C 动态库。于是诞生了 NDK。
什么是 NDK
NDK 的全称是 Native Develop Kit。 官网的解释如下。
The Android NDK is a toolset that lets you implement parts of your app in native code, using languages such as C and C++. For certain types of apps, this can help you reuse code libraries written in those languages.
Android 的开发语言是 Java ,同时 Android 也是基于 Linux 系统的,因此其很多核心库是 C/C++ 开发的。 NDK 本身其实是一个交叉工作链,包含了 Android 上的一些库文件,同时为了方便编译 C/C++, NDK 提供了一些脚本。一般情况下,NDK 会把 C/C++ 编译成 .so
文件,然后在 Java 中调用。相对于直接使用 Java SDK 进行开发,使用 NDK 就会更麻烦复杂些。
为何要用 NDK
使用 NDK 来进行开发都比直接使用 SDK 开发要来的麻烦,但是其仍然被提供,且一直存在,必然是有一定的原因的。如:
- 重复使用现存的库,可以降低耦合度。
- 某些情况下,性能会更好。
- 能直接使用很多第三方使用 C/C++ 提供的库。
- 不依赖于 Dalvik Java 虚拟机的设计。
- 代码的保护。使用编译后的 C/C++ 比使用 Java 的 APK 反编译难度大。
NDK 到 so
一个 Java 应用程序往下走有两条路:
- 是通过 SDK API 直接进入 Java framework。
- 是通过 JNI 调用 Native 方法,再通过 NDK API 成为 C Framework。
什么是 JNI?
Java Native Interface
翻译过来好像是 Java
本地接口
看了这个全称和翻译也还是不懂,那么先解释一下其中的一个关键字 Native
。什么是 Native
?
什么是 Native ?
Native
方法是一类方法的总称,如果一个方法是由非 Java 的其他语言(如 C/C++
)实现的,同时 Java
的代码又去调用此方法,则这个方法就是一个 Native
方法。在 Java
中定义一个 Native
方法时,并不会实现这个方法,就像定义一个接口,因为他们是用其他语言来实现的。
JNI 的作用?
为什么会出现 JNI
呢?
那么在 Java
发明之前,写代码基本上就是用 C/C++
。因此那时候已经有很多现成的写好的代码,而当 Java
来了的时候,程序员就不想重复造轮子,因此他们就想直接利用之前的轮子。
于是就需要出现 JNI
这么一个玩意,通过这个中间件, Java
程序员就可以在 Java
的程序中直接调用一些其他语言实现的方法(这就是前面说的 Native
方法)。即可以在 Java
代码中调用 C/C++
等语言的代码或者在 C/C++
代码中调用 Java
代码。
同时,有些功能使用 Java
代码编写可能会有性能问题,并且反编译代码的安全性问题,因此在某些情况下就需要使用 C/C++
来配合 Java
来开发了。
Java
调用 C/C++
在 Java
语言里面本来就有的,并非 Android
自创的,一般的 Java
程序使用的 JNI
标准可能和 Android
不一样,Android
的 JNI
更简单。由于 JNI
是 JVM
规范的一部分,因此可以将我们写的 JNI
的程序在任何实现了 JNI
规范的 Java
虚拟机中运行。
开发 JNI 程序会受到系统环境限制,因为用 C/C++ 语言写出来的代码或模块,编译过程当中要依赖当前操作系统环境所提供的一些库函数,并和本地库链接在一起。而且编译后生成的二进制代码只能在本地操作系统环境下运行,因为不同的操作系统环境,有自己的本地库和 CPU 指令集,而且各个平台对标准 C/C++ 的规范和标准库函数实现方式也有所区别。这就造成了各个平台使用 JNI 接口的 Java 程序,不再像以前那样自由的跨平台。如果要实现跨平台, 就必须将本地代码在不同的操作系统平台下编译出相应的动态库。
Java 层如何使用 JNI
Java 要使用 JNI 需要两个步骤
- 加载动态库。
- 使用
Native
关键字声明与JNI
层对应的函数。
加载动态库
1 | System.loadLibrary("动态库的名称") |
直接写名称就好,不用带后缀。如
media_jni
系统会自动根据不同平台进行转化,如 Windows 下就是media_jni.dll
。Linux 就是media_jni.so
。
JNI 层调用 Java 层
调用方式
在 JNI
层调用 Java
层函数时,需要根据 Java
层的函数来确定调用的函数的名字和签名。
例如,下面的 Java
层的函数如下:
1 | public void onSuccess(String msg){ |
则 JNI 层在调用 onSuccess
函数时的代码如下:
1 | extern "C" JNIEXPORT void JNICALL |
如上的代码中,有下面两点就可以进行调用了。
- 函数的名字为:“onSuccess ”。
- 函数的签名为: “(Ljava/lang/String;)V”。
签名获取
获取函数的名字固然比较简单,而获取函数的签名就有点点难写。有两种方法可以得到。
方法1,通过 “javap -s” 命令获取
要想获取某个字节码下面的所有的函数的签名信息,可以输入如下命令。
1 | javap -s [path]/xxx.class |
然后终端中就会打印其中所有的函数的情况, descriptor
关键字后面的字符串就是。
方法2,查询函数映射表
签名方法参数类型对应表
Java 类型 | 类型签名 |
---|---|
boolean | Z |
byte | B |
char | C |
short | S |
int | I |
long | L |
float | F |
double | D |
void | V |
类 | L全限定名; 如 String —-> Ljava/lang/util/String;(有分号) |
数组 | [类型签名, 比如 byte[] —-> [B |
参数就是用 ()
括起来,返回值就是跟在 ()
外面。注意类的签名后面都是有分号的。此外并没有其他的任何分割符或者空格。
JNI 原理
Java
语言的执行环境是 Java
虚拟机(JVM
),JVM
其实是主机环境中的一个进程,每个 JVM
都在本地环境中有一个 JavaVM
结构体,该结构体在创建 Java
虚拟机时被返回,在 JNI
环境中创建 JVM
的函数为 JNI_CreateJavaVM。
太复杂了,我暂时选择放弃。
第一个 NDK 程序
鉴于网上找到的 第一个 NDK 程序 教程都比较老旧了,要么没有使用 Android Studio,要么使用的版本比较旧,一些配置和界面早已经过时了,所以这里记录一下自己搭建的过程。如果有机会的话,也可以帮助有需要的人。
新建 demo 项目
配置 Android Studio NDK 和 CMake
参照官方教程,完成此处的配置。打开一个普通的(非 NDK) Android 项目。然后参考下面的步骤。
- 在项目中选择
Tools > SDK Manager
。 - 选择 NDK(Side by Side) 和 CMake 。
- 点击下方的
OK
, 此时系统会显示一个对话框,告诉您 NDK 软件包占用了多少磁盘空间。再次点击弹出来的OK
。然后就会开始安装,等待其安装完毕,点击Finish
。
至此,完成 Android Studio 的 NDK 和 CMake 配置。
创建项目
首先点击创建新项目(File > New > New Project
),选择如图所示的 Native C++
项目,点击下一步 Next
。
然后设置项目名字和包名。点击下一步 Next
。
然后 C++ 标准我们保持默认,直接 Finish
。
运行 demo
debug1
有些同学这个时候可能会看到下方的 console 里面有这么一个报错。
Caused by: sun.security.validator.ValidatorException: PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target
但是没关系,我们打开项目下面的 build.gradle
(项目下面的那个哈,就是 Android Studio 打开后面会显示成 build.gradle(NDK_TEST)
而不是 build.gradle(:app)
) ,然后添加下面的源,并修改 "com.android.tools.build:gradle:4.1.2"
到 3.5.0
。
代码如下, build.gradle
。
1 | // Top-level build file where you can add configuration options common to all sub-projects/modules. |
然后点击右上角的同步 (Sync Project With Gradle File
) 。
然后控制台终端就会开始同步。
debug2
可能有些同学还会遇到下面的问题,报错 CMake 的版本不对或者没找到对应版本,这个是小问题,直接点击终端中的蓝色字体的部分, Android Studio 就会自动帮我们装,等待装好后就点击 Finish
就好了。然后再次点击右上角的同步 (Sync Project With Gradle File
) 。可能还是会报错版本不一样,那就再次点击终端中的蓝色字体等 Android Studio 给切换了版本后,再次点击右上角的同步 (Sync Project With Gradle File
) 。
直到最后,终端中显示 build
成功。
展示 demo
由于新建的 demo 程序,Android Studio 已经自动生成了 demo 代码。我们直接跑就是了。
然后连接上自己的设备,点击 build 编译运行。就可以在设备上看到 demo 打开运行了。如果还是报错不得行的话,那就试试切换一下 Android Studio 的 jdk 的版本吧。修改方法的想法来自于此博客的最后一个方法。修改的方法来自与官方文档 Android Studio 切换 jdk 版本。
最后,祝你顺利。
编写自己的 NDK 程序
想要学会还是得自己动手整一个自己的 demo
出来,才能够记忆深刻,才能够理解。在上面的新建 demo
中进行修改。
修改 activity_main.xml 文件
将原来的内容完全删掉,直接替换为下面的内容。
activity_main.xml
可以根据自己的需要进行修改。
1 |
|
预览页面大概是这个样儿。
修改 MainActivity.java 文件
MainActivity.java
1 | package com.example.ndk_test; |
创建 calc.cpp 文件
右键项目中的 cpp
文件夹,选择 NEW > C/C++ Source File
,创建一个自己的源文件,这里我创建的名字叫做 calc
。要和 Java
代码中导入的一致。
然后就可以开始编写代码了。
calc.cpp
1 |
|
解释一下:
JNIEXPORT
和JNICALL
是关键字,不需要修改。 这两个关键字中的jsting
、jint
等是该方法的返回值类型,具体有个对照表,可以查看这里。extern "C"
按照 C 的规则翻译函数名字。Java_com_example_ndk_1test_MainActivity_add
这类函数名,命名的规则是:Java_
+包名
(以下划线分割) +类名
(在这里类就是 Activity) +方法名
。- 方法的第一个参数都是
JNIEnv*
型,而第二个参数取决于Java
中此方法是否是静态方法(有无static
关键字),有的话就是jobject
类型,否则就是jclass
类型。 - 然后,此函数实际有哪些参数就一一写在后面,然后将类型按照对应表进行对照就行。
- 由于有些高级类的对应并不是一一对应的,存在些许的差异,这里就简单展示了
string
的区别,直接放进去的jstring
类型和c++
中的string
并不是同一个东西,因此需要进行一个转化,就如代码中 72 行所示,可以转化为一个const char
而要从c++
构建一个jstring
可以像 92 行所示,使用env->NewStringUTF
。 - 使用 log 功能,没啥特别的,和
Java
中使用是一样的,直接调用就好。
修改 CMakeList.txt 文件
这个的修改也比较简单,只有两个地方需要修改。
第一处是修改 add_library
。
1 | add_library( # Sets the name of the library. # 将我们自己的 cpp 名字写在这个地方,我注释了原来的 native-lib # native-lib # 原来的 calc # 新的 # Sets the library as a shared library. SHARED # 不变 # Provides a relative path to your source file(s). # 写自己的 cpp 的相对路径,这里就在默认路径下 我就直接写了名字 # native-lib.cpp # 原来的 calc.cpp ) # 新的 |
第二处是修改 target_link_libraries
。
1 | target_link_libraries( # Specifies the target library. # 写自己的目标链接库的名字 需要和 java 中对应起来 # native-lib # 原来的 calc # 新的 # Links the target library to the log library # included in the NDK. ${log-lib} ) |
最后,修改完毕后的 CMakeList.txt
如下:
1 | # For more information about using CMake with Android Studio, read the# documentation: https://d.android.com/studio/projects/add-native-code.html# Sets the minimum version of CMake required to build the native library.cmake_minimum_required(VERSION 3.10.2)# Declares and names the project.project("ndk_test")# Creates and names a library, sets it as either STATIC# or SHARED, and provides the relative paths to its source code.# You can define multiple libraries, and CMake builds them for you.# Gradle automatically packages shared libraries with your APK.add_library( # Sets the name of the library. # 将我们自己的 cpp 名字写在这个地方,我注释了原来的 native-lib # native-lib calc # Sets the library as a shared library. SHARED # Provides a relative path to your source file(s). # 这里是一样的,写自己的 cpp 的相对路径,这里就在默认路径下 我就直接写了名字 # native-lib.cpp calc.cpp )# Searches for a specified prebuilt library and stores the path as a# variable. Because CMake includes system libraries in the search path by# default, you only need to specify the name of the public NDK library# you want to add. CMake verifies that the library exists before# completing its build.find_library( # Sets the name of the path variable. log-lib # Specifies the name of the NDK library that # you want CMake to locate. log )# Specifies libraries CMake should link to your target library. You# can link multiple libraries, such as libraries you define in this# build script, prebuilt third-party libraries, or system libraries.target_link_libraries( # Specifies the target library. # 写自己的目标链接库的名字 需要和 java 中对应起来 # native-lib calc # Links the target library to the log library # included in the NDK. ${log-lib} ) |
运行测试
在上述的所有代码都 ok 的情况下,就可以开始进行测试。链接自己的模拟器,点击 Run
。就可以看到大概下面这样的界面。
然后就可以开始进行计算,这里输入 A=15
和 B=0
,然后点击除法,看看界面上返回的结果是 0.0
程序没有崩溃,这是因为我们在 calc.cpp
中对除数进行了判断。为 0 就直接返回 0。但是我们会打印一个 error
的日志。同时下方还显示了一组从 calc.cpp
中返回的关于第一个参数的信息。
在 Android Studio 中按照我们的 TAG=Ron_calc_cpp
过滤一下日志,可以看到成功打印了如下的,除数不能为零的 error
信息。
然后再随便测试一组数据,可以看到也是可以正常计算和显示日志的。
至此,第一个 NDK 程序算是成功跑通了。
参考链接
参考链接1:JNI 入门了解 https://www.jianshu.com/p/102b2d9db167
参考链接2:native 是啥 https://blog.csdn.net/qq_23994787/article/details/79066336
参考链接3:JNI 调用 Java https://www.jianshu.com/p/ec90ac9cc8d8
参考链接4:NDK 与 JNI 基础 https://www.jianshu.com/p/87ce6f565d37
参考链接5:第一个 NDK 程序 https://www.jianshu.com/p/2096fdd244b3
参考链接6:Android Studio 配置 NDK 和 CMake https://developer.android.com/studio/projects/install-ndk?hl=zh-cn
参考链接7: 解决报错方法一 https://blog.csdn.net/weixin_43766753/article/details/102527228
参考链接8: JNI 数据类型对照表 https://blog.csdn.net/afei__/article/details/80899758
参考链接9:Android Studio 创建自己的源文件 https://developer.android.com/studio/projects/add-native-code?hl=zh-cn#create-sources
参考链接10: CMakeList.txt 文件修改方式 https://blog.csdn.net/u010041075/article/details/68946334
参考链接11:添加 JNI log 信息 https://www.jianshu.com/p/3c1aff6f100f