Java 簡潔程式碼利器 —— Lambda 表示式

lymxit發表於2018-01-25

Java 8 特性(Lambda 表示式 with Android)

[TOC]

參考資料

http://www.importnew.com/16436.html

http://www.infoq.com/cn/articles/Java-8-Lambdas-A-Peek-Under-the-Hood

https://github.com/bazelbuild/bazel/blob/master/src/tools/android/java/com/google/devtools/build/android/desugar

《Mastering Lambdas- Java Programming in a Multicore World》

http://www.lambdafaq.org/

Lambdas in Java: An In-Depth Analysis

http://www.javac.info/

淺談Java 7的閉包與Lambda表示式之優劣

https://martinfowler.com/bliki/Lambda.html

A Definition of Closures

The Original 'Lambda Papers' by Guy Steele and Gerald Sussman

http://viralpatel.net/blogs/Lambda-expressions-java-tutorial/

https://stackoverflow.com/questions/21887358/reflection-type-inference-on-java-8-lambdas

歷史

-2. 1997 年,內部類的加入

-1. 2006 年 8 月,Closures for Java,初步提出想法

-0.5. 2009年 4 月,Oracle 收購 Sun 公司

  1. 2009 年 12 月,正式提出想法: Project Lambda: The Straw-Man Proposal
  2. 2010 年 1 月, 0.1 ver
  3. 2010 年 2 月, 0.15 ver
  4. 2010 年 4 月,Neal Gafter 提出:Lambda 會趕不上 JDK 7 的釋出(主要是不能達到釋出的標準),於是延遲到 Java 8
  5. 2010 年 11 月, JSR 335 出世,Lambda Expressions for the JavaTM Programming Language
  6. 2014 年 5 月, JSR 335 釋出最終版本,Java 終於迎來了 Lambda

基本定義

λ演算(英語:lambda calculus,λ-calculus)是一套從數學邏輯中發展,以變數繫結和替換的規則,來研究函式如何抽象化定義、函式如何被應用以及遞迴形式系統

雖然 Java 已經使用泛型繼承來對資料進行抽象,但還沒有合適的手段來抽象資料處理過程,因此 Lambda 表示式就是為了解決這個問題而引入的。

Lambda 在 程式語言上主要有兩大特性:匿名函式和閉包。

匿名函式

在計算機中,Lambda 一般是匿名函式,而匿名函式本質上就是一個函式,它所抽象出來的東西是一組運算

比如說我們想給某個陣列都加1,就能非常簡潔的實現。

Arrays.asList(1,2,3,4)
        .stream()
        .map(i -> i+1// 列表全+1,但注意其實這裡沒有改變原來的列表,而是重新生成新列表                          // (無副作用)
        .forEach(i -> System.out.println(i));
複製程式碼

閉包

Java 不完全直接支援函式閉包(針對區域性變數),但你可以通過類或者陣列來模擬閉包。

  1. 直接使用函式閉包
public static Map<String, Supplier> createCounter(int initValue) { 
    // the enclosing scope
    int count = initValue;   // this variable is effectively final
    Map<String, Supplier> map = new HashMap<>();
    map.put("val", () -> count);
    map.put("inc", () -> count++);  // error,can't compile
    return map;
}
複製程式碼
  1. Java 通過類來實現閉包
private static class MyClosure {
    public int value;
    public MyClosure(int initValue) { this.value = initValue; }
}

public static Map<String, Supplier> createCounterWithClosure(int initValue) {
    MyClosure closure = new MyClosure(initValue);  // ugly
    Map<String, Supplier> counter = new HashMap<>();
    counter.put("val", () -> closure.value);
    counter.put("inc", () -> closure.value++); 
    return counter;
}
// also can represent this way
 public static Map<String, Supplier> createCounter(int initValue) { 
        final int[] count = {initValue}; // use an array wrapper
        Map<String, Supplier> map = new HashMap<>();
        map.put("val", () -> count[0]);
        map.put("inc", () -> count[0]++);  
        return map;
    }
複製程式碼

Java 沒有像 JavaScript 一樣,從語法上支援這種特性,主要有幾點原因:

1.**多執行緒.**如果允許修改區域性變數,那麼在多執行緒情況下又得加多考慮,比如說同步問題。

比如說你的方法是在主執行緒執行的,而 Lambda 表示式又是在子執行緒建立的,那麼如果在

Lambda 表示式中修改區域性變數,主執行緒的同步應該怎麼處理?而且,這樣就要把變數分配

在堆上面,還是有相當一部分 Java 程式設計師對這個不爽的(沒有使用 new 關鍵字居然你

要在堆上面分配記憶體??)。

還可能引發記憶體洩漏的情況(主執行緒跑完了,結果子執行緒 hold 住了引用)。

2.**效能問題.**這是由上一個問題帶來的,如果需要同步,加鎖、volatile、CAS都有一定的效能損耗

3.**沒必要.**在函式式的思維中,在 Lambda 中修改變數其實就代表著 副作用。消除副作用是通過

​ 區域性變數交給下一個方法去處理,這還能提高併發程式的容錯性,所以最好不要使用之前說的

​ 技巧來實現這種特性。

int sum = 0;
integerList.forEach(e -> { sum += e; };  //wrong
// should do this way
int sum = integerList.stream()   // java 8 way
                    .mapToInt(Integer::intValue)  // 減少裝箱帶來的成本,提高效能
                    .sum();
複製程式碼

Java Lambda

表示式語法

(params) -> expression

(params) -> statement

(params) -> { statements }

list.stream().map(i -> i+1);  // (params) -> expression

run(() -> System.out.println(233));  // (params) -> statement

run(() ->{   //(params) -> { statements }
    System.out.println(233);
    System.out.println(123);
});

private static void run(Runnable r){
   r.run();
}

// some other lambda sample
1. (int x, int y) -> x + y                          
// takes two integers and returns their sum
2. (x, y) -> x - y                                  
// takes two numbers and returns their difference
3. () -> 42                                         
// takes no values and returns the secret of the universe 
4. (String s) -> System.out.println(s)              
// takes a string, prints its value to the console, and returns nothing 
5. x -> 2 * x                                       
// takes a number and returns the result of doubling it
6. c -> { int s = c.size(); c.clear(); return s; } 
// takes a collection, clears it, and returns its previous size
複製程式碼

特點:

1.可以看到引數型別可以顯示宣告出來(比如說 String ),也可以讓編譯器自動推匯出來。

2.如果是 statements 的話,需要顯式地去 return。

3.如果只有一個引數,可以省略括號;但如果是零個引數或者多個引數,則需要括號。

Tips:

通常都會把lambda表示式內部變數的名字起得短一些。這樣能使程式碼更簡短,放在同一行。所以,變數名選用a、b或者x、y會比even、odd要好。

Why Lambda ?

於 Java 而言, Lambda 表示式最大的作用就可以可以結合 Java 8 的 Stream 特性,更容易去併發地操作集合。

之前如果想併發地去處理list,需要開發者去維護這些程式碼(用鎖或者CAS等同步機制),但在 1.8 中,可以交給類庫去處理這些事情,開發者只需要寫好業務程式碼即可。

然而,為了讓開發者來享受這些好處的話, 需要提供一個收集方法的函式。 但我們知道 Java 中函式並不是一等公民,傳參並不能傳函式,如果使用匿名內部類的話就太笨拙了,因此 Lambda 就此誕生。

如果專案中使用了 RxJava 的話,如果不使用 Lambda 的話,其實整個程式碼依舊還是沒有那麼優雅的。

此外,使用 Lambda 並不像 匿名內部類一樣引入了一個新的名稱空間,如果使用匿名內部類:

public class Test {
  public static void main(String args[]){
      Test t = new Test();
      t.run();
  }
  
  public void run(){
        new EnClosing(){
            @Override
            public void run() {
                System.out.println(toString()); // "EnClosing"
                // use outclass's toString(awkward)
                System.out.println(Test.this.toString()); //"Test"
            }
        };
    }
  @Override
  public String toString() {
      return "Test";
  }
  public class EnClosing{
          public void run(){
  
          }
          @Override
          public String toString() {
              return "EnClosing";
          }
      }
}
複製程式碼

但是其實這一點也是被吐槽之後再改的,在第一版的 Java7 Lambda 中,其實並沒有解決以上問題。

Lambda 實戰

一般來說,jdk中可用 Lambda 替換的介面都標註了@FunctionalInterface

這個介面的作用就是檢查介面是否是SAM(Single Abstract Method 單個抽象方法)的作用,如果註解不是在介面上,或者這個介面有多個未實現的介面,那麼IDE就會報錯。

mark

那既然這個介面只有一個抽象方法的話,為何在呼叫的時候還這麼麻煩,例如:

default void forEach(Consumer<T> action) {
    for (T t : this) {
        action.accept(t);  // why not just use `action(t)`?
        //action(t);
    }
}
複製程式碼

大概原因是這樣的:因為方法的名稱空間與變數的名稱空間是沒有衝突的,那麼有時候可能會造成困惑,而且要單獨為 Lambda 表示式創造新的語法可能會讓很多 Java 開發者造成誤解(笑)。

感興趣的話可以看看: 名詞王國 Java

void action(T t) { ... }

default void forEach(Consumer<T> action) {
    for (T t : this) {
        action(t);  // who use use it? the local variable or the method?
    }
}
複製程式碼

事件繫結

// before
mPMChatBottomView.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                LogUtil.d(TAG, "ClickView: " + v);
            }
        });

// after
mPMChatBottomView.setOnClickListener((v) -> LogUtil.d(TAG, "ClickView: " + v));
複製程式碼

排序

List<Integer> list = Arrays.asList(4,3,2,1,7,8,9,5,10);
// 偶數排序在前,奇數在後
// 2 4 8 10 1 3 5 7 9
Collections.sort(list,(a,b) -> {
    boolean remainder1 = (a % 2 == 0);
    boolean remainder2 = (b % 2 == 0);
    return remainder1?(remainder2?a-b:-1):(remainder2?1:a-b);
});
複製程式碼

handler post

mainHandler.post(() -> tv.setText("test"));
複製程式碼

JDK's Lambda

在 java.util.function 中,包含了很多介面:

mark

這邊舉一個例子,有一個介面叫做 Predicate,一般可以用來做過濾:

public interface Predicate<T> {
    // 校驗引數是否符合要求
    boolean test(T t);
	// 兩個條件共同檢查
    default Predicate<T> and(Predicate<? super T> other) {
        Objects.requireNonNull(other);
        return (t) -> test(t) && other.test(t);
    }
    // 取反
    default Predicate<T> negate() {
        return (t) -> !test(t);
    }
    // 類似於 || 
    default Predicate<T> or(Predicate<? super T> other) {
        Objects.requireNonNull(other);
        return (t) -> test(t) || other.test(t);
    }
    // 生產出一個Predicate,用來檢查兩個物件是否相等,在列表操作中很給力
    static <T> Predicate<T> isEqual(Object targetRef) {
        return (null == targetRef)
                ? Objects::isNull
                : object -> targetRef.equals(object);
    }
}
複製程式碼

使用案例:

public static void main(String args[]){
    List<String> languages = Arrays.asList("Java", "Scala", "C++", "Haskell", "Lisp");

    System.out.println("Languages which starts with J :");
    filter(languages, (str)->str.startsWith("J"));

    System.out.println("Languages which ends with a ");
    filter(languages, (str)->str.endsWith("a"));

    System.out.println("Print all languages :");
    filter(languages, (str)->true);

    System.out.println("Print no language : ");
    filter(languages, (str)->false);

    System.out.println("Print language whose length greater than 4:");
    filter(languages, (str)->str.length() > 4);

    System.out.println("Print Only Lisp!");
    filter(languages, Predicate.isEqual("Lisp"));

}


public static <T> void filter(List<T> names, Predicate<T> condition) {
    for(T name: names)  {
        if(condition.test(name)) {
            System.out.println(name + " ");
        }
    }
    // can also present this simple way
    names.stream().filter(condition);
}
複製程式碼

結果:

Languages which starts with J : Java Languages which ends with a Java Scala Print all languages : Java Scala C++ Haskell Lisp Print no language : Print language whose length greater than 4: Scala Haskell Print Only Lisp! Lisp

利用 or 和 and:

Predicate<String> startsWithJ = (n) -> n.startsWith("J");
Predicate<String> fourLetterLong = (n) -> n.length() == 4;
languages.stream()
        .filter(startsWithJ.and(fourLetterLong))
        .forEach((n) -> System.out.println("nName, which starts with 'J' and four letter long is : " + n));

languages.stream()
        .filter(startsWithJ.or(fourLetterLong))
        .forEach((n) -> System.out.println("nName, which starts with 'J' or four letter long is : " + n));
複製程式碼

結果:

nName, which starts with 'J' and four letter long is : Java nName, which starts with 'J' or four letter long is : Java nName, which starts with 'J' or four letter long is : Lisp

使用Stream:

// 將字串換成大寫並用逗號連結起來
List<String> G7 = Arrays.asList("USA", "Japan", "France", "Germany", "Italy", "U.K.","Canada");
String G7Countries = G7.stream().map(x -> x.toUpperCase()).collect(Collectors.joining(", "));
System.out.println(G7Countries);
複製程式碼

結果:

USA, JAPAN, FRANCE, GERMANY, ITALY, U.K., CANADA

基本的介面如下,他們都是為了 Stream 類庫服務的(這些操作符的詳細在此不做介紹):

mark

如果有用過 RxJava 的,對以上這些介面肯定非常熟悉,因為 RxJava 是基本複用了這些名稱,而且做到了 Java 6 的相容。

但要注意到 Stream 和 Observable 的區別在於, Java 8 的 Stream 只能使用一次,pull-base,而 Observable 可以被訂閱多次,而且是 push-base。

Lambda表示式與異常

Lambda 表示式對於 Exception 的處理還是很挫的,主要的問題就是 checked Exception。

List<File> files  = getFileList();
files.stream().map((File a) -> a.getCanonicalPath());  // can't compile
// you can't do like this
try{
    files.stream().map((File a) -> a.getCanonicalPath());
} catch (IOException e){
    e.printStackTrace();
}
// you need do this (garbage code)
files.stream().map((File a) -> {
    try {
        return a.getCanonicalPath();
    } catch (IOException e) {
        e.printStackTrace();
    }
    return null;
});

複製程式碼

不過最好在函數語言程式設計中,Exception 意味著副作用,所以 Exception 和 lambda 表示式並不協調。

在平時程式設計中,儘可能少用 Checked Exception。

Mehtod References(方法引用)

mark

用於更加簡化 Lambda 方法,可以與 Lambda 表示式相互翻譯:

// static method
Arrays.sort(integerArray, Integer::compareUnsigned);
Arrays.sort(integerArray, (x,y) -> Integer.compareUnsigned(x, y));
// Bound Instance,固定例項呼叫
list.forEach(System.out::println);
list.forEach(x -> System.out.println(x));
// Unbound Instance,不同例項呼叫
list.forEach(Age::printAge);
list.forEach(age -> age.printAge());
// 建構函式
List<String> strList = Arrays.asList("1","2","3");
List<Integer> intList = strList.stream().map(Integer::new).collect(Collectors.toList());
List<Integer> intList = 
strList.stream().map(s -> new Integer(s)).collect(Collectors.toList());
複製程式碼

Default methods

Default methods 引入的原因就是為了擴充套件集合類的介面,但同時又不影響開發者(因為每在介面中新引入一個方法就要求實現類必須全部實現)。

例如List.foreach

default void forEach(Consumer<? super T> action) {
    Objects.requireNonNull(action);
    for (T t : this) {
        action.accept(t);
    }
}
複製程式碼

感覺引入了這些新特性之後,介面反而慢慢向著抽象類的層面在發展了,這可能也是因為 Java 單繼承所需要付出的一些妥協吧。

但如果 Default 方法與父類方法有衝突怎麼辦?

這裡有一套原則:

  1. Classes always win. 父類中的方法優先順序永遠大於 default 方法
  2. 否則,如果多個介面中都有一樣的 Default 介面,那麼更具體的介面方法優先。
    public interface A {
        default void hello() { System.out.println("Hello World from A"); }
    }
    public interface B extends A {
        default void hello() { System.out.println("Hello World from B"); }
    }
    public class C implements B, A {
        public static void main(String... args) {
            new C().hello();  // print Hello World from B
        }
    } 
複製程式碼

但如果 A 和 B 之間沒有任何關係呢? 那麼這就會引發一個衝突,具體可以參見:

http://www.lambdafaq.org/how-are-conflicting-method-declarations-resolved/

關於 Java 集合框架的未來設想

隨著硬體成本的降低,多核高效能伺服器已經越來越普遍,那麼並行併發已經成為一個老大難問題了,Java 類庫的成員們就想著可以來通過 函數語言程式設計 的一些特點來簡化併發程式設計,從 Java 7 的 Fork-Join 開始,到 Java 8 的 Stream 庫,Java Library 都想讓開發者儘可能地減少開發工作量。

但目前 Java 8 的 Stream 在轉換過程中就產生許多中間變數,這對於 GC 型別的語言而言不是一件好事,因此這套類庫將來一定會慢慢優化效率(底層併發更加靈活和簡單)和記憶體(類似於 rxJava -> rxJava2),對於開發者而言卻不用關係,因為 API 依舊放在那裡,如果將來想提高效率的話,很簡單,直接 switch Java 8 到 Java 9 就 OK 了。

Stream API 的設計哲學起源於 Unix 系統中的 pipeline

Lambda 與泛型

雖然看起來 Lambda 可以完全替代內部類,但要注意其實它也是有坑:無法獲取泛型資訊。

public class Erasure {
    static class RetainedFunction implements Function<Integer,String> {
        public String apply(Integer t) {
            return String.valueOf(t);
        }
    }

    public static void main(String[] args) throws Exception {
        Function<Integer,String> f0 = new RetainedFunction();
        Function<Integer,String> f1 = new Function<Integer,String>() {
            public String apply(Integer t) {
                return String.valueOf(t);
            }
        };
        Function<Integer,String> f2 = String::valueOf;
        Function<Integer,String> f3 = i -> String.valueOf(i);

        for (Function<Integer,String> f : Arrays.asList(f0, f1, f2, f3)) {
            try {
                System.out.println(f.getClass().getMethod("apply", Integer.class).toString());
            } catch (NoSuchMethodException e) {
                System.out.println(f.getClass().getMethod("apply", Object.class).toString());
            }
            System.out.println(Arrays.toString(f.getClass().getGenericInterfaces()));
        }
    }
}
複製程式碼

結果:

public java.lang.String com.company.Erasure$RetainedFunction.apply(java.lang.Integer) [java.util.function.Function<java.lang.Integer, java.lang.String>]

public java.lang.String com.company.Erasure$1.apply(java.lang.Integer) [java.util.function.Function<java.lang.Integer, java.lang.String>]

public java.lang.Object com.company.Erasure$$Lambda$1/1096979270.apply(java.lang.Object) [interface java.util.function.Function]

public java.lang.Object com.company.Erasure$$Lambda$2/1747585824.apply(java.lang.Object) [interface java.util.function.Function]

可以看到 Lambda 表示式並沒有泛型資訊,所以在某些需要獲取泛型資訊的地方,不要使用 Lambda表示式。

例如我們專案中的事件匯流排,如果使用 Lambda 表示式,就會拿不到泛型:

public interface OnEvent<T> {
    void onRecv(T event);
}

public <T> Eventor addOnEvent(OnEvent<T> onEvent){
    int code = parseCode(onEvent); // 根據泛型資訊獲取 hashcode
    if(code == -1) {
        throw new RuntimeException("addOnEvent ERROR!!!");
    }
    cbMap.put(code, onEvent);
    EventImpl.getInstance().register(onEvent);
    return this;
}

int parseCode(OnEvent cb){
    if(cb != null){
        Type[] genericType = cb.getClass().getGenericInterfaces();
        if(genericType[0] instanceof ParameterizedType){
            ParameterizedType p = (ParameterizedType)genericType[0];
            Type[] args = p.getActualTypeArguments();
            int code = args[0].hashCode();
            return code;
        }
    }
    return -1;
}
複製程式碼

雖然某些專案可以利用 字串常量池 來獲取到 Lambda 的型別資訊,但相容性等方案不太好,因此可以考慮放棄。

具體實現

絕大多數的翻譯策略都是翻譯為內部類(繼承自Object),但這裡為了幫助理解,用方法的角度呈現。

主要分為兩種情況考慮(捕獲變數和不捕獲變數):

1、不捕獲變數:

類似於生成這樣的方法:

private static java.lang.Object lambda$0(java.lang.String);

需要注意的是,這裡的$0並不是代表內部類,這裡僅僅是為了展示編譯後的程式碼而已。

由於這種方法沒有捕獲外部變數,所以 Lambda 工廠會將其快取起來,達到複用的目的。

匿名內部類通常需要我們手動的進行優化(static 匿名內部類 + 單例)來避免額外物件生成,而對於不進行變數捕獲的Lambda表示式,JVM已經為我們做好了優化(單例快取)。

2、捕獲變數:

例如:

int offset = 100;
Function<String, Integer> f = s -> Integer.parseInt(s) + offset; 
複製程式碼

對於捕獲變數的Lambda表示式情況有點複雜,同前面一樣 Lambda 表示式依然會被提取到一個靜態方法中,不同的是被捕獲的變數同正常的引數一樣傳入到這個方法中,因此會生成這樣的實現:

static Integer lambda$1(int offset, String s) {    
  return Integer.parseInt(s) + offset;
}
複製程式碼

這也解釋了為什麼我們無法修改引用的外部變數,因為它是作為引數引入的,和非 static 的匿名內部類操作基本相同。

但如果Lambda表示式用到了類的例項的屬性,其對應生成的方法可以是例項方法,而不是靜態方法,這樣可以避免傳入多餘的引數。

函式式對於併發的支援做的比命令式要好的多,如果可能的話,儘量減少捕獲外部變數。

因為捕獲變數不僅僅可能會隱式生成橋接方法(為了訪問外部 private 變數而生成的 accesss$num 方法),而且 Lambda 表示式也不能達到重用的目的(單例快取),在多執行緒環境下還可能出現同步問題。

擁抱函式式,消除副作用

Why Lambda use invokedynamic?

Java 8的Lambda表示式為什麼要基於invokedynamic?

Java 8 中的Lambda表示式是基於 invokedynamic 來在執行時動態生成匿名類位元組碼。

這樣很明顯會帶來額外的執行時開銷,所以為何這麼設計呢?

假設 Lambda 表示式直接在編譯過程中生成位元組碼(在解語法糖階段)。

那麼將來在優化的時候就很難進行了(源於之前踩過的坑)。

所以好處1:可以將來動態進行優化(目前是使用內部類,將來可以使用優化後的 MethodHandle 【這個暫時會有反射懲罰】)。

好處2:減少了編譯期生成的位元組碼大小,不同 JVM 語言的實現上可以不一樣。

Android 中的 Lambda

Android Studio 3.0 使用 Lambda 的祕密

由於 DVM 和 ART 已經在跑了,所以也不可能說修改版本去支援 Java 8 特性。

具體來說(sdk < 24 equals Java 7)、(sdk >= 24 equals Java 8)

所以怎麼才能讓開發者既能使用 Lambda,又能相容老的系統呢?

那 Android team 團隊決定使用 Desugar.java 來解語法糖了。

mark

具體我大概看了一下,

InvokeDynamicLambdaMethodCollector 先使用訪問者模式訪問所有使用了 InvokeDynamic 的位元組碼,來收集專案中所有的 Lambda 表示式,然後交給 LambdaDesugaring 去解語法糖。

LambdaDusugaring中,使用了內部類 InvokedynamicRewriter 去為每個 Lambda 表示式都生成一個 class 名稱,具體名稱格式為:

internalName + "$$Lambda$" + (lambdaCount++)

舉例來說:LogInActivity$$Lambda$1

然後將 Lambda 類名和需要生成的橋接方法等資訊,傳給交給 LambdaClassMaker 去生成具體的匿名內部類(通過 java.lang.invoke.MethodHandle 去生成)。

生成類結束後,如果是沒有捕獲外部變數的 Lambda 表示式(stateless lambda classes),會在之前的內部類上 生成一個單例物件

// 生成單例物件
super.visitFieldInsn(
              Opcodes.GETSTATIC,
              lambdaClassName,
              LambdaClassFixer.SINGLETON_FIELD_NAME,
              desc.substring("()".length()));
複製程式碼

使用 Lambda 表示式會相對而言增加一些編譯時間,具體時間應該和類的數量有關係。

不過我看到作者的一個 todo :

TODO(kmb): Scan constant pool instead of visiting the class to find bootstrap methods etc.

如果是掃描常量池的話,應該掃描時間會降低不少。

Android 使用 Lambda 的注意事項

如果你是開發一個 Library 給別人使用,Ok,暫時不要使用 Java 1.8

如果你是多 Module 的專案,一定要在主 Module 中設定 使用 Java 1.8

具體原因請參照:https://developer.android.com/studio/write/java8-support.html

相關文章