74、Java 流类详解:从转换流到各类实用流的全面剖析

Java流类全面剖析与应用

Java 流类详解:从转换流到各类实用流的全面剖析

1. 转换流:InputStreamReader 和 OutputStreamWriter

在 Java 编程中, InputStreamReader OutputStreamWriter 这两个转换流起着至关重要的作用。它们能够在字符流和字节流之间进行转换,既可以使用指定的字符集编码,也能采用本地系统的默认编码。这就好比一座桥梁,让我们可以以一致且与平台无关的方式使用现有的 8 位字符编码来处理本地字符集。

InputStreamReader 以字节输入流作为数据源,将字节转换为对应的 UTF - 16 字符;而 OutputStreamWriter 则以字节输出流为目标,把 UTF - 16 字符转换为编码后的字节形式。例如,下面的代码展示了如何读取采用 ISO 8859 - 6 编码的阿拉伯字符字节,并将其转换为合适的 UTF - 16 字符:

public Reader readArabic(String file) throws IOException {
    InputStream fileIn = new FileInputStream(file);
    return new InputStreamReader(fileIn, "iso-8859-6");
}

默认情况下,这些转换流会使用平台的默认字符集编码,但我们也可以指定其他编码。编码值可以通过名称、 Charset 对象,或者 java.nio.charset 包中的 CharsetDecoder CharsetEncoder 对象来表示。

InputStreamReader OutputStreamWriter 提供了多个构造函数,以满足不同的编码需求:
| 构造函数 | 描述 |
| — | — |
| public InputStreamReader(InputStream in) | 使用默认字符集编码从给定的 InputStream 读取数据 |
| public InputStreamReader(InputStream in, Charset c) | 使用给定的字符集编码从给定的 InputStream 读取数据 |
| public InputStreamReader(InputStream in, CharsetDecoder c) | 使用给定的字符集解码器从给定的 InputStream 读取数据 |
| public InputStreamReader(InputStream in, String enc) throws UnsupportedEncodingException | 使用指定名称的字符集编码从给定的 InputStream 读取数据,如果指定的编码不支持,则抛出 UnsupportedEncodingException |
| public OutputStreamWriter(OutputStream out) | 使用默认字符集编码将数据写入给定的 OutputStream |
| public OutputStreamWriter(OutputStream out, Charset c) | 使用给定的字符集编码将数据写入给定的 OutputStream |
| public OutputStreamWriter(OutputStream out, CharsetEncoder c) | 使用给定的字符集编码器将数据写入给定的 OutputStream |
| public OutputStreamWriter(OutputStream out, String enc) throws UnsupportedEncodingException | 使用指定名称的字符集编码将数据写入给定的 OutputStream ,如果指定的编码不支持,则抛出 UnsupportedEncodingException |

InputStreamReader read 方法会从关联的 InputStream 读取字节,并使用相应的编码将其转换为字符; OutputStreamWriter write 方法则会将提供的字符转换为字节,并使用相应的编码将其写入关联的 OutputStream

需要注意的是,关闭转换流时也会关闭关联的字节流。在某些情况下,比如转换标准流时,这可能不是我们所期望的,所以在关闭转换流时需要谨慎考虑。此外,这两个类都支持 getEncoding 方法,该方法会返回一个字符串,表示流的字符编码的历史名称或规范名称,如果流已关闭,则返回 null

FileReader FileWriter 是这些转换流的子类,它们有助于我们以一致且支持 Unicode 的方式,使用本地编码正确地读写本地文件。但如果默认的本地编码不符合需求,就必须使用显式的 InputStreamReader OutputStreamWriter 对象。

2. 流类快速概览

java.io 包定义了多种类型的流,这些流通常有输入/输出对,并且大多数都有字节流和字符流两种变体。下面我们来详细了解一些常见的流类型:
- 过滤流(Filter Streams) :这是抽象类,代表在数据被另一个流读写时应用了某种过滤操作的流。例如, FilterReader 对象从另一个 Reader 对象获取输入,对字符进行某种处理(过滤),然后返回过滤后的结果。我们可以通过将各种过滤器链接成一个大的过滤器来构建过滤流序列。

graph LR
    A[数据源] --> B[FilterReader]
    B --> C[处理后的数据]
  • 缓冲流(Buffered Streams) :添加了缓冲功能,使得读写操作无需每次都直接访问文件系统。字符缓冲流还引入了面向行的文本概念。
  • 管道流(Piped Streams) :以输入/输出对的形式使用,写入输出流的数据可以从输入流读取。管道内部有一个具有实现定义容量的缓冲区,允许读写以不同的速率进行。
  • 内存流(In - memory Streams) :允许我们使用内存中的数据结构作为流的源或目标,包括 ByteArray 流(使用字节数组)、 CharArray 流(使用字符数组)和 String 流(使用字符串类型)。
  • 其他特殊流
    • 打印流(Print Streams) :提供了 print println 方法,方便以人类可读的文本格式将基本类型和对象的值写入流。
    • 行号读取器(LineNumberReader) :在读取文本时跟踪行号。
    • 序列输入流(SequenceInputStream) :将多个字节输入流合并为一个输入流。
    • 回推流(Pushback Streams) :允许在读取过多数据时将字符或字节推回,常用于将输入拆分为标记。
    • 流标记器(StreamTokenizer) :用于简单的输入文本标记化。
3. 流类的同步与并发

字节流和字符流都定义了同步策略,但方式不同。字节流类在执行必须不受干扰的操作时,会对当前流对象进行同步,这使得多个线程可以使用相同的流,并且在调用单个流方法时能获得明确的行为。例如,两个线程分别从一个流中以 n 字节为块读取数据,每个读取操作返回的数据将包含在流中连续出现的最多 n 字节;同样,两个线程向同一个流写入数据时,每个写入操作写入的字节将连续发送到流中,不会在随机点混合。

字符流采用了不同的同步策略,它们在一个受保护的 lock 字段上进行同步,默认情况下,该字段是对流对象本身的引用。不过, Reader Writer 都提供了一个受保护的构造函数,允许传入一个用于锁定的对象。一些子类会将 lock 字段设置为引用不同的对象,例如 StringWriter 类将其锁定对象设置为 StringBuffer 对象。

在很多情况下,一个特定的流对象只是包装了另一个流实例,并将主要的流方法委托给该实例,形成一个连接的流链,如过滤流就是这种情况。此时,方法的同步行为将取决于被包装的最终流对象。只有当包装类需要执行一些必须与主要流操作原子性发生的额外操作时,同步才会成为问题。

大多数输入操作会阻塞,直到有数据可用,输出流操作也可能在尝试写入数据时阻塞,因为最终的源或目标可能是与网络套接字绑定的流。为了使执行阻塞 I/O 的线程对取消请求更具响应性,一些实现可能会响应 Thread 中断请求,通过解除线程阻塞并抛出 InterruptedIOException 。但在大多数情况下,在主 I/O 操作完成之前,线程不会被中断。

4. 过滤流的使用

过滤流可以帮助我们将流链接起来,产生更具实用性的复合流。每个过滤流都绑定到另一个流,并将实际的输入或输出操作委托给该流。过滤流的强大之处在于它们能够对读取或写入的数据进行过滤和处理,以某种方式转换数据。

下面是一个将字符转换为大写的输入过滤器示例:

public class UppercaseConvertor extends FilterReader {
    public UppercaseConvertor(Reader in) {
        super(in);
    }
    public int read() throws IOException {
        int c = super.read();
        return (c == -1 ? c : Character.toUpperCase((char)c));
    }
    public int read(char[] buf, int offset, int count)
        throws IOException
    {
        int nread = super.read(buf, offset, count);
        int last = offset + nread;
        for (int i = offset; i < last; i++)
            buf[i] = Character.toUpperCase(buf[i]);
        return nread;
    }
}

我们可以这样使用这个大写转换器:

public static void main(String[] args)
    throws IOException
{
    StringReader src = new StringReader(args[0]);
    FilterReader f = new UppercaseConvertor(src);
    int c;
    while ((c = f.read()) != -1)
        System.out.print((char)c);
    System.out.println();
}

通过将 StringReader 包装在 UppercaseConvertor 中,我们可以将字符串流中的所有字符转换为大写。过滤流可以任意链接,原始输入源可以是非过滤流,并且可以使用 InputStreamReader 将字节输入流转换为字符输入流。过滤输出流也可以类似地链接,使用 OutputStreamWriter 将字符输出流转换为字节输出流。

5. 缓冲流的优势

缓冲流类( BufferedInputStream BufferedOutputStream BufferedReader BufferedWriter )通过缓冲数据,避免了每次读写都直接访问下一个流。由于访问磁盘文件比使用内存缓冲区慢得多,缓冲有助于减少文件访问次数。

每个缓冲流都支持两个构造函数:一个接受被包装流的引用和要使用的缓冲区大小,另一个只接受被包装流的引用并使用默认缓冲区大小。

当在空的缓冲输入流上调用 read 方法时,它会调用其源流的 read 方法,尽可能多地填充缓冲区,只有在需要等待数据时才会阻塞,然后从缓冲区返回请求的数据。后续的 read 调用将从缓冲区返回数据,直到缓冲区内容耗尽,然后再次调用源流的 read 方法。缓冲输出流的行为类似,当写入操作填满缓冲区时,会调用目标流的 write 方法清空缓冲区。

例如,创建一个缓冲输出流来将字节写入文件的代码如下:

new BufferedOutputStream(new FileOutputStream(path));

需要注意的是,如果想在后续调用 FileOutputStream 对象的方法,必须保留对它的引用,因为无法从过滤流中获取下游对象。并且在操作下游对象之前,必须确保第一个上游对象已刷新,因为写入上游流的数据可能尚未完全写入下游。关闭上游对象也会关闭所有下游对象。

缓冲字符流还能理解文本行, BufferedWriter newLine 方法会向流中写入行分隔符, BufferedReader readLine 方法会返回一行文本作为字符串。

6. 管道流的应用

管道流( PipedInputStream PipedOutputStream PipedReader PipedWriter )用于在不同线程之间进行数据通信。使用管道流时,必须使用两个线程,一个用于读取,一个用于写入。写入管道一端时,如果管道已满,线程会阻塞;如果读写是同一个线程,该线程将永久阻塞。

下面是一个使用管道流连接文本生成器线程和读取生成文本的线程的示例:

class TextGenerator extends Thread {
    private Writer out;
    public TextGenerator(Writer out) {
        this.out = out;
    }
    public void run() {
        try {
            try {
                for (char c = 'a'; c <= 'z'; c++)
                    out.write(c);
            } finally {
                out.close();
            }
        } catch (IOException e) {
            getUncaughtExceptionHandler().
                uncaughtException(this, e);
        }
    }
}

class Pipe {
    public static void main(String[] args)
        throws IOException
    {
        PipedWriter out = new PipedWriter();
        PipedReader in = new PipedReader(out);
        TextGenerator data = new TextGenerator(out);
        data.start();
        int ch;
        while ((ch = in.read()) != -1)
            System.out.print((char) ch);
        System.out.println();
    }
}

管道流在构造时不必连接,也可以在后续通过 connect 方法连接。但在使用未连接的管道流或连接已连接的管道流时,会抛出 IOException

7. 内存流的使用

内存流允许我们使用内存中的数据结构作为流的源或目标,主要包括以下几种:
- ByteArray 字节流
- ByteArrayInputStream :使用字节数组作为输入源,读取操作不会阻塞。它有两个构造函数:

public ByteArrayInputStream(byte[] buf, int offset, int count)
public ByteArrayInputStream(byte[] buf)
- `ByteArrayOutputStream`:提供一个动态增长的字节数组来保存输出。它添加了一些构造函数和方法,如 `size()` 用于返回生成的字节数,`toByteArray()` 用于返回生成字节的副本等。
  • CharArray 字符流 :与 ByteArray 字节流类似, CharArrayReader 使用字符数组作为输入源, CharArrayWriter 提供一个动态增长的字符数组来保存输出。
  • String 字符流
    • StringReader :从字符串中读取字符,不会阻塞,提供一个接受要读取的字符串的构造函数。
    • StringWriter :允许将结果写入一个可以作为 String StringBuffer 对象检索的缓冲区。例如,使用 StringWriter 创建一个包含数组内容的字符串的代码如下:
public static String arrayToStr(Object[] objs) {
    StringWriter strOut = new StringWriter();
    PrintWriter out = new PrintWriter(strOut);
    for (int i = 0; i < objs.length; i++)
        out.println(i + ": " + objs[i]);
    return strOut.toString();
}
8. 打印流的便利

打印流( PrintStream PrintWriter )提供了方便的方法,以人类可读的文本格式将基本类型和对象的值写入流。它们提供了 print println 方法,支持多种类型,如 char int float 等。

PrintStream 作用于字节流, PrintWriter 作用于字符流,由于打印显然是与字符相关的输出,通常应使用 PrintWriter 类。不过,由于历史原因, System.out System.err 是使用默认字符集编码的 PrintStream 对象。

PrintWriter 有八个构造函数,支持不同的初始化方式,还实现了 Appendable 接口,提供了格式化输出的便利方法,如 format printf 。此外,打印流的输出方法不会抛出 IOException ,如果写入底层流时发生错误,方法会正常返回,我们可以通过调用 checkError 方法检查是否发生错误。

9. 行号读取器的功能

LineNumberReader 流在读取文本时会跟踪行号,行通常由换行符( \n )、回车符( \r )或回车符后紧跟换行符( \r\n )终止。

以下是一个查找文件中特定字符首次出现的行号的示例程序:

import java.io.*;
class FindChar {
    public static void main(String[] args)
        throws IOException
    {
        if (args.length != 2)
            throw new IllegalArgumentException(
                                      "need char and file");
        int match = args[0].charAt(0);
        FileReader fileIn = new FileReader(args[1]);
        LineNumberReader in = new LineNumberReader(fileIn);
        int ch;
        while ((ch = in.read()) != -1) {
            if (ch == match) {
                System.out.println("'" + (char)ch +
                    "' at line " + in.getLineNumber());
                return;
            }
        }
        System.out.println((char)match + " not found");
    }
}

我们可以使用 setLineNumber 方法设置当前行号,这在处理包含多个信息部分的文件时可能很有用。 LineNumberReader BufferedReader 的子类,有两个构造函数,可指定缓冲区大小或使用默认大小。

10. 序列输入流的合并

SequenceInputStream 类可以将一个或多个字节输入流合并为一个输入流,按顺序读取每个流,直到其输入结束,然后读取下一个流。它有两个构造函数,一个用于常见的两个输入流的情况,另一个用于任意数量的输入流,使用 Enumeration 抽象。

以下是一个将所有输入连接成单个输出的示例程序:

import java.io.*;
import java.util.*;
class Concat {
    public static void main(String[] args)
        throws IOException
    {
        InputStream in; // stream to read characters from
        if (args.length == 0) {
            in = System.in;
        } else {
            InputStream fileIn, bufIn;
            List<InputStream> inputs =
                new ArrayList<InputStream>(args.length);
            for (String arg : args) {
                fileIn = new FileInputStream(arg);
                bufIn = new BufferedInputStream(fileIn);
                inputs.add(bufIn);
            }
            Enumeration<InputStream> files =
                Collections.enumeration(inputs);
            in = new SequenceInputStream(files);
        }
        int ch;
        while ((ch = in.read()) != -1)
            System.out.write(ch);
    }
    // ...
}

如果没有参数,使用 System.in 作为输入;如果有参数,创建一个 ArrayList 来保存每个文件的缓冲输入流,然后使用 Collections.enumeration 方法获取 Enumeration 对象,最后使用 SequenceInputStream 将所有流合并为一个输入流。

11. 回推流的用途

回推流( PushbackInputStream PushbackReader )允许在读取过多数据时将字符或字节推回,常用于将输入拆分为标记。例如,词法扫描器通常在读取到标记后的第一个字符时才知道一个标记(如标识符)已结束,此时需要将该字符推回输入流,以便作为下一个标记的开始。

以下是一个使用 PushbackInputStream 报告输入中任何单个字节的最长连续序列的示例:

import java.io.*;
class SequenceCount {
    public static void main(String[] args)
        throws IOException
    {
        PushbackInputStream
           in = new PushbackInputStream(System.in);
        int max = 0;    // longest sequence found
        int maxB = -1;  // the byte in that sequence
        int b;          // current byte in input
        do {
            int cnt;
            int b1 = in.read(); // 1st byte in sequence
            for (cnt = 1; (b = in.read()) == b1; cnt++)
                continue;

            if (cnt > max) {
                max = cnt; // remember length
                maxB = b1; // remember which byte value
            }
            in.unread(b);  // pushback start of next seq
        } while (b != -1); // until we hit end of input
        System.out.println(max + " bytes of " + maxB);
    }
}

回推流支持两个构造函数,可指定回推缓冲区的大小或使用默认大小。 unread 方法有三种变体,用于将字符或字节推回输入流。

12. 流标记器的使用

StreamTokenizer 类用于简单的输入文本标记化,它主要设计用于解析类似编程语言风格的输入,但许多配置文件与编程语言类似,也可以使用该标记器进行解析。

StreamTokenizer nextToken 方法会返回流中下一个标记的类型,有四种标记类型:
- TT_WORD :扫描到一个单词, sval 字段包含找到的单词。
- TT_NUMBER :扫描到一个数字, nval 字段包含数字的值,仅识别十进制浮点数。
- TT_EOL :找到行结束符。
- TT_EOF :到达文件末尾。

以下是一个对字符流中的数值进行求和的示例:

static double sumStream(Reader source) throws IOException {
    StreamTokenizer in = new StreamTokenizer(source);
    double result = 0.0;

    while (in.nextToken() != StreamTokenizer.TT_EOF) {
        if (in.ttype == StreamTokenizer.TT_NUMBER)
            result += in.nval;
    }
    return result;
}

还可以使用 StreamTokenizer 读取输入源,查找 name = value 形式的属性,并将其存储在 AttributedImpl 对象中。

StreamTokenizer 提供了一些方法来设置字符类和控制扫描行为,如 wordChars whitespaceChars commentChar 等。默认情况下,新创建的 StreamTokenizer 对象有一些预定义的设置,我们可以根据需要修改这些设置。

通过对这些流类的深入了解,我们可以在 Java 编程中更灵活地处理输入输出操作,提高程序的效率和可维护性。

Java 流类详解:从转换流到各类实用流的全面剖析

13. 流标记器的配置与应用示例

StreamTokenizer 类除了基本的标记化功能外,还提供了丰富的配置方法,以满足不同的输入解析需求。以下是一些常用的配置方法及其作用:
| 方法 | 描述 |
| — | — |
| wordChars(int low, int hi) | 指定范围内的字符为单词字符,可多次调用以定义多个范围 |
| whitespaceChars(int low, int hi) | 指定范围内的字符为空白字符,多次调用时范围取并集 |
| ordinaryChars(int low, int hi) | 指定范围内的字符为普通字符,去除其特殊意义 |
| ordinaryChar(int ch) | 将指定字符设为普通字符,等同于 ordinaryChars(ch, ch) |
| commentChar(int ch) | 指定字符为单行注释的起始字符,其后到行尾的字符视为空白 |
| quoteChar(int ch) | 指定字符为字符串常量的引号字符,匹配的引号对界定字符串 |
| parseNumbers() | 指定应将数字解析为双精度浮点数 |
| resetSyntax() | 重置语法表,使所有字符都变为普通字符 |

下面是一个使用 StreamTokenizer 解析配置文件的示例,配置文件中使用 # 作为注释,格式为 name = value

import java.io.IOException;
import java.io.Reader;
import java.io.StreamTokenizer;

class AttributedImpl {
    // 实现属性存储的类
    // 这里省略具体实现
}

class Attr {
    private String name;
    private Object value;

    public Attr(String name) {
        this.name = name;
    }

    public void setValue(Object value) {
        this.value = value;
    }

    public String getName() {
        return name;
    }

    public Object getValue() {
        return value;
    }
}

public class ConfigParser {
    public static AttributedImpl readAttrs(Reader source) throws IOException {
        StreamTokenizer in = new StreamTokenizer(source);
        AttributedImpl attrs = new AttributedImpl();
        Attr attr = null;
        in.commentChar('#'); // '#' 是忽略到行尾的注释
        in.ordinaryChar('/'); // 去除 '/' 的特殊注释意义

        while (in.nextToken() != StreamTokenizer.TT_EOF) {
            if (in.ttype == StreamTokenizer.TT_WORD) {
                if (attr != null) {
                    attr.setValue(in.sval);
                    attr = null; // 使用完该属性
                } else {
                    attr = new Attr(in.sval);
                    attrs.add(attr);
                }
            } else if (in.ttype == '=') {
                if (attr == null) {
                    throw new IOException("misplaced '='");
                }
            } else {
                if (attr == null) { // 期望一个单词
                    throw new IOException("bad Attr name");
                }
                attr.setValue(new Double(in.nval));
                attr = null;
            }
        }
        return attrs;
    }
}

在这个示例中,我们首先创建了 StreamTokenizer 对象,并设置了注释字符和普通字符。然后在循环中调用 nextToken 方法逐个解析标记,根据标记类型处理属性的读取和存储。

14. 流类的综合应用示例

为了更好地理解和应用各种流类,我们来看一个综合示例:从文件中读取数据,对数据进行处理(如转换为大写),然后将处理后的数据写入另一个文件。

import java.io.*;

public class StreamCombinationExample {
    public static void main(String[] args) {
        if (args.length != 2) {
            System.err.println("Usage: java StreamCombinationExample <inputFile> <outputFile>");
            return;
        }

        String inputFile = args[0];
        String outputFile = args[1];

        try (InputStream fileIn = new FileInputStream(inputFile);
             InputStreamReader isr = new InputStreamReader(fileIn);
             BufferedReader br = new BufferedReader(isr);
             UppercaseConvertor uc = new UppercaseConvertor(br);
             OutputStream fileOut = new FileOutputStream(outputFile);
             OutputStreamWriter osw = new OutputStreamWriter(fileOut);
             BufferedWriter bw = new BufferedWriter(osw)) {

            int c;
            while ((c = uc.read()) != -1) {
                bw.write(c);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

在这个示例中,我们使用了多种流类:
1. 输入部分
- FileInputStream 从文件中读取字节。
- InputStreamReader 将字节流转换为字符流。
- BufferedReader 提供缓冲功能,提高读取效率。
- UppercaseConvertor 自定义的过滤流,将字符转换为大写。
2. 输出部分
- FileOutputStream 将字节写入文件。
- OutputStreamWriter 将字符流转换为字节流。
- BufferedWriter 提供缓冲功能,提高写入效率。

15. 流类使用的注意事项

在使用 Java 流类时,有一些注意事项需要牢记:
- 资源管理 :流对象使用完后必须关闭,以释放系统资源。可以使用 try-with-resources 语句自动管理资源,避免手动关闭流时可能出现的资源泄漏问题。
- 字符编码 :在处理字符流时,要注意字符编码的设置。如果不指定编码,可能会导致数据在不同系统或环境中出现乱码。
- 同步与并发 :不同的流类有不同的同步策略,在多线程环境中使用流时,要确保同步操作正确,避免数据异常。
- 异常处理 :流操作可能会抛出 IOException 等异常,需要进行适当的异常处理,以保证程序的健壮性。

16. 总结

Java 的流类提供了丰富的功能,用于处理各种输入输出场景。通过本文的介绍,我们了解了以下重要内容:
- 转换流 InputStreamReader OutputStreamWriter 用于在字节流和字符流之间进行转换。
- 常见流类型 :包括过滤流、缓冲流、管道流、内存流、打印流、行号读取器、序列输入流、回推流和流标记器等。
- 同步与并发 :字节流和字符流有不同的同步策略,在多线程环境中使用时需要注意。
- 综合应用 :通过综合使用各种流类,可以实现复杂的输入输出处理任务。

在实际开发中,我们应根据具体需求选择合适的流类,并遵循流类使用的注意事项,以提高程序的性能和可维护性。

graph LR
    A[数据源] --> B[字节流]
    B --> C[转换流]
    C --> D[过滤流]
    D --> E[缓冲流]
    E --> F[处理后的数据]
    G[字符流] --> C
    H[内存流] --> B
    H --> G
    I[打印流] --> F
    J[行号读取器] --> E
    K[序列输入流] --> B
    L[回推流] --> D
    M[流标记器] --> E

这个流程图展示了各种流类在数据处理过程中的可能组合和使用顺序,帮助我们更直观地理解流类之间的关系和应用场景。

通过对 Java 流类的深入学习和实践,我们能够更好地掌握 Java 的输入输出操作,为开发高效、可靠的程序打下坚实的基础。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值