二、JNI的编译与运行

如何区分安卓项目是java工程还是native工程?

  1. build文件中是否有externalNativeBuild声明,有则表示属于native工程:
arduino 复制代码
//native工程的入口
externalNativeBuild{
	cmake{
        path"src/main/cpp/CMakeLists.txt"
        version "3.10.2"
    }
}
  1. 检查代码中是否有 native修饰的本地函数声明。

如何区分生成的lib库是静态库还是动态库

在as中打开output中编译的apk,看lib目录下生成的native库是以何种后缀结尾:

  1. .so结尾是动态库。(.so理解为插件化)
  2. .a结尾则是静态库。(.a理解为组件化)

CMake编译

Android Studio中CMake的编译过程

  1. 程序开始编译时,AS会从build.gradle找externalNativeBuild的cmake配置信息。从配置信息中找到CMakeLists.txt 文件的位置、版本号。
    as4.0版本之前的CMakeLists.txt 文件位置是在app-module 下;4.0及之后的版本则是在 /main/cpp文件夹下存放的。
  2. 将第一步骤得到的信息 交给Android SDK目录里的CMake.exe可执行文件。
  3. CMakeLists.txt 文件中的cmake_minimum_required(VERSION 3.10.2) 定义了编译所需的CMake最低版本号,必须高于这个最低版本号。
  4. CMake会按照CMakeLists.txt文件中的配置信息来进行库的编译。

CMakeLists文件基本配置介绍

perl 复制代码
//要求CMake的最小版本
cmake_minimum_required(VERSION 3.10.2)

# Declares and names the project.
# 当前项目的名称
project("MyFecDemo")

#配置要被编译入库中的代码文件
add_library( # Sets the name of the library.
             # 本地lib库的名称
             native-lib

             # Sets the library as a shared library.
        	 # 设置本地库的权限 SHARED属于动态库,STATIC属于静态库
             SHARED

             # Provides a relative path to your source file(s).
             native-lib.cpp 
             # 如果有新增的文件,需要使用到,那么必须在此继续注册,如新增native-lib02.cpp
             native-lib02.cpp
             # 支持添加多个.cpp、.a、.so文件。
           )

#查找三方库
find_library( # Sets the name of the path variable.
              # 给找到的库取的别名为 log-lib ,log-lib是一个变量名。
              log-lib

              # Specifies the name of the NDK library that
              # you want CMake to locate.
              # 从系统环境变量配置的系统库路径中查找liblog.so库
              # 库名叫做log,但是我们编译出来的so库文件会被默认在库名前加上'lib'前缀。
              # 所以编译器去查找log库时,实际找的是liblog.so文件。
              log )

#将查找到的三方库链接到当前库
target_link_libraries( # Specifies the target library.
                      //被链接到 native-lib库中去。
                       native-lib

                       # Links the target library to the log library
                       # included in the NDK.
                      #将find_library得到的库的别名通过 ${} 取出来,然后将这个库链接到native-lib中。
                       ${log-lib} )

可以看第一章节的CMake编译部分复习巩固。

JNI函数语法介绍

以下列函数为例:

c 复制代码
extern "C" //表示以C的语法来编译
JNIEXPORT jstring JNICALL   //jstring 表示返回值为jstring->string类型。
Java_com_xunua_MyFecDemo_NativeLibManager_getString(JNIEnv *env, jobject thiz) {//参数的顺序不可以随意修改。 JNIEnv *env 一定永远是每个jni函数的第一个参数。
    string hello = "Hello from C++";
    return env->NewStringUTF(hello.c_str());
}

C++与java的值传递

C++字符串是C++的对象。JAVA字符串是JAVA的对象。两者是在不同区域的。JAVA的对象在方法区。C++的对象在栈区。

所以如果要将C++栈区的String字符串传递给Java,那么就需要一个转换器;首先将C++的字符串转成C的字符数组,之后再通过转换器将C的字符数组存储到Java的方法区,再将这个java方法区的字符串引用返回给到java代码。

代码中的hello.c_str()是将C++的字符串转成C的字符数组(因为C++的String结构比较复杂,所以转成C的字符数组结构更简单,在内存中读取/转换更简单);这里的转换器就是JNI代码中的env,"env->NewStringUTF"它将C的字符数组转为Java字符串后存储到Java的方法区,然后返回Java方法区中该字符串的引用。接着通过jni函数将JAVA字符串的引用返回给到java代码。

extern"C"

C++高级语言,使用gcc编译器。与c使用的编译器不一样的。所以添加extern"C"的目的是为了告知编译时使用C的编译器来编译。

如果不给jni函数声明extern"C",那么程序在运行该函数时,会崩溃,报错信息为:No implementation found for java.lang.String.xxx.xxxx.func2() 在C++中没有发现该方法的实现。

上述错误的几种出现原因:①对应的jni函数上没有添加extern "C"。 ②在java代码中没有添加static{ System.loadLibrary("libname")} 加载库。 ③没有在build文件中声明externalNativeBuild属性。 ④apk内的lib文件夹下没有对应库(没有被编译进去apk中)

不添加 extern"C" **导致 No implementation found for java.lang.String.xxx.xxxx.func2() 错误的原因:

**因为C不支持重载,所以Java调用JNI函数时,编译器是根据JNI的函数名去匹配的。但是C++支持重载,所以Java调用JNI函数时,编译器会根据JNI的函数名+参数一起去匹配,所以出现找不到对应JNI函数的异常。

JNIEXPORT

复制代码
JNIEXPORT jstring JNICALL

JNIEXPORT是一个宏,它其实是__attribute__ ((visibility ("default")))。

arduino 复制代码
#define JNIEXPORT  __attribute__ ((visibility ("default")))

attribute ((visibility ("default"))) :
attribute:方法属性

visibility:可见性

"default":表示默认值(等价于为public,允许我们的java层去访问)。 取值:"hidden"则表示外部不可见,等价于private。

jstring:表示当前jni函数的返回值类型为string。

JNICALL:在源码中是jni的空实现,可以没有。

JNI函数的参数类型

scss 复制代码
//Java声明的非静态本地函数(native void)在JNI函数中的参数列表
(JNIEnv *env, jobject thiz) 
  • 参数的顺序不可以随意修改。
  • *JNIEnv env 一定永远是每个jni函数的第一个参数。JNIEnv 其实是 _JNIEnv 结构体的别名。
    这个 _JNIEnv 结构体内部定义了许多的函数,这些函数的作用基本上都是为了用于获取Java那边的内容。
    Java主线程中调用jni函数是,env都是同一个。
    如果在子线程或者其他不同的线程调用,那么jni函数收到的env不是同一个,属于不同线程。
    所以在不同现场执行jni函数时,需要在jni函数中切换线程去处理(后面补充)。
  • thiz 表示Java中定义该函数的实例对象

静态本地函数(static native void) 的jni函数中参数列表有些变化,会变为:

kotlin 复制代码
(JNIEnv *env, jclass clazz) 

其中第二个参数由jobject变成了jclass。

  • cLazz ****指的不再是Java中定义该函数的实例对象,而是指的定义该函数的Java类(非对象)。

C++主动访问JAVA对象(C++反射)

C++中访问java对象其实是通过反射去实现访问的。 Java层的反射API其实本质上是调用C++的native函数去实现的,所以C++中的反射可以直接调用native函数去实现,会比Java的反射要更加直接,减少了一层调用链。

比如在Java层中定义了两个成员,并声明一个native函数:

arduino 复制代码
public String mName="Test";
public int num=0;

public native void func3();

JNI层实现func3(),并在函数中做反射去修改mName、num。

ini 复制代码
extern "C"
JNIEXPORT void JNICALL
Java_com_xunua_MyFecDemo_MainActivity_func3(JNIEnv *env, jobject thiz) {
    jclass clazz=env->GetObjectClass(thiz);
    //拿到java中两个成员的id
    jfieldID mName = env->GetFieldID(clazz,"mName","Ljava/lang/String;");
    jfieldID num = env->GetFieldID(clazz,"num","I");
    //修改他们的值
    env->SetObjectField(thiz,mName,env->NewStringUTF("哈哈哈,被JNI改了"));
    env->SetIntField(thiz,num,50);
}

JNI实现回调Java方法

在java层创建普通函数,JNI通过反射来调用该Java函数,从而实现回调:

arduino 复制代码
/**
 * 创建普通函数,用于native来调用当前函数。实现回调。
 * @param code返回的状态码
 */
public void onCallback(int code){
    Toast.makeText(this, "被native回调了,code:"+code, Toast.LENGTH_SHORT).show();
}

因为要模拟JNI去回调Java层的回调函数,所以Java层还需要再写一个native函数来模拟触发JNI函数来回调:

csharp 复制代码
public native void func4();

JNI函数实现func4:

scss 复制代码
extern "C"
JNIEXPORT void JNICALL
Java_com_xunua_MyFecDemo_MainActivity_func4(JNIEnv *env, jobject thiz) {
    //获取Java对象
    jclass clazz=env->GetObjectClass(thiz);
    //获取函数ID
    jmethodID onCallbackId = env->GetMethodID(clazz,"onCallback","(I)V");//通过函数签名信息 "(I)V" 来确保调用的函数准确性。
	env->CallVoidMethod(thiz,onCallbackId,123);
}

当java层调用native函数func4时,JNI层会主动去调起Java层的onCallback回调函数,并返回状态码。

这样就完成了一次模拟JNI回调Java函数的过程。

调用流程:Java.func4() -> JNI.func4() -> java.onCallBack()

签名信息Sig

上述的调用GetMethodID获取函数ID、GetFieldID获取成员变量ID时,最末尾的实参传入了sig字符串 ,那个就是对象的签名信息 ,jni就是通过签名信息来判断成员的数据类型、函数的入参与返回值;用以准确的查找/匹配正确的目标成员。签名信息在第一章的JNI函数签名sig信息章节有讲过。

FAQ-01

  1. java字符串为什么在方法区:JVM方法区主要存储:class的字节码、字符串、常量池。
  2. java调用的JNI方法都需要加 extern "C",后面如果使用动态注册的话就可以不加extern "C"。
  3. 在C语言的JNI函数中,有定义返回值类型的JNI函数,如果不return返回值,那么也可以编译通过,但是运行执行该函数时会报错崩溃。
  4. jni方法中参数无法传接口。因为java和jni是不同的东西,jni没有对java接口的支持。但是JNI可以通过反射去实现回调Java函数。

纯Java项目中编译JNI

我们在Android项目中因为有Android Studio帮助我们去处理了JNI的头文件函数生成、SO的编译等过程,所以我们在Android Studio进行JNI开发的过程时会相对轻松。所以这次我们要在Java项目中去实现JNI开发,在这个过程中,我们需要手动使用javah生成C++头文件,手动调用GCC指令来编译so库。

参考上一篇文章:Java中实现JNI(不借助Android Studio)

Native函数的调用流程

Java层中执行static{System.loadlibrary("")}加载so库时,会在虚拟机中生成一个函数映射表:

一般情况下,JNI(Java Native Interface)库中的函数是通过JNI函数表进行映射的。这个JNI函数表存储了Java中的方法和本地方法之间的映射关系。在Java本地库加载后,JNI函数表会被填充,以便Java虚拟机可以通过JNI调用本地方法。

虚拟机本身是一个C++的可执行文件,叫做libart.so(所有的安卓系统源码会被编译成libart.so);每次程序调用Java函数时都会走到libart.so

在调用函数时,会通过dlopen("libart.so") 去hook虚拟机的.so,接着查找对应的函数,如果是普通函数,则从Java中找;如果为native函数,则通过映射表找。

接着虚拟机从映射表中根据函数名去找到对应函数地址并执行。

因为Java声明的native函数名是很简短的,但是在JNI中定义该函数时,函数名是有很多前缀的。那么Java层声明的Native函数名 是如何 与JNI实现的函数名去对应:

  1. 首先必须在Java代码中实现库的加载loadlibrary,用于生成本地函数的映射表。
  2. Java层调用Native函数时,Java层声明的函数名会主动的拼接 包名+当前类型+函数名。然后通过拼接后的字符串去映射表中找到对应的JNI函数地址。
相关推荐
coder_pig18 分钟前
🤡 公司Android老项目升级踩坑小记
android·flutter·gradle
死就死在补习班1 小时前
Android系统源码分析Input - InputReader读取事件
android
死就死在补习班1 小时前
Android系统源码分析Input - InputChannel通信
android
死就死在补习班2 小时前
Android系统源码分析Input - 设备添加流程
android
死就死在补习班2 小时前
Android系统源码分析Input - 启动流程
android
tom4i2 小时前
Launcher3 to Launchpad 01 布局修改
android
雨白2 小时前
OkHttpClient 核心配置详解
android·okhttp
淡淡的香烟2 小时前
Android auncher3实现简单的负一屏功能
android
RabbitYao3 小时前
Android 项目 通过 AndroidStringsTool 更新多语言词条
android·python
RabbitYao3 小时前
使用 Gemini 及 Python 更新 Android 多语言 Excel 文件
android·python