Привет, Хабр! Предлагаю вашему вниманию перевод замечательной статьи из цикла статей небезызвестного Джейка Вортона о том, как происходит поддержка Андроидом Java 8.



Оригинал статьи лежит тут

Несколько лет я работал из дома, и мне часто приходилось слышать, как мои коллеги жалуются на поддержку Андроидом разных версий Java.

Это довольно сложная тема. Для начала нужно определиться, что мы вообще подразумеваем под «поддержкой Java в Android», ведь в одной версии языка может быть много всего: фичи (лямбды, например), байткод, тулзы, APIs, JVM и так далее.

Когда говорят о поддержке Java 8 в Android, обычно подразумевают поддержку фичей языка. Итак, начнем с них.

Лямбды


Одним из главных нововведений Java 8 были лямбды.
Код стал более лаконичным и простым, лямбды избавили нас от необходимости писать громоздкие анонимные классы, используя интерфейс с единственным методом внутри.

class Java8 {
  interface Logger {
    void log(String s);
  }

  public static void main(String... args) {
    sayHi(s -> System.out.println(s));
  }

  private static void sayHi(Logger logger) {
    logger.log("Hello!");
  }
}

После компиляции этого, используя javac и легаси dx tool, мы получим следующую ошибку:

$ javac *.java
$ ls
Java8.java  Java8.class  Java8$Logger.class
$ $ANDROID_HOME/build-tools/28.0.2/dx --dex --output . *.class
Uncaught translation error: com.android.dx.cf.code.SimException:
  ERROR in Java8.main:([Ljava/lang/String;)V:
    invalid opcode ba - invokedynamic requires --min-sdk-version >= 26
    (currently 13)
1 error; aborting

Эта ошибка происходит из-за того, что лямбды используют новую инструкцию в байткоде — invokedynamic, которая была добавлена в Java 7. Из текста ошибки можно увидеть, что Android поддерживает ее только начиная с 26 API (Android 8).

Звучит не очень, ведь вряд ли кто-то будет выпускать приложение с 26 minApi. Чтобы это обойти, используется так называемый процесс десахаризации (desugaring), который делает возможным поддержку лямбд на всех версиях API.

История десахаризации


Она довольно красочна в мире Android. Цель десахаризации всегда одна и та же — позволить новым языковым фичам работать на всех устройствах.

Изначально, например, для поддержки лямбд в Android разработчики подключали плагин Retrolambda. Он использовал тот же встроенный механизм, что и JVM, конвертируя лямбды в классы, но делал это в рантайме, а не во время компиляции. Сгенерированные классы были очень дорогими с точки зрения количества методов, но со временем, после доработок и улучшений, этот показатель снизился до чего-то более-менее разумного.

Затем команда Android анонсировала новый компилятор, который поддерживал все фичи Java 8 и был более производительным. Он был построен поверх Eclipse Java компилятора, но вместо генерации Java-байткода генерировал Dalvik-байткод. Однако его производительность все равно оставляла желать лучшего.

Когда новый компилятор (к счастью) забросили, трансформатор Java байткода в Java байткод, который и выполнял дешугаринг, был интегрирован в Android Gradle Plugin из Bazel — системы сборки Google. И его производительность все равно была невелика, поэтому параллельно продолжался поиск более хорошего решения.

И вот нам представили новый dexerD8, который должен был заменить dx tool. Десахаризация теперь выполнялась во время конвертации скомпилированных JAR-файлов в.dex (dexing). D8 сильно выигрывает в производительности по сравнению с dx, и, начиная с Android Gradle Plugin 3.1 он стал dexer’ом по?? ?умолчанию.

D8


Теперь, используя D8, у нас получится скомпилировать приведенный выше код.

$ java -jar d8.jar     --lib $ANDROID_HOME/platforms/android-28/android.jar     --release     --output .     *.class

$ ls
Java8.java  Java8.class  Java8$Logger.class  classes.dex

Чтобы посмотреть, как D8 преобразовал лямбду, можно использовать dexdump tool, который входит в Android SDK. Она выведет довольно много всего, но мы заострим внимание только на этом:

$ $ANDROID_HOME/build-tools/28.0.2/dexdump -d classes.dex
[0002d8] Java8.main:([Ljava/lang/String;)V
0000: sget-object v0, LJava8$1;.INSTANCE:LJava8$1;
0002: invoke-static {v0}, LJava8;.sayHi:(LJava8$Logger;)V
0005: return-void
[0002a8] Java8.sayHi:(LJava8$Logger;)V
0000: const-string v0, "Hello"
0002: invoke-interface {v1, v0}, LJava8$Logger;.log:(Ljava/lang/String;)V
0005: return-void
…

Если вы до этого еще не читали байткод, не волнуйтесь: многое из того, что здесь написано, можно понять интуитивно.

В первом блоке наш main метод с индексом 0000 получает ссылку от поля INSTANCE на класс Java8$1. Этот класс был сгенерирован во время десахаризации. Байткод метода main тоже нигде не содержит упоминаний о теле нашей лямбды, поэтому, скорее всего, она связана с классом Java8$1. Индекс 0002 затем вызывает static-метод sayHi, используя ссылку на INSTANCE. Методу sayHi требуется Java8$Logger, поэтому, похоже, Java8$1 имплементирует этот интерфейс. Мы можем убедиться в этом тут:

Class #2            -
  Class descriptor  : 'LJava8$1;'
  Access flags      : 0x1011 (PUBLIC FINAL SYNTHETIC)
  Superclass        : 'Ljava/lang/Object;'
  Interfaces        -
    #0              : 'LJava8$Logger;'

Флаг SYNTHETIC означает, что класс Java8$1 был сгенерирован и список интерфейсов, которые он включает, содержит Java8$Logger.
Этот класс и представляет собой нашу лямбду. Если вы посмотрите на реализацию метода log, то не увидите тело лямбды.

…
[00026c] Java8$1.log:(Ljava/lang/String;)V
0000: invoke-static {v1}, LJava8;.lambda$main$0:(Ljava/lang/String;)V
0003: return-void
…

Вместо этого внутри вызывается static метод класса Java8lambda$main$0. Повторюсь, этот метод представлен только в байткоде.

…
    #1              : (in LJava8;)
      name          : 'lambda$main$0'
      type          : '(Ljava/lang/String;)V'
      access        : 0x1008 (STATIC SYNTHETIC)
[0002a0] Java8.lambda$main$0:(Ljava/lang/String;)V
0000: sget-object v0, Ljava/lang/System;.out:Ljava/io/PrintStream;
0002: invoke-virtual {v0, v1}, Ljava/io/PrintStream;.println:(Ljava/lang/String;)V
0005: return-void

Флаг SYNTHETIC снова говорит нам, что этот метод был сгенерирован, и его байткод как раз содержит тело лямбды: вызов System.out.println. Причина, по которой тело лямбды находится внутри Java8.class, простая — ей может понадобиться доступ к private членам класса, к которым сгенерированный класс иметь доступа не будет.

Все, что нужно для понимания того, как работает десахаризация, описано выше. Однако, взглянув на это в байткоде Dalvik, можно увидеть, что там все намного более сложно и пугающе.

Преобразование исходников — Source Transformation


Чтобы лучше понимать, как происходит десахаризация, давайте попробуем шаг за шагом преобразовывать наш класс в то, что будет работать на всех версиях API.

Возьмем за основу тот же класс с лямбдой:

class Java8 {
  interface Logger {
    void log(String s);

  }
  public static void main(String... args) {
    sayHi(s -> System.out.println(s));
  }

  private static void sayHi(Logger logger) {
    logger.log("Hello!");
  }
}

Сначала тело лямбды перемещается в package private метод.

 public static void main(String... args) {  
-    sayHi(s -> System.out.println(s));
+    sayHi(s -> lambda$main$0(s));
   }
+
+  static void lambda$main$0(String s) {
+    System.out.println(s);
+  }

Затем генерируется класс, имплементирующий интерфейс Logger, внутри которого выполняется блок кода из тела лямбды.

   public static void main(String... args) {
-    sayHi(s -> lambda$main$0(s));
+    sayHi(new Java8$1());
   }
@@
 }
+
+class Java8$1 implements Java8.Logger {
+  @Override public void log(String s) {
+    Java8.lambda$main$0(s);
+  }
+}

Далее создается синглтон инстанс Java8$1, который хранится в static переменной INSTANCE.

   public static void main(String... args) {
-    sayHi(new Java8$1());
+    sayHi(Java8$1.INSTANCE);
   }
@@
 class Java8$1 implements Java8.Logger {
+  static final Java8$1 INSTANCE = new Java8$1();
+
   @Override public void log(String s) {

Вот итоговый задешугаренный класс, который может использоваться на всех версиях API:

class Java8 {
  interface Logger {
    void log(String s);
  }

  public static void main(String... args) {
    sayHi(Java8$1.INSTANCE);
  }

  static void lambda$main$0(String s) {
    System.out.println(s);
  }

  private static void sayHi(Logger logger) {
    logger.log("Hello!");
  }
}

class Java8$1 implements Java8.Logger {
  static final Java8$1 INSTANCE = new Java8$1();

  @Override public void log(String s) {
    Java8.lambda$main$0(s);
  }
}

Если вы посмотрите на сгенерированный класс в байткоде Dalvik, то не найдете имен по типу Java8$1 — там будет что-то вроде -$$Lambda$Java8$QkyWJ8jlAksLjYziID4cZLvHwoY. Причина, по которой для класса генерируется такой нейминг, и в чем его плюсы, тянет на отдельную статью.

Нативная поддержка лямбд


Когда мы использовали dx tool, чтобы скомпилировать класс, содержащий лямбды, сообщение об ошибке говорило, что это будет работать только с 26 API.

$ $ANDROID_HOME/build-tools/28.0.2/dx --dex --output . *.class
Uncaught translation error: com.android.dx.cf.code.SimException:
  ERROR in Java8.main:([Ljava/lang/String;)V:
    invalid opcode ba - invokedynamic requires --min-sdk-version >= 26
    (currently 13)
1 error; aborting

Поэтому кажется логичным, что если мы попробуем скомпилировать это с флагом —min-api 26, то десахаризации происходить не будет.

$ java -jar d8.jar     --lib $ANDROID_HOME/platforms/android-28/android.jar     --release     --min-api 26     --output .     *.class

Однако если мы сдампим .dex файл, то в нем все равно можно будет обнаружить -$$Lambda$Java8$QkyWJ8jlAksLjYziID4cZLvHwoY. Почему так? Это баг D8?

Чтобы ответить на этот вопрос, а также почему десахаризация происходит всегда, нам нужно заглянуть внутрь Java-байткода класса Java8.

$ javap -v Java8.class
class Java8 {
  public static void main(java.lang.String...);
    Code:
       0: invokedynamic #2, 0   // InvokeDynamic #0:log:()LJava8$Logger;
       5: invokestatic  #3      // Method sayHi:(LJava8$Logger;)V
       8: return
}
…

Внутри метода main мы снова видим invokedynamic по индексу 0. Второй аргумент в вызове — 0 — индекс ассоциируемого с ним bootstrap метода.

Вот список bootstrap методов:

…
BootstrapMethods:
  0: #27 invokestatic java/lang/invoke/LambdaMetafactory.metafactory:(
                        Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;
                        Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;
                        Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)
                        Ljava/lang/invoke/CallSite;
    Method arguments:
      #28 (Ljava/lang/String;)V
      #29 invokestatic Java8.lambda$main$0:(Ljava/lang/String;)V
      #28 (Ljava/lang/String;)V

Здесь bootstrap метод назван metafactory в классе java.lang.invoke.LambdaMetafactory. Он живет в JDK и занимается созданием анонимных классов налету (on-the-fly) в рантайме для лямбд так же, как и D8 генерит их в компайлтайме.

Если взглянуть на документацию Android к java.lang.invoke
или на исходники AOSP к java.lang.invoke, увидим, что в рантайме этого класса нет. Вот поэтому дешугаринг всегда происходит во время компиляции, независимо от того, какой у вас minApi. VM поддерживает байткод инструкцию, похожую на invokedynamic, но встроенный в JDK LambdaMetafactory недоступен для использования.

Method References


Вместе с лямбдами в Java 8 добавили ссылки на методы — это эффективный способ создать лямбду, тело которой ссылается на уже существующий метод.

Наш интерфейс Logger как раз является таким примером. Тело лямбды ссылалось на System.out.println. Давайте превратим лямбду в метод референc:

   public static void main(String... args) {
-    sayHi(s -> System.out.println(s));
+    sayHi(System.out::println);
   }

Когда мы это скомпилируем и взглянем на байткод, то увидим одно различие с предыдущей версией:

[000268] -$$Lambda$1Osqr2Z9OSwjseX_0FMQJcCG_uM.log:(Ljava/lang/String;)V
0000: iget-object v0, v1, L-$$Lambda$1Osqr2Z9OSwjseX_0FMQJcCG_uM;.f$0:Ljava/io/PrintStream;
0002: invoke-virtual {v0, v2}, Ljava/io/PrintStream;.println:(Ljava/lang/String;)V
0005: return-void

Вместо вызова сгенерированного Java8.lambda$main$0, который содержит вызов System.out.println, теперь System.out.println вызывается напрямую.

Класс с лямбдой больше не static синглтон, а по индексу 0000 в байткоде видно, что мы получаем ссылку на PrintStreamSystem.out, который затем используется для того, чтобы вызвать на нем println.

В итоге наш класс превратился в это:

   public static void main(String... args) {
-    sayHi(System.out::println);
+    sayHi(new -$$Lambda$1Osqr2Z9OSwjseX_0FMQJcCG_uM(System.out));
   }
@@
 }
+
+class -$$Lambda$1Osqr2Z9OSwjseX_0FMQJcCG_uM implements Java8.Logger {
+  private final PrintStream ps;
+
+  -$$Lambda$1Osqr2Z9OSwjseX_0FMQJcCG_uM(PrintStream ps) {
+    this.ps = ps;
+  }
+
+  @Override public void log(String s) {
+    ps.println(s);
+  }
+}

Default и static методы в интерфейсах


Еще одним важным и серьезным изменением, которое принесла Java 8, стала возможность объявлять default и static методы в интерфейсах.

interface Logger {
  void log(String s);

  default void log(String tag, String s) {
    log(tag + ": " + s);
  }

  static Logger systemOut() {
    return System.out::println;
  }
}

Все это тоже поддерживается D8. Используя те же инструменты, что и ранее, несложно увидеть задешугаренную версию Logger’a с default и static методами. Одно из различий с лямбдами и method references в том, что дефолтные и статик методы реализованы в Android VM и, начиная с 24 API, D8 не будет дешугарить их.

Может, просто использовать Kotlin?


Читая статью, большинство из вас, наверное, подумали о Kotlin. Да, он поддерживает все фичи Java 8, но реализованы они kotlinc точно так же, как и D8, за исключением некоторых деталей.

Поэтому поддержка Андроидом новых версий Java до сих пор очень важна, даже если ваш проект на 100% написан на Kotlin.

Не исключено, что в будущем Kotlin перестанет поддерживать байткод Java 6 и Java 7. IntelliJ IDEA, Gradle 5.0 перешли на Java 8. Количество платформ, работающих на более старых JVM, сокращается.

Desugaring APIs


Все это время я рассказывал про фичи Java 8, но ничего не говорил о новых API — стримы, CompletableFuture, date/time и так далее.

Возвращаясь к примеру с Logger’ом, мы можем использовать новый API даты/времени, чтобы узнать, когда сообщения были отправлены.

import java.time.*;

class Java8 {
  interface Logger {
    void log(LocalDateTime time, String s);
  }

  public static void main(String... args) {
    sayHi((time, s) -> System.out.println(time + " " + s));
  }

  private static void sayHi(Logger logger) {
    logger.log(LocalDateTime.now(), "Hello!");
  }
}

Снова компилируем это с помощью javac и преобразуем его в байткод Dalvik с D8, который дешугарит его для поддержки на всех версиях API.

$ javac *.java

$ java -jar d8.jar     --lib $ANDROID_HOME/platforms/android-28/android.jar     --release     --output .     *.class

Можете даже запушить это на свой девайс, чтобы убедиться, что оно работает.

$ adb push classes.dex /sdcard
classes.dex: 1 file pushed. 0.5 MB/s (1620 bytes in 0.003s)

$ adb shell dalvikvm -cp /sdcard/classes.dex Java8
2018-11-19T21:38:23.761 Hello

Если на этом устройстве API 26 и выше, появится месседж Hello. Если нет — увидим следующее:

java.lang.NoClassDefFoundError: Failed resolution of: Ljava/time/LocalDateTime;
  at Java8.sayHi(Java8.java:13)
  at Java8.main(Java8.java:9)

D8 справился с лямбдами, метод референсами, но не сделал ничего для работы с LocalDateTime, и это очень печально.

Разработчикам приходится использовать свои собственные реализации или обертки над date/time api, либо использовать библиотеки по типу ThreeTenBP для работы со временем, но почему то, что ты можешь написать руками, не может сделать D8?

Эпилог


Отсутствие поддержки всех новых API Java 8 остается большой проблемой в экосистеме Android. Ведь вряд ли каждый из нас может позволить указать 26 min API в своем проекте. Библиотеки, поддерживающие и Android и JVM, не могут позволить себе использовать API, представленный нам 5 лет назад!

И даже несмотря на то, что саппорт Java 8 теперь является частью D8, каждый разработчик все равно должен явно указывать source и target compatibility на Java 8. Если вы пишете собственные библиотеки, то можете усилить эту тенденцию, выкладывая библиотеки, которые используют Java 8 байткод (даже если вы не используете новые фичи языка).

Над D8 ведется очень много работ, поэтому, кажется, в будущем с поддержкой фичей языка все будет ок. Даже если вы пишете только на Kotlin, очень важно заставлять команду разработки Android поддерживать все новые версии Java, улучшать байткод и новые API.

Этот пост — письменная версия моего выступления Digging into D8 and R8.

Комментарии (8)


  1. lgorSL
    04.12.2019 13:30

    Не всё так просто. Пробовал на самой новой версии апи (кажется, 28) включить java 1.8 и использовать библиотеку на scala 2.12 — падало в рантайме, какие-то инструкции телефону не нравились.


    1. Bringoff
      04.12.2019 13:40

      Ну дык а библиотека собрана не с помощью D8, очевидно же. Иногда полезно читать статью перед комментированием.

      Здесь bootstrap метод назван metafactory в классе java.lang.invoke.LambdaMetafactory. Он живет в JDK и занимается созданием анонимных классов налету (on-the-fly) в рантайме для лямбд так же, как и D8 генерит их в компайлтайме.

      Если взглянуть на документацию Android к java.lang.invoke
      или на исходники AOSP к java.lang.invoke, увидим, что в рантайме этого класса нет. Вот поэтому дешугаринг всегда происходит во время компиляции, независимо от того, какой у вас minApi. VM поддерживает байткод инструкцию, похожую на invokedynamic, но встроенный в JDK LambdaMetafactory недоступен для использования.


      1. lgorSL
        04.12.2019 13:43

        Мне казалось, что андроид плагин сам конвертирует зависимости, если они используют java8.


        1. Bringoff
          04.12.2019 13:46

          Боюсь, вам только казалось. Такой магии там нет. Зависимость может и стримы использовать, или то же DateTime API, и плагин с этим ничего не сделает.


          1. dev_troy
            05.12.2019 12:45

            Ну не совсем так. В последней альфа версии плагина были добавлены именно бэкпорты некоторых API, включая Stream и DateTime.


            1. Bringoff
              05.12.2019 12:55

              Интересно, не видел еще. Возможно, потому что это альфа :) Надо будет попробовать в будущем, может, отпадет потребность в библиотеках ThreeTenABP и LightweightStreamAPI.
              Но я так понимаю, все равно этот дешугаринг должен происходить при сборке непосредственно библиотеки; с готовыми библиотеками, собранными под Java 8 для стандартной JVM, плагин ничего делать не будет.


        1. Neikist
          04.12.2019 14:45

          Блин, тоже был в этом уверен. Хорошо что узнал об этом на хабре, а не пытаясь что то заставить работать)


      1. Javian
        04.12.2019 15:32

        Спасибо. Теперь мне понятно почему на AOSP падают некоторые приложения, например aliexpress.