JNI编程经验(JNI Tips)

转载自:http://blog.csdn.net/nicebooks/article/details/17925521

JNI编程经验(JNI Tips) .

翻译原文来自:http://developer.android.com/intl/zh-cn/training/articles/perf-jni.html

JNI全称是Java Native Interface, 它是一种使用java语言和原生C/C++语言相互调用,混合编程的方法. 它支持从动态链接库中加载代码, 并能使用C/C++的高效的特性

如果你之前对这个还不熟悉, 完整的读一遍Java Native Interface文档(http://docs.oracle.com/javase/7/docs/technotes/guides/jni/spec/jniTOC.html)可以对JNI的功能有个基本的了解. 有些功能在你读第一遍时还没什么特别的感觉, 但你可能会发现有些功能使用起来会很便利.

Java虚拟机和JNIEnv


JNI定义了2个数据结构, java虚拟机和JNIEnv, 这两个本质上都是指向函数表的指针. (在C++版本中, 他们是2个类, 包含了成员函数, 这些成员函数都是JNI的函数.) java虚拟机提供”调用接口”函数, 它允许你创建和销毁java虚拟机, 理论上你可以在一个进程里有多个java虚拟机. 但android只允许1个.

JNIEnv提供了大部分的JNI函数, 在C语言里, 你所有的native函数都要接收JNIEnv作为第一个参数.

JNIEnv用在线程本身的存储, 这样会导致你不能在多个线程里共享JNIEnv, 如果部分代码没有办法获得JNIEnv, 那么可以把java虚拟机共享, 通过java虚拟机来获得这个线程的JNIEnv.

C语言声明的JavaVM和JNIEnv与C++声明的不同, “jni.h”头文件里使用宏定义把C和C++的声明作了区别. 基于这原因, 把JNIEnv作为参数放在头文件里是不好的方式, 这导致在C和C++都用这个头文件时会有很大的麻烦.(也可以这么说: 如果你的头文件需要定义#ifdef __cplusplus, 那么你必须对这个指向JNIEnv的头文件作一些改动来同时适应C和C++语言.

线程


JNI里的所有的线程都是linux的线程, 由系统内核进行调度, 他们通常由Thread.start来启动, 但也可以在通过关联上Java虚拟机后在native语言中启动。 例如,使用C语言的pthread_create也可以创建线程,但在使用JNI方法AttachCurrentThread或AttachCurrentThreadAsDaemon来关联之前, 这个线程是没有JNIEnv的。同时也不能调用JNI的接口。

关联上native语言创建的线程会导致一个java.lang.Thread对象被创建同时添加到”main”线程组里,这样也可以被调试器检测到。如果再使用AttachCurrentThread来关联一个已经被关联的线程将不会有任何动作。

Android不能暂停线程去执行native的代码, 如果一个GC在运行或者调试器触发了一个暂停请求,Android将会暂停在下一个JNI的调用,而不是当前的native代码。

使用AttachCurrentThread或AttachCurrentThreadAsDaemon关联到JNI的线程在结束前必须调用DetachCurrentThread来解除关联。假如直接写DetachCurrentThread来取消关联会比较别扭,在android2.0之后,你可以用pthread_key_create来定义一个线程的析构方法,并且在这个析构方法里调用DetachCurrentThread。(使用pthread_setspecific来把JNIEnv保存在线程本地的存储里, 这个将会作为参数传到析构方法里.)

jclass, jmethodID和jfieldID


如果你想在native代码里访问一个对象的属性,你需要按照以下几步来做:

• 使用FindClass获取类对象的引用

• 使用GetFieldID获取属性的ID

• 使用恰当的方法来获取属性的值,例如GetIntField

类似的,如果要调用一个方法,你首先需要获得类对象的引用, 然后是方法的ID,这些ID通常是指向内部运行的数据结构,要获得这些ID可能需要多次字符串的比较,但是一旦你获得这些ID,那么调用这些方法或者获取属性将会非常快。

如果你追求高性能,最好是做一次查找,然后这些ID存起来,由于android有每个进程只能有一个Java虚拟机的限制,这是个很好的理由把这些数据存在本地。

类的引用,属性的ID和方法的ID能保证在类卸载前一直有效。类只有在GC时没有发现任何使用者时才可能卸载,这个在android中基本不可能发生。不管怎么样,jclass作为类引用必须使用NewGlobalRef来保护。

如果你喜欢在加载一个类时把那些ID保存起来, 并且在类卸载和重新加载时重新保存他们,正确的方法是像下面的代码这样来初始化这些ID。

/*

  • We use a class initializer to allow the native code to cache some

  • field offsets. This native function looks up and caches interesting

  • class/field/method IDs. Throws on failure.

    */

    private static native void nativeInit();

    static {

nativeInit();

}

临时和全局引用


每个传给原生方法的参数,和几乎所有由JNI方法返回的对象都是临时引用,这意味这个引用的有效期与当前原生方法在当前线程的执行过程是一样长的,所以在原生方法返回后,这个引用将不在有效。

这个规则也可以适用在所有jobject的子类,包括jclass, jstring和jarray.(如果扩展JNI检测被设上后,在运行时会警告你大部分的引用使用错误)

只有使用NewGlobalRef 和 NewWeakGlobalRef可以获得全局的引用。

如果你想保持一个引用更长的时间, 你必须使用”global”引用.NewGlobalRef方法接收一个临时引用的参数,然后返回一个全局引用。全局引用一直有效,直到你使用DeleteGlobalRef来删除它。

全局引用通常用来保存使用FindClass返回的jclass.例如:

jclass localClass = env->FindClass("MyClass");

jclass globalClass = reinterpret_cast<jclass>(env->NewGlobalRef(localClass));

所有JNI方法接受临时和全局引用作为参数。引用到同一个对象却有多个值是可能的。例如,连续使用NewGlobalRef来引用同一个对象的返回值可能是不一样的。要比较2个引用是不是指向同一个对象,必须使用IsSameObject方法来判断,永远不要在原生代码中用==来判断两个引用是否一样。

所以你不能假设原生代码中对象的引用是常量或者唯一的。不能用jobject的值来作为对象的key。

我们要求开发者不要过分的使用临时引用,这意味着如果你创建了很多临时引用,你最好使用DeleteLocalRef来释放这些临时引用,而不是让JNI来做这事。因为有的系统只允许使用16个临时引用,滥用临时引用会导致内存溢出。如果你需要更多的临时引用,要么你把之前的释放掉,要么使用EnsureLocalCapacity/PushLocalFrame/PopLocalFrame来增加临时引用的最大数目。

注意:jfieldID和jmethodID是不透明的类型,而不是对象引用,所以不能用NewGlobalRef方法来调用。由某些方法例如GetStringUTFChars和GetByteArrayElements返回的原始数据指针也不是对象.(这些类型可以在线程间传递,并且在相应的对象释放前一直有效.)

有一个不常见的用例值得单独提到,如果你使用AttachCurrentThread关联一个线程,线程中代码生成的临时引用要到线程取消关联后才会自动释放.所以你需要手动释放你创建的临时引用,通常要遵循谁创建,谁释放的规则。

UTF-8和UTF-16字符串


java语言用的是UTF-16编码字符串. 为了便利,JNI也提供了修改Mod UTF-8字符串的方法. 修改Mod编码的方法在C语言中很有用,因为它把\u0000编码为0xc0 0x80而不是0x00. 更好的是你可以处理\0结尾的C语言格式字符串, 这适用于那些使用标准libc的字符串函数.不好的一面是你不能随意的把UTF-8数据传给JNI就期望它能正常工作。

如果可能,一般处理UTF-16的字符串会比较快, Android现在不要求在GetStringChars中进行拷贝,而GetStringUTFChars要求一次分配内存和转换成UTF-8字符串. 注意UTF-16字符串不是\0结尾的,并且\u0000是允许的。所以你需要跟jchar指针一样获取字符串的长度。

使用Get获得的字符串记得要释放. 这些字符串函数返回jchar*或者jbyte*, 这种C语言格式的指针指向原始数据比临时引用好,他们保证在释放前是有效的,这意味着在原生方法返回后他们还是有效的,未释放的。

传给NewStringUTF的参数必须是Mod UTF-8格式的字符串, 一个容易犯的错误是把从文件或网络流中读到字符串传给NewStringUTF而没有过滤他,除非你知道这个数据是7位ASCII,你需要去掉高位ASCII字符或者转换他们为正确的Mod UTF-8格式.如果你没有这样做,默认UTF-16的转换应该不是你所期望的.扩展的JNI检查会扫描字符串和警告是无效数据,但他们不会捕获所有的异常。

原始数组


JNI提供了方法来方法数组对象的内容,但必须一次访问一个数组对象的数据。原始数组可以像C语言那样直接读和写。

为了使接口高效而且不受虚拟机实现的制约, GetArrayElements系列函数允许返回一个指向实际元素的指针, 或者分配一些内存来拷贝数据. 无论哪种方式, 返回的原始数据的指针在调用释放方法前是保证一直有效的(这意味着如果数据没有被拷贝, 这个对象数组将被限制在压缩堆数据时不能移动). 你必须自己释放每个你获取的数组. 同时如果Get方法失败的话,你的代码一定不能尝试释放一个NULL指针.

最有用的是, 你可以通过传一个非空指针作为isCopy的参数来决定是否拷贝数据.

那些释放方法有一个mode参数, 这个参数有3个值, 根据这个参数, 这个方法在运行时会根据指针是指向原始数据还是拷贝的数据有不同的表现.

•0

◦原始数据: 对象数组将不会被限制.

◦拷贝数据: 数据将会拷贝回原始数据, 同时释放拷贝数据.

•JNI_COMMIT

◦原始数据: 什么都不作.

◦拷贝数据: 数据将会拷贝回原始数据, 不释放拷贝数据.

•JNI_ABORT

◦原始数据: 对象数组将不会被限制, 之前的数据操作有效..

◦拷贝数据: 释放拷贝数据, 之前的任何数据操作会丢弃.

如果你对对象数组做了修改, 并且你有可能会根据对象数组的内容来决定是修改还是继续执行其他代码, 那么

检查isCopy参数是你是否需要用JNI_COMMIT来调用释放方法的一个理由, 你可以不做操作. 另一个可能的原因,检查该参数是为了使用JNI_ABORT的高效处理.例如, 你可能需要获得一个数组, 临时修改一下,然后将部分数据传给其他方法, 最后丢弃之前的修改. 如果你知道JNI是作了拷贝数据的Get, 那就不需要另外创建一份拷贝来修改. 但如果JNI是把原始数据传给你, 那么你需要自己拷贝一份数据来进行修改.

一个最容易犯的错误是如果isCopy是false, 有人以为可以不调用释放方法(这在例子代码中有很多这样的错误). 因为即使没有拷贝数据, 原始数据也是被限制不能回收的.所以要主动调用释放方法来释放.

同时要注意JNI_COMMIT标志不会释放数组, 你终究需要再次用另外一个标志来调用释放方法.

局部方法


当你想对数组进行写入或读出, 这还有一个像GetArrayElements和GetStringChars非常有用的方式:

jbyte* data = env->GetByteArrayElements(array, NULL);    

if (data != NULL) {

memcpy(buffer, data, len);        

env->ReleaseByteArrayElements(array, data, JNI_ABORT);    

}

这个方式是获取了数组, 然后拷贝了len长度的byte, 最后释放掉数组. 根据上面的实现, Get方法会限制或者拷贝数据. 在单独的拷贝数据后(也许就是第二次), 就调用释放方法来释放数据. 在这种情形下, JNI_ABORT保证这里没有机会有第三个拷贝.

还有一个更简单实现同样功能的方式:

env->GetByteArrayRegion(array, 0, len, buffer);

这种方式有几个好处:

•只需要一个JNI调用而不是两个, 减少开销.

•不需要对原始数据进行限制或者额外的拷贝数据

•减少开发者的风险(不会有在某些出错后忘记释放的风险)

同样的, 你可以用SetArrayRegion系列方法来拷贝数据到一个数组, GetStringRegion或者GetStringUTFRegion是从一个String中拷贝字符.

异常


你不能在有异常等待处理时调用大多数的JNI方法。你的代码应该要预期到有异常(通过函数的返回值,ExceptionCheck或ExceptionOccurred)并返回,或清除异常并处理它。

你只可以在异常等待处理时调用下面的JNI方法:

•DeleteGlobalRef

•DeleteLocalRef

•DeleteWeakGlobalRef

•ExceptionCheck

•ExceptionClear

•ExceptionDescribe

•ExceptionOccurred

•MonitorExit

•PopLocalFrame

•PushLocalFrame

•ReleaseArrayElements

•ReleasePrimitiveArrayCritical

•ReleaseStringChars

•ReleaseStringCritical

•ReleaseStringUTFChars

很多JNI方法会跑出异常, 但通常会提供一个简单的方法来检查失败. 例如,如果NewString返回一个非空值, 你不需要去检查异常. 然而,如果你调用一个方法(比如CallObjectMethod), 你必须检查异常, 因为返回值在异常发生时是不正确的.

在解释代码抛出异常时不会放松本地的栈帧, android现在还不支持C++异常. JNI的Throw和ThrowNew只是在当前线程设置一个异常指针. 直到从原生代码返回到java代码, 异常才会被抛出或者正确的处理.

原生代码可以用ExceptionCheck或者ExceptionOccurred来捕获一个异常, 并使用ExceptionClear来清除它. 通常丢弃异常没有处理它们可能导致问题

这里没有内置的方法来控制Throwable对象, 所以如果你想获得异常的描述, 你需要找到Throwable类, 查找它的 getMessage “()Ljava/lang/String;”方法的ID, 然后调用它, 如果结果不是空,那么用GetStringUTFChars来获取内容,这个内容可以用printf来显示.

扩展的检查


JNI会做一些错误检查, 错误通常结果是崩溃. Android也提供了一个名为CheckJNI模式, 其中的JavaVM与JNIEnv的函数表指针调用标准实现之前会切换到扩展系列的检查功能表执行检查。

附加的检查包括:

•数组: 尝试分配一个大小为负值的数组

•错误的指针: 传一个错误的jarray/jclass/jobject/jstring到JNI方法, 或者传一个空指针连同一个非空的参数到JNI方法

•类名:传一个不是“java/lang/String”风格的类名到JNI方法里.

•重要的调用: 在一个重要的Get和Release之间调用一个JNI方法.

•直接的Byte缓存:传一个错误的参数给NewDirectByteBuffer

•异常:在有异常等待处理时调用一个JNI方法

•JNIEnv*: 在不合适的线程中使用JNIEnv*

•jfieldIDs: 使用一个NULL jfieldID, 或者使用一个jfieldID来设置错误类型的值(试图给一个String属性赋StringBuilder的值), 或使用jfieldID的静态字段设置一个实例字段或反之亦然, 或使用一个类中jfieldID到另一个类的实例里.

•jmethodIDs:在调用JNI方法时使用错误的jmethodID类型: 不正确的返回类型, 静态和非静态类型的错误匹配, “this”的错误类型(非静态调用)或者错误的类(静态调用).

•References(引用):对不正确的引用使用DeleteGlobalRef/DeleteLocalRef.

•Release modes(释放模式):传一个错误的释放模式来调用释放方法(比如0, JNI_ABORT或者JNI_COMMIT).

•类型安全检查:从原生方法返回一个不兼容的类型(从一个声明返回值为String的原生方法里返回一个StringBuilder).

•UTF-8:传一个无效的Mod UTF-8字节序列到JNI方法里.

(方法和属性的访问限制不做检查:访问限制并不适用于原生代码)

这里有几个方法来设置CheckJNI

如果你使用的是模拟器, CheckJNI默认是开启的

如果你root了一个设备, 你可以用下列命令来重起runtime开启CheckJNI:

adb shell stop

adb shell setprop dalvik.vm.checkjni true

adb shell start

在这两种情况下,你将会在logcat中看到runtime启动的log:

D AndroidRuntime: CheckJNI is ON

如果你有一个没root的设备, 你可以用下列命令:

adb shell setprop debug.checkjni 1

这个不会影响已经运行的应用, 但从这之后运行的应用将是开启CheckJNI的.(把这个值改为其他任何值或者重起设备将会关闭CheckJNI.)这种情况下, 你会看到logcat在应用运行时显示

D Late-enabling CheckJNI

原生库


你可以使用标准System.loadLibrary来从共享库里加载原生代码. 推荐的实现代码如下:

• 在类的静态构造方法里调用System.loadLibrary(见之前的例子, 那个使用nativeClassInit的例子.) 参数是未修饰库名, 如果库文件是”libfubar.so”, 那么这里传”fubar”.

• 提供一个原生方法:jint JNI_OnLoad(JavaVM* vm, void reserved)

• 在JNI_OnLoad中, 注册所有的原生方法. 你应该把这些方法声明为”static”, 这样这些方法名就不会在设备符号表里占用空间.

JNI_OnLoad方法在C++中应该像下面这样写:

jint JNI_OnLoad(JavaVM* vm, void* reserved){

JNIEnv* env;

if (vm->GetEnv(reinterpret_cast

64位机器的注意事项


Android当前是主要为在32位系统上运行而设计的. 理论上它也可以在64位系统上运行, 但目前这个不是Android的首要目标. 在64位机器很多地方你不需要担心跟原生代码的交互, 但如果你计划在原生数据结构中保存指针, 那么这就是一个要注意的问题.为了支持使用64位的指针, 你需要把你的指针保存在long类型中, 而不是int类型.

不支持的特性和向后的兼容性


所有JNI 1.6的特性都支持, 包括下列异常:

•DefineClass没有实现. Android不使用Java字节编码或类文件, 所以传入二进制类数据是不支持的.

为了向后兼容旧的Android版本, 你需要注意的是:

•动态查找原生方法

直到Android2.0(Eclair), 在搜索方法名时, ‘$’字符无法正确转换成”_00024”. 解决方案是使用显示注册方法或者把原生方法从内嵌里移出来.

•取消关联线程

直到Android2.0(Eclair),Android不支持使用pthread_key_create设置线程析构方法来避免”线程必须在退出前取消关联”的检查.(runtime也会使用线程析构方法, 所以这个比谁先获得调用的竞赛.)

•弱全局引用

直到Android2.2(Froyo), 弱全局引用没有实现. 旧版本将会在使用它时提示拒绝. 你可以使用Android平台版本常量来测试是否支持.

直到Android4.0(Ice Cream sandwich), 弱全局引用只能传给NewLocalRef, NewGlobalRef和DeleteWeakGlobalRef.(文档强烈建议开发者把弱全局引用赋给强引用后再开始做相应工作, 所以这不应该是在所有限制)

从Android4.0(Ice Cream sandwich)开始, 弱全局引用可以像其他JNI引用一样使用.

临时引用

直到Android4.0(Ice Cream sandwich), 临时引用实际上就是直接指针. 4.0添加了必要的间接指针来支持更好的GC, 但这意味着很多JNI bug无法在旧版本上检测到. 请看下面的链接获取详细信息 http://android-developers.blogspot.com/2011/11/jni-local-reference-changes-in-ics.html

•使用GetObjectRefType确定引用类型

直到Android4.0(Ice Cream sandwich),作为使用直接指针的一个结果, 它是不可能正确实现GetObjectRefType的.相应的是,我们使用了启发式,透过弱全局引用表, 参数, 临时引用表和全局引用表的各个指针地址来查找引用类型. 当第一次找到直接指针, 它将会把他检测到的类型返回给你. 这个意思是, 例如, 如果你对一个全局jclass调用GetObjectRefType, 恰好当时jclass传了一个显示参数到一个静态原生方法, 你将会得到的返回值是JNILocalRefType而不是JNIGlobalRefType.

FAQ: 为什么会碰到UnsatisfiedLinkError错误?


当执行到原生代码时, 你可能会碰到这个错误log:

java.lang.UnsatisfiedLinkError: Library foo not found

在某些情况下这意味着就是库找不到. 其他情况是库存在但不能被dlopen打开, 并且详细的错误可以在异常的详细信息读到.

“library not found”异常发生的原因通常是:

•库不存在或app没权限访问它. 使用adb shell ls -l 去检查库的状态和访问权限.

•库不是由NDK编成的, 这可能会导致在没有该设备上存在的库函数的依赖关系。

还有一个UnsatisfiedLinkError的错误是这样的:

java.lang.UnsatisfiedLinkError: myfunc

at Foo.myfunc(Native Method)

at Foo.main(Foo.java:10)

在logcat中, 你会看到:

W/dalvikvm( 880): No implementation found for native LFoo;.myfunc ()V

这意味着runtime尝试匹配方法, 但没匹配到, 通常的原因是:

• 库没有被加载. 检查logcat关于加载库的信息.

• 由于方法名或签名的不匹配导致找不到方法. 这是最常见的原因:

◦ 对于懒得方法查找算法, 没有对C++方法加上extern "C"的声明和适当的可见性(JNIEXPORT).注意在4.0之前, JNIEXPORT宏是不正确的, 所以使用一个新的GCC和旧的jni.h文件来编译是不能正常工作的.你可以使用arm-eabi-nm来看库中的符号表.如果你看到错位的符号(比如看到的是_Z15Java_Foo_myfuncP7_JNIEnvP7_jclass而不是Java_Foo_myfunc), 或者符号类型是小写't'而不是大写的'T', 那么你需要调整声明.

◦ 对于显示注册的方式, 最小的错误是方法签名的错误. 确定你调用注册方法时传的签名是正确的. 记得’B’是byte类型和’Z’是boolean类型.类名组件在签名中是以’L’开始, ‘;’结束, 使用’/’来分隔包名和类名, 使用’   (Ljava/util/Map  Entry;).

使用javah来自动生成JNI头文件会对某些问题有帮助.

FAQ: 为什么FindClass找不到我的类?


确定类名有正确的格式. JNI类名字由包名开始, 由’/’分隔, 例如 java/lang/String. 如果你查找一个数组类, 类名应该由中括号开始,里面使用’L’和’;’包含的类名, 所以一个一维String数组定义为[Ljava/lang/String;.

如果类名正确找到了, 你可能碰到一个加载类的问题. FindClass需要在一个类加载器里进行类的搜索. 它会检查调用栈, 这个栈会像这样:

Foo.myfunc(Native Method)

Foo.main(Foo.java:10)

dalvik.system.NativeStart.main(Native Method)

最上面的方法是Foo.myfunc. FindClass找到类加载对象关联的Foo类名并使用它.

这通常就是你所需要的. 但如果你在线程中使用FindClass可能会使你碰到麻烦, 这时调用栈应该是这样的:

 dalvik.system.NativeStart.run(Native Method)

最上面的方法是NativeStart.run, 这个不是你程序中的代码. 如果你在这个线程中使用FindClass, Java虚拟机将会在”system”类加载器而不是你自己应用对应的类加载器中进行查找, 所以这时查找一个你应用的类时是会失败的.

针对这种情况这里有几个解决方案:

• 在JNI_OnLoad里只做一次FindClass, 然后保存这些类的引用. 在JNI_OnLoad里使用FindClass是能保证使用应用关联的类加载器.

• 传一个类的实例到方法里, 通过声明原生方法接收一个类作为参数, 然后把Foo.class传入.

• 保存类加载器的引用, 然后直接使用这个类加载器, 这需要作额外的工作.

FAQ: 我在原生代码中怎样分享原始数据?


你可能发现有这么一个情形, 你有一个大的原始数据, 你需要在java和原生代码中都访问这个数据而不是把原始数据转换成java的数据来处理. 常见的情况是一个处理图片或声音数据. 这里有2种方式来实现.

你可以把数据存在byte[]中, 这个可以在java中有非常快的访问速度. 在原生代码中, 你不一定能保证不拷贝就能访问它. 在某些实现中, GetByteArrayElements和GetPrimitiveArrayCritical将返回指向实际数据的指针, 但其他实现将会原生代码层分配一块内存来把数据拷贝进去.

还有一个方法是保存数据在直接byte缓存. 这个可以用java.nio.ByteBuffer.allocateDirect来创建, 或者用JNI的NewDirectByteBuffer方法. 不像通常的byte缓存, 这个存储不在java的堆上分配空间, 并可以在原生代码中直接使用访问(通过GetDirectBufferAddress获得地址). 由于依赖直接byte缓存的实现, 在有些系统上从java层访问这个数据有可能会很慢.

可以依据下面2个因素来决定选择哪个方案:

  1. 大部分的访问是在Java层还是原生代码层?

  2. 如果数据最终传给系统调用, 那么最终是以什么格式传入?(例如, 如果最终数据是以byte[]类型传给方法, 那么使用直接byte缓存来保存将不是个明智的选择.)

如果没有一个明显的因素, 那么使用直接byte缓存. 这个的支持是直接内置在JNI里的, 而且将来还可能会被优化.

感谢博主的分享! 转载自:http://blog.csdn.net/nicebooks/article/details/17925521

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值