Як Java 8 підтримується Android

Alex Alex 04 грудня 2019

Як Java 8 підтримується Android
Привіт, Хабр! Пропоную вашій увазі переклад чудової статті з циклу статей відомого Джейка Вортона про те, як відбувається підтримка Андроїдом 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 , LJava8;.sayHi:(LJava8$Logger;)V
0005: return-void
[0002a8] Java8.sayHi:(LJava8$Logger;)V
0000: const-string v0, "Hello"
0002: invoke-interface , 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 , 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 , 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. Давайте перетворимо лямбду в метод референс:

 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 , 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.003 s)

$ 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?

Епілог


Відсутність підтримки всіх нових Java API 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.

Source: habr.com

Коментарі (0)

    Ще немає коментарів

Щоб залишити коментар необхідно авторизуватися.