- PVSM.RU - https://www.pvsm.ru -
Привет! Предлагаю вашему вниманию перевод замечательной статьи из цикла статей небезызвестного Джейка Вортона [1] о том, как происходит поддержка Андроидом Java 8.
Оригинал статьи лежит тут [2]
Несколько лет я работал из дома, и мне часто приходилось слышать, как мои коллеги жалуются на поддержку Андроидом разных версий 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 [3]. Он использовал тот же встроенный механизм, что и JVM, конвертируя лямбды в классы, но делал это в рантайме, а не во время компиляции. Сгенерированные классы были очень дорогими с точки зрения количества методов, но со временем, после доработок и улучшений, этот показатель снизился до чего-то более-менее разумного.
Затем команда Android анонсировала новый компилятор [4], который поддерживал все фичи Java 8 и был более производительным. Он был построен поверх Eclipse Java компилятора, но вместо генерации Java-байткода генерировал Dalvik-байткод. Однако его производительность все равно оставляла желать лучшего.
Когда новый компилятор (к счастью) забросили, трансформатор Java байткода в Java байткод, который и выполнял дешугаринг, был интегрирован в Android Gradle Plugin [5] из Bazel [6] — системы сборки Google. И его производительность все равно была невелика, поэтому параллельно продолжался поиск более хорошего решения.
И вот нам представили новый dexer
[7] — D8, который должен был заменить dx tool
. Десахаризация теперь выполнялась во время конвертации скомпилированных JAR-файлов в.dex
(dexing). D8 сильно выигрывает в производительности по сравнению с dx
, и, начиная с Android Gradle Plugin 3.1 он стал dexer’ом по
умолчанию.
Теперь, используя 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, можно увидеть, что там все намного более сложно и пугающе.
Чтобы лучше понимать, как происходит десахаризация, давайте попробуем шаг за шагом преобразовывать наш класс в то, что будет работать на всех версиях 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 [8] метода.
Вот список 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 [9] и занимается созданием анонимных классов налету (on-the-fly) в рантайме для лямбд так же, как и D8 генерит их в компайлтайме.
Если взглянуть на документацию Android к java.lang.invoke
[10]
или на исходники AOSP к java.lang.invoke
[11], увидим, что в рантайме этого класса нет. Вот поэтому дешугаринг всегда происходит во время компиляции, независимо от того, какой у вас minApi. VM поддерживает байткод инструкцию, похожую на invokedynamic
, но встроенный в JDK LambdaMetafactory
недоступен для использования.
Вместе с лямбдами в 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. Да, он поддерживает все фичи Java 8, но реализованы они kotlinc
точно так же, как и D8, за исключением некоторых деталей.
Поэтому поддержка Андроидом новых версий Java до сих пор очень важна, даже если ваш проект на 100% написан на Kotlin.
Не исключено, что в будущем Kotlin перестанет поддерживать байткод Java 6 и Java 7. IntelliJ IDEA [12], Gradle 5.0 перешли на Java 8. Количество платформ, работающих на более старых JVM, сокращается.
Все это время я рассказывал про фичи 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 [13].
Автор: lounah
Источник [14]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/android/338801
Ссылки в тексте:
[1] небезызвестного Джейка Вортона: https://twitter.com/JakeWharton?lang=en
[2] Оригинал статьи лежит тут: https://jakewharton.com/androids-java-8-support/
[3] Retrolambda: https://github.com/evant/gradle-retrolambda
[4] анонсировала новый компилятор: https://android-developers.googleblog.com/2014/12/hello-world-meet-our-new-experimental.htm
[5] был интегрирован в Android Gradle Plugin: https://android-developers.googleblog.com/2017/04/java-8-language-features-support-update.html
[6] Bazel: https://docs.bazel.build/versions/master/bazel-and-android.html
[7] представили новый dexer
: https://android-developers.googleblog.com/2017/08/next-generation-dex-compiler-now-in.html
[8] bootstrap: https://stackoverflow.com/questions/30733557/what-is-a-bootstrap-method
[9] живет в JDK: https://docs.oracle.com/javase/8/docs/api/java/lang/invoke/LambdaMetafactory.html
[10] документацию Android к java.lang.invoke
: https://developer.android.com/reference/java/lang/invoke/package-summary
[11] исходники AOSP к java.lang.invoke
: https://android.googlesource.com/platform/libcore/+/master/ojluni/src/main/java/java/lang/invoke/
[12] IntelliJ IDEA: https://blog.jetbrains.com/idea/2015/12/intellij-idea-16-eap-144-2608-is-out/
[13] Digging into D8 and R8: https://jakewharton.com/digging-into-d8-and-r8/
[14] Источник: https://habr.com/ru/post/478692/?utm_campaign=478692&utm_source=habrahabr&utm_medium=rss
Нажмите здесь для печати.