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

Оригинал статьи лежит тут
Несколько лет я работал из дома, и мне часто приходилось слышать, как мои коллеги жалуются на поддержку Андроидом разных версий Java.
Это довольно сложная тема. Для начала нужно определиться, что мы вообще подразумеваем под «поддержкой Java в Android», ведь в одной версии языка может быть много всего: фичи (лямбды, например), байткод, тулзы, APIs, JVM и так далее.
Когда говорят о поддержке Java 8 в Android, обычно подразумевают поддержку фичей языка. Итак, начнем с них.
Одним из главных нововведений Java 8 были лямбды.
Код стал более лаконичным и простым, лямбды избавили нас от необходимости писать громоздкие анонимные классы, используя интерфейс с единственным методом внутри.
После компиляции этого, используя javac и легаси
Эта ошибка происходит из-за того, что лямбды используют новую инструкцию в байткоде —
Звучит не очень, ведь вряд ли кто-то будет выпускать приложение с 26 minApi. Чтобы это обойти, используется так называемый процесс десахаризации (desugaring), который делает возможным поддержку лямбд на всех версиях API.
Она довольно красочна в мире Android. Цель десахаризации всегда одна и та же — позволить новым языковым фичам работать на всех устройствах.
Изначально, например, для поддержки лямбд в Android разработчики подключали плагин Retrolambda. Он использовал тот же встроенный механизм, что и JVM, конвертируя лямбды в классы, но делал это в рантайме, а не во время компиляции. Сгенерированные классы были очень дорогими с точки зрения количества методов, но со временем, после доработок и улучшений, этот показатель снизился до чего-то более-менее разумного.
Затем команда Android анонсировала новый компилятор, который поддерживал все фичи Java 8 и был более производительным. Он был построен поверх Eclipse Java компилятора, но вместо генерации Java-байткода генерировал Dalvik-байткод. Однако его производительность все равно оставляла желать лучшего.
Когда новый компилятор (к счастью) забросили, трансформатор Java байткода в Java байткод, который и выполнял дешугаринг, был интегрирован в Android Gradle Plugin из Bazel — системы сборки Google. И его производительность все равно была невелика, поэтому параллельно продолжался поиск более хорошего решения.
И вот нам
Теперь, используя D8, у нас получится скомпилировать приведенный выше код.
Чтобы посмотреть, как D8 преобразовал лямбду, можно использовать
Если вы до этого еще не читали байткод, не волнуйтесь: многое из того, что здесь написано, можно понять интуитивно.
В первом блоке наш
Флаг
Этот класс и представляет собой нашу лямбду. Если вы посмотрите на реализацию метода
Вместо этого внутри вызывается
Флаг
Все, что нужно для понимания того, как работает десахаризация, описано выше. Однако, взглянув на это в байткоде Dalvik, можно увидеть, что там все намного более сложно и пугающе.
Чтобы лучше понимать, как происходит десахаризация, давайте попробуем шаг за шагом преобразовывать наш класс в то, что будет работать на всех версиях API.
Возьмем за основу тот же класс с лямбдой:
Сначала тело лямбды перемещается в
Затем генерируется класс, имплементирующий интерфейс
Далее создается синглтон инстанс
Вот итоговый задешугаренный класс, который может использоваться на всех версиях API:
Если вы посмотрите на сгенерированный класс в байткоде Dalvik, то не найдете имен по типу Java8$1 — там будет что-то вроде
Когда мы использовали
Поэтому кажется логичным, что если мы попробуем скомпилировать это с флагом
Однако если мы сдампим
Чтобы ответить на этот вопрос, а также почему десахаризация происходит всегда, нам нужно заглянуть внутрь Java-байткода класса
Внутри метода
Вот список bootstrap методов:
Здесь bootstrap метод назван
Если взглянуть на
или на
Вместе с лямбдами в Java 8 добавили ссылки на методы — это эффективный способ создать лямбду, тело которой ссылается на уже существующий метод.
Наш интерфейс
Когда мы это скомпилируем и взглянем на байткод, то увидим одно различие с предыдущей версией:
Вместо вызова сгенерированного
Класс с лямбдой больше не
В итоге наш класс превратился в это:
Еще одним важным и серьезным изменением, которое принесла Java 8, стала возможность объявлять
Все это тоже поддерживается D8. Используя те же инструменты, что и ранее, несложно увидеть задешугаренную версию Logger’a с
Читая статью, большинство из вас, наверное, подумали о Kotlin. Да, он поддерживает все фичи Java 8, но реализованы они
Поэтому поддержка Андроидом новых версий Java до сих пор очень важна, даже если ваш проект на 100% написан на Kotlin.
Не исключено, что в будущем Kotlin перестанет поддерживать байткод Java 6 и Java 7. IntelliJ IDEA, Gradle 5.0 перешли на Java 8. Количество платформ, работающих на более старых JVM, сокращается.
Все это время я рассказывал про фичи Java 8, но ничего не говорил о новых API — стримы,
Возвращаясь к примеру с Logger’ом, мы можем использовать новый API даты/времени, чтобы узнать, когда сообщения были отправлены.
Снова компилируем это с помощью
Можете даже запушить это на свой девайс, чтобы убедиться, что оно работает.
Если на этом устройстве API 26 и выше, появится месседж Hello. Если нет — увидим следующее:
D8 справился с лямбдами, метод референсами, но не сделал ничего для работы с
Разработчикам приходится использовать свои собственные реализации или обертки над date/time api, либо использовать библиотеки по типу
Отсутствие поддержки всех новых 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.

Оригинал статьи лежит тут
Несколько лет я работал из дома, и мне часто приходилось слышать, как мои коллеги жалуются на поддержку Андроидом разных версий 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. И его производительность все равно была невелика, поэтому параллельно продолжался поиск более хорошего решения.
И вот нам
представили новый dexer
— D8, который должен был заменить 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
метод класса Java8
— lambda$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
в байткоде видно, что мы получаем ссылку на PrintStream
— System.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.