Привет, Хабр! Предлагаю вашему вниманию перевод замечательной статьи из цикла статей небезызвестного Джейка Вортона о том, как происходит поддержка Андроидом 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.
