JNI学习笔记(五)——fields和methods

之前的学习,知道了JNI可以让native代码访问基础类型和引用类型,本章节,我们要学习如果访问一个对象的字段(其实就是对象中的变量)和方法。此外,还将学习如何在native代码调用java编程语言实现的方法——这对回调函数,尤其有用。



访问字段


java编程语言,支持两种字段:实例字段和static字段,(可以这么理解:实例变量和static变量)。

JNI提供了可以用来获取和设置这两种域的函数。同样,我们从一个例子入手:
[java]  view plain copy
  1. class InstanceFieldAccess {  
  2.     private String s;  
  3.     private native void accessField();  
  4.     public static void main(String args[]) {  
  5.         InstanceFieldAccess c = new InstanceFieldAccess();  
  6.         c.s = "abc";  
  7.         c.accessField();  
  8.         System.out.println("In Java:");  
  9.         System.out.println("  c.s = \"" + c.s + "\"");  
  10.     }  
  11.     static {  
  12.         System.loadLibrary("InstanceFieldAccess");  
  13.     }  
  14. }  

这是InstanceFieldAccess.accessField方法的native代码实现:
[cpp]  view plain copy
  1. JNIEXPORT void JNICALL  
  2. Java_InstanceFieldAccess_accessField(JNIEnv *env, jobject obj)  
  3. {  
  4.     jfieldID fid;   /* store the field ID */  
  5.     jstring jstr;  
  6.     const char *str;  
  7.     /* Get a reference to obj’s class */  
  8.     jclass cls = (*env)->GetObjectClass(env, obj);  
  9.     printf("In C:\n");  
  10.     /* Look for the instance field s in cls */  
  11.     fid = (*env)->GetFieldID(env, cls, "s",  
  12.                              "Ljava/lang/String;");  
  13.     if (fid == NULL) {  
  14.         return/* failed to find the field */  
  15.     }  
  16.     /* Read the instance field s */  
  17.     jstr = (*env)->GetObjectField(env, obj, fid);  
  18.     str = (*env)->GetStringUTFChars(env, jstr, NULL);  
  19.     if (str == NULL) {  
  20.         return/* out of memory */  
  21.     }  
  22.     printf("  c.s = \"%s\"\n", str);  
  23.     (*env)->ReleaseStringUTFChars(env, jstr, str);  
  24.     /* Create a new string and overwrite the instance field */  
  25.     jstr = (*env)->NewStringUTF(env, "123");  
  26.     if (jstr == NULL) {  
  27.         return/* out of memory */  
  28.     }  
  29.     (*env)->SetObjectField(env, obj, fid, jstr);  
  30. }  

这是允许结果:
[cpp]  view plain copy
  1. In C:  
  2.   c.s = "abc"  
  3. In Java:  
  4.   c.s = "123"  


访问一个实例字段的步骤


为了进入一个实例字段,native方法遵循两个过程:第一,调用GetFieldID,由相关的类、字段名字和字段描述符,来获得字段field的ID:
fid = (*env)->GetFieldID(env, cls, "s", "Ljava/lang/String");

上示例子中的代码,相关的类cls是通过在相关对象obj上调用GetObjectClass获得的。一旦获得了字段ID之后,就可以把相关对象和该字段ID传给合适的实例字段访问函数。由于字符串和数组是特殊类型的对象,我们使用GetObjectField来访问例子中的String实例变量:
jstr = (*env)->GetObjectField(env, obj, fid);
在GetObjectField和SetObjectField 函数之外,JNI还提供了访问基础类型的实例字段的方法,例如GetIntField,SetIntField,GetFloatField,SetFloatField等等。



字段描述符(重头戏上马了)


JNI使用一种叫做”JNI字段描述“的C字符串来表示java编程语言中的字段的类型。如之前出现的”Ljava/lang/String;“来表示java编程语言中的String类型,”I“表示int,”F“表示float,”D“表示double,”Z“表示boolean等等。

一个引用类型的描述符,例如java.lang.String,由L字母开头,并且以分号结束,在类的全名”java.lang.String“中的”.“被”/“替换。所以java.lang.String被表示为:”Ljava/lang/String;"

数组的描述符包含”[“字符,紧随其后的是数组的类型,例如java语言中的int[ ]数组,在JNI中这样表示:”[I“。

可以用javap工具来从类文件中生成字段描述符,通常情况下,javap输出一个给定类中的方法和字段。而如果加上-s选项,javap则输出JNI描述符:
javap -s InstanceFieldAccess
它输出了以下结果:
...
s Ljava/lang/String;
...

基础类型的JNI描述符
JNI字段描述符java编程语言
Zboolean
Bbyte
Cchar
Sshort
Iint
Jlong
Ffloat
Ddouble


访问静态字段


访问一个静态字段和访问实例字段是相似的:
[java]  view plain copy
  1. class StaticFielcdAccess {  
  2.     private static int si;  
  3.     private native void accessField();  
  4.     public static void main(String args[]) {  
  5.         StaticFieldAccess c = new StaticFieldAccess();  
  6.         StaticFieldAccess.si = 100;  
  7.         c.accessField();  
  8.         System.out.println("In Java:");  
  9.         System.out.println("  StaticFieldAccess.si = " + si);  
  10.     }  
  11.     static {  
  12.         System.loadLibrary("StaticFieldAccess");  
  13.     }  
  14. }  

与访问实例字段不同的是,访问静态字段时,使用GetStaticFieldID
[cpp]  view plain copy
  1. JNIEXPORT void JNICALL  
  2. Java_StaticFieldAccess_accessField(JNIEnv *env, jobject obj)  
  3. {  
  4.     jfieldID fid;   /* store the field ID */  
  5.     jint si;  
  6.     /* Get a reference to obj’s class */  
  7.     jclass cls = (*env)->GetObjectClass(env, obj);  
  8.     printf("In C:\n");  
  9.     /* Look for the static field si in cls */  
  10.     fid = (*env)->GetStaticFieldID(env, cls, "si""I");  
  11.     if (fid == NULL) {  
  12.         return/* field not found */  
  13.     }  
  14.     /* Access the static field si */  
  15.     si = (*env)->GetStaticIntField(env, cls, fid);  
  16.     printf("  StaticFieldAccess.si = %d\n", si);  
  17.     (*env)->SetStaticIntField(env, cls, fid, 200);  
  18. }  

其运行结果如下:
[cpp]  view plain copy
  1. In C:  
  2.   StaticFieldAccess.si = 100  
  3. In Java:  
  4.   StaticFieldAccess.si = 200  


从上所示代码,我们可以看出访问实例字段和访问静态字段,有两处不同:
1)之前已经提到的,用GetStaticFieldID替代访问实例字段中用到GetFieldID。
2)在得到静态字段ID之后,使用合适的静态字段方法。


调用方法


在java语言中有多种方法,实例方法,静态方法,构造方法,等等。JNI支持一组允许你在native代码中执行回调的函数。
[java]  view plain copy
  1. class InstanceMethodCall {  
  2.     private native void nativeMethod();  
  3.     private void callback() {  
  4.         System.out.println("In Java");  
  5.     }  
  6.     public static void main(String args[]) {  
  7.         InstanceMethodCall c = new InstanceMethodCall();  
  8.         c.nativeMethod();  
  9.     }  
  10.     static {  
  11.         System.loadLibrary("InstanceMethodCall");  
  12.     }  
  13. }  

native方法的实现:
[cpp]  view plain copy
  1. JNIEXPORT void JNICALL  
  2. Java_InstanceMethodCall_nativeMethod(JNIEnv *env, jobject obj)  
  3. {  
  4.     jclass cls = (*env)->GetObjectClass(env, obj);  
  5.     jmethodID mid =  
  6.         (*env)->GetMethodID(env, cls, "callback""()V");  
  7.     if (mid == NULL) {  
  8.         return/* method not found */  
  9.     }  
  10.     printf("In C\n");  
  11.     (*env)->CallVoidMethod(env, obj, mid);  
  12. }  

执行结果
[cpp]  view plain copy
  1. In C  
  2. In Java  


调用实例方法


首先要取得方法的ID,例子中调用了GetMethodID来获取MethodID。该函数,在给定类中寻找该方法。寻找的标准基于方法名以及方法的类型描述符。如果方法不存在,怎函数返回NULL。并且在java语言中调用该naitive方法的调用者处抛出异常NoSuchMethodError。

然后,调用CallVoidMethod(该方法调用了一个返回类型是void的实例方法)。在此,给该方法传入对象,方法ID,以及实际参数。

在CallVoidMethod之外,JNI同样也支持调用其他返回类型的函数,如CallIntMethod。同样也可以使用CallVoidMethod来调用返回对象的方法。(如返回值是字符串或者数组)



格式化方法描述符


和字段一样,native方法需要描述符来告诉JNI native代码和java语言中对应的方法。(或者更加合适的叫法叫做方法签名)。一个方法的描述符,由参数类型、方法返回值类型组成。参数类型在前,并且由一对括号包围,方法返回值类型紧随其后,在多个参数之间没有分隔符。例如一个返回值为void型,并且拥有一个int型参数的方法,可以由“(I)V”来表示。而"()D"则表示一个返回值是double型的,没有参数的方法。
1)方法的描述符,可能还包含类描述符(类描述符,我们将在后面学到),java中的代码是:
private native  String getLine(String);
则,其方法描述符是:
“(Ljava.lang.String;)Ljava.lang.String;”
2)数组的描述是“[”开头的,后面更数组元素的类型的描述,所以
public static void main(String[ ] args);
的描述符是:
"(Ljava.lang.String;)V"

下表提供了一个关于如何格式话方法描述符的完整的描述:
方法描述符java语言类型
"()Ljava/lang/String"String f();
"(ILjava/lang/Class)J"long f(int i, Class c);
"([B)v"void f(byte[ ] bytes);


调用静态方法

这是一个和访问静态字段相对应的章节,自然与之相似,在调用静态方法时,和调用实例方法,也有两点不同:
1)调用静态方法时,取GetStaticMethodID而代实例方法中的GetMethodID。
2)改调用JNI函数CallVoidMethod为调用CallStaticVoidMethod,同样JNI也为静态方法提供了,CallStatic<Type>Method系列方法。

在java语言中,可以这样调用Class cls的静态方法f:cls.f或者obj.f。然而在JNI中,在调用静态方法时,必须指定引用类。再看一个例子:
[java]  view plain copy
  1. class StaticMethodCall {  
  2.     private native void nativeMethod();  
  3.     private static void callback() {  
  4.         System.out.println("In Java");  
  5.     }  
  6.     public static void main(String args[]) {  
  7.         StaticMethodCall c = new StaticMethodCall();  
  8.         c.nativeMethod();  
  9.     }  
  10.     static {  
  11.         System.loadLibrary("StaticMethodCall");  
  12.     }  
  13. }  

native代码中的实现:
[cpp]  view plain copy
  1. JNIEXPORT void JNICALL  
  2. Java_StaticMethodCall_nativeMethod(JNIEnv *env, jobject obj)  
  3. {  
  4.     jclass cls = (*env)->GetObjectClass(env, obj);  
  5.     jmethodID mid =  
  6. (*env)->GetStaticMethodID(env, cls, "callback""()V");  
  7.     if (mid == NULL) {  
  8.         return;  /* method not found */  
  9.     }  
  10.     printf("In C\n");  
  11.     (*env)->CallStaticVoidMethod(env, cls, mid);  
  12. }  

输出结果:
[cpp]  view plain copy
  1. In C  
  2. In Java  


调用一个超类的实例方法


之前我们了解了调用一个类的实例方法和静态方法。这里介绍如何调用一个已经在子类中被覆盖了的超类的方法。JNI提供了一组CallNonvitural<Type>Method函数来实现这个目的。为了调用在超类中的实例方法,需要遵循以下步骤:
1)用GetMethodID来从该超类的一个引用中获取方法的ID。
2)给nonvirtual族的合适的JNI函数(例如CallNonvirtualVoidMethod,CallNonvirtualBooleanMethod等)传参数:对象、超类、方法ID以及方法的参数。

这种调用超类中的实例方法的情况很少见。这里介绍的方法和在java语言中调用一个被覆盖的超类的方法的情形比较相似(在java语言中,使用构造函数:super.f())。

CallNonvirtualVoidMethod同样也能调用构造函数。


调用构造函数


在JNI中,构造函数可以和其他实例方法一样,以类似的步骤被调用。为了获得一个构造函数的方法ID,将"<init>"作为方法名,并且在方法描述符中,用”V“作为方法的返回类型。这之后,就可以调用构造函数,并且传递方法ID到JNI函数(例如NewObject)。以下代码,用java.lang.String构造函数,实现一个等同JNI函数NewString功能的函数。
[cpp]  view plain copy
  1. jstring  
  2. MyNewString(JNIEnv *env, jchar *chars, jint len)  
  3. {  
  4.     jclass stringClass;  
  5.     jmethodID cid;  
  6.     jcharArray elemArr;  
  7.     jstring result;  
  8.     stringClass = (*env)->FindClass(env, "java/lang/String");  
  9.     if (stringClass == NULL) {  
  10.         return NULL; /* exception thrown */  
  11.     }  
  12.     /* Get the method ID for the String(char[]) constructor */  
  13.     cid = (*env)->GetMethodID(env, stringClass,  
  14.                               "<init>""([C)V");  
  15.     if (cid == NULL) {  
  16.         return NULL; /* exception thrown */  
  17.     }  
  18.     /* Create a char[] that holds the string characters */  
  19.     elemArr = (*env)->NewCharArray(env, len);  
  20.     if (elemArr == NULL) {  
  21.         return NULL; /* exception thrown */  
  22.     }  
  23.     (*env)->SetCharArrayRegion(env, elemArr, 0, len, chars);  
  24.     /* Construct a java.lang.String object */  
  25.     result = (*env)->NewObject(env, stringClass, cid, elemArr);  
  26.     /* Free local references */  
  27.     (*env)->DeleteLocalRef(env, elemArr);  
  28.     (*env)->DeleteLocalRef(env, stringClass);  
  29.     return result;  
  30. }  

这段代码曾经在我的《JNI学习笔记(一)》中出现过。它从一个以Unicode编码方式存储在C缓冲区中的字符串,构造为java.lang.String的一个对象,和NewString功能等效。
1)首先,FindClass返回一个java.lang.String类的引用。
2)然后,GetMethodID返回java.lang.String的构造函数String(char[ ] chars)的方法ID。
3)接着,调用NewCharArray来分配一个字符数组,用来保存所有的字符串的元素。
4)再后来,JNI函数NewObject函数,调用了由方法ID指定的构造函数,来构造对象。NewObject的参数:类的引用,方法ID,以及该构造函数所需要的参数。

DeleteLocalRef函数用来允许VM释放被本地引用elemArr和stringClass所使用的资源。在下一章节我们将详细学习DeleteLocalRef。

既然我们能够实现一个等效的函数,那为什么JNI还要提供一个内建的函数(例如NewString)?这是因为,内建字符串函数远远比在native code中调用java.lang.String API更高效。字符串是一个被高频使用的对象类型,一个像这样的,值得JNI作出特殊支持。

同样也有可能使用CallNonvirtualVoidMetho方法来调用构造函数,在这个例子里,native代码必须首先通过AllocObject函数来创建一个未初始化的对象。这样:
result = (*env)->NewObject(env, stringClass, cid, elemArr);
可以被AllocObject和CallNonvirtualVoidMetho方法替代:
[cpp]  view plain copy
  1. result = (*env)->AllocObject(env, stringClass);  
  2. if (result) {  
  3.     (*env)->CallNonvirtualVoidMethod(env, result, stringClass,  
  4.                                      cid, elemArr);  
  5.     /* we need to check for possible exceptions */  
  6.     if ((*env)->ExceptionCheck(env)) {  
  7.         (*env)->DeleteLocalRef(env, result);  
  8.         result = NULL;  
  9.     }  
  10. }  

AllocObject创建了一个未初始化的对象,但是使用时必须要小心,所以对于每个对象,构造函数最多只能被调用一次。不可以在native代码对同一个对象调用多次构造函数。

虽然有时候,可能会发现,先创建一个对象,然后再之后的某个时间调用构造函数,非常有用。但是更多时候,应该使用NewObject来避免和减少因为使用AllocObject、CallNonvirtualVoidMethod对而带来更容易错误的几率。


抓住字段和方法的ID


为了获取字段和方法的ID,需要基于字段和方法的名字、和描述符来进行符号查找。符号查找是相对昂贵的,本节,介绍一种可以降低这种费用的技术。

这个想法是:计算字段和方法ID,并且为后面重复使用它们而缓存它们。有两种方式可以缓存字段、方法ID,取决于缓存是否执行在使用字段和方法ID的点上,或者是在定义自动和方法的类的静态初始化器中。

在使用是捕获


字段和方法ID可以在native代码访问字段值或者执行方法回调的时候被捕获。下面的Java_InstaceFieldAccess_accessField函数的实现中,缓存字段ID到一个静态变量中,这样就不需要在每次调用的InstanceFieldAccess.accessField时候都去重新计算。
[cpp]  view plain copy
  1. JNIEXPORT void JNICALL  
  2. Java_InstanceFieldAccess_accessField(JNIEnv *env, jobject obj)  
  3. {  
  4.     static jfieldID fid_s = NULL; /* cached field ID for s */  
  5.     jclass cls = (*env)->GetObjectClass(env, obj);  
  6.     jstring jstr;  
  7.     const char *str;  
  8.     if (fid_s == NULL) {  
  9.     fid_s = (*env)->GetFieldID(env, cls, "s""Ljava/lang/String;");  
  10.         if (fid_s == NULL) {  
  11.             return/* exception already thrown */  
  12.         }  
  13.     }  
  14.     printf("In C:\n");  
  15.     jstr = (*env)->GetObjectField(env, obj, fid_s);  
  16.     str = (*env)->GetStringUTFChars(env, jstr, NULL);  
  17.     if (str == NULL) {  
  18.         return/* out of memory */  
  19.     }  
  20.     printf("  c.s = \"%s\"\n", str);  
  21.     (*env)->ReleaseStringUTFChars(env, jstr, str);  
  22.     jstr = (*env)->NewStringUTF(env, "123");  
  23.     if (jstr == NULL) {  
  24.         return/* out of memory */  
  25.     }  
  26.     (*env)->SetObjectField(env, obj, fid_s, jstr);  
  27. }  

同样,我们可以缓存java.lang.String构造函数的方法ID:
[cpp]  view plain copy
  1. jstring  
  2. MyNewString(JNIEnv *env, jchar *chars, jint len)  
  3. {  
  4.     jclass stringClass;  
  5.     jcharArray elemArr;  
  6.     static jmethodID cid = NULL;  
  7.     jstring result;  
  8.     stringClass = (*env)->FindClass(env, "java/lang/String");  
  9.     if (stringClass == NULL) {  
  10.         return NULL; /* exception thrown */  
  11.     }  
  12.     /* Note that cid is a static variable */  
  13.     if (cid == NULL) {  
  14.         /* Get the method ID for the String constructor */  
  15. cid = (*env)->GetMethodID(env, stringClass,  
  16.                                   "<init>""([C)V");  
  17.         if (cid == NULL) {  
  18.             return NULL; /* exception thrown */  
  19.         }  
  20.     }  
  21.     /* Create a char[] that holds the string characters */  
  22.     elemArr = (*env)->NewCharArray(env, len);  
  23.     if (elemArr == NULL) {  
  24.         return NULL; /* exception thrown */  
  25.     }  
  26.     (*env)->SetCharArrayRegion(env, elemArr, 0, len, chars);  
  27.     /* Construct a java.lang.String object */  
  28. result = (*env)->NewObject(env, stringClass, cid, elemArr);  
  29.     /* Free local references */  
  30.     (*env)->DeleteLocalRef(env, elemArr);  
  31.     (*env)->DeleteLocalRef(env, stringClass);  
  32.     return result;  
  33. }  


在类的初始化时捕获


在使用时捕获字段和方法的ID,我们必须检查ID是否已经被缓存了。可是它同时会导致重复的缓存和检查。如果有多个native方法需要访问同一个字段,那么他们都需要检查、计算并且缓存对应的字段ID。

在更多情形下,在应用程序有机会调用native方法之前,就初始化字段和方法ID,将更加便利。VM一直都在调用一个类的任何方法之前,先执行该类的static初始化器。所以,在初始化器中执行计算、缓存字段和方法ID是一个合适的地方。

例如,为了缓存InstanceMethodCall.callback的方法ID,我们引入一个新的native方法initIDs,它在InstanceMethodCall类中的静态初始化器中被调用:
[java]  view plain copy
  1. class InstanceMethodCall {  
  2.     private static native void initIDs();  
  3.     private native void nativeMethod();  
  4.     private void callback() {  
  5.         System.out.println("In Java");  
  6.     }  
  7.     public static void main(String args[]) {  
  8.         InstanceMethodCall c = new InstanceMethodCall();  
  9.         c.nativeMethod();  
  10.     }  
  11.     static {  
  12.         System.loadLibrary("InstanceMethodCall");  
  13.         initIDs();  
  14.     }  
  15. }  

initIDs的实现:
[cpp]  view plain copy
  1. jmethodID MID_InstanceMethodCall_callback;  
  2. JNIEXPORT void JNICALL  
  3. Java_InstanceMethodCall_initIDs(JNIEnv *env, jclass cls)  
  4. {  
  5.     MID_InstanceMethodCall_callback =  
  6.         (*env)->GetMethodID(env, cls, "callback""()V");  
  7. }  

虚拟机VM运行静态初始化器,并且在执行任何其他方法(例如nativeMethod、main)之前,调用initIDs方法,在InstaceMethodCall.nativeMthod方法ID被保存到全局变量以后,它的实现中,就不需要在进行符号检查了:
[cpp]  view plain copy
  1. JNIEXPORT void JNICALL  
  2. Java_InstanceMethodCall_nativeMethod(JNIEnv *env, jobject obj  
  3. {  
  4.     printf("In C\n");  
  5.     (*env)->CallVoidMethod(env, obj,  
  6.                            MID_InstanceMethodCall_callback);  
  7. }  


比较两种缓存ID的方法


在使用时捕获、缓存IDS的方法,如果JNI开发人员不能掌握定义字段或者方法的类的源代码时,是一个合理的解决方案。例如,在MyNewString例子中,我们不能向java.lang.String类注入一个自定义的initIDs方法。

和在类的静态初始化器中缓存相比,在使用时缓存有一些缺点:
1)在使用时缓存,需要对同一个字段和方法ID进行重复的检查和初始化。
2)直到协作类时,方法和字段ID一直都是有效的。如果在使用时缓存,必须保证定义它们的的类在native代码还依赖缓存的ID值期间,不会被卸载和重新载入。另外一方面,如果是在静态初始化器中完成缓存ID,那么这些缓存的ID在类被卸载和重新载入时,会被自动地重新计算。

因此在可能的情况下,最好是在类的静态初始化器中缓存自动和方法的ID。


JNI字段和方法操作的性能


在学习了如何缓存字段和方法ID来提高性能之后,可能会想知道:使用JNI访问字段和调用方法的性能特点是什么?在native代码中执行一个回调的费用和在java中调用一个native 方法的费用,以及一个普通的方法调用的费用的比较,如何?

这些问题的答案,无疑需要依赖VM实现JNI的效率。所以并不能够给出一个确切的性能特性。所以这里,我们只分析native方法固有的费用,调用JNI自动和方法操作的费用,以及提供一个总体上的性能指引。

比较java/native调用的的开销和java/java调用的开销,java/native调用可能比java/java调用慢的原因:
1)native方法比在java VM中实现的java/java调用更有可能遵循一个不同的调用转换。这样一来,VM必须在进入native方法入口前,执行额外的操作,来建立和设置堆栈帧。
2)内联方法调用,是虚拟机常见的方式。内联java/native调用比内联java/java调用困难很多。

我们估计,一个典型的VM执行一个java/native调用比执行一个java/java调用可能要慢上2~3倍。因为java/java调用,只需要很少的几个周期,因此而增加的开销激活可以忽略不计(除非nativ方法只是执行琐碎的操作)。生成一个java/native调用的性能和java/java调用的性能接近或者相等的虚拟机实现,是可能的(这类VM实现,可能会采用JNI调用约定来作为内部JAVA/JAVA调用的约定)。

一个native/java回调的性能特点,在技术上和java/native调用是相近的。从理论上讲,native/JAVA回调的性能可能也在java/java调用的2~3倍之间。然而,实际上,native/java回调是比较少见的。VM实现,一般不会优化回调的性能。所以一个native/java回调的开销可能比java/java调用的开销高出10之多。

使用JNI访问字段的开销主要花费在通过JNIEnv调用上。native代码必须执行一个C函数调用,来解引用对象,而不是直接在解引用对象上。这样的函数调用是有必要的,因为它使由VM实现管理的内部对象和native代码分开来。JNI字段访问的开销,通常可以忽略不计的,因为一个函数调用只需要很少的周期。
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值