Jetpack Compose是一个声明式的UI框架,但一个项目不可能光有UI,还需要有业务逻辑。Compose所用的编程语言是Kotlin,一种基于JVM的多范式通用编程语言,尽管非常强大,但因为现代的项目都非常复杂,多语言混合,有些东西用Kotlin没法实现,或者有些现成的C/C++代码可以复用,这时就需要能把native代码集成到Compose项目中去。
想要在Compose中使用native代码是完全可行的,这是因为Compose是基于Kotlin的,而Kotlin本质上是JVM的字节码,也就是运行在虚拟机之上的语言。Java的Native接口,即JNI其实是虚拟机开出的口子,只要能在JVM上运行就可以用JNI,所以标准的Java JNI是完全可以用在Compose里面的。
注意: native代码(原生代码)在不同的语境有不同的意思,它通常指操作系统直接支持的可执行程序。Java(字节码)是运行在虚拟机上的,操作系统被虚拟机给隔离了,对Java是透明的,这时像可以编译为直接在操作系统上运行的代码(如C/C++)称为native代码;假如换个语境,如运行在WebView中的Web前端,则可以直接运行在Android上的或者iOS上的原生SDK代码则称为native代码。
先来看一下如何在Compose项目中添加native支持。
新项目
新的项目在创建项目的时候可以选择C++,无论是Kotlin的类,以及C++的实现,以及配置文件都会有模板。但除了demo以外,一般不会有新建项目的机会,极少项目是从0开始。绝大多数情况都是在现有项目中添加native支持,所以我们重点看看如何在现有项目中添加native支持。
现有项目添加JNI支持
现在的Android Studio已经对JNI有了很好的支持,AGP中也提供了支持,所以可以不用NDK中命令行式的ndk-build了。对于现有项目想添加JNI支持也不麻烦,有两种方式:一种是添加一个native的Module,新建Module时选择native library就可以了,这个Module里面与新建的Native项目是差不多的。这种方式适合于比较独立的一个新的需要native支持的模块,然后此模块再作为主模块的依赖,比较合适的场景是一个独立的功能模块;
第二种方式就是,像新建 的native项目那样,直接添加native支持:
Step 1 添加C/C++源码目录
先在对应的module如app中添加cpp源码目录,要放在与java或者kotlin同级别的目录,如app/src/main/下面,之后所有native层的东西都在app/src/main/cpp下面。
Step 2 设置CMake
在建 好的目录下面添加源码LocalJNI.cpp和编译文件CMakeLists.txt。
1 2 3 4 5 6 7 8 9 10 11 |
|
CMake是一个跨平台的C/C++编译系统,可以参考 其官文档了解详细信息。
Step 3 在Gradle脚本中添加native build关联
在android块中加入externalNativeBuild:
1 2 3 4 5 6 7 8 9 |
|
Step 4 添加带有native方法的类
这一步要特别注意,因为JNI是Java Native Interface,所以必须要严格符合Java的方式,native方法的声明必须是某个类的方法;另外,JNI调用Java时也必须先找到某个类,然后再调用它的方法。所以必须 要有一个Java的public类:
1 2 3 4 5 6 7 8 9 10 11 12 |
|
当然,这个类可以放在任何文件中。因为Kotlin放宽了Java的限制,在Java中每一个public的类必须要放在一个名字一样的文件中,但Kotlin的文件与类没有对应的关系,所以可以把这个类放在任何文件中,当然了package要指明,因为在JNI中查找class时,要指定package name。
Step 5 实现native方法
具体native方法的实现就看具体要做什么了。这里只是演示所以简单返回一个字符串。
1 2 3 4 5 6 7 8 9 10 |
|
注意: 虽然Compose使用的编程语言是Kotlin,但毕竟Kotlin是JVM语言,也与Java可以相互替换。对于JNI来说,Kotlin与Java无区别,所以后面会以Java来统一当作native的另一端。
JNI注册
无论是用C/C++去实现native接口,还是复用现成的native方法,都需要要把native方法与Java层声明的方法进行关联映射,以让JVM能找到此方法的实现,这也即所谓的JNI注册。有两种方式进行JNI注册。
静态方式,其实就是Java默认支持的方式,它要求Native的实现函数是纯C的,要用『extern C』包裹起来,还有就是方法的名字要是Java_包名_类名_方法名,比较严格。前面的示例用的就是静态注册。
动态注册的原理是加载so的时候,当虚拟机在找到so以后,会查找里面一个叫做JNI_OnLoad的函数指针,然后执行此函数。那么,在so的实现中,写一个叫做JNI_OnLoad的函数,在里面手动进行Native方法注册,然后当so被加载时JNI_OnLoad就会被执行,JNI方法就注册好了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
|
这个JNI_OnLoad的方法的参数很有意思是一个JavaVM对象指针,JavaVM对象每个应用进程只有一个,可以认为就是应用的虚拟机。但每个JNI方法都有一个JNIEnv对象指针,它给native方法提供一个JNI上下文,这个则是每个线程都有一个。
推荐使用动态注册方式进行JNI注册,这是因为这种方式更为灵活,不必写繁琐的方法声明,也不必用extern C限制,可以是常规的C++函数。
JNI是一个接口层
JNI是一个口子,可以让Java调用native代码,也能让native代码调用Java代码,调用Java代码就相当于反射。JNI是一个传送门,虽然入口处有一些限制,但深入到native里面就是完全的C和C++世界了,只要是C和C++能实现的事情都可以做。
JNI线程
需要注意的是结构体JavaVM是所有线程共享,它代表着进程所在的虚拟机。但结构体JNIEnv则是代表着栈中的执行环境(因为JNI仅一个方法,而方法必然运行在某个线程之中),每个线程有一个。创建的局部引用也不能跨线程使用。
从JNIEnv获取JavaVM:env->GetJavaVM(&vm)
从JavaVM获得当前JNIENV:vm->AttachCurrentThread(&env, null)
最好都从Java层来管理线程,JNI只是某些方法的实现。
如果JNI的native代码也很复杂需要线程的话,也可以用pthread创建线程,但也应该维持在一定的作用域范围内,不应该再从此线程去调用Java。这样只会制造混乱。
两个世界的对象连接
需要注意JNI是纯C接口,没有对象的概念,入口处的native方法不属于任何C++对象。假如native深入层足够复杂也有一套对象,如何建立起 Java层对象和native对象的连接呢?可以参考Android frameworks的作法,它通常会给Java层的对象有一个整形域变量,用以存放native层对象指针,这样就能建立起来对象与对象的一一对应关系。
添加已编译好的native库
JNI是连接Java层与C/C++层的传送门,除了新写的native代码,也可以直接使用已编译好的C/C++的库,静态库libxxx.a和动态库libxxx.so。
预编译的库通常作为JNI的依赖,当然也可以直接加载,前提是so里面已包含了JNI接口。但需要特别注意的是静态的库.a是无法直接在Java中加载的,也即无法通过System.loadLibrary()来加载native的静态库。因此静态库只能作为依赖,要包一层,写一个Wrapper层编译为so,静态库作为so的依赖,然后把so加载为JNI。
通过CMake中的add_library指令来添加预编译好的库,具体可以 参考其文档。
NDK的版本
在项目的配置gradle文件中可以指定具体的NDK版本:
1 2 3 |
|
NDK的版本可以看官方发布历史,NDK主要是指Android提供的native API(C/C++ API),主要是一些系统提供的能力,如音频视频能力,图形图像能力等,可以看其接口说明文档,以及NDK开发文档。
C/C++的版本指定
C++语言自从其诞生,在Java和新一代编程语言出现后,曾一度长期停滞,在泛型,函数式编程,并发上面落后于其他语言,并被诟病。但从C++11开始,(C++语言的版本以年份的后两位来命名,如C++11是指2011年发布的版本,C++17指2017年发布的,以此类推)这门古老的语言焕然一新,增加了很多新时代编程语言的特性,其后的C++17继续前进,到现在的C++20已经完全是一个现代化的编程语言了,lambda,函数式,泛型和并发都有了非常好的支持,甚至已经超越了老对手Java。因此,C++11以后的版本也称为『现代C++(Modern C++)』。
都4202年了,肯定要用最新的C++20才行啊。CMake使用的是LLVM编译器,而LLVM已经完全支持C++20了,但默认的版本使用的是C++17,想要特别的版本,就需要在CMakefile.txt中进行指定,也即通过添加编译选项来指定C++的版本:
1 2 3 |
|
JNI内存管理
Java层是自动档(自动内存管理),但C/C++是手动档,因此穿过JNI后就需要特别小心内存管理。有一些注意事项:
- Java 层传过来的对象,不需要手动去释放。比如说传过来的数组或者字符串。
- 传回给Java层的对象,也不需要手动释放。比如像上面的例子新创建出来的字符串,尽管使用了New,但不需要管。GC会追踪。而且你也没法释放,创建完对象交给Java层了,不确定Java还在不在使用中呢,你咋delete?
- 只应该管理生命周期全都在native的new出来的对象,和引用。
- 需要特别注意方法运行的上下文,也即JNIEnv,这个东西每个线程有一个,且是不同的。要保证在同一个JNIEnv中管理内存,不同的JNIEnv无法共享创建出来的对象和引用,不能交叉使用,更不能交叉式的释放。
JNI能做什么
JNI是一个接口层,能够让Java进入C/C++世界,调用C/C++的代码,包括现有代码。所以只要编译出来了目标平台(ARM)的so,就可以在JNI中用。
当然了,为了兼容性,使用的so最好用NDK进行编译。
因为Android是Linux内核的,所以,理论上Linux系统调用支持的东西全都能在JNI里面搞。当然,使用native最为正确的体位是使用NDK来实现想要的功能,可以查看NDK的开发文档来明确可以做哪些事情。
使用JNI的正确姿式
JNI虽好,但不要滥用,不能单单以『C/C++语言性能高于Java(JVM)』为理由就去使用JNI。JNI本身是一个口子,单从方法调用角度讲,从Java层调用过来要有历经查询和数据转换,不见得会比Java方法高效到哪里去。而且JNI在线程调度,异常管理和内存管理上面都较Java层相比非常的不方便,那点看起来的性能优势的代价是很大的,所以说能不用JNI就别用。
使用JNI的正确理由:
- 做一些Java层无法做到的事情,比如一些底层的系统调用(System calls),Java层做不到,那自然得用C/C++
- 使用一些现有的C/C++代码,这个是最为正统的理由
- 基于安全角度考量,把一些关键的实现放在C/C++层,这个也合理,因为C/C++相较于Java字节码要略难破解一些
- 基于跨平台角度考虑,把一些与平台关联密切的,且独立的模块用C/C++实现,比如像通信协议,或者加密,或者压缩之类的非常独立的功能模块,用C/C++来实现,屏蔽名个平台的不同,这会让Java层更加的简单
除此之外,似乎没有理由使用JNI。另外,在使用的时候也要注意尽可能的单进单出,也就是说从Java层调用native方法,进去后一直在native运算,得到结果后返回给Java。而不应该频繁的有交互,比如说Java层调用进了native方法,但在native中又频繁 的调用Java层的方法。这明显是设计不合理,应该在Java层把需要的数据准备齐全后,再调用native层。