JNI/NDK入门指南之JNI字符串处理

       JNI/NDK入门指南之JNI字符串处理


Android JNI/NDK入门指南目录

JNI/NDK入门指南之正确姿势了解JNI和NDK
JNI/NDK入门指南之JavaVM和JNIEnv
JNI/NDK入门指南之JNI数据类型,描述符详解
JNI/NDK入门指南之jobject和jclass
JNI/NDK入门指南之javah和javap的使用和集成
JNI/NDK入门指南之Eclipse集成NDK开发环境并使用
JNI/NDK入门指南之JNI动/静态注册全分析
JNI/NDK入门指南之JNI字符串处理
JNI/NDK入门指南之JNI访问数组
JNI/NDK入门指南之C/C++通过JNI访问Java实例属性和类静态属性
JNI/NDK入门指南之C/C++通过JNI访问Java实例方法和类静态方法
JNI/NDK入门指南之JNI异常处理
JNI/NDK入门指南之JNI多线程回调Java方法
JNI/NDK入门指南之正确姿势了解,使用,管理,缓存JNI引用
JNI/NDK入门指南之调用Java构造方法和父类实例方法
JNI/NDK入门指南之C/C++结构体和Java对象转换方式一
JNI/NDK入门指南之C/C++结构体和Java对象转换方式二




引言

  在前面的章节JNI/NDK入门指南之JNI数据类型,描述符详解中我们了解到JNI中定义的基本类型,细心的读者会发现这其中不包括字符串。那么在JNI中要怎么对Java中String类型的数据进行处理呢?让我们带着这个疑问出发,开启本篇的重点讲解对JNI中字符串的处理!




一.初探字符串处理

从前面的篇章我们了解到了JNI中基本数据类型和Java中的基本数据类型是相互呼应并且一一对照的。用一句通俗的话来说其实它们就是一回事,只是在不同环境中被叫的名字不一样而已,就好像你在村里被叫做"狗娃",然而在高大的CBD可能别人叫做某某总,某某工而已。接下来让我们看看JNI中基本数据类型的定义:

#ifdef HAVE_INTTYPES_H
# include <inttypes.h>      /* C99 */
typedef uint8_t         jboolean;       /* unsigned 8 bits */
typedef int8_t          jbyte;          /* signed 8 bits */
typedef uint16_t        jchar;          /* unsigned 16 bits */
typedef int16_t         jshort;         /* signed 16 bits */
typedef int32_t         jint;           /* signed 32 bits */
typedef int64_t         jlong;          /* signed 64 bits */
typedef float           jfloat;         /* 32-bit IEEE 754 */
typedef double          jdouble;        /* 64-bit IEEE 754 */
#else
typedef unsigned char   jboolean;       /* unsigned 8 bits */
typedef signed char     jbyte;          /* signed 8 bits */
typedef unsigned short  jchar;          /* unsigned 16 bits */
typedef short           jshort;         /* signed 16 bits */
typedef int             jint;           /* signed 32 bits */
typedef long long       jlong;          /* signed 64 bits */
typedef float           jfloat;         /* 32-bit IEEE 754 */
typedef double          jdouble;        /* 64-bit IEEE 754 */
#endif

/* "cardinal indices and sizes" */
typedef jint            jsize;

这么看JNI基本类型就很容易理解了,就是对C/C++中的基本类型用 typedef 重新定义了一个新的名字(穿了一个新的马甲而已),这些数据在JNI中是可以被直接访问的。JNI 把 Java 中的所有对象或者对象数组当作一个C指针传递到本地方法中,这个指针指向 JVM 中的内部数据结构(对象用jobject来表示,而对象数组用jobjectArray或者具体是基本类型数组),而内部的数据结构在内存中的存储方式是不可见的。只能从 JNIEnv 指针指向的函数表中选择合适的 JNI 函数来操作JVM 中的数据结构。JNI/NDK入门指南之JNI数据类型,描述符详解中,我们访问 java.lang.String 对应的 JNI 类型 jstring 时,没有像访问基本数据类型一样直接使用,因为它在 Java 是一个引用类型。那么我们在JNI中,怎么操作处理jstring数据呢,我们可以通过JNIEnv结构体提供的函数方法GetStringUTFChars 这样的JNI 函数来访问字符串的内容。



1.1 实例演示

理论知识补充完毕了,那么得来点硬菜了,翠花上酸菜。上实例(这里都以Android工程演示):

Java端

package com.xxx.jni;

public class OperateString {

    public native String helloJni(String str);//Java的Native方法
    
    
    static {
        System.loadLibrary("jni");//加载so库
    }
}
package com.xxx.jni;

import android.app.Activity;
import android.os.Bundle;
import android.util.Log;

public class MainActivity extends Activity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        
        fun();
    }
    private void fun(){
        OperateString mOperateString = new OperateString();
        String str = mOperateString.helloJni("Hello JNI!");
        Log.e("JNI","String from JNI : " + str);
    }
}

JNI端
头文件com_xxx_jni_OperateString.h

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

#ifndef _Included_com_xxx_jni_OperateString
#define _Included_com_xxx_jni_OperateString
#ifdef __cplusplus
extern "C" {
#endif
/*
 * Class:     com_xxx_jni_OperateString
 * Method:    helloJni
 * Signature: (Ljava/lang/String;)Ljava/lang/String;
 */
JNIEXPORT jstring JNICALL Java_com_xxx_jni_OperateString_helloJni
  (JNIEnv *, jobject, jstring);

#ifdef __cplusplus
}
#endif
#endif

头文件对应的实现文件com_xxx_jni_OperateString.cpp

#include "com_xxx_jni_OperateString.h"
#include <stdio.h>
#include <android/log.h>
#include <jni.h>


#define TAG "JNI"
#define LOGE(TAG,...) __android_log_print(ANDROID_LOG_INFO,TAG,__VA_ARGS__)

/*
 * Class:     com_xxx_jni_OperateString
 * Method:    helloJni
 * Signature: (Ljava/lang/String;)Ljava/lang/String;
 */
JNIEXPORT jstring JNICALL Java_com_xxx_jni_OperateString_helloJni
  (JNIEnv * env, jobject object, jstring string)
{
	char const * c_str = NULL;
	jboolean isCopy; // 返回JNI_TRUE表示原字符串的拷贝,返回JNI_FALSE表示返回原字符串的指针
	c_str = env->GetStringUTFChars(string, &isCopy);//从jstring指针中获取数据
	printf("isCopy:%d\n",isCopy);
	if(c_str == NULL)//判断是否获取数据成功
	{
		return NULL;
	}
	LOGE(TAG,"The String from Java : %s\n", c_str);
	env->ReleaseStringUTFChars(string, c_str);
	
	char buff[128] = "Hello Im from JNI!";
	return env->NewStringUTF(buff);
}

Android.mk文件的实现

LOCAL_PATH:= $(call my-dir)  #获取当前目录
include $(CLEAR_VARS)		 #清空环境变量
LOCAL_MODULE_TAGS :=optional
LOCAL_C_INCLUDES := $(KERNEL_HEADERS)
LOCAL_SHARED_LIBRARIES := libcutils liblog	libutils #导入库
LOCAL_LDLIBS := -llog   #引入库

LOCAL_MODULE:= libjni		#so库的名称
LOCAL_SRC_FILES:= com_xxx_jni_OperateString.cpp
LOCAL_PRELINK_MODULE := false
include $(BUILD_SHARED_LIBRARY)

运行演示

λ adb logcat  -s JNI
--------- beginning of main
--------- beginning of system
12-19 14:25:41.322 10596 10596 I JNI     : The String from Java : Hello JNI!
12-19 14:25:41.323 10596 10596 E JNI     : String from JNI : Hello Im from JNI!


1.2 实例解析

通过前面的实例,我们演示了在Java中使用Native方法传入String作为参数和String作为返回值的一个实例。在Java的本地方法对应的JNI函数Java_com_xxx_jni_OperateString_helloJni中接收一个jstring类型的参数string(此类型和Java的java.lang.String相对应),此jstring在jni.h中的定义如下:

#ifdef __cplusplus
/*
 * Reference types, in C++
 */
class _jobject {};
class _jstring : public _jobject {};
class _jstring : public _jobject {};
typedef _jstring*       jstring;
#else /* not __cplusplus */
typedef void*           jobject;
typedef jobject         jstring;

可以看到jstring在C/C++环境中的不同定义,在C环境中是一个指针类型,而在C++中继承于_jobject 类都指向JavaVM内部的特定字符串。和 C 风格的字符串类型 char* 不同,所以在 JNI 中不能通把 jstring 当作普通 C 字符串一样来使用,必须使用合适的 JNI 函数来访问 JVM 内部的字符串数据结构。这里常用的JNI函数是GetStringUTFChars。


1.2.1 GetStringUTFChars访问字符串详解

该函数在jni.h中的原型定义如下:

    /* JNI spec says this returns const jbyte*, but that's inconsistent */
    const char* (*GetStringUTFChars)(JNIEnv*, jstring, jboolean*);//C环境中的定义
    
    const char* GetStringUTFChars(jstring string, jboolean* isCopy)//C++环境中的定义
    { return functions->GetStringUTFChars(this, string, isCopy); }

参数说明

  • this: JNIEnv函数表指针,这个在前面章节有详细介绍过
  • string: jstring类型(Java 传递给本地代码的字符串指针)
  • isCopy: 它的取值可以是JNI_TRUE(值为1)或者为JNI_FALSE(值为0)。如果值为 JNI_TRUE,表示返回 JVM 内部源字符串的一份拷贝,并为新产生的字符串分配内存空间。如果值为 JNI_FALSE,表示返回 JVM 内部源字符串的指
    针,意味着可以通过指针修改源字符串的内容,不推荐这么做,因为这样做就打破了 Java 字符串不能修改的规定。但我们在开发当中,并不关心这个值是多少,通常情况下这个参数填 NULL 即可。

这里需要特别补充一点的是,因为 Java 默认使用 Unicode 编码,而 C/C++ 默认使用 UTF 编码,所以在本地代码中操作字符串的时候,必须使用合适的 JNI 函数把 jstring 转换成 C 风格的字符串。JNI 支持字符串在 Unicode 和 UTF-8 两种编码之间转换,GetStringUTFChars 可以把一个 jstring 指针(指向 JVM 内部的 Unicode 字符序列)转换成一个UTF-8 格式的 C 字符串。在上例中 helloJni函数中我们通过 GetStringUTFChars 正确取得了 JVM 内部的字符串内容。与GetStringUTFChars 相对应的函数是GetStringChars,有兴趣的读者可以自行研究一下它的使用,在后面的章节我会初步的过一下。


1.2.2 异常检查和处理

这里调用完 GetStringUTFChars 之后不要忘记安全检查,因为虚拟机需要为新诞生的字符串分配内存空间,当内存空间不够分配的时候,会导致调用失败,失败后 GetStringUTFChars 会返回 NULL,并抛出一个OutOfMemoyError 异常。JNI 的异常和 Java 中的异常处理流程是不一样的,Java 遇到异常如果没有捕获,程序会立即停止运行。而 JNI 遇到未处理的异常不会改变程序的运行流程,也就是程序会继续往下走,这样后面针对这个字符串的所有操作都是非常危险的,因此,我们需要用 return 语句跳过后面的代码,并立即结束当前方法。


1.2.3 ReleaseStringUTFChars释放字符串详解

该函数在jni.h中的原型定义如下:

    void (*ReleaseStringUTFChars)(JNIEnv*, jstring, const char*);//C中的定义
    
    void ReleaseStringUTFChars(jstring string, const char* utf)//C++中的定义
    { functions->ReleaseStringUTFChars(this, string, utf); }

参数说明

  • this: JNIEnv函数表指针,这个在前面章节有详细介绍过
  • string: 指向一个jstring变量,即是要释放的本地字符串的来源。在当前环境下指向Java中传递过来的String字符串对应的JNI数据类型jstring
  • utf:将要释放的C/C++本地字符串。即我们调用GetStringUTFChars获取的数据的存储指针。

在我们实例代码中在调用 GetStringUTFChars 函数从 虚拟机内部获取一个字符串之后,虚拟机内部会分配一块新的内存,用于存储源字符串的拷贝,以便本地代码访问和修改。即然有内存分配,用完之后马上释放是一个编程的好习惯。通过调用ReleaseStringUTFChars 函数通知 虚拟机这块内存已经不使用了,你可以清除了。注意:这两个函数是配对使用的,用了 GetXXX 就必须调用 ReleaseXXX,而且这两个函数的命名也有规律,除了前面的 Get 和 Release 之外,后面的都一样。


1.2.4 NewStringUTF创建字符串详解

该函数在jni.h中的原型定义如下:

    jstring     (*NewStringUTF)(JNIEnv*, const char*);//C环境中定义

    jstring NewStringUTF(const char* bytes)//C++环境中的定义
    { return functions->NewStringUTF(this, bytes); }

参数说明

  • this: JNIEnv函数表指针,这个在前面章节有详细介绍过
  • bytes: 指向一个char *变量,即要返回给Java层的C/C++中字符串。

在我们的实际代码中通过调用 NewStringUTF 函数,会构建一个新的 java.lang.String 字符串对象。这个新创建的字符串会自动转换成 Java 支持的 Unicode 编码。如果 虚拟机不能为构造 java.lang.String 分配足够的内存NewStringUTF会抛出一个 OutOfMemoryError 异常,并返回 NULL。在这个例子中我们不必检查它的返回值,如果NewStringUTF 创建 java.lang.String 失败,OutOfMemoryError 这个异常会被在 Java 方法中抛出。如果NewStringUTF 创建 java.lang.String 成功,则返回一个 JNI 引用,这个引用指向新创建的java.lang.String对象。
虽然我们这里使用了NewStringUTF直接创建jstring类型的字符串,但是这里不推荐直接使用因为函数的过程中遇到如下错误JNI DETECTED ERROR IN APPLICATION: input is not valid Modified UTF-8: illegal start byte 0x80,这里我们推荐使用如下的代码实现char * 转为jstring的转换。

jstring chartoJstring(JNIEnv *env, const char *pat) {
    jclass strClass = (*env)->FindClass(env, "java/lang/String");
    jmethodID ctorID = (*env)->GetMethodID(env, strClass, "<init>", "([BLjava/lang/String;)V");
    jbyteArray bytes = (*env)->NewByteArray(env, (jsize) strlen(pat));
    (*env)->SetByteArrayRegion(env, bytes, 0, (jsize) strlen(pat), (jbyte *) pat);
    jstring encoding = (*env)->NewStringUTF(env, "utf-8");
    return (jstring) (*env)->NewObject(env, strClass, ctorID, bytes, encoding);
}



二.再探字符串处理函数

在前面的章节里面讲解了常见的字符串处理函数,那么我们在本篇章节里面继续看看在jni.h头文件中还有那些其它的字符串处理函数,下面我们罗列整理一番:

    jstring     (*NewString)(JNIEnv*, const jchar*, jsize);
    
    jsize       (*GetStringLength)(JNIEnv*, jstring);
    jsize       (*GetStringUTFLength)(JNIEnv*, jstring);
    
    const jchar* (*GetStringChars)(JNIEnv*, jstring, jboolean*);
    void        (*ReleaseStringChars)(JNIEnv*, jstring, const jchar*);
    
    
    void        (*GetStringRegion)(JNIEnv*, jstring, jsize, jsize, jchar*);
    void        (*GetStringUTFRegion)(JNIEnv*, jstring, jsize, jsize, char*);
    
    const jchar* (*GetStringCritical)(JNIEnv*, jstring, jboolean*);
    void        (*ReleaseStringCritical)(JNIEnv*, jstring, const jchar*);

2.1 NewString

函数原型:jstring (NewString)(JNIEnv env, const jchar* unicodeChars, jsize size);
功能:利用 Unicode 字符数组构造新的 java.lang.String 对象。
参数:

  • env: JNIEnv接口指针
  • unicodeChars: 指向 Unicode 字符串的指针
  • len: Unicode 字符串的长度

返回值:返回Java的Unicode编码的字符串对象。如果无法构造该字符串,则为NULL。
异常处理:OutOfMemoryError:如果系统内存不足。


2.2 GetStringUTFLength和GetStringLength

下面让我们分别对这组字符串处理函数进行讲解:
GetStringUTFLength
函数原型:jsize (GetStringUTFLength)(JNIEnv env, jstring string);
功能: 以字节为单位返回字符串的UTF-8编码的长度,这里也可以通过标准 C 函数 strlen 获取。
参数说明:

  • env: JNIEnv接口指针
  • string: Java字符串对象

返回值: UTF-8字符串编码的Java字符串长度

GetStringLength
函数原型:jsize (GetStringLength)(JNIEnv env, jstring string);
功能: 以字节为单位返回字符串的Unicode编码的长度,由于 UTF-8 编码的字符串以’\0’结尾,而 Unicode 字符串不是,所以不能通过strlen获取jstring的长度,必须通过该函数
参数说明:

  • env: JNIEnv接口指针
  • string: Java字符串对象

返回值: Unicode 字符串编码的Java字符串长度


2.3 GetStringChars和ReleaseStringChars

下面让我们分别对这组字符串处理函数进行讲解:
GetStringChars
函数原型: const jchar* (GetStringChars)(JNIEnv env, jstring string, jboolean* isCopy);
功能: 返回指向字符串string内容的Unicode 字符数组的指针。该指针在调用 ReleaseStringchars() 前一直有效。和GetStringUTFChars可以对照学习。
参数说明

  • env: JNIEnv接口指针
  • string: Java的字符串对象
  • isCopy: 指向布尔值的指针

返回值:指向 Unicode 字符串的指针,如果操作失败,则返回NULL。

ReleaseStringChars
函数原型: void ReleaseStringChars (JNIEnv *env, jstring string, const jchar *chars);
功能: 通知虚拟机平台释放chars所引用的相关资源,以免造成内存泄漏。参数chars 是一个指针,可通过 GetStringChars() 从 string 获得。
参数说明

  • env: JNIEnv接口指针
  • string:Java字符串对象
  • chars:调用GetStringChars获取的Unicode 字符串的指针

通过前面的详细解释我们可以看到这对函数和 Get/ReleaseStringUTFChars 函数功能差不多,用于获取和释放以 Unicode 格式编码的字符串。后者是用于获取和释放 UTF-8 编码的字符串。


2.4 GetStringCritical和ReleaseStringCritical

该对函数主要是为了提高 从虚拟机平台返回源字符串直接指针的可能性。
Get/ReleaseStringChars 和 Get/ReleaseStringUTFChars 这对函数返回的源字符串会后分配内存,如果有一个字符串内容相当大,有 1M 左右,而且只需要读取里面的内容打印出来,用这两对函数就有些不太合适了。此时用 Get/ReleaseStringCritical 可直接返回源字符串的指针应该是一个比较合适的方式。不过这对函数有一个很大的限制,在这两个函数之间的本地代码不能调用任何会让线程阻塞或等待 JVM 中其它线程的本地函数或 JNI 函数。因为通过 GetStringCritical 得到的是一个指向 JVM 内部字符串的直接指针,获取这个直接指针后会导致暂停 GC 线程,当 GC 被暂停后,如果其它线程触发 GC 继续运行的话,都会导致阻塞调用者。所以在Get/ReleaseStringCritical 这对函数中间的任何本地代码都不可以执行导致阻塞的调用或为新对象在 JVM 中分
配内存,否则,JVM 有可能死锁。另外一定要记住检查是否因为内存溢出而导致它的返回值为 NULL,因为 JVM 在执行 GetStringCritical 这个函数时,仍有发生数据复制的可能性,尤其是当 JVM 内部存储的数组不连续时,为了返回一个指向连续内存空间的指针,JVM 必须复制所有数据。下面代码演示这对函数的正确用法:

JNIEXPORT jstring JNICALL Java_com_xxx_jni_OperateString_helloJni
  (JNIEnv * env, jobject object, jstring j_str)
{
		const jchar* c_str= NULL;
		char buff[128] = "hello ";
		char* pBuff = buff + 6;
		/*
		* 在GetStringCritical/RealeaseStringCritical之间是一个关键区。
		* 在这关键区之中,绝对不能呼叫JNI的其他函数和会造成当前线程中断或是会让当前线程等待的任何本地代码,
		* 否则将造成关键区代码执行区间垃圾回收器停止运作,任何触发垃圾回收器的线程也会暂停。
		* 其他触发垃圾回收器的线程不能前进直到当前线程结束而激活垃圾回收器。
		*/
		c_str = env->GetStringCritical(j_str,NULL); // 返回源字符串指针的可能性
		if (c_str == NULL) // 验证是否因为字符串拷贝内存溢出而返回NULL
		{
			return NULL;
		}
		while(*c_str)
		{
			*pBuff++ = *c_str++;
		}
		env->ReleaseStringCritical(j_str,c_str);
		return env->NewStringUTF(buff);
}

JNI 中没有 Get/ReleaseStringUTFCritical 这样的函数,因为在进行编码转换时很可能会促使 JVM 对数据进行复制,因为 JVM 内部表示的字符串是使用 Unicode 编码的。


2.5 GetStringRegion 和 GetStringUTFRegion

分别表示获取 Unicode 和 UTF-8 编码字符串指定范围内的内容。这对函数会把源字符串复制到一个预先分配的缓冲区内。下面代码用 GetStringUTFRegion 重新实现 helloJni函数:

JNIEXPORT jstring JNICALL Java_com_xxx_jni_OperateString_helloJni
  (JNIEnv * env, jobject object, jstring j_str)
{
	jsize len = env->GetStringUTFLength(j_str); // 获取utf-8字符串的长度
	printf("str_len:%d\n",len);
	char buff[128] = "hello ";
	char* pBuff = buff + 6;
	// 将虚拟机平台中的字符串以utf-8编码拷入C缓冲区,该函数内部不会分配内存空间
	env->GetStringUTFRegion(j_str,0,len,pBuff);
	return env->NewStringUTF(buff);
}

GetStringUTFRegion 这个函数会做越界检查,如果检查发现越界了,会抛出StringIndexOutOfBoundsException 异常,这个方法与 GetStringUTFChars 比较相似,不同的是,GetStringUTFRegion 内部不分配内存,不会抛出内存溢出异常。
注意:GetStringUTFRegion 和 GetStringRegion 这两个函数由于内部没有分配内存,所以 JNI 没有提供ReleaseStringUTFRegion 和 ReleaseStringRegion 这样的函数。




JNI字符串处理总结

  通过前面的章节,我想读者一定对JNI中的字符串处理有了比较深的掌握了,进行入门的开发应该问题不大了。但是这个有一个点必须要注意就是注意字符串编码的问题,是UTF-8还是Unicode 然后选择对应的JNI函数进行处理。下面再温故而知新一下,回顾一下前面的重点内容:

  • 对于小字符串来说,GetStringRegion 和 GetStringUTFRegion 这两对函数是最佳选择,因为缓冲区可以被编译器提前分配,而且永远不会产生内存溢出的异常。当你需要处理一个字符串的一部分时,使用这对函数也是不错。因为它们提供了一个开始索引和子字符串的长度值。另外,复制少量字符串的消耗 也是非常小的。
  • 使用 GetStringCritical 和 ReleaseStringCritical 这对函数时,必须非常小心。一定要确保在持有一个由 G
    etStringCritical 获取到的指针时,本地代码不会在 JVM 内部分配新对象,或者做任何其它可能导致系统死锁的阻塞性调用。
  • 获取 Unicode 字符串和长度,使用 GetStringChars 和 GetStringLength 函数。获取 UTF-8 字符串的长度,使用 GetStringUTFLength 函数。
  • 创建 Unicode 字符串,使用NewString,创建UTF-8使用 NewStringUTF 函数。
  • 通过 GetStringUTFChars、GetStringChars、GetStringCritical 获取字符串,这些函数内部会分配内
    存,必须调用相对应的 ReleaseXXXX 函数释放内存。

好了今天得章节到这里就结束了,希望知识点各位都get到了。青山不改绿水长流,各位江湖见!

  • 7
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 6
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 6
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值