Java IO类库之InputStreamReader

一、InputStreamReader介绍

    JDK源码注释中将InputStreamReader在Java IO中的角色定义为连接字节流和字符流的桥梁,它使用指定的编码方式Charset将输入的的字节数据解码为字符数据,编码方式可能基于编码名称或者被明确定义,或者使用平台默认的编码方式。每次调用read方法都会导致底层字节流一个或者多个字节数据被读取进来,为了提升字节到字符数据的转化效率,可能一次性从底层字节流读取超出所需的字节数据。为了更高的性能,可以考虑自外层包裹一个BufferedReader类。

二、InputStreamReade数据结构

1 - InputStreamReader继承结构
public class InputStreamReader extends Reader

    InputStreamReader继承自Reader类,支持字符流Reader提供的一些基本操作

2 - InputStreamReader的成员变量
public class InputStreamReader extends Reader {
    //InputStreamReader的功能依赖于此类实现,稍后源码分析我们会讲解到这个类
    private final StreamDecoder sd;
}

三、InputStream源码分析

1 - 构造函数
    /**
     * 构造函数,使用默认的编码方式
     */
    public InputStreamReader(InputStream in) {
        super(in);
        try {
            sd = StreamDecoder.forInputStreamReader(in, this, (String)null); // ## check lock object
        } catch (UnsupportedEncodingException e) {
            // The default encoding should always be available
            throw new Error(e);
        }
    }

    /**
     * 构造函数,使用指定名称的编码方式。若指定名称的编码方式不支持或者不存在抛出UnsupportedEncodingException异 
     * 常,支持的编码方式可查看java.nio.charset.Charset charset类
     */
    public InputStreamReader(InputStream in, String charsetName)
        throws UnsupportedEncodingException
    {
        super(in);
        if (charsetName == null)
            throw new NullPointerException("charsetName");
        sd = StreamDecoder.forInputStreamReader(in, this, charsetName);
    }

    /**
     * Creates an InputStreamReader that uses the given charset.
     *
     * @param  in       An InputStream
     * @param  cs       A charset
     *
     * @since 1.4
     * @spec JSR-51
     */
    public InputStreamReader(InputStream in, Charset cs) {
        super(in);
        if (cs == null)
            throw new NullPointerException("charset");
        sd = StreamDecoder.forInputStreamReader(in, this, cs);
    }

    /**
     * 构造函数,使用指定的CharsetDecoder(decoder)对象解码
     */
    public InputStreamReader(InputStream in, CharsetDecoder dec) {
        super(in);
        if (dec == null)
            throw new NullPointerException("charset decoder");
        sd = StreamDecoder.forInputStreamReader(in, this, dec);
    }

    三个构造函数逻辑大体一致,我们选择InputStreamReader(InputStream in, String charsetName)这个方法分析下InputStream构造函数的大体流程。

    首先看下上述方法源码,可知它第一步调用父类构造方法super(in),具体做了什么我们直接进入该方法

    protected Reader(Object lock) {
        if (lock == null) {
            throw new NullPointerException();
        }
        this.lock = lock;
    }

    看到这里大部分人应该知道他实际上是指定构造方法传入的底层字节流in作为同步代码块锁对象。

    继续回到构造函数往下分析,接下来判断传入的编码方式名称字符串的是否为null,再下来调用StreamDecoder.forInputStreamReader(in, this, charsetName)我们继续进入该方法的内部源码:

    public static StreamDecoder forInputStreamReader(InputStream var0, Object var1, CharsetDecoder var2) {
        return new StreamDecoder(var0, var1, var2);
    }

    方法内部调用构造方法创建了一个StreamDecoder对象,并让InputStreamReader内部的sd引用指向它。我们这里查看下StreamDecoder的类结构和它的构造函数的源码:

package sun.nio.cs;

public class StreamDecoder extends Reader {
    private static final int MIN_BYTE_BUFFER_SIZE = 32;
    private static final int DEFAULT_BYTE_BUFFER_SIZE = 8192;
    private volatile boolean isOpen;
    private boolean haveLeftoverChar;
    private char leftoverChar;
    private static volatile boolean channelsAvailable = true;
    private Charset cs;
    private CharsetDecoder decoder;
    private ByteBuffer bb;
    private InputStream in;
    private ReadableByteChannel ch;

    StreamDecoder(InputStream var1, Object var2, CharsetDecoder var3) {
        //将内部锁对象lock设置为var2即上一步传入的InputStreamReader对象
        super(var2);
        //更新状态
        this.isOpen = true;
        //是否存在多解析的一个字符
        this.haveLeftoverChar = false;
        //获取对应的编码方式
        this.cs = var3.charset();
        this.decoder = var3;
        if (this.ch == null) {
            this.in = var1;
            this.ch = null;
            this.bb = ByteBuffer.allocate(8192);
        }
        //移动position位置,清空数据,重复利用缓冲区
        this.bb.flip();
    }
}

    分析StreamDecoder源码我们知道他也继承自Reader,这里绑定的StreamCoder构造函数主要是做了一些初始化操作,包括指定它所属的InputStreamReader为锁对象,绑定了底层操作的字节流in,更新流状态,分配一个初始容量8192的数组用于缓存从底层字节流in读取的字节数据。

    总的来说InputStreamReader的构造函数其实就做了一件事,基于指定底层字节输入流in和编码方式初始化了该类所绑定的StreamDecoder对象,该对象是InputStreamReader的核心,InputStreamReader的所有读取转化操作都是委托给该类对象完成的。

 

2 - int read()方法
    public int read() throws IOException {
        return sd.read();
    }

    内部调用sd.read(),跟进StreamDecoder的read()方法

    private int read0() throws IOException {
     
        Object var1 = this.lock;
        /**注意这里是对所属的InputStreamReader对象加锁,如果有多个线程操作该InputStreamReader对象 
         * 绑定的底层字节流目标,对象例如硬盘保存的某个文件数据还是会有数据不一致问题
         */
        synchronized(this.lock) {
            if (this.haveLeftoverChar) {
                this.haveLeftoverChar = false;
                return this.leftoverChar;
            } else {
                char[] var2 = new char[2];
                //一次读取解析两个字符
                int var3 = this.read(var2, 0, 2);
                switch(var3) {
                case -1:
                    return -1;
                case 0:
                default:
                    assert false : var3;

                    return -1;
                case 2:
                    this.leftoverChar = var2[1];
                    this.haveLeftoverChar = true;
                case 1:
                    return var2[0];
                }
            }
        }
    }

    继续跟进read(char[] var1, int var2, int var3)方法源码:

    public int read(char[] var1, int var2, int var3) throws IOException {
        int var4 = var2;
        int var5 = var3;
        Object var6 = this.lock;
        synchronized(this.lock) {
            this.ensureOpen();
            if (var4 >= 0 && var4 <= var1.length && var5 >= 0 && var4 + var5 <= var1.length && var4 + var5 >= 0) {
                if (var5 == 0) {
                    return 0;
                } else {
                    byte var7 = 0;
                    if (this.haveLeftoverChar) {
                        var1[var4] = this.leftoverChar;
                        ++var4;
                        --var5;
                        this.haveLeftoverChar = false;
                        var7 = 1;
                        if (var5 == 0 || !this.implReady()) {
                            return var7;
                        }
                    }
                    //如果有之前保留的未被读取的解析字符,继续从字节流读取解析下一个字符
                    if (var5 == 1) {
                        int var8 = this.read0();
                        if (var8 == -1) {
                            return var7 == 0 ? -1 : var7;
                        } else {
                            var1[var4] = (char)var8;
                            return var7 + 1;
                        }
                    } else {//否则读取解析两个字符,这是真正进行字节流读取转化为字符的地方
                        return var7 + this.implRead(var1, var4, var4 + var5);
                    }
                }
            } else {
                throw new IndexOutOfBoundsException();
            }
        }
    }

    前面的零碎边缘逻辑直接略过我们直接进入StreamDecoder.implRead方法源码:

    int implRead(char[] var1, int var2, int var3) throws IOException {
        assert var3 - var2 > 1;
        //将保存解析后的字符数据的数组封装到一个CharBuffer对象
        CharBuffer var4 = CharBuffer.wrap(var1, var2, var3 - var2);
        if (var4.position() != 0) {
            var4 = var4.slice();
        }

        boolean var5 = false;

        while(true) {
            //当字节流缓存bb和保存解析后字符数组的CharBuffer都准备好,decoder.decode方法真正负责字符解析
            CoderResult var6 = this.decoder.decode(this.bb, var4, var5);
            if (var6.isUnderflow()) {
                if (var5 || !var4.hasRemaining() || var4.position() > 0 && !this.inReady()) {
                    break;
                }
                //如果未到位字节流尾部继续读取字节流到字节流缓存bb中
                int var7 = this.readBytes();
            /** 省略后续代码 **/
            }
        }
    }

    看到这里同学们可能已经知道最终字节数据解码为字符是由StreamDecode内部的成员变量decoder完成的,decoder基于创建InputStreamReader时构造函数传入的编码方式选择对应的decoder,这个decoder位于Charset子类的内部类中,如果不指定则按照系统默认的编码方式对应的decoder进行解码。这部分核心逻辑是在JDK中java.nio.charset.Charset类的lookup方法中,内部源码如下:

    private static Charset lookup(String charsetName) {
        if (charsetName == null)
            throw new IllegalArgumentException("Null charset name");
        Object[] a;
        if ((a = cache1) != null && charsetName.equals(a[0]))
            return (Charset)a[1];
        return lookup2(charsetName);
    }

    查看源码我们可知,它内部创建了一个长度为2的对象数组缓存了一个第一优先级的编码名称到编码方式Charset的映射,因为JDK认为大部分情况下程序使用的编码方式时相同的这样可以提升性能,基于编码名称获取对应编码方式类Charset的源码是在这个lookup2方法内,我们进入方法源码看下:

    private static Charset lookup2(String charsetName) {
        Object[] a;
        if ((a = cache2) != null && charsetName.equals(a[0])) {
            cache2 = cache1;
            cache1 = a;
            return (Charset)a[1];
        }
        Charset cs;
        if ((cs = standardProvider.charsetForName(charsetName)) != null ||
            (cs = lookupExtendedCharset(charsetName))           != null ||
            (cs = lookupViaProviders(charsetName))              != null)
        {
            cache(charsetName, cs);
            return cs;
        }

        /* Only need to check the name if we didn't find a charset for it */
        checkName(charsetName);
        return null;
    }

    在这个方法里面我们发现Charset内部还使用了同样长度为2的Object数组缓存了一个charsetName编码名称和Charset对象的映射,这个作为系统使用编码方式Charset的第2优先级缓存,如果第2优先级缓存命中编码名称那么,第1第2优先级缓存位置对换,否则真正开始基于charsetName获取Charset对象,这里依照先后顺序查询standardProvider(标准编码)、lookupExtendedCharset(扩展编码)、lookupViaProviders(自定义编码)获取匹配名称的Charset,我们选择标准编码方式分析下,这里standardProvider的类是StandardCharsets,方法是在父类FastCharsetProvider中实现的,进入该类对应方法源码:

    public final Charset charsetForName(String var1) {
        synchronized(this) {
            return this.lookup(this.canonicalize(var1));
        }
    }

    这里方法内部先调用canonicalize方法基于传入的charsetName去查询内部维护的编码名称别名映射aliasMap如果是别名则使用对应的编码名称替换别名,然后将此编码名称字符串作为参数调用了本类FastCharsetProvider的lookup方法,继续进入该方法源码:

private Charset lookup(String charsetName) {
    String csn = canonicalize(toLower(charsetName));
    ......
    String cln = classMap.get(csn);
    
    ......
    try {
        Class<?> c = Class.forName(packagePrefix + "." + cln,
                                true,
                                this.getClass().getClassLoader());
        cs = (Charset)c.newInstance();
        cache.put(csn, cs);
        return cs;
    } catch (ClassNotFoundException |
             IllegalAccessException |
             InstantiationException x) {
        return null;
    }
}

    在源码中可以看到,在方法开头对传入的charsetName先进行了转小写操作,比如我们之前传入的是UTF-8,那么代码中得到的csn就是utf-8,这表明了我们在调用InputStreamReader不需要区分大小写,JDK内部会统一转换为小写,不过最好统一使用小写,原因参考前面Charset第1第2级缓存的讲解。这里方法内部又进行了一次别名转化操作,可能是为了防止上一次调用方方法传入charsetName与aliasMap(保存别名映射的容器)中大小写匹配失败的情况,接着方法基于转化后的charsetName到classMap中去找对应的class类,classMap中是保存了编码方式(字符集)名称如utf-8到对应class类的映射如UTF_8。最后拼凑出对应的Charset类名称包含全路径,如sun.nio.cs.UTF_8,通过Class.forName方法去加载类,调用Class的newInstance方法实例化一个UTF_8对象,强制转化为Charset并缓存到cache。

    到了这里decoder对象如何获取的问题已经很清晰了,StreamDecoder内部负责字节解码的成员变量decoder实例就是由之前步骤获取到的Charset对象的newDecoder方法返回的,在类UTF_8中它是直接返回内部类Decoder。

    有了decoder我们可以继续回到前面的implRead方法源码继续往下分析字符的读取。

    int implRead(char[] var1, int var2, int var3) throws IOException {
        assert var3 - var2 > 1;
        //将保存解析后的字符数据的数组封装到一个CharBuffer对象
        CharBuffer var4 = CharBuffer.wrap(var1, var2, var3 - var2);
        if (var4.position() != 0) {
            var4 = var4.slice();
        }

        boolean var5 = false;

        while(true) {
            //当字节流缓存bb和保存解析后字符数组的CharBuffer都准备好,decoder.decode方法真正负责字符解析
            CoderResult var6 = this.decoder.decode(this.bb, var4, var5);
            if (var6.isUnderflow()) {
                if (var5 || !var4.hasRemaining() || var4.position() > 0 && !this.inReady()) {
                    break;
                }
                //如果未到位字节流尾部继续读取字节流到字节流缓存bb中
                int var7 = this.readBytes();
            /** 省略后续代码 **/
            }
        }
    }

    这里方法就做了两件事:1将InputStream中的数据读取到ByteBuffer中,也就是bb中;2将ByteBuffer也就是bb中的数据通过CharsetDecoder的decode方法不断解码到CharBuffer也就是cb中。

    对于第1件事他是通过this.readBytes()方法完成的,进入该方法

 private int readBytes() throws IOException {
        ......
                int var4 = this.in.read(this.bb.array(), this.bb.arrayOffset() + var2, var3);
                if (var4 < 0) {
                    int var5 = var4;
                    return var5;
                }

        ......
    }

    如代码所示内部就是通过内部绑定的InputStream读取字节到待解析的字节缓冲区bb中,这个InputStream是在InputStreamReader创建时绑定到内部的成员变量StreamReader中的,这样InputStream、StreamReader和InputStreamReader的关系就很清晰了。

    第2件事情,也就是对ByteBuffer(即成员变量bb)中的字节进行解码并保存到字符缓冲区CharBuffer中,是通过CharsetDecoder中的decode方法来完成的,此方法接收三个参数,其中前两个分别是保存待解码字节数据的字节缓冲区和用来存放解码后数据的字符缓冲区。这里我们选取UTF-8字符集编码方式进行分析,UTF-8负责解码的方法decodeLoop位于UTF_8类的内部Decoder类,我们进入该方法

        protected CoderResult decodeLoop(ByteBuffer var1, CharBuffer var2) {
            return var1.hasArray() && var2.hasArray() ? this.decodeArrayLoop(var1, var2) : this.decodeBufferLoop(var1, var2);
        }

    我们这边的ByteBuffer和CharBuffer都支持数组,因此进入decodeArrayloop方法

        private CoderResult decodeArrayLoop(ByteBuffer var1, CharBuffer var2) {
            byte[] var3 = var1.array();
            int var4 = var1.arrayOffset() + var1.position();
            int var5 = var1.arrayOffset() + var1.limit();
            char[] var6 = var2.array();
            int var7 = var2.arrayOffset() + var2.position();
            int var8 = var2.arrayOffset() + var2.limit();

            for(int var9 = var7 + Math.min(var5 - var4, var8 - var7); var7 < var9 && var3[var4] >= 0; var6[var7++] = (char)var3[var4++]) {
                ;
            }

            while(true) {
                while(var4 < var5) {
                    byte var10 = var3[var4];
                    //多字节
                    if (var10 < 0) {
                        //双字节字符 格式 110xxxxx 10xxxxxx
                        if (var10 >> 5 == -2 && (var10 & 30) != 0) {
                            if (var5 - var4 < 2 || var7 >= var8) {
                                return xflow(var1, var4, var5, var2, var7, 2);
                            }

                            byte var17 = var3[var4 + 1];
                            if (isNotContinuation(var17)) {
                                return malformedForLength(var1, var4, var2, var7, 1);
                            }

                            var6[var7++] = (char)(var10 << 6 ^ var17 ^ 3968);
                            var4 += 2;
                        } else {
                            int var11;
                            byte var12;
                            byte var13;
                            //三字节 格式 1110xxxx 10xxxxxx 10xxxxxx
                            if (var10 >> 4 == -2) {
                                var11 = var5 - var4;
                                if (var11 < 3 || var7 >= var8) {
                                    if (var11 > 1 && isMalformed3_2(var10, var3[var4 + 1])) {
                                        return malformedForLength(var1, var4, var2, var7, 1);
                                    }

                                    return xflow(var1, var4, var5, var2, var7, 3);
                                }

                                var12 = var3[var4 + 1];
                                var13 = var3[var4 + 2];
                                if (isMalformed3(var10, var12, var13)) {
                                    return malformed(var1, var4, var2, var7, 3);
                                }

                                char var18 = (char)(var10 << 12 ^ var12 << 6 ^ var13 ^ -123008);
                                if (Character.isSurrogate(var18)) {
                                    return malformedForLength(var1, var4, var2, var7, 3);
                                }

                                var6[var7++] = var18;
                                var4 += 3;
                            } else {
                                if (var10 >> 3 != -2) {
                                    return malformed(var1, var4, var2, var7, 1);
                                }
                          //四字节,格式1111xxxx 10xxxxxx 10xxxxxx 10xxxxxx 
                                var11 = var5 - var4;
                                if (var11 >= 4 && var8 - var7 >= 2) {
                                    var12 = var3[var4 + 1];
                                    var13 = var3[var4 + 2];
                                    byte var14 = var3[var4 + 3];
                                    int var15 = var10 << 18 ^ var12 << 12 ^ var13 << 6 ^ var14 ^ 3678080;
                                    if (!isMalformed4(var12, var13, var14) && Character.isSupplementaryCodePoint(var15)) {
                                        var6[var7++] = Character.highSurrogate(var15);
                                        var6[var7++] = Character.lowSurrogate(var15);
                                        var4 += 4;
                                        continue;
                                    }

                                    return malformed(var1, var4, var2, var7, 4);
                                }

                                int var16 = var10 & 255;
                                if (var16 <= 244 && (var11 <= 1 || !isMalformed4_2(var16, var3[var4 + 1] & 255))) {
                                    if (var11 > 2 && isMalformed4_3(var3[var4 + 2])) {
                                        return malformedForLength(var1, var4, var2, var7, 2);
                                    }

                                    return xflow(var1, var4, var5, var2, var7, 4);
                                }

                                return malformedForLength(var1, var4, var2, var7, 1);
                            }
                        }
                    } else {//单字节 格式0xxxxxxx
                        if (var7 >= var8) {
                            return xflow(var1, var4, var5, var2, var7, 1);
                        }

                        var6[var7++] = (char)var10;
                        ++var4;
                    }
                }

                return xflow(var1, var4, var5, var2, var7, 0);
            }
        }

    由于方法涉及到UTF-8编码我们先来简单了解下UTF-8编码存储方式

Unicode编码和UTF-8编码

首先Unicode是一个符号集,它只规定了符号的二进制代码,却没有规定这个二进制代码应该如何存储。而UTF-8则是对Unicode的一种实现方式。

UTF-8 最大的一个特点,就是它是一种变长的编码方式。它可以使用1~4个字节表示一个符号,根据不同的符号而变化字节长度。

UTF-8 的编码规则很简单,只有二条:

1)对于单字节的符号,字节的第一位设为0,后面7位为这个符号的 Unicode 码。因此对于英语字母,UTF-8 编码和 ASCII 码是相同的。

2)对于n字节的符号(n > 1),第一个字节的前n位都设为1,第n + 1位设为0,后面字节的前两位一律设为10。剩下的没有提及的二进制位,全部为这个符号的 Unicode 码。

下表总结了编码规则,字母x表示可用编码的位:

Unicode符号范围UTF-8编码方式(二进制)
0000 0000-0000 007F0xxxxxxx
0000 0080-0000 07FF110xxxxx 10xxxxxx 
0000 0800-0000 FFFF1110xxxx 10xxxxxx 10xxxxxx
0001 0000-0010 FFFF11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

    根据上表,很容易能够判断出一个UTF-8编码的字符占用几个字节。如果前面是一个0,则此字符只占用一个字节;如果第一位是1,则连续有多少个1,就表示当前字符占用多少个字节。

下面,还是以汉字严为例,讲解如何实现 UTF-8 编码:

严的 Unicode 是4E25(100111000100101),根据上表,可以发现4E25处在第三行的范围内(0000 0800 - 0000 FFFF),因此严的 UTF-8 编码需要三个字节,即格式是1110xxxx 10xxxxxx 10xxxxxx。然后,从严的最后一个二进制位开始,依次从后向前填入格式中的x,多出的位补0。这样就得到了,严的 UTF-8 编码是11100100 10111000 10100101,转换成十六进制就是E4B8A5。

    下面基础知识讲完我们继续回到decodeLoop方法,方法较长因此我将非核心逻辑省略掉了。

    查看源码,decodeLoop在方法开始时,会将ByteBuffer中的数据转为数组并保存到sa中,也就是说sa中包存了所有的从InputStream中读取到的byte数据;同时会将CharBuffer中的数据转为数组并保存到da中,也就是说转码后的数据都将保存到da中,由于采用的是数组引用,因此数据最终是保存在我们文章开始声明的char数组当中的(CharBUffer也是对char数组的包裹)。

    之后我们把注意力重点放在while循环中,进入循环体中,首先读取一个字节存放到b1中,之后通过if-else语句来判断当前字符采用UTF-8编码方式占用了几个字节,判断的逻辑非常简单,我们前面提到UTF-8是变长存储的,可以占用1-4个字节,参考前面UTF-8对于占用不同字节字符的编码规则很容易得出以下解码流程:

1)假设当前字符占用一个字节,那么读取出的var10的格式就是0xxxxxxx,而我们知道以0开头的二进制数为正数,1开头的为负数,因此如果var10大于0,则当前字符肯定使用一个字节存储。这正是第一个if语句的逻辑,如果只占用一个字节,则将var10转为char,保存到CharBuffer内部的数组中。

2)假设当前字符占用两个字节,那么读取出的var10的格式就是110xxxxx,因为此数据后面五位都是数据位,因此向右移动五位,得到11111110。将此数据转为十进制就是-2(负数二进制是采用补码表示的,因此需要转到原码,也就是:10000010)。如果当前字符占用两个字节,则从ByteBuffer中再读取一个字节,存放到var17中,之后通过解码策略将var10和var17解码为char类型存放到CharBuffer内部的数组中。

3)假设当前字符占用三个字节,那么读取出的var0的格式就是1110xxxx,因为此数据后面四位都是数据位,因此向右移动四位,得到11111110。将此数据转为十进制就是-2(负数二进制是采用补码表示的,因此需要转到原码,也就是:10000010)。如果当前字符占用三个字节,则从ByteBuffer中再读取两个字节,分别存放到var12和var13中,之后通过解码策略将var10、var12和var13解码为char类型存放到CharBuffer中

4)假设当前字符占用四个字节,那么读取出的var10的格式就是11110xxx,因为此数据后面三位都是数据位,因此向右移动三位,得到11111110。将此数据转为十进制就是-2(负数二进制是采用补码表示的,因此需要转到原码,也就是:10000010)。如果当前字符占用四个字节,则从ByteBuffer中再读取三个字节,分别存放到var10、var12、var13和var14中,之后通过解码策略将var10、var12、var13和var14解码为char类型存放到CharBuffer中

    以上就是整个解码过程,通过以上过程最终将读取到的字节转换为字符保存到了字符数组中。

    总结read方法做的事情有:1.基于指定编码方式对应的Charset提供的decoder将字节流解码为字符,一次解析两个字符,返回一个,另一个字符缓存于绑定StreamDecoder对象的成员变量lazeOverChar,通过这种方式提升read()方法的查询速度。

 

转载于:https://my.oschina.net/zhangyq1991/blog/1924727

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值