Java8指南

5 篇文章 0 订阅

翻译自国外大神博客,地址:java-8-tutorial,大神的博客里面有很多关于Java8讲解的文章,看了之后受益匪浅,写的非常好。

“Java is still not dead—and people are starting to figure that out.”

欢迎来到 Java 8指南. 这篇教程会一步步指导你深入Java8的新特点,在简短的代码示例的支持下,您将学习到如何使用default默认接口方法, lambda表达式, 方法引用(method references) and 重复注解(repeatable annotations). 文章结尾你还会熟悉最新的API改动,比如streams,函数式接口,map扩展,和新的Date API。No walls of text, just a bunch of commented code snippets. Enjoy!

接口默认方法(default)

Java 8让我们可以通过default关键字在接口中添加非抽象的方法,这个特点也叫作虚拟扩展方法(virtual extension methods)。

这是我的第一个示例:

interface Formula {
    double calculate(int a);
​
    default double sqrt(int a) {
        return Math.sqrt(a);
    }
}
除了抽象方法 calculateFormula 接口中还定义了一个默认方法 sqrt,具体的实现类只需要实现抽象方法 calculate即可,默认方法 sqrt的使用非常方便。
Formula formula = new Formula() {
    @Override
    public double calculate(int a) {
        return sqrt(a * 100);
    }
};
​
formula.calculate(100);     // 100.0
formula.sqrt(16);           // 4.0
formula被实现为一个隐匿的Formula对象,这段代码是比较冗长的:花了6行代码去实现一个简单的计算 sqrt(a * 100),在下一节中我们将看到,在Java 8中有一个更好的方式实现单个方法对象。

Lambda 表达式

我们先来看在Java之前的版本中是如何对一个字符串列表进行排序的:

List<String> names = Arrays.asList("peter", "anna", "mike", "xenia");
​
Collections.sort(names, new Comparator<String>() {
    @Override
    public int compare(String a, String b) {
        return b.compareTo(a);
    }
});
静态工具类 Collections.sort 接收一个列表和一个比较器对象,以达到对列表进行排序的目的。你通常会创建匿名的比较器,然后将它们传递给sort方法。现在,再也不需要整天创建匿名对象了,java 8配备了一个更短的句法-lamda表达式:
Collections.sort(names, (String a, String b) -> {
    return b.compareTo(a);
});
可以看到这段代码很简单,而且可读性很高,但是它还可以更简洁:
Collections.sort(names, (String a, String b) -> b.compareTo(a));
对于单行的方法体,你可以很好地跳过 {}return关键字,但是,不可思议的是它还可以更简洁:
names.sort((a, b) -> b.compareTo(a));
因为List中有了一个 sort方法,java编译器能自动读取List中的泛型类型,所以在sort方法中可以不用添加具体类型。那接下来我们就更深一层地去看如何更熟练地使用lamda表达式。

函数式接口

lambda表达式是如何适配Java的类系统的呢?每个lambda对应着一个给定的类型,这个类型是由接口指定的。所谓的函数式接口,必须要包含一个抽象方法声明,给定的类型的每个lambda表达式都会被匹配到这个抽象方法中。当然,由于default方法不是抽象的,你可以随意往函数式接口中添加default方法。只要接口中只包含一个抽象方法,那么我们就可以使用这个接口作为lambda表达式,为了确保您的接口符合要求,您应该添加@FunctionalInterfacee 注解,Java编译器会识别这个注解,如果在接口添加第二个抽象方法,则会提示错误。Example:

@FunctionalInterface
interface Converter<F, T> {
    T convert(F from);
}

Converter<String, Integer> converter = (from) -> Integer.valueOf(from);
Integer converted = converter.convert("123");
System.out.println(converted);    // 123
切记:如果没有 @FunctionalInterface注解,这段代码也是有效的(这个注解只是起到一个编译时的错误提示作用)

方法和构造器引用

上面的示例代码也可以通过简单调用静态方法实现:

Converter<String, Integer> converter = Integer::valueOf;
Integer converted = converter.convert("123");
System.out.println(converted);   // 123
Java8让我们可以通过 ::关键字来调用方法或者构造函数,上面的例子显示的是如何调用静态方法,但是我们也可以调用对象的方法:
class Something {
    String startsWith(String s) {
        return String.valueOf(s.charAt(0));
    }
}

Something something = new Something();
Converter<String, String> converter = something::startsWith;
String converted = converter.convert("Java");
System.out.println(converted);    // "J"
接下来我们来看 ::关键字是如何对构造方法起作用的,首先,我们定义一个示例Person类,并重载了一个构造方法:
class Person {
    String firstName;
    String lastName;
​
    Person() {}
​
    Person(String firstName, String lastName) {
        this.firstName = firstName;
        this.lastName = lastName;
    }
}
接下来我们指定一个 PersonFactory接口来创建新的Person对象:
interface PersonFactory<P extends Person> {
    P create(String firstName, String lastName);
}

我们可以通过调用构造方法将所有内容组合在一起,而不需要手动实现该PersonFactory工厂:

PersonFactory<Person> personFactory = Person::new;
Person person = personFactory.create("Peter", "Parker");
我们可以通过 Person::new来引用Person的构造方法,Java 编译器通过匹配 PersonFactory.create方法的参数自动选择正确的构造函数。

Lambda表达式作用域

从lambda表达式中访问外部变量和匿名对象类似,您可以从局部外部范围以及实例字段和静态变量访问final变量。

局部变量访问

我们可以从lambda表达式的外部范围读取到final修饰的局部变量。

final int num = 1;
Converter<Integer, String> stringConverter =
        (from) -> String.valueOf(from + num);
​
stringConverter.convert(2);     // 3
但是和匿名对象不同的是,变量num不必声明为 final,所以这段代码这么写也是有效的:
int num = 1;
Converter<Integer, String> stringConverter =
        (from) -> String.valueOf(from + num);
​
stringConverter.convert(2);     // 3
然而,对于要编译的代码, num必须被final修饰,下面的这段代码会编译不通过:
int num = 1;
Converter<Integer, String> stringConverter = (from) -> String.valueOf(from + num);
num = 3;
另外,也禁止在lambda 表达式中修改num,也就是说,在lambda表达式中不能改变num的值。

字段和静态变量访问

相对于局部变量来说,我们可以使用lambda 表达式对实例字段和静态变量进行读和写的操作,这就是我们熟知的匿名对象中的操作类似。

class Lambda4 {
    static int outerStaticNum;
    int outerNum;
​
    void testScopes() {
        Converter<Integer, String> stringConverter1 = (from) -> {
            outerNum = 23;
            return String.valueOf(from);
        };
​
        Converter<Integer, String> stringConverter2 = (from) -> {
            outerStaticNum = 72;
            return String.valueOf(from);
        };
    }
}

默认接口方法访问

还记得文章开头那个公式的代码示例吗?Formula接口中定义了一个default方法sqrt,它可以被任何一个包含了匿名对象的formula实例访问,但并不适用于lambda表达式。默认方法不能再lambda表达式内访问,下面的这段代码不能编译通过:

Formula formula = (a) -> sqrt(a * 100);

内置函数式接口

JDK 1.8的API包含了很多内置的函数式接口,一些是大家在旧版本的JDK中很熟悉的,比如Comparator or Runnable。这些已经存在的接口通过@FunctionalInterface 注解被扩展,所以支持lambda表达式。但是Java8的API内置了一大波新函数式接口,让我们的代码编写更加轻松。其中一些新接口是来自很熟知的Google Guava类库,可能你以前已经对这个类库很熟悉,但是你还是应该关注一下这些所谓的函数式接口是如何通过一些扩展方法来扩展的。

Predicates

谓词(Predicates)是含有一个参数的布尔值函数接口,包含了各种用于将谓词组合成复杂逻辑术语(and,or,negate)的默认方法(通俗上来说,就是转换为一个判断条件)。

Predicate<String> predicate = (s) -> s.length() > 0;
​
predicate.test("foo");              // true
predicate.negate().test("foo");     // false
​
Predicate<Boolean> nonNull = Objects::nonNull;
Predicate<Boolean> isNull = Objects::isNull;
​
Predicate<String> isEmpty = String::isEmpty;
Predicate<String> isNotEmpty = isEmpty.negate();

Functions

函数(Functions)接收一个参数,会返回一个结果,接口中的默认方法可用于将多个函数串联起来 (如compose, andThen)。

Function<String, Integer> toInteger = Integer::valueOf;
Function<String, String> backToString = toInteger.andThen(String::valueOf);
​
backToString.apply("123");     // "123"

Suppliers

供应者(Suppliers)能产生一个给定的泛型类型的结果,和Functions不同的是,Suppliers不接收参数(直接调用泛型类中的对象方法)。

Supplier<Person> personSupplier = Person::new;
personSupplier.get();   // new Person

Consumers

消费者(Consumers)代表接收一个输入参数来运行的操作

Consumer<Person> greeter = (p) -> System.out.println("Hello, " + p.firstName);
greeter.accept(new Person("Luke", "Skywalker"));

Comparators

比较器(Comparators)在老版本的Java中是大家所熟知的, Java 8为Comparators接口添加了多个默认方法。

Comparator<Person> comparator = (p1, p2) -> p1.firstName.compareTo(p2.firstName);
​
Person p1 = new Person("John", "Doe");
Person p2 = new Person("Alice", "Wonderland");
​
comparator.compare(p1, p2);             // > 0
comparator.reversed().compare(p1, p2);  // < 0

Optionals

Optionals并不是函数式接口,但是它是一个能很好用于预防NullPointerException的工具,在后面的章节中它是一个很重要的概念,所以这里我们就快速过一下Optionals 是如何运作的。

Optionals是一个简单的容器,容器里面的值可以null也可以不是null,试想一个方法可能会返回一个非空结果,但有时会返回空,在Java8中,你就可以不必返回null,直接返回一个Optional

Optional<String> optional = Optional.of("bam");
​
optional.isPresent();           // true
optional.get();                 // "bam"
optional.orElse("fallback");    // "bam"
​
optional.ifPresent((s) -> System.out.println(s.charAt(0)));     // "b"

Streams

一个java.util.Stream表示可以在其Stream上执行一个或多个操作的元素序列,流操作要么是中间操作(intermediate),要么是最终操作(terminal),当执行最终操作时返回一个确定的类型,中间操作则返回stream本身对象,所以你可以链接多个方法调用。Streams对从像java.util.Collection一样的列表或者集合(Map暂不支持Streams)源创建,Streams操作可以按顺序执行,也可以并行执行。

Streams是极其强大的API,所以我单独写了一个教程Java 8 Streams Tutorial,你也可以去看看web开发相似的资料Sequency(我去看了一下这个框架:是一个前端框架,API和Streams很相似)。我们来看顺序streams 是怎么工作的,首先我们从一个字符串列表作为一个示例源。

List<String> stringCollection = new ArrayList<>();
stringCollection.add("ddd2");
stringCollection.add("aaa2");
stringCollection.add("bbb1");
stringCollection.add("aaa1");
stringCollection.add("bbb3");
stringCollection.add("ccc");
stringCollection.add("bbb2");
stringCollection.add("ddd1");
Java8中的Collections接口被扩展了,所以你可以通过调用Collection.stream()Collection.parallelStream()来创建一个Stream,以下各节将介绍最常见的stream操作。

Filter

Filter接收一个Predicate对象来过滤流中的所有元素,这个操作是中间操作,以致于我们可以在结果后调用别的stream操作(比如说forEach),ForEach 接收一个consumer 对象,过滤后的流的每个元素都会执行此comsumer对象,ForEach 是一个最终操作,返回值为void, 所以我们不能再调用其他的流操作。

stringCollection
    .stream()
    .filter((s) -> s.startsWith("a"))
    .forEach(System.out::println);
​
// "aaa2", "aaa1"

Sorted

Sorted是一个中间操作,返回流的排序视图,所有的元素按自然顺序排序,除非您传递自定义Comparator比较器。

stringCollection
    .stream()
    .sorted()
    .filter((s) -> s.startsWith("a"))
    .forEach(System.out::println);
​
// "aaa1", "aaa2"
记住一点,sorted 只会创建一个stream的排序视图,而不会实际对原有的collection集合进行排序,所以这里stringCollection的顺序是还是原来的顺序的。
System.out.println(stringCollection);
// ddd2, aaa2, bbb1, aaa1, bbb3, ccc, bbb2, ddd1

Map

map是一个中间操作,通过给定函数将每个元素转换为另一个对象,下面的示例将每个字符串转换为了大写的字符串,你也可以使用map将每个对象转换为另一种类型,最终结果流的泛型类型取决于你传递给映射函数的泛型类型。

stringCollection
    .stream()
    .map(String::toUpperCase)
    .sorted((a, b) -> b.compareTo(a))
    .forEach(System.out::println);
​
// "DDD2", "DDD1", "CCC", "BBB3", "BBB2", "AAA2", "AAA1"

Match

可以使用各种Match操作来检查某个predicate是否和当前的stream是否匹配,所有的操作都是最终操作,返回一个boolean值

boolean anyStartsWithA =
    stringCollection
        .stream()
        .anyMatch((s) -> s.startsWith("a"));
​
System.out.println(anyStartsWithA);      // true
​
boolean allStartsWithA =
    stringCollection
        .stream()
        .allMatch((s) -> s.startsWith("a"));
​
System.out.println(allStartsWithA);      // false
​
boolean noneStartsWithZ =
    stringCollection
        .stream()
        .noneMatch((s) -> s.startsWith("z"));
​
System.out.println(noneStartsWithZ);      // true

Count

Count是一个最终操作,返回一个long类型的元素,表示当前流中所有的元素个数。

long startsWithB =
    stringCollection
        .stream()
        .filter((s) -> s.startsWith("b"))
        .count();
​
System.out.println(startsWithB);    // 3

Reduce

Reduce是一个最终操作,根据给定的函数,对流中的元素进行缩减,结果是一个包含了缩减后的元素值得Optional对象。

Optional<String> reduced =
    stringCollection
        .stream()
        .sorted()
        .reduce((s1, s2) -> s1 + "#" + s2);
​
reduced.ifPresent(System.out::println);
// "aaa1#aaa2#bbb1#bbb2#bbb3#ccc#ddd1#ddd2"

Parallel Streams

上面我们提到了,流可以是顺序的也可以是并行的,顺序流的所有操作都是在单个线程执行,而并行流的操作时再多个线程上同时执行的。以下示例证明,很容易通过使用并行流提高性能,首先我们创建一个较大的包含独特元素的list(list中的元素不重复)。

int max = 1000000;
List<String> values = new ArrayList<>(max);
for (int i = 0; i < max; i++) {
    UUID uuid = UUID.randomUUID();
    values.add(uuid.toString());
}
现在我们测试一下对这个集合进行排序需要花费的时间

Sequential Sort

long t0 = System.nanoTime();
​
long count = values.stream().sorted().count();
System.out.println(count);
​
long t1 = System.nanoTime();
​
long millis = TimeUnit.NANOSECONDS.toMillis(t1 - t0);
System.out.println(String.format("sequential sort took: %d ms", millis));
​
// sequential sort took: 899 ms

Parallel Sort

long t0 = System.nanoTime();
​
long count = values.parallelStream().sorted().count();
System.out.println(count);
​
long t1 = System.nanoTime();
​
long millis = TimeUnit.NANOSECONDS.toMillis(t1 - t0);
System.out.println(String.format("parallel sort took: %d ms", millis));
​
// parallel sort took: 472 ms
结果显而易见,这两个代码片段几乎相同,但并行排序大约快50%,你只需要将stream()改为parallelStream()即可。

Maps

我们在前面提到了,maps不直接支持流操作。在Map接口中也没有stream()方法,但是你可以使用方法map.keySet().stream(),map.values().stream()map.entrySet().stream()通过map中的keys、values或者entries创建一个特殊的流。此外,Maps支持执行任务中常见的各种new和有用的方法。

Map<Integer, String> map = new HashMap<>();
​
for (int i = 0; i < 10; i++) {
    map.putIfAbsent(i, "val" + i);
}
​
map.forEach((id, val) -> System.out.println(val));
这段代码是显而易见的,putIfAbsent不让我们进行额外的空值检查,forEach接收一个consumer,为map中的每个值执行操作,下面的这个例子将展示如何利用函数对map中的元素进行计算。
map.computeIfPresent(3, (num, val) -> val + num);
map.get(3);             // val33
​
map.computeIfPresent(9, (num, val) -> null);
map.containsKey(9);     // false
​
map.computeIfAbsent(23, num -> "val" + num);
map.containsKey(23);    // true
​
map.computeIfAbsent(3, num -> "bam");
map.get(3);             // val33
接下来我们学习如何根据给定的key值从map中删除entries(仅当前key映射到的值)
map.remove(3, "val3");
map.get(3);             // val33
​
map.remove(3, "val33");
map.get(3);             // null
另外一个有用的方法:
map.getOrDefault(42, "not found");  // not found
合并map中的entries也是很容易的:
map.merge(9, "val9", (value, newValue) -> value.concat(newValue));
map.get(9);             // val9
​
map.merge(9, "concat", (value, newValue) -> value.concat(newValue));
map.get(9);             // val9concat
合并的操作指的是如果没有当前key值的entry存在,就put当前的key/value到map中,如果存在则合并会更改已存在的值。

Date API

Java 8在包java.time下包含一个全新的日期和时间API,新的API与Joda-Time库相似,但不一样,以下示例涵盖了此新的API中最重要部分。

Clock

Clock中可以获取到当前的日期和时间,Clocks 意味着一个时区,可以用它代替System.currentTimeMillis()来获得从Unix EPOCH至今的毫秒数,时间线上的一个瞬时点由类Instant表示, Instant对象则可以用来创建java.util.Date对象。

Clock clock = Clock.systemDefaultZone();
long millis = clock.millis();
​
Instant instant = clock.instant();
Date legacyDate = Date.from(instant);   // legacy java.util.Date

Timezones

时区是由ZoneId表示的,我们很容易通过静态工厂方法访问到。Timezones 定义了开端,这个开端对于Instant对象和当地时间和日期的转换是相当重要的。

System.out.println(ZoneId.getAvailableZoneIds());
// prints all available timezone ids
​
ZoneId zone1 = ZoneId.of("Europe/Berlin");
ZoneId zone2 = ZoneId.of("Brazil/East");
System.out.println(zone1.getRules());
System.out.println(zone2.getRules());
​
// ZoneRules[currentStandardOffset=+01:00]
// ZoneRules[currentStandardOffset=-03:00]

LocalTime

本地时间(LocalTime)表示没有时区的时间,例如10pm 或者17:30:15,下面的例子是从上面定义好的时区创建两个本地时间,然后我们通过两次比较,并计算两次之间的小时和分钟的差异。

LocalTime now1 = LocalTime.now(zone1);
LocalTime now2 = LocalTime.now(zone2);
​
System.out.println(now1.isBefore(now2));  // false
​
long hoursBetween = ChronoUnit.HOURS.between(now1, now2);
long minutesBetween = ChronoUnit.MINUTES.between(now1, now2);
​
System.out.println(hoursBetween);       // -3
System.out.println(minutesBetween);     // -239
LocalTime自带各种工厂方法,以简化新实例的创建,包括解析时间字符串。
LocalTime late = LocalTime.of(23, 59, 59);
System.out.println(late);       // 23:59:59
​
DateTimeFormatter germanFormatter =
    DateTimeFormatter
        .ofLocalizedTime(FormatStyle.SHORT)
        .withLocale(Locale.GERMAN);
​
LocalTime leetTime = LocalTime.parse("13:37", germanFormatter);
System.out.println(leetTime);   // 13:37

LocalDate

本地日期(LocalDate)表示一个确定的日期,例如 2014-03-11,它是不可变的,并且与LocalTime完全类似。下面的例子会演示如何通过增加或减少天数来计算新的日期、月份和年份,记住一点,每一步都返回一个新的实例。

LocalDate today = LocalDate.now();
LocalDate tomorrow = today.plus(1, ChronoUnit.DAYS);
LocalDate yesterday = tomorrow.minusDays(2);
​
LocalDate independenceDay = LocalDate.of(2014, Month.JULY, 4);
DayOfWeek dayOfWeek = independenceDay.getDayOfWeek();
System.out.println(dayOfWeek);    // FRIDAY
从字符串解析LocalDate就和解析LocalTime一样简单:
DateTimeFormatter germanFormatter =
    DateTimeFormatter
        .ofLocalizedDate(FormatStyle.MEDIUM)
        .withLocale(Locale.GERMAN);
​
LocalDate xmas = LocalDate.parse("24.12.2014", germanFormatter);
System.out.println(xmas);   // 2014-12-24

LocalDateTime

LocalDateTime表示一个date-time格式的时间,它将上述中的日期(LocalDate)和时间(LocalTime)合并为一个实例 ,和LocalDate和LocalTime的机制类似,LocalDateTime是不可变的,我们可以调用方法从一个date-time格式的时间中获取特定的字段。

LocalDateTime sylvester = LocalDateTime.of(2014, Month.DECEMBER, 31, 23, 59, 59);
​
DayOfWeek dayOfWeek = sylvester.getDayOfWeek();
System.out.println(dayOfWeek);      // WEDNESDAY
​
Month month = sylvester.getMonth();
System.out.println(month);          // DECEMBER
​
long minuteOfDay = sylvester.getLong(ChronoField.MINUTE_OF_DAY);
System.out.println(minuteOfDay);    // 1439
它还可以转换为一个带有时区附加信息的Instant对象,Instant对象可以很容易转换为java.util.Date类型的合法日期。
Instant instant = sylvester
        .atZone(ZoneId.systemDefault())
        .toInstant();
​
Date legacyDate = Date.from(instant);
System.out.println(legacyDate);     // Wed Dec 31 23:59:59 CET 2014
格式化date-time格式的日期就和格式化时间和日期一样,我们可以使用自定义模式创建格式化程序(formatters),而不是使用预定义的格式。
DateTimeFormatter formatter =
    DateTimeFormatter
        .ofPattern("MMM dd, yyyy - HH:mm");
​
LocalDateTime parsed = LocalDateTime.parse("Nov 03, 2014 - 07:13", formatter);
String string = formatter.format(parsed);
System.out.println(string);     // Nov 03, 2014 - 07:13
java.text.NumberFormat不一样,新的DateTimeFormatter 是不可变的,并且是线程安全的。具体信息可以参考这里的格式语法。

Annotations

Java8中的注解是可以重复使用的,所谓的重复使用,是指可以在同一个方法中多次使用同一个注解,话不多说,我们直接通过一个例子来说明:首先,我们定义一个封装注解,包含了一个实际注解的数组:

@interface Hints {
    Hint[] value();
}
​
@Repeatable(Hints.class)
@interface Hint {
    String value();
}
Java 8 允许我们通过声明 @Repeatable注解在同一个类使用多个相同的注解。

情形1: 使用容器注解 (传统写法)

@Hints({@Hint("hint1"), @Hint("hint2")})
class Person {}

情形2: 使用重复注解 (新的写法)

@Hint("hint1")
@Hint("hint2")
class Person {}
在情形2的情况下,Java编译器在底层隐式地设置@Hints注解,这对于通过反射读取注释信息非常重要。
Hint hint = Person.class.getAnnotation(Hint.class);
System.out.println(hint);                   // null
​
Hints hints1 = Person.class.getAnnotation(Hints.class);
System.out.println(hints1.value().length);  // 2
​
Hint[] hints2 = Person.class.getAnnotationsByType(Hint.class);
System.out.println(hints2.length);          // 2
即使我们没有在Person类中声明@Hints注解,通过getAnnotation(Hints.class)仍然可以读取到@Hints 注解。其实,最简便的方法是getAnnotationsByType,通过这个方法可以直接访问到所有的@Hint注解。另外,在Java8中注解的使用扩增了两个新的目标(Target):
@Target({ElementType.TYPE_PARAMETER, ElementType.TYPE_USE})
@interface MyAnnotation {}

相关链接

Java8指南也就到此结束啦,如果您想要学习更多关于JDK8 API的新类以及新的特点,可以去看看原博主的JDK8 API Explorer,这篇文章列出了JDK8中所有的新类,以及JDK8中不容易发现的实用的东西,像Arrays.parallelSort,StampedLock and CompletableFuture

下面是原博主发表的一些后续的文章,如果感兴趣的可以去看一下:

写在后面的话:上面的文章都写的非常好,真的值得去细读,原谅我翻译水平有限,如有不恰当或翻译不正确的地方,还望在评论中指出,我会修正,谢谢!

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值