文档章节

Android:JNI与NDK(一)

o
 osc_gu9d45li
发布于 2019/04/08 10:04
字数 4437
阅读 21
收藏 0

精选30+云产品,助力企业轻松上云!>>>

友情提示:欢迎关注本人公众号,那里有更好的阅读体验以及第一时间获取最新文章

 

本篇目录


以下举例代码均来自:NDK示例代码

一、前言

安卓开发中很多场景需要用到NDK来开发,比如,音视频的渲染,图像的底层绘制,秘籍计算应用,复用C/C++库等等,安卓绝大部分核心代码都是在Native层来完成,也就是用C/C++来完成,有的时候我们看系统源码的时候追着追着就发现最终调用一个native声明的方法,接下来就需要深入native层来查看具体逻辑了,那java代码是怎么调用native层代码的呢?或者说java是怎么调用C/C++代码的呢?这里就用到JNI/NDK方面技术了,本系列不会细讲C/C++语言知识,语言方面需要你自己私下学习,如果你想深入NDK层学习,那么请务必先学习一下C/C++语言知识,起码能看得懂啊,学习的时候可以尝试用C/C++来刷LeetCode,防止不用慢慢就忘记了,好了,接下来我们进入本篇正题。

二、什么是JNI/NDK

JNI

JNI是java的特性,与安卓无关,用来增强java与本地代码交互的能力,JNI是Java的一个框架,定义了一系列方法可以用于Java与C/C++互相调用。

NDK

NDK是安卓平台的开发工具包,是安卓的特性,与java无关,用来快速开发生成C、 C++的动态库,通过 NDK我们可以在 Android中将C/C++代码编译到原生库中,然后使用 IDE 集成构建系统 Gradle 将您的库封装入 APK。

JNI是Java特性,在window平台可以用java的JNI特性来完成java与C/C++互相调用,linux平台也可以,NDK是安卓平台的开发工具包,在安卓开发的时候我们可以通过Java的JNI特性来完成java与C/C++互相调用,但是C/C++代码怎么编译到原生库中呢?这时就用安卓平台提供的NDK开发工具了。

接下来我们就来看一下具体实现Java与C/C++互调。

三、Java与C/C++互调

AS配置NDK环境在3.0以上已经十分简单了,环境的配置请自行查阅搭建,这里我们直接讲解Java与C/C++互调知识。

JNI数据类型

JNI数据类型与Java数据类型对应如下:

Java类型 本地类型
boolean jboolean
byte jbyte
char jchar
short jshort
int jint
long jlong
float jfloat
double jdouble
Object jobject
Class jclass
String jstring
Object[] jobjectArray
boolean[] jbooleanArray
byte[] jbyteArray
char[] jcharArray
short[] jshortArray
int[] jintArray
long[] jlongArray
float[] jfloatArray
double[] jdoubleArray

这些对应关系什么意思呢?接下来通过具体实例了解一下:

创建新项目,我们在MainActivity中声明如下native方法:

1 native int arrayTest(int i,int[] a1, String[] a2);

意思是这个方法需要native层来实现,java调用的时候会传递三个参数,分别是:int ,int[] , String[] 类型的,接下来我们需要在native层来实现这个方法,AS中通过快捷键"alt+/"会自动帮助我们在native层来实现方法的声明:

1 JNIEXPORT jint JNICALL Java_com_wanglei55_ndk_MainActivity_arrayTest(JNIEnv *env,
2        jobject instance,jint i,jintArray a1_,jobjectArray a2)

方法声明生成规则为:Java_包名_类名_方法名
java中声明的arrayTest方法参数类型分别为int,int[],String[]类型,在JNI中生成的方法声明分别对应jint ,jintArray ,jobjectArray ,这里就用到了上面的数据类型对应表,至于其余参数类型依照上表对应即可。

我们观察JNI中方法声明还发现生成的方法对了一些额外信息:JNIEXPORT ,JNICALL,参数中多了JNIEnv *env, jobject instance这些又都是什么鬼?我们一一解释

JNIEXPORT

在 Windows 中,定义为__declspec(dllexport)。因为Windows编译 dll 动态库规定,如果动态库中的函数要被外部调用,需要在函数声明中添加此标识,表示将该函数导出在外部可以调用。

在 Linux/Unix/Mac os/Android 这种类Unix系统中,定义为__attribute__ ((visibility ("default")))

GCC 有个visibility属性, 该属性是说, 启用这个属性:

  1. 当visibility=hidden时

动态库中的函数默认是被隐藏的即 hidden. 除非显示声明为__attribute__((visibility("default"))).

  1. 当visibility=default时

动态库中的函数默认是可见的.除非显示声明为__attribute__((visibility("hidden"))).

JNIEXPORT 主要用于window平台,在安卓平台可不加,去掉即可。

JNICALL:

在类Unix中无定义,在Windows中定义为:_stdcall ,一种函数调用约定

在安卓平台 定义如下:

#define JNICALL 什么也没定义

所以,同JNIEXPORT 一样在安卓平台JNICALL可不加,去掉即可。

jobject instance:

在AS中自动为我们生成的JNI方法声明都会带一个这样的参数,这个instance就代表Java中native方法声明所在的类,比如上面arrayTest方法声明在MainActivity中,这里的instance就表示MainActivity实例。

JNIEnv *env:

JNIEnv 指针可是JNI中非常非常重要的一个概念,代表了JNI的环境,JNI层实现的方法都是通过这个指针来调用,通过JNIEnv 指针我们可以调用JNI层的方法访问Java虚拟机,进而操作Java对象。

JNIEnv 指针只在创建它的线程有效,不能跨线程传递,对于这句话的理解我们会在后面涉及线程的时候会再次提到,这里不懂可以看完全文回来再看一下。

我们看下JNIEnv 是怎么定义的:

jni.h中对JNIEnv定义如下:

1 #if defined(__cplusplus) //c++环境
2 typedef _JNIEnv JNIEnv;//c++环境中JNIEnv为_JNIEnv 
3 typedef _JavaVM JavaVM;
4 #else
5 typedef const struct JNINativeInterface* JNIEnv;//c环境JNIEnv为const struct JNINativeInterface*
6 typedef const struct JNIInvokeInterface* JavaVM;
7 #endif

C++中JNIEnv为_JNIEnv 而 C环境JNIEnv为const struct JNINativeInterface*

我们先看_JNIEnv,定义如下:

1 struct _JNIEnv {
 2
 3    const struct JNINativeInterface* functions;
 4
 5 #if defined(__cplusplus)
 6
 7    jint GetVersion()
 8    { return functions->GetVersion(this); }
 9
10    jclass DefineClass(const char *name, jobject loader, const jbyte* buf,
11        jsize bufLen)
12    { return functions->DefineClass(this, name, loader, buf, bufLen); }
13
14    jclass FindClass(const char* name)
15    { return functions->FindClass(this, name); }
16    。。。。。
17 }

_JNIEnv 是对 const struct JNINativeInterface类型的包装,间接调用了const struct JNINativeInterface 上定义的方法。

我们继续看JNINativeInterface定义:

1 struct JNINativeInterface {
 2    。。。
 3    jint        (*GetVersion)(JNIEnv *);
 4    jclass      (*DefineClass)(JNIEnv*, const char*, jobject, const jbyte*,
 5                        jsize);
 6    jclass      (*FindClass)(JNIEnv*, const char*);
 7    jmethodID   (*FromReflectedMethod)(JNIEnv*, jobject);
 8    jfieldID    (*FromReflectedField)(JNIEnv*, jobject);
 9    /* spec doesn't show jboolean parameter */
10    jobject     (*ToReflectedMethod)(JNIEnv*, jclass, jmethodID, jboolean);
11    。。。
12 }

这里才是接口真正定义的地方,具体的实现在Java虚拟机中。

通过以上分析,我们得出以下结论:

  • C++中JNIEnv *env相当于 struct _JNIEnv *env 调用方法只需如下方式即可间接调用JNINativeInterface 中方法:

1 env-> FindClass(JNIEnv*, const char*)
  • C中JNIEnv *env相当于 JNINativeInterface **env,二级指针,调用方法需要先解引用在调用如下:

1 (*env)-> FindClass(JNIEnv*, const char*)

明白了以上概念后我们可以继续在native层来实现

1 JNIEXPORT jint JNICALL
2 Java_com_wanglei55_ndk_MainActivity_arrayTest(JNIEnv *env,jobject instance,jint i,jintArray a1_,jobjectArray a2)

方法了。

使用Java层传递过来的数据

Java层传递过来的数据可能为基本数据类型,数组,对象等,不同数据类型我们要想使用需要不同的处理方式,具体如下。

基本类型数据

Java层传递过来的基本数据类型无需其余操作,直接使用即可。

数组类型数据

数组分为基本数据类型的数组与对象数据类型的数组,比如,int[]与String[],在Native我们怎么获取数组中的数据呢?如下:

1 JNIEXPORT jint JNICALL
 2 Java_com_wanglei55_ndk_MainActivity_arrayTest(JNIEnv *env,
 3        jobject instance,jint i,jintArray a1_,jobjectArray a2) {
 4    LOGE("i的值为:%d", i);
 5    // 第二个参数:
 6    // true:拷贝一个新数组
 7    // false: 就是使用的java的数组 (地址)
 8    jint *a1 = env->GetIntArrayElements(a1_, 0);//返回指针,指向数组地址
 9    jsize len = env ->GetArrayLength(a1_);//获取数组长度
10    for (int i = 0; i < len; ++i) {
11        LOGE("int数组的值为:%d", *(a1+i));
12        //改变java中数组的值,如果下面参数3 mode设置为2则改变不了
13        *(a1+i) = 666;
14    }
15    // 参数3:mode
16    // 0:  刷新java数组 并 释放c/c++数组
17    // 1 = JNI_COMMIT:只刷新java数组
18    // 2 = JNI_ABORT:只释放
19    env->ReleaseIntArrayElements(a1_, a1, 0);
20    //
21    jsize  slen = env->GetArrayLength(a2);//获取数组长度
22    for (int i = 0; i < slen; ++i) {
23        jstring str = static_cast<jstring>(env->GetObjectArrayElement(a2, i));//获取数组中的数据
24        const char* s = env->GetStringUTFChars(str,0);
25        LOGE("jni获取java字符串数组:%s", s);
26        env->ReleaseStringUTFChars(str, s);
27    }
28    return 3;
29 }

上面展示了native层获取java传递过来的数组数据,这里只是遍历了一下,可以看到核心方法都是通过JNIEnv 指针来调用方法操作的,所以JNIEnv 是十分重要的。

对象类型数据

Java传递过来的对象怎么处理呢?这里需要用到反射了,同样也是通过JNIEnv 指针来调用相应方法的,我们在MainActivity添加如下方法:

1 native void objectTest(Student s, String str);

Student 类如下:

1 public class Student {
 2
 3    private int num = 100;
 4
 5    public int getNum() {
 6        return num;
 7    }
 8
 9    public void setNum(int num) {
10        this.num = num;
11    }
12
13    public static void printMsg(Card card){//调用方法需要传递Card类
14        Log.e("JNI","printMsg Card: "+card.id);
15    }
16
17    public static void printMsg(String str){
18        Log.e("JNI","printMsg: "+str);
19    }
20 }

Card类:

1 public class Card {
2    int id;
3
4    public Card(int id) {
5        this.id = id;
6    }
7 }

都很简单,这里就是演示一下。

接下来我们看下native层怎么获取传递过来的对象数据以及调用其方法,这里我们直接看代码,注释给了详细的说明:

1 extern "C"
 2 JNIEXPORT void JNICALL
 3 Java_com_wanglei55_ndk_MainActivity_objectTest(JNIEnv *env, jobject instance, jobject bean,
 4                                               jstring str_) {
 5    //
 6    const char *str = env->GetStringUTFChars(str_, 0);
 7    LOGE("objectTest: %s",str);
 8    env->ReleaseStringUTFChars(str_, str);
 9    //bean就是java层传递过来的Student对象
10    //反射方式调用bean中的set/get方法
11    jclass beanClass = env->GetObjectClass(bean);//获取class
12    //修改属性值
13    //jfieldID fieldID = env->GetFieldID(beanClass,"num","I");
14    //env->SetIntField(bean,fieldID,444);
15
16    //调用set方法设置
17    jmethodID setMethodID = env->GetMethodID(beanClass,"setNum","(I)V");//获取方法信息
18    env->CallVoidMethod(bean,setMethodID,999);//调用bean中的setMethodID对应的方法
19    //调用get方法获取
20    jmethodID getMethodID = env->GetMethodID(beanClass,"getNum","()I");//获取方法信息
21    jint result = env->CallIntMethod(bean,getMethodID);
22    LOGE("调用Student中getNum返回值: %d",result);
23
24    //调用静态方法:public static void printMsg(String str)
25    jmethodID staticMID = env->GetStaticMethodID(beanClass,"printMsg","(Ljava/lang/String;)V");
26    jstring jstring1 = env->NewStringUTF("JNI中的String");
27    env->CallStaticVoidMethod(beanClass,staticMID,jstring1);
28    env->DeleteLocalRef(jstring1);//释放
29
30    //调用静态方法:public static void printMsg(Card card)
31    jmethodID staticMID2 = env->GetStaticMethodID(beanClass,"printMsg","(Lcom/wanglei55/ndk/Card;)V");
32    //创建参数Card
33    jclass  cardclz = env->FindClass("com/wanglei55/ndk/Card");//通过完整类名获取class
34    jmethodID constructorID = env->GetMethodID(cardclz,"<init>","(I)V");//<init>表示获取构造方法
35    jobject cardObj = env->NewObject(cardclz,constructorID,333);//反射创建Card对象
36    env->CallStaticVoidMethod(beanClass,staticMID2,cardObj);
37    env->DeleteLocalRef(cardObj);
38 }

上面已经给了详细注释,不再说明,这里需要额外说一下方法的签名。

方法签名

调用GetMethodID与GetStaticMethodID的时候我们需要传递方法的签名信息,怎么配置呢?如下有个对应表:

Java类型 签名
boolean Z
short S
float F
byte B
int I
double D
char C
long J
void V
引用类型 L + 全限定名 + ;
数组 [+类型签名

如果有内部类 则用$来分隔 如:Landroid/os/FileUtils$FileStatus;

什么意思呢?

比如以Student类中getNum()方法为例,其定义如下:

1 public int getNum()

方法调用不用传递参数,返回值为int类型,int对应签名为I,大写的啊,所以方法签名为"()I",()里面填写参数对应的签名,()右面紧跟方法返回值签名。

再来个复杂的,比如如下方法:

1 String getInfo(long[], List list);

签名是什么呢?其签名为:

1 "([JLjava/util/List)Ljava/lang/String;"

其中 "[J" 代表long[]的签名,"Ljava/util/List" 代表List list的签名,"Ljava/lang/String;" 代表返回值String的签名。

四、静态注册与动态注册以及JNI_OnLoad方法

静态注册

像上面我们在java层定义native方法:

1 native void objectTest(Student s, String str);

然后在JNI层定义对应方法:

1 JNIEXPORT void JNICALL
2 Java_com_wanglei55_ndk_MainActivity_objectTest(JNIEnv *env, jobject instance, jobject bean,jstring str_) {
3    。。。
4 }

当我们在Java中调用objectTest(Student s, String str)方法时,就会从JNI层寻找Java_com_wanglei55_ndk_MainActivity_objectTest(JNIEnv *env, jobject instance, jobject bean,jstring str_) 方法,并为二者建立联系。 
静态注册就是根据方法名,将Java层native方法和JNI层对应方法建立关联,这种方式就是静态注册,静态注册有如下缺点:

  • JNI层方法名很长

  • 第一次调用native方法会比较耗时,需要查找对应方法建立联系(通过指针记录方法)

    有没有一种方式在加载的时候就建立起二者的联系呢?这样第一次调用native方法的时候就不需要查找了,这种方式就是动态注册

动态注册

动态注册可以在加载的时候就建立起java层native方法与JNI层方法的联系,那具体怎么建立联系呢?加载的时候是指什么时候?

我们在调用动态库so中方法的时候都会先加载对应so库,比如:

1    static {
2        System.loadLibrary("native-lib");
3    }

在加载native-lib动态库的时候JVM会检查对应C/C++文件中是否有int JNI_OnLoad(JavaVM *vm, void *reserved)方法,有的话则会调用这个方法,在这个方法里面我们可以做一些初始化的操作,进而可以动态注册一些方法。

接下来我们具体操作一下看看怎么动态注册:

首先java层同样定义native方法,如下:

1 native  void dynamicJavaTest();
2 native  int dynamicJavaTest2(int i);

接下来在JNI层定义对应方法:

1 void dynamicTest(){
2    LOGE("JNI dynamicTest");
3 }
4
5 jint dynamicTest2(JNIEnv *env, jobject instance,jint i){
6    LOGE("JNI dynamicTest2:%d",i);
7    return 9999;
8 }

这里我并没有把方法名设置为一样,方法名你可以随便起,如果想接收JNIEnv *env, jobject instance参数可以在方法上加上,Jvm调用的时候会传递这两个参数给JNI层方法,不想接收也可以去掉。

java层方法与JNI层怎么建立起关联呢?接下来我们还需要定义JNINativeMethod类型的数组,将两者对应起来,JNINativeMethod定义在jni.h中定义如下:

1 typedef struct {
2    const char* name;//java层的方法名
3    const char* signature;//java层方法的签名
4    void*       fnPtr;//JNI层对应方法的指针
5 } JNINativeMethod;

这里我们将java层dynamicJavaTest方法与JNI层dynamicTest对应
java层dynamicJavaTest2方法与JNI层dynamicTest2对应

所以数组定义如下:

1 static const JNINativeMethod methods[] = {
2        {"dynamicJavaTest","()V",(void*)dynamicTest},
3        {"dynamicJavaTest2","(I)I",(int*)dynamicTest2},
4 };

接下来就可以在JNI_OnLoad方法中动态注册了:

1 static const char *mClassName = "com/wanglei55/ndk/MainActivity";
 2
 3 JavaVM *_vm;//记录JavaVM 
 4
 5 int JNI_OnLoad(JavaVM *vm, void *reserved){
 6    //
 7    LOGE("JNI_Onload");
 8    //
 9    _vm = vm;
10    // 获得JNIEnv
11    JNIEnv *env = 0;
12    // 小于0 失败 ,等于0 成功
13    int r = vm->GetEnv((void**)&env,JNI_VERSION_1_4);
14    if (r != JNI_OK){
15        return -1;
16    }
17    //获得 class对象
18    jclass jcls = env->FindClass(mClassName);
19    //动态注册方法
20    env->RegisterNatives(jcls,methods, sizeof(methods)/ sizeof(JNINativeMethod));
21    return JNI_VERSION_1_4;// 返回native 组件使用的 JNI 版本
22 }

核心就是调用RegisterNatives方法来完成动态注册的逻辑,到此动态注册就完成了,此外动态注册不用定义那么长的方法。

在安卓系统源码中JNI层大量使用了动态注册方法而不是静态注册,静态注册多用于平常NDK的开发。

五、native线程调用Java

native调用java需要用到JNIEnv指针,而JNIEnv是由Jvm传入与线程相关的变量,如果我们在native中开启一个线程完成工作后回调java层方法怎么办呢?可以通过JavaVM的AttachCurrentThread方法来获取到当前线程中JNIEnv指针。

接下来我们看一下怎么操作。

java层定义native方法与回调的方法:

1    public void callBack(){
 2        if (Looper.myLooper() == Looper.getMainLooper()){
 3            Toast.makeText(this,"MainLooper",Toast.LENGTH_SHORT).show();
 4        }else{
 5            runOnUiThread(new Runnable() {
 6                @Override
 7                public void run() {
 8                    Toast.makeText(MainActivity.this,"runOnUiThread",Toast.LENGTH_SHORT).show();
 9                }
10            });
11        }
12    }
13
14    native void testThread();

JNI层采用静态注册的方式注册对应方法:

1 jobject _instance;
 2
 3 void* threadTask(void* args){
 4    // native线程 附加 到 Java 虚拟机
 5    JNIEnv *env;//JNIEnv *是与线程有关的
 6    //调用JavaVM 的AttachCurrentThread方法来获取与线程有关的JNIEnv
 7    jint i = _vm->AttachCurrentThread(&env,0);//JNI_OnLoad会传递过来JavaVM *vm参数
 8    if (i != JNI_OK){
 9        return nullptr;
10    }
11    //回调
12    //获得MainActivity的class对象
13    jclass cls = env->GetObjectClass(_instance);
14    jmethodID  updateUI = env->GetMethodID(cls,"callBack","()V");
15    env->CallVoidMethod(_instance,updateUI);
16    //释放内存
17    env->DeleteGlobalRef(_instance);
18    //退出线程,释放线程资源
19    _vm->DetachCurrentThread();
20    return 0;
21}
22
23 extern "C"
24 JNIEXPORT void JNICALL
25 Java_com_wanglei55_ndk_MainActivity_testThread(JNIEnv *env, jobject instance) {
26
27    pthread_t pid;
28    //启动线程
29    _instance = env->NewGlobalRef(instance);
30    pthread_create(&pid,0,threadTask,0);//记得引入头文件 #include <pthread.h>
31}

native线程中使用JNIEnv一定要记得获取当前线程的JNIEnv,因为不同线程的JNIEnv是不同的,同时使用完记得调用DetachCurrentThread()方法释放线程资源。

六、总结

本篇算是NDK开发的入门篇,介绍了一些基础的操作,一定要记住,如果想深入NDK层先把C/C++语言基础打好,否则上面代码看起来很蒙圈,后续文章读起来也很难受。

o
粉丝 0
博文 500
码字总数 0
作品 0
私信 提问
加载中
请先登录后再评论。
Android—JNI调用简单实例解析

转自:http://www.cnblogs.com/sevenyuan/p/4202759.html 感谢原作者的细心整理! 1. 在Eclipse中创建项目:TestJNI 2. 新创建一个class:TestJNI.java package com.wwj.jni; public class ......

80后小子
2015/11/12
7.3K
0
skywang的博客目录(持续更新中...)

Java 知识 知识点 01. Java 随机数 02, Java hashCode() 和 equals()的若干问题解答 03, Java 中 Comparable 和 Comparator 比较 04. Java Annotation认知(包括框架图、详细介绍、示例说明)...

skywang12345
2013/06/14
0
0
android NDK 二、编译方法

1、NDK 一中已经提到,使用eclipse中的配置 ,自动编译 2、手动编译(推荐) 打开bash.exe(即启动cygwin) 使用cd $NDK 进入/cygdrive/e/android-ndk-r5/ 用cd进入到对应的目录; 进入对应的...

大凉龙雀
2015/05/16
149
1
ndk编译opencl出现问题,大家来讨论一下

D:/utils/android-ndk-r9d/toolchains/x86-4.6/prebuilt/windows-x86_64/bin/../lib/gcc/i686-linux-android/4.6/../../../../i686-linux-android/bin/ld.exe: warning: skipping incompatib......

冰冻流星
2016/08/10
734
0
Android: NDK编程入门笔记

为何要用到NDK? 概括来说主要分为以下几种情况: 1. 代码的保护,由于apk的java层代码很容易被反编译,而C/C++库反汇难度较大。 2. 在NDK中调用第三方C/C++库,因为大部分的开源库都是用C/C...

gongweixin
2013/04/23
528
3

没有更多内容

加载失败,请刷新页面

加载更多

macz技巧分享—macOS高端使用技巧

Macos 的占有量不如 Windows,两者之间当操作方式也有很大的不同,当很多人熱悉 Windows 的操作之后,再接触 macos,觉得难上手,其实是习惯问题。如果你学习一些技巧,会觉得 macos 其实也不...

mac小叮当
45分钟前
11
0
手把手教你如何用黑白显示器显示彩色!

来源:大数据文摘 本文约1000字,建议阅读6分钟。 本文为你介绍如何通过黑白显示器上也能显示出彩色。 原来在黑白显示器上也能显示出彩色啊!通过在监视器上覆盖拜耳滤色镜,并拼接彩色图像,...

osc_jklrr90y
45分钟前
18
0
key-value结构排序:给定一个字符串,统计每个字符出现频率,先按value降序,再按key升序

对于key-value结构的排序 第一种:lambda表达式 第二种:函数 第三种:类对()的重载,仿函数形式 #include <iostream>#include <vector>#include <unordered_map>#include <string>#in......

osc_gwtkg2dc
46分钟前
0
0
BlockChain:2020年7月10日世界人工智能大会WAIC《链智未来 赋能产业区块链主题论坛——2020全球区块链创新50强》

BlockChain:2020年7月10日世界人工智能大会WAIC《链智未来 赋能产业区块链主题论坛——2020全球区块链创新50强》 目录 世界人工智能大会WAIC《链智未来 赋能产业区块链主题论坛——2020全球...

osc_vew1u0h0
47分钟前
0
0
BlockChain:2020年7月10日世界人工智能大会WAIC《链智未来 赋能产业区块链主题论坛》(三)

BlockChain:2020年7月10日世界人工智能大会WAIC《链智未来 赋能产业区块链主题论坛》(三) 目录 2020年7月10日世界人工智能大会WAIC《链智未来 赋能产业区块链主题论坛》 演讲嘉宾 演讲内容 ...

osc_8o71811p
48分钟前
0
0

没有更多内容

加载失败,请刷新页面

加载更多

返回顶部
顶部