java8新特性之stream详细介绍,以及lambda表达式的使用

​ Stream 就如同一个迭代器(Iterator),单向,不可往复,数据只能遍历一次,遍历过一次后即用尽,像极了逝去的时光。

(当第二次使用会报异常:Exception in thread “main” java.lang.IllegalStateException: stream has already been operated upon or closed)。

集合是急切创建,要等待计算所有值,而流吃延迟创建,只计算需要的值。

​ ① 流是“从支持数据处理操作的源生成的一系列元素”。

​ ② 流利用内部迭代:迭代通过filter、map、sorted等操作被抽象掉了。

​ ③ 流操作有两类:中间操作和终端操作。

​ ④ filter和map等中间操作会返回一个流,并可以链接在一起。可以用它们来设置一条流水线,但并不会生成任何结果。

​ ⑤ forEach和count等终端操作会返回一个非流的值,并处理流水线以返回结果。

​ ⑥ 流中的元素是按需计算的

1.多种方式生成 Stream Source:

  • 从 Collection 和数组
    • Collection.stream()
    • Collection.parallelStream()
    • Arrays.stream(T array) or Stream.of()
  • 从 BufferedReader
    • java.io.BufferedReader.lines()
  • 静态工厂
    • java.util.stream.IntStream.range()
    • java.nio.file.Files.walk()
  • 自己构建
    • java.util.Spliterator
  • 其它
    • Random.ints()
    • BitSet.stream()
    • Pattern.splitAsStream(java.lang.CharSequence)
    • JarFile.stream()

2.流(stream)的操作类型分为两种:

  • Intermediate:一个流可以后面跟随零个或多个 intermediate 操作。其目的主要是打开流,做出某种程度的数据映射/过滤,然后返回一个新的流,交给下一个操作使用。这类操作都是惰性化的(lazy),就是说,仅仅调用到这类方法,并没有真正开始流的遍历。
  • Terminal:一个流只能有一个 terminal 操作,当这个操作执行后,流就被使用“光”了,无法再被操作。所以这必定是流的最后一个操作。Terminal 操作的执行,才会真正开始流的遍历,并且会生成一个结果,或者一个 side effect。

在对于一个 Stream 进行多次转换操作 (Intermediate 操作),每次都对 Stream 的每个元素进行转换,而且是执行多次,这样时间复杂度就是 N(转换次数)个 for 循环里把所有操作都做掉的总和吗?其实不是这样的,转换操作都是 lazy 的,多个转换操作只会在 Terminal 操作的时候融合起来,一次循环完成。我们可以这样简单的理解,Stream 里有个操作函数的集合,每次转换操作就是把转换函数放入这个集合中,在 Terminal 操作的时候循环 Stream 对应的集合,然后对每个元素执行所有的函数。

还有一种操作被称为 short-circuiting。用以指:

  • 对于一个 intermediate 操作,如果它接受的是一个无限大(infinite/unbounded)的 Stream,但返回一个有限的新 Stream。
  • 对于一个 terminal 操作,如果它接受的是一个无限大的 Stream,但能在有限的时间计算出结果。

3.流的使用详解

简单说,对 Stream 的使用就是实现一个 filter-map-reduce 过程,产生一个最终结果,或者导致一个副作用(side effect)

4.流的构造与转换

下面提供最常见的几种构造 Stream 的样例。

// 1. Individual values
Stream stream = Stream.of("a", "b", "c");
// 2. Arrays
String [] strArray = new String[] {"a", "b", "c"};
stream = Stream.of(strArray);
stream = Arrays.stream(strArray);
// 3. Collections
List<String> list = Arrays.asList(strArray);
stream = list.stream();

需要注意的是,对于基本数值型,目前有三种对应的包装类型 Stream:

IntStream、LongStream、DoubleStream。当然我们也可以用 Stream、Stream >、Stream,但是 boxing 和 unboxing 会很耗时,所以特别为这三种基本数值型提供了对应的 Stream。

数值流的构造

IntStream.of(new int[]{1, 2, 3}).forEach(System.out::println);
IntStream.range(1, 3).forEach(System.out::println);
IntStream.rangeClosed(1, 3).forEach(System.out::println);

流转换为其它数据结构

// 1. Array
String[] strArray1 = stream.toArray(String[]::new);
// 2. Collection
List<String> list1 = stream.collect(Collectors.toList());
List<String> list2 = stream.collect(Collectors.toCollection(ArrayList::new));
Set set1 = stream.collect(Collectors.toSet());
Stack stack1 = stream.collect(Collectors.toCollection(Stack::new));
// 3. String
String str = stream.collect(Collectors.joining()).toString();

一个 Stream 只可以使用一次,上面的代码只是示例,为了简洁而重复使用了数次(正常开发只能使用一次)。

4.流的操作

接下来,当把一个数据结构包装成 Stream 后,就要开始对里面的元素进行各类操作了。常见的操作可以归类如下。

  • Intermediate:

map (mapToInt, flatMap 等)、 filter、 distinct、 sorted、 peek、 limit、 skip、 parallel、 sequential、 unordered

  • Terminal:

forEach、 forEachOrdered、 toArray、 reduce、 collect、 min、 max、 count、 anyMatch、 allMatch、 noneMatch、 findFirst、 findAny、 iterator

  • Short-circuiting:

anyMatch、 allMatch、 noneMatch、 findFirst、 findAny、 limit

典型用法示例

① map/flatMap
//转换大写,wordList为单词集合List<String>类型
List<String> output = wordList.stream().map(String::toUpperCase).collect(Collectors.toList());

求平方数

//这段代码生成一个整数 list 的平方数 {1, 4, 9, 16}。
List<Integer> nums = Arrays.asList(1, 2, 3, 4);
List<Integer> squareNums = nums.stream().map(n -> n * n).collect(Collectors.toList());

从上面例子可以看出,map 生成的是个 1:1 映射,每个输入元素,都按照规则转换成为另外一个元素。还有一些场景,是一对多映射关系的,这时需要 flatMap。

//将最底层元素抽出来放到一起,最终 output 的新 Stream 里面已经没有 List 了,都是直接的数字。
Stream<List<Integer>> inputStream = Stream.of(
 Arrays.asList(1),
 Arrays.asList(2, 3),
 Arrays.asList(4, 5, 6)
 );
Stream<Integer> outputStream = inputStream.flatMap((childList) -> childList.stream());
List<Integer> list = outputStream.collect(Collectors.toList());
System.out.println(list.toString());
② filter(筛选)

Streams接口支持filter方法,该操作会接受一个谓词(一个返回boolean的函数)作为参数,并返回一个包括所有符合谓词的元素的流,filter 对原始 Stream 进行某项测试,通过测试的元素被留下来生成一个新 Stream。

//留下偶数,经过条件“被 2 整除”的 filter,剩下的数字为 {2, 4, 6}。
Integer[] sixNums = {1, 2, 3, 4, 5, 6};
Integer[] evens =Stream.of(sixNums).filter(n -> n%2 == 0).toArray(Integer[]::new);
//把每行的单词用 flatMap 整理到新的 Stream,然后保留长度不为 0 的,就是整篇文章中的全部单词了。
//REGEXP为正则表达式,具体逻辑具体分析
List<String> output = reader.lines().
    flatMap(line -> Stream.of(line.split(REGEXP))).
    filter(word -> word.length() > 0).collect(Collectors.toList());
③ forEach

forEach 方法接收一个 Lambda 表达式,然后在 Stream 的每一个元素上执行该表达式。

//打印所有男性姓名,roster为person集合类型为List<Pserson>
// Java 8
roster.stream().filter(p -> p.getGender() == Person.Sex.MALE).forEach(p -> System.out.println(p.getName()));
// Pre-Java 8
for (Person p : roster) {
    if (p.getGender() == Person.Sex.MALE) {
        System.out.println(p.getName());
    }
}

当需要为多核系统优化时,可以 parallelStream().forEach(),只是此时原有元素的次序没法保证,并行的情况下将改变串行时操作的行为,此时 forEach 本身的实现不需要调整,而 Java8 以前的 for 循环 code 可能需要加入额外的多线程逻辑。另外一点需要注意,forEach 是 terminal 操作,因此它执行后,Stream 的元素就被“消费”掉了,你无法对一个 Stream 进行两次 terminal 运算。下面代码是错误的

//错误代码示例,一个stream不可以使用两次
stream.forEach(element -> doOneThing(element));
stream.forEach(element -> doAnotherThing(element));

相反,具有相似功能的 intermediate 操作 peek 可以达到上述目的。如下是出现在该 api javadoc 上的一个示例。

// peek 对每个元素执行操作并返回一个新的 Stream
Stream.of("one", "two", "three", "four")
     .filter(e -> e.length() > 3)
     .peek(e -> System.out.println("Filtered value: " + e))
     .map(String::toUpperCase)
     .peek(e -> System.out.println("Mapped value: " + e))
     .collect(Collectors.toList());
④ findFirst

这是一个 termimal 兼 short-circuiting 操作,它总是返回 Stream 的第一个元素,或者空。这里比较重点的是它的返回值类型:Optional。这也是一个模仿 Scala 语言中的概念,作为一个容器,它可能含有某值,或者不包含。使用它的目的是尽可能避免 NullPointerException。

String strA = " abcd ", strB = null;
print(strA);
print("");
print(strB);
getLength(strA);
getLength("");
getLength(strB);
//输出text不为null的值
public static void print(String text) {
    // Java 8
     Optional.ofNullable(text).ifPresent(System.out::println);
    // Pre-Java 8
     if (text != null) {
        System.out.println(text);
     }
 }
//输出text的长度,避免空指针
public static int getLength(String text) {
    // Java 8
    return Optional.ofNullable(text).map(String::length).orElse(-1);
    // Pre-Java 8
    //return if (text != null) ? text.length() : -1;
}

在更复杂的 if (xx != null) 的情况中,使用 Optional 代码的可读性更好,而且它提供的是编译时检查,能极大的降低 NPE 这种 Runtime Exception 对程序的影响,或者迫使程序员更早的在编码阶段处理空值问题,而不是留到运行时再发现和调试。

Stream 中的 findAny、max/min、reduce 等方法等返回 Optional 值。还有例如 IntStream.average() 返回 OptionalDouble 等等

⑤ reduce

这个方法的主要作用是把 Stream 元素组合起来。它提供一个起始值(种子),然后依照运算规则(BinaryOperator),和前面 Stream 的第一个、第二个、第 n 个元素组合。从这个意义上说,字符串拼接、数值的 sum、min、max、average 都是特殊的 reduce。也有没有起始值的情况,这时会把 Stream 的前面两个元素组合起来,返回的是 Optional。

// reduce用例
// 字符串连接,concat = "ABCD"
String concat = Stream.of("A", "B", "C", "D").reduce("", String::concat); 
// 求最小值,minValue = -3.0
double minValue = Stream.of(-1.5, 1.0, -3.0, -2.0).reduce(Double.MAX_VALUE, Double::min); 
// 求和,sumValue = 10, 有起始值
int sumValue = Stream.of(1, 2, 3, 4).reduce(0, Integer::sum);
// 求和,sumValue = 10, 无起始值,返回Optional,所以有get()方法
sumValue = Stream.of(1, 2, 3, 4).reduce(Integer::sum).get();
// 过滤,字符串连接,concat = "ace"
concat = Stream.of("a", "B", "c", "D", "e", "F")
    .filter(x -> x.compareTo("Z") > 0)
    .reduce("", String::concat);
⑥ limit/skip

limit 返回 Stream 的前面 n 个元素;skip 则是扔掉前 n 个元素(它是由一个叫 subStream 的方法改名而来)。

// limit 和 skip 对运行次数的影响
public void testLimitAndSkip() {
     List<Person> persons = new ArrayList();
     for (int i = 1; i <= 10000; i++) {
         Person person = new Person(i, "name" + i);
         persons.add(person);
    }
    List<String> personList2 = persons.stream()
        .map(Person::getName).limit(10).skip(3)
        .collect(Collectors.toList());
    System.out.println(personList2);
}
private class Person {
    public int no;
    private String name;
    public Person (int no, String name) {
        this.no = no;
        this.name = name;
    }
     public String getName() {
        System.out.println(name);
        return name;
     }
}
//结果
name1
name2
name3
name4
name5
name6
name7
name8
name9
name10
[name4, name5, name6, name7, name8, name9, name10]
//这是一个有 10,000 个元素的 Stream,但在 short-circuiting 操作 limit 和 skip 的作用下,管道中 map 操作指定的 getName() 方法的执行次数为 limit 所限定的 10 次,而最终返回结果在跳过前 3 个元素后只有后面 7 个返回。

有一种情况是 limit/skip 无法达到 short-circuiting 目的的,就是把它们放在 Stream 的排序操作后,原因跟 sorted 这个 intermediate 操作有关:此时系统并不知道 Stream 排序后的次序如何,所以 sorted 中的操作看上去就像完全没有被 limit 或者 skip 一样。

 List<Person> persons = new ArrayList();
 for (int i = 1; i <= 5; i++) {
     Person person = new Person(i, "name" + i);
     persons.add(person);
 }
List<Person> personList2 = persons.stream().sorted((p1, p2) -> 
p1.getName().compareTo(p2.getName())).limit(2).collect(Collectors.toList());
System.out.println(personList2);
//结果
name2
name1
name3
name2
name4
name3
name5
name4
[stream.StreamDW$Person@816f27d, stream.StreamDW$Person@87aac27]
//虽然最后的返回元素数量是 2,但整个管道中的 sorted 表达式执行次数没有像前面例子相应减少。
⑦ sorted
1.升序
List<userInfo> userList = new ArrayList<>();
// userInfo字段 UserId; Age; NickName; Sex 1:男  2:女;  moneny零花钱
userList.add(new userInfo(1,24,"小明",1,new BigDecimal("11")));
userList.add(new userInfo(2,22,"小红",2,new BigDecimal("22")));
userList.add(new userInfo(3,24,"小张",1,new BigDecimal("33")));


List<Integer> integers = Arrays.asList(1, 2, 3, 4);

//年龄升序
List<userInfo> userList1 = userList.stream()
				.sorted(Comparator.comparing(userInfo::getAge))
				.collect(Collectors.toList());

List<Integer> filter1 = integers.stream()
				.sorted(Comparator.comparing(Integer::intValue))
				.collect(Collectors.toList());
2.降序 reversed
List<userInfo> userList2 = userList.stream()
			.sorted(Comparator.comparing(userInfo::getAge).reversed())
			.collect(Collectors.toList());

List<Integer> filter2 = integers.stream()
			.sorted(Comparator.comparing(Integer::intValue).reversed())
			.collect(Collectors.toList())
3.多字段排序

关键字thenComparing

1.先以年龄升序
2.当年龄相同时,在以零花钱升序

List<userInfo> userList3 = userList.stream()
	.sorted(Comparator.comparing(userInfo::getAge).thenComparing(userInfo::getMoney))
	.collect(Collectors.toList());
4.多字段区分升序降序

关键字thenComparing、Comparator.reverseOrder()

1.先以年龄升序
2.当年龄相同时,在以零花钱降序 Comparator.reverseOrder()

List<userInfo> userList3 = userList.stream()
			.sorted(Comparator.comparing(userInfo::getAge).thenComparing(userInfo::getMoney,Comparator.reverseOrder()))
			.collect(Collectors.toList());

对 Stream 的排序通过 sorted 进行,它比数组的排序更强之处在于你可以首先对 Stream 进行各类 map、filter、limit、skip 甚至 distinct 来减少元素数量后,再排序,这能帮助程序明显缩短执行时间。

List<Person> persons = new ArrayList();
 for (int i = 1; i <= 5; i++) {
     Person person = new Person(i, "name" + i);
     persons.add(person);
 }
List<Person> personList2 = persons.stream().limit(2).sorted((p1, p2) -> p1.getName().compareTo(p2.getName())).collect(Collectors.toList());
System.out.println(personList2);
//结果
name2
name1
[stream.StreamDW$Person@6ce253f1, stream.StreamDW$Person@53d8d10a]
⑧ min/max/distinct(去重)

min 和 max 的功能也可以通过对 Stream 元素先排序,再 findFirst 来实现,但前者的性能会更好,为 O(n),而 sorted 的成本是 O(n log n)。同时它们作为特殊的 reduce 方法被独立出来也是因为求最大最小值是很常见的操作。

//找出最长一行的长度
BufferedReader br = new BufferedReader(new FileReader("c:\\Service.log"));
int longest = br.lines().mapToInt(String::length).max().getAsInt();
br.close();
System.out.println(longest);
//找出全文的单词,转小写,并排序,使用 distinct 来找出不重复的单词。单词间只有空格
List<String> words = br.lines()
    .flatMap(line -> Stream.of(line.split(" ")))
    .filter(word -> word.length() > 0)
    .map(String::toLowerCase)
    .distinct().sorted()
    .collect(Collectors.toList());
br.close();
System.out.println(words);
1.distinct的方法

它会返回一个元素各异(根据流所生成元素的hashCode和equals方法实现)的流。例如,以下代码会筛选出列表中所有的偶数,并确保没有重复。

// 筛选出所有的偶数
List<Integer> numbers = Arrays.asList(1, 2, 1, 3, 3, 2, 4);
List<Integer> num = numbers.stream().filter(n -> n % 2 == 0).distinct().collect(Collectors.toList());

结果:[2, 4]

⑨ Match

Stream 有三个 match 方法,从语义上说:

  • allMatch:Stream 中全部元素符合传入的 predicate,返回 true
  • anyMatch:Stream 中只要有一个元素符合传入的 predicate,返回 true
  • noneMatch:Stream 中没有一个元素符合传入的 predicate,返回 true

它们都不是要遍历全部元素才能返回结果。例如 allMatch 只要一个元素不满足条件,就 skip 剩下的所有元素,返回 false。

//Match使用示例
List<Person> persons = new ArrayList();
persons.add(new Person(1, "name" + 1, 10));
persons.add(new Person(2, "name" + 2, 21));
persons.add(new Person(3, "name" + 3, 34));
persons.add(new Person(4, "name" + 4, 6));
persons.add(new Person(5, "name" + 5, 55));
boolean isAllAdult = persons.stream().allMatch(p -> p.getAge() > 18);
System.out.println("All are adult? " + isAllAdult);
boolean isThereAnyChild = persons.stream().anyMatch(p -> p.getAge() < 12);
System.out.println("Any child? " + isThereAnyChild);
//结果
All are adult? false
Any child? true
⑩ limit

流支持limit(n)方法,该方法会返回一个不超过给定长度的流。所需的长度作为参数传递给limit。如果流是有序的,则最多会返回前n个元素。

注意limit也可以用在无序流上,比如源是一个Set。这种情况下,limit的结果不会以任何顺序排列。

 List<Dish> menu = Arrays.asList(
                new Dish("pork", false, 800, Dish.Type.MEAT),
                new Dish("beef", false, 700, Dish.Type.MEAT),
                new Dish("chicken", false, 400, Dish.Type.MEAT),
                new Dish("french fries", true, 530, Dish.Type.OTHER),
                new Dish("rice", true, 350, Dish.Type.OTHER),
                new Dish("season fruit", true, 120, Dish.Type.OTHER),
                new Dish("pizza", true, 550, Dish.Type.OTHER),
                new Dish("prawns", false, 300, Dish.Type.FISH),
                new Dish("salmon", false, 450, Dish.Type.FISH)
        );
        List<String> list = menu.stream().filter(dish -> dish.getCalories() > 300).map(Dish::getName).collect(Collectors.toList());
        System.out.println(list);
        // limit
        List<String> listLimit = menu.stream().filter(dish -> dish.getCalories() 		>300).limit(3).map(Dish::getName).collect(Collectors.toList());
        System.out.println(listLimit);

输入结果:

[pork, beef, chicken, french fries, rice, pizza, salmon]
[pork, beef, chicken]

⑪ count 返回的是一个long类型
       Long count = menu.stream().map(Dish::getName).count();
        System.out.println(count);

输出结果:

9

⑫ skip(n)(跳过元素)

流还支持skip(n)方法,返回一个扔掉了前n个元素的流。如果流中元素不足n个,则返回一个空流。请注意,limit(n)和skip(n)是互补的!

        List<Integer> numbers = Arrays.asList(1, 2, 1, 3, 3, 2, 4);
        List<Integer> num = numbers.stream().skip(10).collect(Collectors.toList());

输出结果:

[]

5.自己生成流

通过实现 Supplier 接口,你可以自己来控制流的生成。这种情形通常用于随机数、常量的 Stream,或者需要前后元素间维持着某种状态信息的 Stream。把 Supplier 实例传递给 Stream.generate() 生成的 Stream,默认是串行(相对 parallel 而言)但无序的(相对 ordered 而言)。由于它是无限的,在管道中,必须利用 limit 之类的操作限制 Stream 大小。

//生成10个随机数
Random seed = new Random();
Supplier<Integer> random = seed::nextInt;
Stream.generate(random).limit(10).forEach(System.out::println);
//Another way
IntStream.generate(() -> (int) (System.nanoTime() % 100)).
limit(10).forEach(System.out::println);

Stream.generate() 还接受自己实现的 Supplier。例如在构造海量测试数据的时候,用某种自动的规则给每一个变量赋值;或者依据公式计算 Stream 的每个元素值。这些都是维持状态信息的情形。

Stream.generate(new PersonSupplier()).
    limit(10).forEach(p -> System.out.println(p.getName() + ", " + p.getAge()));
private class PersonSupplier implements Supplier<Person> {
     private int index = 0;
     private Random random = new Random();
     @Override
     public Person get() {
        return new Person(index++, "StormTestUser" + index, random.nextInt(100));
     }
}
//结果
StormTestUser1, 9
StormTestUser2, 12
StormTestUser3, 88
StormTestUser4, 51
StormTestUser5, 22
StormTestUser6, 28
StormTestUser7, 81
StormTestUser8, 51
StormTestUser9, 4
StormTestUser10, 76

6.Stream.iterate

iterate 跟 reduce 操作很像,接受一个种子值,和一个 UnaryOperator(例如 f)。然后种子值成为 Stream 的第一个元素,f(seed) 为第二个,f(f(seed)) 第三个,以此类推

//生成等差数列
Stream.iterate(0, n -> n + 3).limit(10). forEach(x -> System.out.print(x + " "));
//结果
0 3 6 9 12 15 18 21 24 27

Stream.generate 相仿,在 iterate 时候管道必须有 limit 这样的操作来限制 Stream 大小。

7.用 Collectors 来进行 reduction 操作

java.util.stream.Collectors 类的主要作用就是辅助进行各类有用的 reduction 操作,例如转变输出为 Collection,把 Stream 元素进行归组。

① groupingBy/partitioningBy
//按照年龄归组
Map<Integer, List<Person>> personGroups = Stream.generate(new PersonSupplier())
    .limit(100).collect(Collectors.groupingBy(Person::getAge));
Iterator it = personGroups.entrySet().iterator();
while (it.hasNext()) {
     Map.Entry<Integer, List<Person>> persons = (Map.Entry) it.next();
     System.out.println("Age " + persons.getKey() + " = " + persons.getValue().size());
}
//上面的 code,首先生成 100 人的信息,然后按照年龄归组,相同年龄的人放到同一个 list 中,如下的输出:
Age 0 = 2
Age 1 = 2
Age 5 = 2
Age 8 = 1
Age 9 = 1
Age 11 = 2
……
Map<Boolean, List<Person>> children = Stream.generate(new PersonSupplier())
    .limit(100).collect(Collectors.partitioningBy(p -> p.getAge() < 18));
System.out.println("Children number: " + children.get(true).size());
System.out.println("Adult number: " + children.get(false).size());
//结果
Children number: 23 
Adult number: 77

8. Stream 的特性归纳:

  • 不是数据结构
  • 它没有内部存储,它只是用操作管道从 source(数据结构、数组、generator function、IO channel)抓取数据。
  • 它也绝不修改自己所封装的底层数据结构的数据。例如 Stream 的 filter 操作会产生一个不包含被过滤元素的新 Stream,而不是从 source 删除那些元素。
  • 所有 Stream 的操作必须以 lambda 表达式为参数
  • 不支持索引访问
  • 你可以请求第一个元素,但无法请求第二个,第三个,或最后一个。不过请参阅下一项。
  • 很容易生成数组或者 List
  • 惰性化
  • 很多 Stream 操作是向后延迟的,一直到它弄清楚了最后需要多少数据才会开始。
  • Intermediate 操作永远是惰性化的。
  • 并行能力
  • 当一个 Stream 是并行化的,就不需要再写多线程代码,所有对它的操作会自动并行进行的。
  • 可以是无限的
    • 集合有固定大小,Stream 则不必。limit(n) 和 findFirst() 这类的 short-circuiting 操作可以对无限的 Stream 进行运算并很快完成。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值