Java8指南

翻譯自國外大神博客,地址: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

下面是原博主發表的一些後續的文章,如果感興趣的可以去看一下:

寫在後面的話:上面的文章都寫的非常好,真的值得去細讀,原諒我翻譯水平有限,如有不恰當或翻譯不正確的地方,還望在評論中指出,我會修正,謝謝!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章