Jetpack Compose添加Native支持

文章主要介绍了如何给 Jetpack Compose 添加 Native 支持,包括在新项目和现有项目中添加的方法,如创建 C++项目、添加 JNI 支持的步骤、JNI 注册方式、线程处理、对象连接、库添加、版本指定、内存管理以及使用 JNI 的正确姿势和能做的事等。

想要在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。

cmake_minimum_required(VERSION 3.22.1)

project("effectivejni")

add_library(${CMAKE_PROJECT_NAME} SHARED
        # List C/C++ source files with relative paths to this CMakeLists.txt.
        LocalJNI.cpp)
target_link_libraries(${CMAKE_PROJECT_NAME}
        # List libraries link to the target library
        android
        log)

CMake是一个跨平台的C/C++编译系统,可以参考 其官文档了解详细信息。

Step 3 在Gradle脚本中添加native build关联

在android块中加入externalNativeBuild:

android {
    // ...
    externalNativeBuild {
        cmake {
            path("src/main/cpp/CMakeLists.txt")
            version = "3.22.1"
        }
    }
}

Step 4 添加带有native方法的类

这一步要特别注意,因为JNI是Java Native Interface,所以必须要严格符合Java的方式,native方法的声明必须是某个类的方法;另外,JNI调用Java时也必须先找到某个类,然后再调用它的方法。所以必须 要有一个Java的public类:

package net.toughcoder.effectivejni

class LocalJNI {

    external fun stringFromLocal(): String

    companion object {
        init {
            System.loadLibrary("effectivejni")
        }
    }
}

当然,这个类可以放在任何文件中。因为Kotlin放宽了Java的限制,在Java中每一个public的类必须要放在一个名字一样的文件中,但Kotlin的文件与类没有对应的关系,所以可以把这个类放在任何文件中,当然了package要指明,因为在JNI中查找class时,要指定package name。

Step 5 实现native方法

具体native方法的实现就看具体要做什么了。这里只是演示所以简单返回一个字符串。

#include <jni.h>
#include <string>

extern "C" JNIEXPORT jstring JNICALL
Java_net_toughcoder_effectivejni_LocalJNI_stringFromJNI(
        JNIEnv* env,
        jobject /* this */) {
    std::string hello = "Hello from C++ with static mapping";
    return env->NewStringUTF(hello.c_str());
}

注意: 虽然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方法就注册好了。

#include <jni.h>
#include <string>

// Method declaration
jstring dynamicString(JNIEnv *env, jobject thiz);

// JNI wrapper
const char className[] = "net/toughcoder/effectivejni/LocalJNI";

const JNINativeMethod methods[] = {
        {"stringFromJNI", "()Ljava/lang/String;", reinterpret_cast<void *>(dynamicString)}
};

JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved) {
    JNIEnv *env;
    if (vm->GetEnv(reinterpret_cast<void **>(&env), JNI_VERSION_1_6) != JNI_OK) {
        return JNI_ERR;
    }
    jclass clazz = env->FindClass(className);
    env->RegisterNatives(clazz, methods, (int) (sizeof(methods) / sizeof(methods[0])));

    return JNI_VERSION_1_6;
}

// The implementation
jstring dynamicString(JNIEnv *env, jobject thiz) {
    std::string str = "String from JNI via dynamic mapping.";
    return env->NewStringUTF(str.c_str());
}

这个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版本:

android {
	ndkVersion = "28.0.12433566"
}

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++的版本:

set(CMAKE_ANDROID_STL_TYPE "c++_shared")
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -std=c99")
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -std=c++20")

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的正确理由:

  1. 做一些Java层无法做到的事情,比如一些底层的系统调用(System calls),Java层做不到,那自然得用C/C++
  2. 使用一些现有的C/C++代码,这个是最为正统的理由
  3. 基于安全角度考量,把一些关键的实现放在C/C++层,这个也合理,因为C/C++相较于Java字节码要略难破解一些
  4. 基于跨平台角度考虑,把一些与平台关联密切的,且独立的模块用C/C++实现,比如像通信协议,或者加密,或者压缩之类的非常独立的功能模块,用C/C++来实现,屏蔽名个平台的不同,这会让Java层更加的简单

除此之外,似乎没有理由使用JNI。另外,在使用的时候也要注意尽可能的单进单出,也就是说从Java层调用native方法,进去后一直在native运算,得到结果后返回给Java。而不应该频繁的有交互,比如说Java层调用进了native方法,但在native中又频繁 的调用Java层的方法。这明显是设计不合理,应该在Java层把需要的数据准备齐全后,再调用native层。

阅读全文
下载说明:
1、本站所有资源均从互联网上收集整理而来,仅供学习交流之用,因此不包含技术服务请大家谅解!
2、本站不提供任何实质性的付费和支付资源,所有需要积分下载的资源均为网站运营赞助费用或者线下劳务费用!
3、本站所有资源仅用于学习及研究使用,您必须在下载后的24小时内删除所下载资源,切勿用于商业用途,否则由此引发的法律纠纷及连带责任本站和发布者概不承担!
4、本站站内提供的所有可下载资源,本站保证未做任何负面改动(不包含修复bug和完善功能等正面优化或二次开发),但本站不保证资源的准确性、安全性和完整性,用户下载后自行斟酌,我们以交流学习为目的,并不是所有的源码都100%无错或无bug!如有链接无法下载、失效或广告,请联系客服处理!
5、本站资源除标明原创外均来自网络整理,版权归原作者或本站特约原创作者所有,如侵犯到您的合法权益,请立即告知本站,本站将及时予与删除并致以最深的歉意!
6、如果您也有好的资源或教程,您可以投稿发布,成功分享后有站币奖励和额外收入!
7、如果您喜欢该资源,请支持官方正版资源,以得到更好的正版服务!
8、请您认真阅读上述内容,注册本站用户或下载本站资源即您同意上述内容!
原文链接:https://www.dandroid.cn/archives/22776,转载请注明出处。
0

评论0

显示验证码
没有账号?注册  忘记密码?