Java通过native关键字调用c语言代码

1 - Java native关键字

尽管使用Java代码编程已经比较方便,但是在某些特定情况下,比如我们希望程序能够直接与底层进行交互,或者是进一步提升程序的性能,又或是对于系统的一些功能JVM并没有进行封装,那么这个关键字的作用就来了

一个native方法能够让Java去调用非Java代码书写的接口,JVM将这一部分又封装,让我们在使用Java代码的时候不用去关注底层实现的细节转而更多的关注Java本身代码逻辑的书写。
参考文章:
Java安全详谈-JNI 底层分析
深入理解JNI

2 - native方法的注册方式

native方法的注册方式主要是静态方式动态方式两种,前者会将Java代码中书写的函数具体的实现(位于DLL源文件当中)与函数声明(位于基于Java代码生成的头文件中)进行绑定,从而在Java代码调用native方法的时候,JVM能够具体地找到对应的方法实现。

相较于静态方式进行注册,动态方式的注册则具有更高的灵活度,动态方式不需要显式地将函数声明和实现绑定,其对应的函数命名在DLL源文件中可以是任意的,更加灵活,并且拥有更高的查找效率,两种方式都是通过Java System类提供的loadLibrary方法将项目中的动态库文件加载到JVM中以供调用,下面分别介绍两种注册方式

JDK版本:jdk-17.0.5
开发工具:Intellij Idea, Visual Studio 2022

2.1 - 静态方式的注册

2.1.1 - 头文件生成

通过Java代码调用c语言实现的求和计算来演示原生方法静态方式的注册过程。项目结构:
静态方式注册原生方法项目结构
在测试类当中书写原生方法,使用静态代码块的形式,在类被加载的时候将对应的动态链接库加载进入虚拟机当中,在主方法中测试原生方法是否生效:

package club.pineclone.jni.demo;

/**
 * 静态方式注册native方法
 */
public class IntSum {

    //原生方法声明
    private native int sum(int a, int b);

    //将含有原生方法实现的DLL文件加载到Java虚拟机
    static {
        System.loadLibrary("DLL_NAME");
    }

    //在这里测试原生方法
    public static void main(String[] args) {
        System.out.println(new IntSum().sum(5, 6));
    }

}

注意loadLibrary里面的参数,后面要回来改的
可以使用Java bin目录中的javac命令,来生成Java类中原生方法对应的头文件(曾经是javah,高版本jdk改用javac),可以打开idea的控制台进入src/main/java目录下,然后键入如下命令:

javac -h . club\pineclone\jni\demo\IntSum.java

如果因为中文乱码报错的可以用下面这一条:

javac -encoding UTF-8 -h . club\pineclone\jni\demo\IntSum.java

输入代码后的结果执行完毕之后在当前目录下就可以看到根据类中的原生方法生成的c头文件了
头文件在头文件中可以看到java中的原生方法在c语言头文件中的声明,头文件中有几个点需要稍加注意:

  • JNIEXPORT jint JNICALL Java_club_pineclone_jni_demo_IntSum_sum(JNIEnv *, jobject, jint, jint)
    生成c头文件之后对应方法的函数名遵循JNI的命名规范,即Java_<PackageName>_<ClassName>_<MethodName>,如果方法名本身就带有下划线,那么将会以_数字的形式进行替换,相较于原先方法的声明,c头文件中的声明多了JNIEnv指针以及jobject两个参数,这两个参数在方法被调用的时候由JVM传入,JNIEXPORT以及JNICALL两个宏定义在后面会介绍到。
  • #include <jni.h>
    引入名为jni.h的头文件,这个头文件中声明了所有JNI相关的接口规范,位于$JAVA_HOME/include目录下,其中包含了JNI所有的宏定义,结构体以及函数定义
  • extern “C”
    这一句主要是告诉编译器按照C的方式去编译C函数,使用C语言的规则进行编译和连接,而不是C++。由于C和C++在函数编译上的差异,前者不支持函数重载,在编译前后函数名不变,而C++在编译之后,无论函数是否重名,编译之后函数会被命名为函数名+参数类型的特殊命名格式,
    使用如此编译产生的DLL文件在项目中执行时会导致抛出Exception in thread "main" java.lang.UnsatisfiedLinkError异常
    因此采用C的方式进行编译连接。

2.1.2 - 为头文件书写对应的实现

对应原生方法的c头文件已经创建完成了,下一步就是为头文件中的方法填充对应的实现了,使用Visual Studio创建一个DLL项目:
在这里插入图片描述
项目框架:
在这里插入图片描述在头文件中新建项DLL_JNI_01.h
在这里插入图片描述
将上面生成的头文件中的内容复制进去即可:
在这里插入图片描述项目没有检测到引入的头文件,#include <jni.h>报红了,需要手动给项目引入一下JNI开发的目录:
右击项目进入属性 -> C/C++ -> 常规找到包含附加目录一栏,手动添加$JAVA_HOME/include以及%JAVA_HOME/include/system32两个目录
在这里插入图片描述

在这里插入图片描述
添加完成之后可以看到报红已经正常了
在这里插入图片描述头文件对方法声明的注释可以留意一下:

	/*
	 * Class:     club_pineclone_jni_demo_IntSum
	 * Method:    sum
	 * Signature: (II)I
	 */

从上到下依次是:Java层完整类名Java层方法名以及Java层方法的签名

$JAVA_HOME/include/win32目录中我们可以看到名为jni_md.h,其中就包括了JNIEXPORT,JNICALL,JNIIMPORT的宏定义:

#define JNIEXPORT __declspec(dllexport)
#define JNIIMPORT __declspec(dllimport)
#define JNICALL __stdcall

上面是windows操作系统下JDK中对JNIEXPORTJNIIMPORTJNICALL三者的宏定义,需要注意的是Linux操作系统下的JDK对三者的宏定义有一些差别,下面是Linux JDK中的宏定义:

#define JNIIMPORT
#define JNIEXPORT  __attribute__ ((visibility ("default")))
#define JNICALL
  • JNIEXPORT和JNIIMPORT的作用
    对于所生成的动态链接库中的方法,JNIEXPORT负责将这个方法导出,这种情况下函数对于外部调用DLL的程序而言是可见的,从而可以被成功调用,JNIEXPORT的作用类似于Java当中的public,如果一个方法不被JNIEXPORT修饰,对于外部程序而言它就是不可见的。在Windows操作系统下,它被定义成__declspec(dllexport),而在Linux操作系统中它被定义为__attribute__((visibility("default")))
  • JNICALL的作用
    在Windows中JNICALL被宏定义为__stdcall,是一种函数调用的约定,由于在Windows当中,函数调用时参数以的形式保存,栈是一种后进先出的结构,__stdcall规定参数从右到左进行保存,从而维持传入参数的顺序正确。而在Linux当中似乎没有这个特性,因此Linux中的JDK的JNICALL是置空的,在Linux当中是否将函数修饰为JNICALL没有区别

创建好头文件之后,我们就可以为头文件书写对应的实现了,一个DLL动态库项目中已经包含了一个cpp源文件,可以将上面生成的函数声明复制到源文件当中书写其实现:

// dllmain.cpp : 定义 DLL 应用程序的入口点。
#include "pch.h"
#include "framework.h"

//注意引入一下头文件
#include "DLL_JNI_01.h"

//书写对应的实现
JNIEXPORT jint JNICALL Java_club_pineclone_jni_demo_IntSum_sum(JNIEnv* env, jobject obj, jint a, jint b) {
    return a + b;
};

BOOL APIENTRY DllMain( HMODULE hModule,
                       DWORD  ul_reason_for_call,
                       LPVOID lpReserved
                     )
{
    switch (ul_reason_for_call)
    {
    case DLL_PROCESS_ATTACH:
    case DLL_THREAD_ATTACH:
    case DLL_THREAD_DETACH:
    case DLL_PROCESS_DETACH:
        break;
    }
    return TRUE;
}

2.1.3 - 生成动态库DLL文件

找到生成 -> 生成解决方案即可在项目当中生成DLL文件
在这里插入图片描述来到DLL-JNI-01/x64/Debug下可以看到DLL文件
在这里插入图片描述把文件复制到前面Idea的项目根目录下即可:
在这里插入图片描述
修改原先的代码,执行main方法就可以看到效果了:
在这里插入图片描述
注意改一下加载的DLL文件名字,控制台成功输出结果:
在这里插入图片描述

2.1.4 - JNIEnv*和jobject

回顾上面生成在头文件中的函数,除开我们原先就在方法形式参数表中写上的两个int参数a和b,还多出了一个JNIEnv*jobject,这两个参数在函数被调用的时候由虚拟机传入,两者的实现可以在头文件引入的#include <jni.h>中找到,实际上jni.h包含了JNI编程需要的所有宏,结构体以及函数定义:

/*
 * JNI Native Method Interface.
 */

struct JNINativeInterface_;

struct JNIEnv_;

#ifdef __cplusplus
typedef JNIEnv_ JNIEnv;
#else
typedef const struct JNINativeInterface_ *JNIEnv;
#endif

在C++环境下,JNIEnv被定义成结构体JNIEnv_,而在C环境下,JNIEnv则被定义成JNINativeInterface*,这里我们关注C++中的实现,找到结构体JNIEnv_的定义:

/*
 * We use inlined functions for C++ so that programmers can write:
 *
 *    env->FindClass("java/lang/String")
 *
 * in C++ rather than:
 *
 *    (*env)->FindClass(env, "java/lang/String")
 *
 * in C.
 */

struct JNIEnv_ {
    const struct JNINativeInterface_ *functions;
#ifdef __cplusplus

    jint GetVersion() {
        return functions->GetVersion(this);
    }
    jclass DefineClass(const char *name, jobject loader, const jbyte *buf,
                       jsize len) {
        return functions->DefineClass(this, name, loader, buf, len);
    }
    jclass FindClass(const char *name) {
        return functions->FindClass(this, name);
	......

JNIEnv_结构体中的functions是一个JNI提供的函数表,囊括了JVM提供的能力,供C++代码进行调用从而实现JAVA代码的功能,通过JNINativeInterface函数表,C++代码也可以做到例如创建JAVA对象,调用JAVA方法,获取VM指针等等

函数表存在的作用就是在原生代码执行的时候,C++能够对Java代码进行操作,函数表为JVM和C/C++之间搭建起了沟通的桥梁,使原生方法能够顺利执行,JNINativeInterface结构体的定义在jni.h中也可以找到:

struct JNINativeInterface_ {
    void *reserved0;
    void *reserved1;
    void *reserved2;

    void *reserved3;
    jint (JNICALL *GetVersion)(JNIEnv *env);

    jclass (JNICALL *DefineClass)
      (JNIEnv *env, const char *name, jobject loader, const jbyte *buf,
       jsize len);
    jclass (JNICALL *FindClass)
      (JNIEnv *env, const char *name);

    jmethodID (JNICALL *FromReflectedMethod)
      (JNIEnv *env, jobject method);
    jfieldID (JNICALL *FromReflectedField)
      (JNIEnv *env, jobject field);

    jobject (JNICALL *ToReflectedMethod)
      (JNIEnv *env, jclass cls, jmethodID methodID, jboolean isStatic);
      ......

函数形式参数表中的另一个参数jobect则是调用函数对应的,Java方法的对象本身,

2.1.5 - Java,JNI,C/C++中的数据类型映射

Java,JNI,C/C++各自保持着自己拥有的数据类型,Java代码中的一个数据类型到原生方法执行的时候一共通过两次映射,第一次是由Java到JNI的映射,第二次是JNI到C/C++的映射,JNI会接受Java传入的参数,并且JNI保持自己使用的一套数据类型,这是为独立性考虑的,JNI的数据类型不管是在Windows还是Linux平台上都是一致的,从而适配发生变动的C/C++中的数据类型。

JNI对基础数据类型的定义:

// jni_md.h
// 'long' is always 32 bit on windows so this matches what jdk expects
typedef long jint;
typedef __int64 jlong;
typedef signed char jbyte;
// jni.h
typedef unsigned char   jboolean;
typedef unsigned short  jchar;
typedef short           jshort;
typedef float           jfloat;
typedef double          jdouble;

typedef jint            jsize;

包括Java中的数组和对象,JNI针对C和C++环境也为它们进行了适配:

// jni.h
// c++
#ifdef __cplusplus

class _jobject {};
class _jclass : public _jobject {};
class _jthrowable : public _jobject {};
class _jstring : public _jobject {};
class _jarray : public _jobject {};
class _jbooleanArray : public _jarray {};
class _jbyteArray : public _jarray {};
class _jcharArray : public _jarray {};
class _jshortArray : public _jarray {};
class _jintArray : public _jarray {};
class _jlongArray : public _jarray {};
class _jfloatArray : public _jarray {};
class _jdoubleArray : public _jarray {};
class _jobjectArray : public _jarray {};

typedef _jobject *jobject;
typedef _jclass *jclass;
typedef _jthrowable *jthrowable;
typedef _jstring *jstring;
typedef _jarray *jarray;
typedef _jbooleanArray *jbooleanArray;
typedef _jbyteArray *jbyteArray;
typedef _jcharArray *jcharArray;
typedef _jshortArray *jshortArray;
typedef _jintArray *jintArray;
typedef _jlongArray *jlongArray;
typedef _jfloatArray *jfloatArray;
typedef _jdoubleArray *jdoubleArray;
typedef _jobjectArray *jobjectArray;

// c
#else

struct _jobject;

typedef struct _jobject *jobject;
typedef jobject jclass;
typedef jobject jthrowable;
typedef jobject jstring;
typedef jobject jarray;
typedef jarray jbooleanArray;
typedef jarray jbyteArray;
typedef jarray jcharArray;
typedef jarray jshortArray;
typedef jarray jintArray;
typedef jarray jlongArray;
typedef jarray jfloatArray;
typedef jarray jdoubleArray;
typedef jarray jobjectArray;

#endif

有意思的是JNI在C++的环境下也保持了原先的继承体系,就像是Java当中所有类都继承自Object类,不过如果C的编译器进行的编译没有对象集成的概念的话,那么所有类型都都被定义为jobject

  • 基本数据类型关系表:
Java类型JNI类型C/C++类型本地类型
intjsize/jintintint32_t
shortjshortshortint16_t
longjlonglong/__int64int64_t
bytejbytesigned charchar
booleanjbooleanunsigned charuint8_t
charjcharunsigned shortuint16_t
floatjfloatfloatfloat
doublejdoubledoubledouble
  • 引用数据类型关系表
Java类型JNI类型C类型C++类型
类实例化对象jobject_jobject*_jobject*
jclass_jobject*_jclass*
java.lang.Stringjstring_jobject*_jstring*
java.lang.Throwablejthrowable_jobject*_jthrowable*
int[]jintArray_jobject*_jintArray*

当一个原生方法被调用之后,Java当中的参数会按照Java -> JNI -> C/C++的映射顺序进行转换

2.1.6 - 生成头文件中的方法签名

回看生成头文件时函数的注释部分:

	/*
	 * Class:     club_pineclone_jni_demo_IntSum
	 * Method:    sum
	 * Signature: (II)I
	 */

前面两项都比较好理解,那么所谓方法的签名(Signature)是什么
在Java中能够被native关键字修饰的部分有字段函数两种情况,签名的生成规则如下:

  1. 字段: 标识字段类型的描述符
  2. 函数: 表示函数结构的描述符,即:(参数描述符) + 返回值类型描述符
  • Java数据类型描述符关系表
Java类型字段描述符(签名)备注
intIint首字母大写
floatFfloat首字母大写
doubleDdouble首字母大写
shortSshort首字母大写
longLlong首字母大写
charCchar首字母大写
byteBbyte首字母大写
booleanZZ(B已经被byte使用)
objectL + /分割完整类名java.lang.String -> Ljava/lang/String
array[ + 描述符类型int[] -> [I
  • Java函数结构描述符关系表
Java函数函数描述符(签名)备注
voidV无返回值类型
Method(每个参数字段描述符)double sum(int a, int b) -> (II)D

2.2 - 动态方式的注册

相较于静态注册,动态注册提供了例如安全性更高原生方法声明简洁等优点,注册动态原生方法的核心步骤在于通过JNIEnv提供的核心方法RegisterNatives进行动态注册,我们不需要再像动态注册那样实现一个例如

JNIEXPORT jint JNICALL Java_club_pineclone_jni_demo_IntSum_sum(JNIEnv* env, jobject obj, jint a, jint b) {
    return a + b;
};

样式的代码,冗长的代码会让我们的代码可读性和可维护性下降,通过动态注册,我们只需要像正常的C/C++编程那样编写一个方法,然后通过JNIEnv的RegisterNatives方法进行动态绑定即可,那么如何进行这种绑定呢,和上面一样,这次采用原生方法实现一个乘法运算来演示动态注册native方法。

2.2.1 - 动静态结合,进行动态注册

club\pineclone\jni\demo下创建第二个类IntMultiple.java,代码结构如下:
在这里插入图片描述

package club.pineclone.jni.demo;

/**
 * 动态方式注册native方法
 */
public class IntMultiple {

    //动态注册核心方法,主要目的是获取JNIEnv对象从而进行原生方法注册
    private static native void registerNatives();

    //原生方法,将来和DLL动态库中的实现进行动态绑定
    private static native int multiple(int a, int b);

    //通过静态代码块的形式,在类被JVM加载的时候进行native的动态绑定
    static {
        System.loadLibrary("DLL-JNI-02");
        registerNatives();
    }

    public static void main(String[] args) {
        System.out.println(multiple(5 , 6));
    }

}

和静态注册一样,进入项目的src\main\java目录下,键入如下命令生成头文件

javac -encoding UTF-8 -h . club\pineclone\jni\demo\IntMultiple.java

观察生成的头文件,包含两个原生方法声明

/* DO NOT EDIT THIS FILE - it is machine generated */
#include <jni.h>
/* Header for class club_pineclone_jni_demo_IntMultiple */

#ifndef _Included_club_pineclone_jni_demo_IntMultiple
#define _Included_club_pineclone_jni_demo_IntMultiple
#ifdef __cplusplus
extern "C" {
#endif
/*
 * Class:     club_pineclone_jni_demo_IntMultiple
 * Method:    registerNatives
 * Signature: ()V
 */
JNIEXPORT void JNICALL Java_club_pineclone_jni_demo_IntMultiple_registerNatives
  (JNIEnv *, jclass);

/*
 * Class:     club_pineclone_jni_demo_IntMultiple
 * Method:    multiple
 * Signature: (II)I
 */
JNIEXPORT jint JNICALL Java_club_pineclone_jni_demo_IntMultiple_multiple
  (JNIEnv *, jclass, jint, jint);

#ifdef __cplusplus
}
#endif
#endif

只需要实现registerNatives方法,multiple方法需要记住注释当中的Method以及Signature两项对应的值即可,它们是将来进行动态绑定的依据。和前面一样,创建一个新的DLL项目,命名DLL-JNI-02,将头文件迁移到项目当中,然后前往dllmain.cpp书写对应的实现,如果项目找不到jni.h的话记得给项目的属性中的C/C++ -> 附加包含目录项手动添加一些$JAVA_HOME/include以及$JAVA_HOME/include/win32,具体的过程在静态native注册中有提及。

在这里插入图片描述复制过来的头文件只需要保留registerNatives函数声明即可,这一个函数采用静态方式进行注册,从而使得其他动态方式注册的native方法得到绑定,有一种化静为动的感觉

来到dllmain.cpp给声明的方法写好实现

// dllmain.cpp : 定义 DLL 应用程序的入口点。
#include "pch.h"
#include "framework.h"
// 引入生成的头文件
#include "DLL_JNI_02.h"

//写一个multiple方法实现以下native,命名任意
jint multiple(JNIEnv* env, jclass cls, jint a, jint b) {
    return a * b;
}

//native方法列表以JNINativeMethod数组的形式编写:
static JNINativeMethod methods[] = {
    //三个参数分别是注释中的Method,Signature以及dllmain.cpp中实现的函数
    {(char*)"multiple", (char*)"(II)I", (void*)multiple},
};

//以静态方式注册registerNatives方法,然后在方法中通过JNIEnv参数进行动态native方法的绑定
JNIEXPORT void JNICALL Java_club_pineclone_jni_demo_IntMultiple_registerNatives(JNIEnv* env, jclass cls) {
    //动态注册时需要cls,动态方法列表,方法个数三个参数
    env->RegisterNatives(cls, methods, sizeof(methods) / sizeof(methods[0]));
}

BOOL APIENTRY DllMain( HMODULE hModule,
                       DWORD  ul_reason_for_call,
                       LPVOID lpReserved
                     )
{
    switch (ul_reason_for_call)
    {
    case DLL_PROCESS_ATTACH:
    case DLL_THREAD_ATTACH:
    case DLL_THREAD_DETACH:
    case DLL_PROCESS_DETACH:
        break;
    }
    return TRUE;
}

写好之后还是一样的生成 -> 生成解决方案,将生成好的DLL文件拷贝到Java项目当中即可,完整项目结构:
在这里插入图片描述
代码执行结果:
在这里插入图片描述

2.2.2 - 重写JNI_OnLoad()函数进行动态native注册

分析上面的动态native方式注册,注册的入口点为一个静态的native方法registerNatives,那么是否可以有一种方法可以直接进行动态注册,而不需要额外添置一个registerNatives方法作为入口点。

加载DLL文件的方法System.loadLibrary(),会调用一个名为JNI_OnLoad()的函数,也就是说这个函数在我们加载动态库的时候是一定会被调用的,把它当作钩子,我们通过重写JNI_OnLoad的形式,在函数当中执行native的动态注册逻辑,也可以完成native注册,相较于上面的registerNatives作动态注册入口点,这种方式要更加方便一些。

通过一个减法运算,来实现这种形式下的native动态注册,club\pineclone\jni\deno创建类IntSub.java,代码如下:

package club.pineclone.jni.demo;

/**
 * 动态方式注册native方法,重写JNI_OnLoad形式
 */
public class IntSub {
    
    //native方法声明
    private static native int sub(int a, int b);
    
    //加载DLL动态库
    static {
        System.loadLibrary("DLL-JNI-02");
    }

    public static void main(String[] args) {
        System.out.println(sub(5 , 6));
    }
    
}

JNI_OnLoad在jni.h中的声明:

/* Defined by native libraries. */
JNIEXPORT jint JNICALL
JNI_OnLoad(JavaVM *vm, void *reserved);

依照前面,生成对应头文件,创建一个DLL项目DLL-JNI-03,将$JAVA_HOME/include$JAVA_HOME/include/win32加入到项目作为依赖项,添加头文件,书写头文件对应的实现如下:

// dllmain.cpp : 定义 DLL 应用程序的入口点。
#include "pch.h"
#include "framework.h"
#include "jni.h"

//native方法实现
jint sub(JNIEnv* env, jclass cls, jint a, jint b) {
    return a - b;
}

static JNINativeMethod methods[] = {
    {(char*)"sub", (char*)"(II)I", (void*)sub},
};

//重写JNI_OnLoad方法
JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void* reserved) {
    JNIEnv* env = NULL;

    //获取JNIEnv
    if (vm -> GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_6) != JNI_OK) {
        return -1;
    }

    //获取Java Native方法注册的class
    jclass jClassName = env->FindClass("club/pineclone/jni/demo/IntSub");
    //动态注册native方法
    jint ret = env->RegisterNatives(jClassName, methods, sizeof(methods) / sizeof(methods[0]));
    return JNI_VERSION_1_6;
}

BOOL APIENTRY DllMain( HMODULE hModule,
                       DWORD  ul_reason_for_call,
                       LPVOID lpReserved
                     )
{
    switch (ul_reason_for_call)
    {
    case DLL_PROCESS_ATTACH:
    case DLL_THREAD_ATTACH:
    case DLL_THREAD_DETACH:
    case DLL_PROCESS_DETACH:
        break;
    }
    return TRUE;
}

在这种方式下的cpp源文件不需要像之前引入生成的头文件,直接引入jni.h,将生成头文件中的方法注释信息写入函数表,然后进行动态注册即可,Java代码中在System.loadLibrary的过程中,就会执行JNI_OnLoad方法,从而执行JNIEnv的RegisterNatives方法将动态native方法进行注册,和上面化静为动实际上是一样的,不过减少了Java中书写静态入口registerNatives这一步。

项目代码:https://github.com/clclFL/JNI-Demo

  • 10
    点赞
  • 17
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值