JNI 从入门到实践,万字爆肝详解!

Connor 币安交易所下载 2022-10-21 129 0

前言

在 Android 生态中主要有 C/C++、Java、Kotlin 三种语言 ,它们的关系不是替换而是互补Huobi Global。其中,C/C++ 的语境是算法和高性能,Java 的语境是平台无关和内存管理,而 Kotlin 则融合了多种语言中的优秀特性,带来了一种更现代化的编程方式;

JNI 是实现 Java 代码与 C/C++ 代码交互的特性, 思考一个问题 —— Java 虚拟机是如何实现两种毫不相干的语言的交互的呢?今天,我们来全面总结 JNI 开发知识框架,为 NDK 开发打下基础Huobi Global。本文部分演示代码可以从 DemoHall·HelloJni下载查看。

这篇文章是 NDK 系列文章第 5 篇Huobi Global,专栏文章列表:

一、语言基础:

1、NDK 学习路线:怎么学 & Huobi Global我的经验

2、C 语言基础

3、C ++ 语言基础

4、C/C++ 编译过程:从源码到程序运行

二、NDK 开发:

1、JNI 基础:Java 与 Native 交互(本文)

2、注册 JNI 函数:静态注册 & 动态注册

3、NDK 基础:ndk-build & CMake

4、so 文件加载过程分析:理解 Android 中 loadLibrary 的执行流程

5、so 文件适配 64 位架构:Gradle 插件一键检索未适配项

6、so 文件动态化:动态下载

7、so 文件体积优化:文件精简

三、基础理论

展开全文

1、视频基础理论

2、音频基础理论

3、H.264 视频压缩编码

4、音频压缩编码

5、FFMPEG 基础

6、OPENSL ES 基础

7、PNG 图片:无损压缩编码

四、计算机基础

1、字符编码:ASCII、Unicode、UTF-8、UTF-16、UTF-32

JNI 学习路线图:

1

认识 JNI

1.1 为什么要使用 JNIHuobi Global

JNI(Java Native Interface,Java 本地接口)是 Java 生态的特性,它扩展了 Java 虚拟机的能力,使得 Java 代码可以与 C/C++ 代码进行交互Huobi Global。通过 JNI 接口,Java 代码可以调用 C/C++ 代码,C/C++ 代码也可以调用 Java 代码。

这就引出第 1 个问题(为什么要这么做):Java 为什么要调用 C/C++ 代码Huobi Global,而不是直接用 Java 开发需求呢?我认为主要有 4 个原因:

• 原因 1 - Java 天然需要 JNI 技术:虽然 Java 是平台无关性语言,但运行 Java 语言的虚拟机是运行在具体平台上的,所以 Java 虚拟机是平台相关的Huobi Global。因此,对于调用平台 API 的功能(例如打开文件功能,在 Window 平台是 openFile 函数,而在 Linux 平台是 open 函数)时,虽然在 Java 语言层是平台无关的,但背后只能通过 JNI 技术在 Native 层分别调用不同平台 API。类似的,对于有操作硬件需求的程序,也只能通过 C/C++ 实现对硬件的操作,再通过 JNI 调用;

• 原因 2- Java 运行效率不及 C/C++:Java 代码的运行效率相对于 C/C++ 要低一些Huobi Global,因此,对于有密集计算(例如实时渲染、音视频处理、游戏引擎等)需求的程序,会选择用 C/C++ 实现,再通过 JNI 调用;

• 原因 3 - Native 层代码安全性更高:反编译 so 文件的难度比反编译 Class 文件高Huobi Global,一些跟密码相关的功能会选择用 C/C++ 实现,再通过 JNI 调用;

• 原因 4- 复用现有代码:当 C/C++ 存在程序需要的功能时,则可以直接复用Huobi Global

还有第 2 个问题(为什么可以这么做):为什么两种独立的语言可以实现交互呢?因为 Java 虚拟机本身就是 C/C++ 实现的,无论是 Java 代码还是 C/C++ 代码,最终都是由这个虚拟机支撑,共同使用一个进程空间Huobi Global。JNI 要做的只是在两种语言之间做桥接。

1.2 JNI 开发的基本流程

一个标准的 JNI 开发流程主要包含以下步骤:

1、创建 HelloWorld.javaHuobi Global,并声明 native 方法 sayHi;

2、使用 javac 命令编译源文件Huobi Global,生成HelloWorld.class字节码文件;

3、使用 javah 命令导出 HelloWorld.h头文件(头文件中包含Huobi Global了本地方法的函数原型);

4、在源文件 HelloWorld.cpp 中实现函数原型;

5、编译本地代码Huobi Global,生成 Hello-World.so 动态原生库文件;

6、在 Java 代码中调用 System.loadLibrary(...)加载 so 文件;

7、使用 Java 命令运行 HelloWorld 程序Huobi Global

该流程用示意图表示如下:

1.3 JNI 的性能误区

JNI 本身本身并不能解决性能问题Huobi Global,错误地使用 JNI 反而可能引入新的性能问题,这些问题都是要注意的:

• 问题 1 - 跨越 JNI 边界的调用:从 Java 调用 Native 或从 Native 调用 Java 的成本很高Huobi Global,使用 JNI 时要限制跨越 JNI 边界的调用次数;

• 问题 2- 引用类型数据的回收:由于引用类型数据(例如字符串、数组)传递到 JNI 层的只是一个指针,为避免该对象被垃圾回收虚拟机会固定住(pin)对象,在 JNI 方法返回前会阻止其垃圾回收Huobi Global。因此,要尽量缩短 JNI 调用的执行时间,它能够缩短对象被固定的时间(关于引用类型数据的处理,在下文会说到)。

1.4 注册 JNI 函数的方式

Java 的native 方法和 JNI 函数是一一对应的映射关系Huobi Global,建立这种映射关系的注册方式有 2 种:

• 方式 1- 静态注册:基于命名约定建立映射关系;

• 方式 2 - 动态注册:通过 JNINativeMethod结构体建立映射关系Huobi Global

关于注册 JNI 函数的更多原理分析,见 注册 JNI 函数 Huobi Global

1.5 加载 so 库的时机

so 库需要在运行时调用System.loadLibrary(…) 加载Huobi Global,一般有 2 种调用时机:

1、在类静态初始化中:如果只在一个类或者很少类中使用到该 so 库Huobi Global,则最常见的方式是在类的静态初始化块中调用;

2、在 Application 初始化时调用:如果有很多类需要使用到该 so 库,则可以考虑在 Application 初始化等场景中提前加载Huobi Global

关于加载 so 库的更多原理分析,见 so 文件加载过程分析Huobi Global

2

JNI 模板代码

本节我们通过一个简单的 HelloWorld 程序来帮助你熟悉 JNI 的模板代码Huobi Global

JNI Demo

JNIEXPORT voidJNICALL Java_com_xurui_hellojni_HelloWorld_sayHi(JNIEnv *, jobject);

2.1 JNI 函数名

为什么 JNI 函数名要采用 Java_com_xurui_HelloWorld_sayHi 的命名方式呢?—— 这是 JNI 函数静态注册约定的函数命名规则Huobi Global。Java 的 native 方法和 JNI 函数是一一对应的映射关系,而建立这种映射关系的注册方式有 2 种:静态注册 + 动态注册。

其中,静态注册是基于命名约定建立的映射关系,一个 Java 的 native 方法对应的 JNI 函数会采用约定的函数名,即 Java_[类的全限定名 (带下划线)]_[方法名] Huobi Global。JNI 调用 sayHi 方法时,就会从 JNI 函数库中寻找函数 Java_com_xurui_HelloWorld_sayHi,更多内容见 注册 JNI 函数 。

2.2 关键词 JNIEXPORT

JNIEXPORT 是宏定义,表示一个函数需要暴露给共享库外部使用时Huobi Global。JNIEXPORT 在 Window 和 Linux 上有不同的定义:

jni.h

// Windows 平台 :

# defineJNIEXPORT __declspec(dllexport)

# defineJNIIMPORT __declspec(dllimport)

// Linux 平台:

# defineJNIIMPORT

# defineJNIEXPORT __attribute__ ((visibility ( "default")))

2.3 关键词 JNICALL

JNICALL 是宏定义,表示一个函数是 JNI 函数Huobi Global。JNICALL 在 Window 和 Linux 上有不同的定义:

jni.h

// Windows 平台 :

# defineJNICALL __stdcall // __stdcall 是一种函数调用参数的约定 ,表示函数的调用参数是从右往左Huobi Global

// Linux 平台:

# defineJNICALL

2.4 参数 jobject

jobject 类型是 JNI 层对于 Java 层应用类型对象的表示Huobi Global。每一个从 Java 调用的native 方法,在 JNI 函数中都会传递一个当前对象的引用。区分 2 种情况:

1、静态 native 方法:第二个参数为 jclass 类型Huobi Global,指向 native 方法所在类的 Class 对象;

2、实例 native 方法:第二个参数为 jobject 类型,指向调用 native 方法的对象Huobi Global

2.5 JavaVM 和 JNIEnv 的作用

JavaVM 和 JNIEnv 是定义在 jni.h 头文件中最关键的两个数据结构:

• JavaVM:代表 Java 虚拟机Huobi Global,每个 Java 进程有且仅有一个全局的 JavaVM 对象,JavaVM 可以跨线程共享;

• JNIEnv:代表 Java 运行环境,每个 Java 线程都有各自独立的 JNIEnv 对象,JNIEnv 不可以跨线程共享Huobi Global

JavaVM 和 JNIEnv 的类型定义在 C 和 C++ 中略有不同,但本质上是相同的,内部由一系列指向虚拟机内部的函数指针组成Huobi Global。类似于 Java 中的 Interface 概念,不同的虚拟机实现会从它们派生出不同的实现类,而向 JNI 层屏蔽了虚拟机内部实现(例如在 Android ART 虚拟机中,它们的实现分别是 JavaVMExt 和 JNIEnvExt)。

jni.h

struct_ JNIEnv;

struct_ JavaVM;

# ifdefined(__cplusplus)

// 如果定义了 __cplusplus 宏Huobi Global,则按照 C++ 编译

typedef_JNIEnv JNIEnv;

typedef_JavaVM JavaVM;

# else

// 按照 C 编译

typedefconststructJNINativeInterface* JNIEnv;

typedefconststructJNIInvokeInterface* JavaVM;

# endif

* C++ 版本的 _JavaVMHuobi Global,内部是对 JNIInvokeInterface* 的包装

struct_ JavaVM{

// 相当于 C 版本中的 JNIEnv

conststructJNIInvokeInterface* functions;

// 转发给 functions 代理

jint DestroyJavaVM

{ returnfunctions->DestroyJavaVM( this); }

* C++ 版本的 JNIEnvHuobi Global,内部是对 JNINativeInterface* 的包装

struct_ JNIEnv{

// 相当于 C 版本的 JavaVM

conststructJNINativeInterface* functions;

// 转发给 functions 代理

jint GetVersion

{ returnfunctions->GetVersion( this); }

可以看到,不管是在 C 语言中还是在 C++ 中,JNINativeInterface*和 JNINativeInterface*这两个结构体指针才是 JavaVM 和 JNIEnv 的实体Huobi Global。不过 C++ 中加了一层包装,在语法上更简洁,例如:

示例程序

// 在 C 语言中Huobi Global,要使用 (*env)->

// 注意看这一句:typedef const struct JNINativeInterface* JNIEnv;

(*env)->FindClass(env, "java/lang/String");

// 在 C++ 中Huobi Global,要使用 env->

// 注意看这一句:jclass FindClass(const char* name)

//{ return functions->FindClass(this, name); }

env->FindClass( "java/lang/String");

后文提到的大量 JNI 函数,其实都是定义在JNINativeInterface和 JNINativeInterface内部的函数指针Huobi Global

jni.h

* JavaVM

structJNIInvokeInterface{

// 一系列函数指针

jint (*DestroyJavaVM)(JavaVM*);

jint (*AttachCurrentThread)(JavaVM*, JNIEnv**, void*);

jint (*DetachCurrentThread)(JavaVM*);

jint (*GetEnv)(JavaVM*, void**, jint);

jint (*AttachCurrentThreadAsDaemon)(JavaVM*, JNIEnv**, void*);

* JNIEnv

structJNINativeInterface{

// 一系列函数指针

jint (*GetVersion)(JNIEnv *);

jclass (*DefineClass)(JNIEnv*, constchar*, jobject, constjbyte*, jsize);

jclass (*FindClass)(JNIEnv*, constchar*);

3

数据类型转换

这一节我们来讨论 Java 层与 Native 层之间的数据类型转换Huobi Global

3.1 Java 类型映射(重点理解)

JNI 对于 Java 的基础数据类型(int 等)和引用数据类型(Object、Class、数组等)的处理方式不同Huobi Global。这个原理非常重要,理解这个原理才能理解后面所有 JNI 函数的设计思路:

• 基础数据类型:会直接转换为 C/C++ 的基础数据类型,例如 int 类型映射为 jint 类型Huobi Global。由于 jint 是 C/C++ 类型,所以可以直接当作普通 C/C++ 变量使用,而不需要依赖 JNIEnv 环境对象;

• 引用数据类型:对象只会转换为一个 C/C++ 指针,例如 Object 类型映射为 jobject 类型Huobi Global。由于指针指向 Java 虚拟机内部的数据结构,所以不可能直接在 C/C++ 代码中操作对象,而是需要依赖 JNIEnv 环境对象。另外,为了避免对象在使用时突然被回收,在本地方法返回前,虚拟机会固定(pin)对象,阻止其 GC。

另外需要特别注意一点,基础数据类型在映射时是直接映射,而不会发生数据格式转换Huobi Global。例如,Java char 类型在映射为 jchar 后旧是保持 Java 层的样子,数据长度依旧是 2 个字节,而字符编码依旧是 UNT-16 编码。

具体映射关系都定义在jni.h头文件中Huobi Global,文件摘要如下:

jni.h

typedefuint8_tjboolean; /* unsigned 8 bits */

typedefint8_tjbyte; /* signed 8 bits */

typedefuint16_tjchar; /* unsigned 16 bits *//* 注意:jchar 是 2 个字节 */

typedefint16_tjshort; /* signed 16 bits */

typedefint32_tjint; /* signed 32 bits */

typedefint64_tjlong; /* signed 64 bits */

typedeffloatjfloat; /* 32-bit IEEE 754 */

typedefdoublejdouble; /* 64-bit IEEE 754 */

typedefjint jsize;

# ifdef__cplusplus

// 内部的数据结构由虚拟机实现Huobi Global,只能从虚拟机源码看

class_ jobject{ };

class_ jclass: public_jobject {};

class_ jstring: public_jobject {};

class_ jarray: public_jobject {};

class_ jobjectArray: public_jarray {};

class_ jbooleanArray: public_jarray {};

// 说明Huobi Global我们接触到到 jobject、jclass 其实是一个指针

typedef_jobject* jobject;

typedef_jclass* jclass;

typedef_jstring* jstring;

typedef_jarray* jarray;

typedef_jobjectArray* jobjectArray;

typedef_jbooleanArray* jbooleanArray;

# else/* not __cplusplus */

# endif/* not __cplusplus */

Huobi Global我将所有 Java 类型与 JNI 类型的映射关系总结为下表:

Java 类型

JNI 类型

描述

长度(字节)

boolean

jboolean

unsigned char

1

byte

jbyte

signed char

1

char

jchar

unsigned short

2

short

jshort

signed short

2

int

jint、jsize

signed int

4

long

jlong

signed long

8

float

jfloat

signed float

4

double

jdouble

signed double

8

Class

jclass

Class 类对象

1

String

jstrting

字符串对象

Object

jobject

对象

Throwable

jthrowable

异常对象

boolean[]

jbooleanArray

布尔数组

byte[]

jbyteArray

byte 数组

char[]

jcharArray

char 数组

short[]

jshortArray

short 数组

int[]

jinitArray

int 数组

long[]

jlongArray

long 数组

float[]

jfloatArray

float 数组

double[]

jdoubleArray

double 数组

3.2 字符串类型操作

上面提到 Java 对象会映射为一个 jobject 指针,那么 Java 中的java.lang.String字符串类型也会映射为一个 jobject 指针Huobi Global。可能是因为字符串的使用频率实在是太高了,所以 JNI 规范还专门定义了一个 jobject 的派生类 jstring 来表示 Java String 类型,这个相对特殊。

jni.h

// 内部的数据结构还是看不到Huobi Global,由虚拟机实现

class_ jstring: public_jobject {};

typedef_jstring* jstring;

structJNINativeInterface{

// String 转换为 UTF-8 字符串

constchar* (*GetStringUTFChars)(JNIEnv*, jstring, jboolean*);

// 释放 GetStringUTFChars 生成的 UTF-8 字符串

void(*ReleaseStringUTFChars)(JNIEnv*, jstring, constchar*);

// 构造新的 String 字符串

jstring (*NewStringUTF)(JNIEnv*, constchar*);

// 获取 String 字符串的长度

jsize (*GetStringUTFLength)(JNIEnv*, jstring);

// 将 String 复制到预分配的 char* 数组中

void(*GetStringUTFRegion)(JNIEnv*, jstring, jsize, jsize, char*);

由于 Java 与 C/C++ 默认使用不同的字符编码,因此在操作字符数据时,需要特别注意在 UTF-16 和 UTF-8 两种编码之间转换Huobi Global。关于字符编码,我们在 Unicode 和 UTF-8 是什么关系? 这篇文章里讨论过,这里就简单回顾一下:

• Unicode:统一化字符编码标准Huobi Global,为全世界所有字符定义统一的码点,例如 U+0011;

• UTF-8:Unicode 标准的实现编码之一,使用 1~4 字节的变长编码Huobi Global。UTF-8 编码中的一字节编码与 ASCII 编码兼容。

• UTF-16:Unicode 标准的实现编码之一,使用 2 / 4 字节的变长编码Huobi Global。UTF-16 是 Java String 使用的字符编码;

• UTF-32:Unicode 标准的实现编码之一,使用 4 字节定长编码Huobi Global

以下为 2 种较为常见的转换场景:

1、Java String 对象转换为 C/C++ 字符串:调用GetStringUTFChars函数将一个 jstring 指针转换为一个 UTF-8 的 C/C++ 字符串Huobi Global,并在不再使用时调用 ReleaseStringChars 函数释放内存;

2、构造 Java String 对象:调用 NewStringUTF函数构造一个新的 Java String 字符串对象Huobi Global

Huobi Global我们直接看一段示例程序:

示例程序

// 示例 1:将 Java String 转换为 C/C++ 字符串

jstring jStr = ...; // Java 层传递过来的 String

constchar *str = env->GetStringUTFChars(jStr, JNI_FALSE);

if(!str) {

// OutOfMemoryError

return;

// 释放 GetStringUTFChars 生成的 UTF-8 字符串

env->ReleaseStringUTFChars(jStr, str);

// 示例 2:构造 Java String 对象(将 C/C++ 字符串转换为 Java String)

jstring newStr = env->NewStringUTF( "在 Native 层构造 Java String");

if(newStr) {

// 通过 JNIEnv 方法将 jstring 调用 Java 方法(jstring 本身就是 Java String 的映射Huobi Global,可以直接传递到 Java 层)

此处对 GetStringUTFChars 函数的第 3 个参数 isCopy 做解释:它是一个布尔值参数Huobi Global,将决定使用拷贝模式还是复用模式:

1、 JNI_TRUE:使用拷贝模式Huobi Global,JVM 将拷贝一份原始数据来生成 UTF-8 字符串;

2、 JNI_FALSE:使用复用模式,JVM 将复用同一份原始数据来生成 UTF-8 字符串Huobi Global。复用模式绝不能修改字符串内容,否则 JVM 中的原始字符串也会被修改,打破 String 不可变性。

另外还有一个基于范围的转换函数:GetStringUTFRegion:预分配一块字符数组缓冲区,然后将 String 数据复制到这块缓冲区中Huobi Global。由于这个函数本身不会做任何内存分配,所以不需要调用对应的释放资源函数,也不会抛出 OutOfMemoryError。另外,GetStringUTFRegion这个函数会做越界检查并抛出 StringIndexOutOfBoundsException异常。

示例程序

jstring jStr = ...; // Java 层传递过来的 String

char outbuf[ 128];

intlen= env->GetStringLength(jStr);

env->GetStringUTFRegion(jStr, 0, len, outbuf);

3.3 数组类型操作

与 jstring 的处理方式类似Huobi Global,JNI 规范将 Java 数组定义为 jobject 的派生类 jarray :

• 基础类型数组:定义为 jbooleanArray 、jintArray 等;

• 引用类型数组:定义为 jobjectArray Huobi Global

下面区分基础类型数组和引用类型数组两种情况:

操作基础类型数组(以 jintArray 为例):

1、 Java 基本类型数组转换为 C/C++ 数组:调用 GetIntArrayElements 函数将一个 jintArray 指针转换为 C/C++ int 数组;

2、 修改 Java 基本类型数组:调用 ReleaseIntArrayElements 函数并使用模式 0;

3、 构造 Java 基本类型数组:调用 NewIntArray 函数构造 Java int 数组Huobi Global

Huobi Global我们直接看一段示例程序:

示例程序

extern"C"

JNIEXPORT jintArray JNICALL

Java_com_xurui_hellojni_HelloWorld_generateIntArray(JNIEnv *env, jobject thiz, jint size){

// 新建 Java int[]

jintArray jarr = env->NewIntArray(size);

// 转换为 C/C ++ int[]

int*carr = env->GetIntArrayElements(jarr, JNI_FALSE);

// 赋值

for( inti = 0; i < size; i++) {

carr[i] = i;

// 释放资源并回写

env->ReleaseIntArrayElements(jarr, carr, 0);

// 返回数组

returnjarr;

此处重点对 ReleaseIntArrayElements函数的第 3 个参数 mode 做解释:它是一个模式参数:

参数 mode

描述

将 C/C++ 数组的数据回写到 Java 数组Huobi Global,并释放 C/C++ 数组

JNI_COMMIT

将 C/C++ 数组的数据回写到 Java 数组Huobi Global,并不释放 C/C++ 数组

JNI_ABORT

不回写数据Huobi Global,但释放 C/C++ 数组

另外 JNI 还提供了基于范围函数:GetIntArrayRegion 和 SetIntArrayRegion,使用方法和注意事项和GetStringUTFRegion也是类似的,也是基于一块预分配的数组缓冲区Huobi Global

操作引用类型数组(jobjectArray):

1、 将 Java 引用类型数组转换为 C/C++ 数组:不支持!与基本类型数组不同Huobi Global,引用类型数组的元素 jobject 是一个指针,不存在转换为 C/C++ 数组的概念;

2、 修改 Java 引用类型数组:调用 SetObjectArrayElement函数修改指定下标元素;

3、 构造 Java 引用类型数组:先调用 FindClass函数获取 Class 对象,再调用 NewObjectArray函数构造对象数组Huobi Global

Huobi Global我们直接看一段示例程序:

示例程序

extern"C"

JNIEXPORT jobjectArray JNICALL

Java_com_xurui_hellojni_HelloWorld_generateStringArray(JNIEnv *env, jobject thiz, jint size){

// 获取 String Class

jclass jStringClazz = env->FindClass( "java/lang/String");

// 初始值(可为空)

jstring initialStr = env->NewStringUTF( "初始值");

// 创建 Java String[]

jobjectArray jarr = env->NewObjectArray(size, jStringClazz, initialStr);

// 赋值

for( inti = 0; i < size; i++) {

charstr[ 5];

sprintf(str, "%d", i);

jstring jStr = env->NewStringUTF(str);

env->SetObjectArrayElement(jarr, i, jStr);

// 返回数组

returnjarr;

4

JNI 访问 Java 字段与方法

这一节我们来讨论如何从 Native 层访问 Java 的字段与方法Huobi Global。在开始访问前,JNI 首先要找到想访问的字段和方法,这就依靠字段描述符和方法描述符。

4.1 字段描述符与方法描述符

在 Java 源码中定义的字段和方法,在编译后都会按照既定的规则记录在 Class 文件中的字段表和方法表结构中Huobi Global。例如,一个 public String str; 字段会被拆分为字段访问标记(public)、字段简单名称(str)和字段描述符(Ljava/lang/String)。因此,从 JNI 访问 Java 层的字段或方法时,首先就是要获取在 Class 文件中记录的简单名称和描述符。

Class 文件的一级结构:

字段表结构:包含字段的访问标记、简单名称、字段描述符等信息Huobi Global。例如字段 String str 的简单名称为 str,字段描述符为 Ljava/lang/String;

方法表结构:包含方法的访问标记、简单名称、方法描述符等信息Huobi Global。例如方法 void fun; 的简单名称为 fun,方法描述符为V

4.2 描述符规则

• 字段描述符:字段描述符其实就是描述字段的类型Huobi Global,JVM 对每种基础数据类型定义了固定的描述符,而引用类型则是以 L 开头的形式:

Java 类型

描述符

boolean

Z

byte

B

char

C

short

S

int

I

long

J

floag

F

double

D

void

V

引用类型

以 L 开头 ; 结尾,中间是 / 分隔的包名和类名Huobi Global。例如 String 的字段描述符为 Ljava/lang/String;

• 方法描述符:方法描述符其实就是描述方法的返回值类型和参数表类型,参数类型用一对圆括号括起来,按照参数声明顺序列举参数类型,返回值出现在括号后面Huobi Global。例如方法 void fun; 的简单名称为 fun,方法描述符为V

4.3 JNI 访问 Java 字段

本地代码访问 Java 字段的流程分为 2 步:

1、 通过 jclass 获取字段 IDHuobi Global,例如: Fid = env->GetFieldId(clz, "name", "Ljava/lang/String;");

2、 通过字段 ID 访问字段Huobi Global,例如: Jstr = env->GetObjectField(thiz, Fid);

Java 字段分为静态字段和实例字段Huobi Global,相关方法如下:

• GetFieldId:获取实例方法的字段 IDHuobi Global

• GetStaticFieldId:获取静态方法的字段 IDHuobi Global

• GetField:获取类型为 Type 的实例字段(例如 GetIntField)Huobi Global

• SetField:设置类型为 Type 的实例字段(例如 SetIntField)Huobi Global

• GetStaticField:获取类型为 Type 的静态字段(例如 GetStaticIntField)Huobi Global

• SetStaticField:设置类型为 Type 的静态字段(例如 SetStaticIntField)Huobi Global

示例程序

extern"C"

JNIEXPORT voidJNICALL

Java_com_xurui_hellojni_HelloWorld_accessField(JNIEnv *env, jobject thiz){

// 获取 jclass

jclass clz = env->GetObjectClass(thiz);

// 示例:修改 Java 静态变量值

// 静态字段 ID

jfieldID sFieldId = env->GetStaticFieldID(clz, "sName", "Ljava/lang/String;");

// 访问静态字段

if(sFieldId) {

// Java 方法的返回值 String 映射为 jstring

jstring jStr = static_cast<jstring>(env->GetStaticObjectField(clz, sFieldId));

// 将 jstring 转换为 C 风格字符串

constchar*sStr = env->GetStringUTFChars(jStr, JNI_FALSE);

// 释放资源

env->ReleaseStringUTFChars(jStr, sStr);

// 构造 jstring

jstring newStr = env->NewStringUTF( "静态字段 - Peng");

if(newStr) {

// jstring 本身就是 Java String 的映射Huobi Global,可以直接传递到 Java 层

env->SetStaticObjectField(clz, sFieldId, newStr);

// 示例:修改 Java 成员变量值

// 实例字段 ID

jfieldID mFieldId = env->GetFieldID(clz, "mName", "Ljava/lang/String;");

// 访问实例字段

if(mFieldId) {

jstring jStr = static_cast<jstring>(env->GetObjectField(thiz, mFieldId));

// 转换为 C 字符串

constchar*sStr = env->GetStringUTFChars(jStr, JNI_FALSE);

// 释放资源

env->ReleaseStringUTFChars(jStr, sStr);

// 构造 jstring

jstring newStr = env->NewStringUTF( "实例字段 - Peng");

if(newStr) {

// jstring 本身就是 Java String 的映射Huobi Global,可以直接传递到 Java 层

env->SetObjectField(thiz, mFieldId, newStr);

4.4 JNI 调用 Java 方法

本地代码访问 Java 方法与访问 Java 字段类似Huobi Global,访问流程分为 2 步:

1、 通过 jclass 获取「方法 ID」Huobi Global,例如: Mid = env->GetMethodID(jclass, "helloJava", "V");

2、 通过方法 ID 调用方法Huobi Global,例如: env->CallVoidMethod(thiz, Mid);

Java 方法分为静态方法和实例方法Huobi Global,相关方法如下:

• GetMethodId:获取实例方法 IDHuobi Global

• GetStaticMethodId:获取静态方法 IDHuobi Global

• CallMethod:调用返回类型为 Type 的实例方法(例如 GetVoidMethod)Huobi Global

• CallStaticMethod:调用返回类型为 Type 的静态方法(例如 CallStaticVoidMethod)Huobi Global

• CallNonvirtualMethod:调用返回类型为 Type 的父类方法(例如 CallNonvirtualVoidMethod)Huobi Global

示例程序

extern "C"

JNIEXPORT void JNICALL

Java_com_xurui_hellojni_HelloWorld_accessMethod(JNIEnv *env, jobject thiz) {

// 获取 jclass

jclass clz = env->GetObjectClass(thiz);

// 示例:调用 Java 静态方法

// 静态方法 ID

jmethodID sMethodId = env->GetStaticMethodID(clz, "sHelloJava", "V");

if(sMethodId) {

env->CallStaticVoidMethod(clz, sMethodId);

// 示例:调用 Java 实例方法

// 实例方法 ID

jmethodID mMethodId = env->GetMethodID(clz, "helloJava", "V");

if(mMethodId) {

env->CallVoidMethod(thiz, mMethodId);

4.5 缓存 ID

访问 Java 层字段或方法时,需要先利用字段名 / 方法名和描述符进行检索,获得 jfieldID / jmethodIDHuobi Global。这个检索过程比较耗时,优化方法是将字段 ID 和方法 ID 缓存起来,减少重复检索。

提示:从不同线程中获取同一个字段或方法 的 ID 是相同的,缓存 ID 不会有多线程问题Huobi Global

提示:从不同线程中获取同一个字段或方法 的 ID 是相同的,缓存 ID 不会有多线程问题Huobi Global

缓存字段 ID 和 方法 ID 的方法主要有 2 种:

1、 使用时缓存:使用时缓存是指在首次访问字段或方法时,将字段 ID 或方法 ID 存储在静态变量中Huobi Global。这样将来再次调用本地方法时,就不需要重复检索 ID 了。例如:

2、 类初始化时缓存:静态初始化时缓存是指在 Java 类初始化的时候,提前缓存字段 ID 和方法 IDHuobi Global。可以选择在 JNI_方法中缓存,也可以在加载 so 库后调用一个 native 方法进行缓存。

两种缓存 ID 方式的主要区别在于缓存发生的时机和时效性:

1、 时机不同:使用时缓存是延迟按需缓存Huobi Global,只有在首次访问 Java 时才会获取 ID 并缓存,而类初始化时缓存是提前缓存;

2、 时效性不同:使用时缓存的 ID 在类卸载后失效,在类卸载后不能使用,而类加载时缓存在每次加载 so 动态库时会重新更新缓存,因此缓存的 ID 是保持有效的Huobi Global

5

JNI 中的对象引用管理

5.1 Java 和 C/C++ 中对象内存回收区别(重点理解)

在讨论 JNI 中的对象引用管理Huobi Global,我们先回顾一下 Java 和 C/C++ 在对象内存回收上的区别:

• Java:对象在堆 / 方法区上分配,由垃圾回收器扫描对象可达性进行回收Huobi Global。如果使用局部变量指向对象,在不再使用对象时可以手动显式置空,也可以等到方法返回时自动隐式置空。如果使用全局变量(static)指向对象,在不再使用对象时必须手动显式置空。

• C/C++:栈上分配的对象会在方法返回时自动回收,而堆上分配的对象不会随着方法返回而回收,也没有垃圾回收器管理,因此必须手动回收(free/delete)Huobi Global

而 JNI 层作为 Java 层和 C/C++ 层之间的桥接层Huobi Global,那么它就会兼具两者的特点:对于

• 局部 Java 对象引用:在 JNI 层可以通过 NewObject 等函数创建 Java 对象,并且返回对象的引用,这个引用就是 Local 型的局部引用Huobi Global。对于局部引用,可以通过 DeleteLocalRef 函数手动显式释放(这类似于在 Java 中显式置空局部变量),也可以等到函数返回时自动释放(这类似于在 Java 中方法返回时隐式置空局部变量);

• 全局 Java 对象引用:由于局部引用在函数返回后一定会释放,可以通过 NewGlobalRef函数将局部引用升级为 Global 型全局变量,这样就可以在方法使用对象(这类似于在 Java 中使用 static 变量指向对象)Huobi Global。在不再使用对象时必须调用 DeleteGlobalRef 函数释放全局引用(这类似于在 Java 中显式置空 static 变量)。

提示:我们这里所说的 ”置空“ 只是将指向变量的值赋值为 null,而不是回收对象,Java 对象回收是交给垃圾回收器处理的Huobi Global

提示:我们这里所说的 ”置空“ 只是将指向变量的值赋值为 null,而不是回收对象,Java 对象回收是交给垃圾回收器处理的Huobi Global

5.2 JNI 中的三种引用

1、 局部引用:大部分 JNI 函数会创建局部引用,局部引用只有在创建引用的本地方法返回前有效,也只在创建局部引用的线程中有效Huobi Global。在方法返回后,局部引用会自动释放,也可以通过 DeleteLocalRef 函数手动释放;

2、 全局引用:局部引用要跨方法和跨线程必须升级为全局引用,全局引用通过 NewGlobalRef 函数创建,不再使用对象时必须通过DeleteGlobalRef 函数释放Huobi Global

3、弱全局引用:弱引用与全局引用类似,区别在于弱全局引用不会持有强引用,因此不会阻止垃圾回收器回收引用指向的对象Huobi Global。弱全局引用通过 NewGlobalWeakRef 函数创建,不再使用对象时必须通过DeleteGlobalWeakRef 函数释放。

示例程序

// 局部引用

jclass localRefClz = env->FindClass( "java/lang/String");

env->DeleteLocalRef(localRefClz);

// 全局引用

jclass globalRefClz = env->NewGlobalRef(localRefClz);

env->DeleteGlobalRef(globalRefClz);

// 弱全局引用

jclass weakRefClz = env->NewWeakGlobalRef(localRefClz);

env->DeleteGlobalWeakRef(weakRefClz);

5.3 JNI 引用的实现原理

在 JavaVM 和 JNIEnv 中Huobi Global,会分别建立多个表管理引用:

• JavaVM 内有 globals 和 weak_globals 两个表管理全局引用和弱全局引用Huobi Global。由于 JavaVM 是进程共享的,因此全局引用可以跨方法和跨线程共享;

• JavaEnv 内有 locals 表管理局部引用,由于 JavaEnv 是线程独占的,因此局部引用不能跨线程Huobi Global。另外虚拟机在进入和退出本地方法通过 Cookie 信息记录哪些局部引用是在哪些本地方法中创建的,因此局部引用是不能跨方法的。

5.4 比较引用是否指向相同对象

可以使用 JNI 函数IsSameObject 判断两个引用是否指向相同对象(适用于三种引用类型),返回值为JNI_TRUE时表示相同,返回值为 JNI_FALSE表示不同Huobi Global。例如:

示例程序

jclass localRef = ...

jclass globalRef = ...

bool isSampe = env->IsSamObject(localRef, globalRef)

另外Huobi Global,当引用与 NULL 比较时含义略有不同:

• 局部引用和全局引用与 NULL 比较:用于判断引用是否指向 NULL 对象;

• 弱全局引用与 NULL 比较:用于判断引用指向的对象是否被回收Huobi Global

6

JNI 中的异常处理

6.1 JNI 的异常处理机制(重点理解)

JNI 中的异常机制与 Java 和 C/C++ 的处理机制都不同:

• Java 和 C/C++:程序使用关键字 throw 抛出异常,虚拟机会中断当前执行流程,转而去寻找匹配的catch{} 块,或者继续向外层抛出寻找匹配 catch {} 块Huobi Global

• JNI:程序使用 JNI 函数 ThrowNew 抛出异常,程序不会中断当前执行流程,而是返回 Java 层后,虚拟机才会抛出这个异常Huobi Global

因此Huobi Global,在 JNI 层出现异常时,有 2 种处理选择:

方法 1:直接 return 当前方法Huobi Global,让 Java 层去处理这个异常(这类似于在 Java 中向方法外层抛出异常);

方法 2:通过 JNI 函数 ExceptionClear 清除这个异常,再执行异常处理程序(这类似于在 Java 中 try-catch 处理异常)Huobi Global。需要注意的是,当异常发生时,必须先处理-清除异常,再执行其他 JNI 函数调用。 因为当运行环境存在未处理的异常时,只能调用 2 种 JNI 函数:异常护理函数和清理资源函数。

JNI 提供Huobi Global了以下与异常处理相关的 JNI 函数:

• ThrowNew:向 Java 层抛出异常;

• ExceptionDescribe:打印异常描述信息;

• ExceptionOccurred:检查当前环境是否发生异常Huobi Global,如果存在异常则返回该异常对象;

• ExceptionCheck:检查当前环境是否发生异常Huobi Global,如果存在异常则返回JNI_TRUE,否则返回JNI_FALSE;

• ExceptionClear:清除当前环境的异常Huobi Global

jni.h

structJNINativeInterface{

// 抛出异常

jint (*ThrowNew)(JNIEnv *, jclass, constchar*);

// 检查异常

jthrowable (*ExceptionOccurred)(JNIEnv*);

// 检查异常

jboolean (*ExceptionCheck)(JNIEnv*);

// 清除异常

void(*ExceptionClear)(JNIEnv*);

示例程序

// 示例 1:向 Java 层抛出异常

jclass exceptionClz = env->FindClass( "java/lang/IllegalArgumentException");

env->ThrowNew(exceptionClz, "来自 Native 的异常");

// 示例 2:检查当前环境是否发生异常(类似于 Java try{})

jthrowable exc = env->ExceptionOccurred(env);

if(exc) {

// 处理异常(类似于 Java 的 catch{})

// 示例 3:清除异常

env->ExceptionClear;

6.2 检查是否发生异常的方式

异常处理的步骤我懂了Huobi Global,由于虚拟机在遇到 ThrowNew 时不会中断当前执行流程,那我怎么知道当前已经发生异常呢?有 2 种方法:

方法 1:通过函数返回值错误码,大部分 JNI 函数和库函数都会有特定的返回值来标示错误,例如 -1、NULL 等Huobi Global。在程序流程中可以多检查函数返回值来判断异常。

方法 2:通过 JNI 函数ExceptionOccurred或ExceptionCheck检查当前是否有异常发生Huobi Global

7

JNI 与多线程

这一节我们来讨论 JNI 层中的多线程操作Huobi Global

7.1 不能跨线程的引用

在 JNI 中Huobi Global,有 2 类引用是无法跨线程调用的,必须时刻谨记:

• JNIEnv:JNIEnv 只在所在的线程有效,在不同线程中调用 JNI 函数时,必须使用该线程专门的 JNIEnv 指针,不能跨线程传递和使用Huobi Global。通过 AttachCurrentThread 函数将当前线程依附到 JavaVM 上,获得属于当前线程的 JNIEnv 指针。如果当前线程已经依附到 JavaVM,也可以直接使用GetEnv函数。

示例程序

JNIEnv * env_child;

vm->AttachCurrentThread(&env_child, nullptr);

// 使用 JNIEnv*

vm->DetachCurrentThread;

• 局部引用:局部引用只在创建的线程和方法中有效,不能跨线程使用Huobi Global。可以将局部引用升级为全局引用后跨线程使用。

示例程序

// 局部引用

jclass localRefClz = env->FindClass( "java/lang/String");

// 释放全局引用(非必须)

env->DeleteLocalRef(localRefClz);

// 局部引用升级为全局引用

jclass globalRefClz = env->NewGlobalRef(localRefClz);

// 释放全局引用(必须)

env->DeleteGlobalRef(globalRefClz);

7.2 监视器同步

在 JNI 中也会存在多个线程同时访问一个内存资源的情况,此时需要保证并发安全Huobi Global。在 Java 中我们会通过synchronized 关键字来实现互斥块(背后是使用监视器字节码),在 JNI 层也提供了类似效果的 JNI 函数:

• MonitorEnter:进入同步块Huobi Global,如果另一个线程已经进入该 jobject 的监视器,则当前线程会阻塞;

• MonitorExit:退出同步块,如果当前线程未进入该 jobject 的监视器,则会抛出 IllegalMonitorStateException 异常Huobi Global

jni.h

structJNINativeInterface {

jint(*MonitorEnter)(JNIEnv*, jobject);

jint(*MonitorExit)(JNIEnv*, jobject);

示例程序

// 进入监视器

if(env->MonitorEnter(obj) != JNI_OK) {

// 建立监视器的资源分配不成功等

// 此处为同步块

if(env->ExceptionOccurred) {

// 必须保证有对应的 MonitorExitHuobi Global,否则可能出现死锁

if(env->MonitorExit(obj) != JNI_OK) {

return;

// 退出监视器

if(env->MonitorExit(obj) != JNI_OK) {

7.3 等待与唤醒

JNI 没有提供 Object 的wati/notify相关功能的函数Huobi Global,需要通过 JNI 调用 Java 方法的方式来实现:

示例程序

staticjmethodID MID_Object_wait;

staticjmethodID MID_Object_notify;

staticjmethodID MID_Object_notifyAll;

void

JNU_MonitorWait( JNIEnv *env, jobject object, jlong timeout ) {

env->CallVoidMethod( object, MID_Object_wait, timeout);

void

JNU_MonitorNotify( JNIEnv *env, jobject object) {

env->CallVoidMethod( object, MID_Object_notify);

void

JNU_MonitorNotifyAll( JNIEnv *env, jobject object) {

env->CallVoidMethod( object, MID_Object_notifyAll);

7.4 创建线程的方法

在 JNI 开发中Huobi Global,有两种创建线程的方式:

方法 1 - 通过 Java API 创建:使用我们熟悉的Thread#start 可以创建线程Huobi Global,优点是可以方便地设置线程名称和调试;

方法 2 - 通过 C/C++ API 创建:使用pthread_create 或std::thread也可以创建线程Huobi Global

示例程序

void* thr_fn( void*arg) {

printids( "new thread: ");

returnNULL;

intmain( void) {

pthread_tntid;

// 第 4 个参数将传递到 thr_fn 的参数 arg 中

err = pthread_create(&ntid, NULL, thr_fn, NULL);

if(err != 0) {

printf( "can't create thread: %s\n", strerror(err));

return0;

8

通用 JNI 开发模板

光说不练假把式,以下给出一个简单的 JNI 开发模板,将包括上文提到的一些比较重要的知识点Huobi Global。程序逻辑很简单:Java 层传递一个媒体文件路径到 Native 层后,由 Native 层播放媒体并回调到 Java 层。为了程序简化,所有真实的媒体播放代码都移除了,只保留模板代码。

• Java 层:由 start方法开始Huobi Global,调用startNative 方法进入 Native 层;

• Native 层:创建 MediaPlayer 对象,其中在子线程播放媒体文件,并通过预先持有的 JavaVM 指针获取子线程的 JNIEnv 对象回调到 Java 层 onStarted 方法Huobi Global

MediaPlayer.kt

// Java 层模板

classMediaPlayer{

companionobject{

init {

// 注意点:加载 so 库

System.loadLibrary( "hellondk")

// Native 层指针

privatevarnativeObj: Long? = null

funstart(path : String) {

// 注意点:记录 Native 层指针Huobi Global,后续操作才能拿到 Native 的对象

nativeObj = startNative(path)

funrelease{

// 注意点:使用 start 中记录的指针调用 native 方法

nativeObj?.let {

releaseNative(it)

nativeObj = null

privateexternal funstartNative(path : String) : Long

privateexternal funreleaseNative(nativeObj: Long)

funonStarted{

// Native 层回调(来自 JNICallbackHelper#onStarted)

native-lib.cpp

// 注意点:记录 JavaVM 指针Huobi Global,用于在子线程获得 JNIEnv

JavaVM *vm = nullptr;

jint JNI_(JavaVM *vm, void*args) {

::vm = vm;

returnJNI_VERSION_1_6;

extern"C"

JNIEXPORT jlong JNICALL

Java_com_pengxr_hellondk_MediaPlayer_startNative(JNIEnv *env, jobject thiz, jstring path){

// 注意点:String 转 C 风格字符串

constchar*path_ = env->GetStringUTFChars(path, nullptr);

// 构造一个 Native 对象

auto*helper = newJNICallbackHelper(vm, env, thiz);

auto*player = newMediaPlayer(path_, helper);

player->start;

// 返回 Native 对象的指针

returnreinterpret_cast<jlong>(player);

extern"C"

JNIEXPORT voidJNICALL

Java_com_pengxr_hellondk_MediaPlayer_releaseNative(JNIEnv *env, jobject thiz, jlong native_obj){

auto* player = reinterpret_cast<MediaPlayer *>(native_obj);

player->release;

JNICallbackHelper.h

# ifndefHELLONDK_JNICALLBACKHELPER_H

# defineHELLONDK_JNICALLBACKHELPER_H

# include<jni.h>

# include"util.h"

classJNICallbackHelper{

private:

// 全局共享的 JavaVM*

// 注意点:指针要初始化 0 值

JavaVM *vm = 0;

// 主线程的 JNIEnv*

JNIEnv *env = 0;

// Java 层的对象 MediaPlayer.kt

jobject job;

// Java 层的方法 MediaPlayer#onStarted

jmethodID jmd_prepared;

public:

JNICallbackHelper(JavaVM *vm, JNIEnv *env, jobject job);

~JNICallbackHelper;

voidonStarted;

# endif//HELLONDK_JNICALLBACKHELPER_H

JNICallbackHelper.cpp

# include"JNICallbackHelper.h"

JNICallbackHelper::JNICallbackHelper(JavaVM *vm, JNIEnv *env, jobject job) {

// 全局共享的 JavaVM*

this->vm = vm;

// 主线程的 JNIEnv*

this->env = env;

// C 回调 Java

jclass mediaPlayerKTClass = env->GetObjectClass(job);

jmd_prepared = env->GetMethodID(mediaPlayerKTClass, "onPrepared", "V");

// 注意点:jobject 无法跨越线程Huobi Global,需要转换为全局引用

// Error:this->job = job;

this->job = env->NewGlobalRef(job);

JNICallbackHelper::~JNICallbackHelper {

vm = nullptr;

// 注意点:释放全局引用

env->DeleteGlobalRef(job);

job = nullptr;

env = nullptr;

voidJNICallbackHelper::onStarted {

// 注意点:子线程不能直接使用持有的主线程 envHuobi Global,需要通过 AttachCurrentThread 获取子线程的 env

JNIEnv * env_child;

vm->AttachCurrentThread(&env_child, nullptr);

// 回调 Java 方法

env_child->CallVoidMethod(job, jmd_prepared);

vm->DetachCurrentThread;

MediaPlayer.h

# ifndefHELLONDK_MEDIAPLAYER_H

# defineHELLONDK_MEDIAPLAYER_H

# include<cstring>

# include<pthread.h>

# include"JNICallbackHelper.h"

classMediaPlayer{

private:

char*path = 0;

JNICallbackHelper *helper = 0;

pthread_tpid_start;

public:

MediaPlayer( constchar*path, JNICallbackHelper *helper);

~MediaPlayer;

voiddoOpenFile;

voidstart;

voidrelease;

# endif//HELLONDK_MEDIAPLAYER_H

MediaPlayer.cpp

# include"MediaPlayer.h"

MediaPlayer::MediaPlayer( constchar*path, JNICallbackHelper *helper) {

// 注意点:参数 path 指向的空间被回收会造成悬空指针Huobi Global,应复制一份

// this->path = path;

this->path = newchar[ strlen(path) + 1];

strcpy( this->path, path);

this->helper = helper;

MediaPlayer::~MediaPlayer {

if(path) {

deletepath;

if(helper) {

deletehelper;

// 在子线程执行

voidMediaPlayer::doOpenFile {

// 省略真实播放逻辑...

// 媒体文件打开成功

helper->onStarted;

// 在子线程执行

void* task_open( void*args) {

// args 是 主线程 MediaPlayer 的实例的 this变量

auto*player = static_cast<MediaPlayer *>(args);

player->doOpenFile;

returnnullptr;

voidMediaPlayer::start {

// 切换到子线程执行

pthread_create(&pid_start, 0, task_open, this);

voidMediaPlayer::release {

9

总结

到这里,JNI 的知识就讲完了,你可以按照学习路线图来看Huobi Global。下一篇,我们开始讲 Android NDK 开发。关注我,带你建立核心竞争力,我们下次见。

参考资料

《JNI 编程指南》

JNI 提示—— Android 官方文档

Java 原生接口规范 —— Java 官方文档

深入理解 Android:卷 1(第 2 章 · 深入理解 JNI)—— 邓凡平 著

深入理解 Android:Java 虚拟机 ART(第 11 章 · ART 中的 JNI)—— 邓凡平 著

Android 应用安全防护和逆向分析(基础篇) —— 姜维 著

Java 性能权威指南:第 2 版(第 12.5 节:Java 原生接口)—— [美]Scott Oaks 著

Android 对 so 体积优化的探索与实践 —— 洪凯 常强(美团技术团队)著

最后推荐一下我做的网站Huobi Global,玩Android: wanandroid.com,包含详尽的知识体系、好用的工具,还有本公众号文章合集,欢迎体验和收藏!

Android软键盘弹起交互的几种方案Huobi Global,如何做效果最丝滑?

裸辞-闭关-复习-大厂offer(二)

Android阴影实现的几种方案

点击关注Huobi Global我的公众号

如果你想要跟大家分享你的文章Huobi Global,欢迎投稿~

┏(^0^)┛明天见Huobi Global

评论