Bonjour, Habr! J'attire votre attention sur la traduction d'un merveilleux article d'une série d'articles du
célèbre Jake Worton sur la façon dont Android 8 est pris en charge par Java.
L'article d'origine est iciJ'ai travaillé à la maison pendant plusieurs années et j'ai souvent entendu mes collègues se plaindre d'Android prenant en charge différentes versions de Java.
C'est un sujet assez compliqué. Vous devez d'abord décider ce que nous entendons par «prise en charge Java dans Android», car dans une version du langage, il peut y avoir beaucoup de choses: fonctionnalités (lambdas, par exemple), bytecode, outils, API, JVM et ainsi de suite.
Lorsque les gens parlent de la prise en charge de Java 8 dans Android, cela signifie généralement la prise en charge des fonctionnalités linguistiques. Commençons donc avec eux.
Lambdas
Une des principales innovations de Java 8 a été les lambdas.
Le code est devenu plus concis et plus simple, les lambdas nous ont évités d'avoir à écrire des classes anonymes volumineuses en utilisant une interface avec une seule méthode à l'intérieur.
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!"); } }
Après avoir compilé cela en utilisant javac et l'
dx tool
hérité, nous obtenons l'erreur suivante:
$ 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
Cette erreur se produit du fait que les lambdas utilisent une nouvelle instruction dans le bytecode -
invokedynamic
, qui a été ajoutée dans Java 7. À partir du texte d'erreur, vous pouvez voir qu'Android ne la prend en charge qu'à partir de l'API 26 (Android 8).
Cela ne semble pas très bon, car presque personne ne publiera une application avec 26 minApi. Pour contourner ce
problème, le processus dit de
désuchage est
utilisé , ce qui rend possible la prise en charge de lambda sur toutes les versions de l'API.
Histoire de la désaccharization
Elle est assez colorée dans le monde Android. Le but de la désaccharization est toujours le même: permettre à de nouvelles fonctionnalités de langue de fonctionner sur tous les appareils.
Initialement, par exemple, pour prendre en charge les lambdas dans Android, les développeurs ont connecté le plugin
Retrolambda . Il a utilisé le même mécanisme intégré que la JVM, convertissant les lambdas en classes, mais il l'a fait au moment de l'exécution, et non au moment de la compilation. Les classes générées étaient très coûteuses en termes de nombre de méthodes, mais au fil du temps, après des améliorations et des améliorations, cet indicateur est tombé à quelque chose de plus ou moins raisonnable.
Ensuite, l'équipe Android a
annoncé un nouveau compilateur qui prend en charge toutes les fonctionnalités de Java 8 et est plus productif. Il a été construit sur le compilateur Java Eclipse, mais au lieu de générer un bytecode Java, il a généré un bytecode Dalvik. Cependant, ses performances laissaient encore beaucoup à désirer.
Lorsque le nouveau compilateur a été (heureusement) abandonné, le transformateur de bytecode Java dans le bytecode Java, qui a fait le jonglage, a
été intégré dans le plugin Android Gradle de
Bazel , le système de build de Google. Et ses performances étant encore faibles, la recherche d'une meilleure solution s'est poursuivie en parallèle.
Et maintenant, on nous a
dexer
-
D8 , qui était censé remplacer l'
dx tool
. Désaccharization a maintenant été effectuée lors de la conversion des fichiers JAR compilés en
.dex
(dexing). Les performances du D8 sont bien meilleures que celles du
dx
, et depuis Android Gradle Plugin 3.1, il est devenu le dexer par défaut.
D8
Maintenant, en utilisant D8, nous pouvons compiler le code ci-dessus.
$ 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
Pour voir comment le lambda D8 a été converti, vous pouvez utiliser l'
dexdump tool
, qui est inclus dans le SDK Android. Il affichera beaucoup de tout, mais nous nous concentrerons uniquement sur cela:
$ $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 …
Si vous n'avez pas encore lu le bytecode, ne vous inquiétez pas: une grande partie de ce qui est écrit ici peut être comprise intuitivement.
Dans le premier bloc, notre méthode
main
avec l'index
0000
obtient une référence du champ
INSTANCE
Ă la classe
INSTANCE
Java8$1
. Cette classe a été générée pendant la
. Le bytecode de la méthode principale ne contient aucune mention du corps de notre lambda, donc, très probablement, il est associé à la classe
Java8$1
. L'index
0002
appelle ensuite la méthode statique
sayHi
utilisant le lien vers
INSTANCE
. La
sayHi
nécessite
Java8$Logger
, il semble donc que
Java8$1
implémente cette interface. Nous pouvons le vérifier ici:
Class #2 - Class descriptor : 'LJava8$1;' Access flags : 0x1011 (PUBLIC FINAL SYNTHETIC) Superclass : 'Ljava/lang/Object;' Interfaces - #0 : 'LJava8$Logger;'
L'indicateur
SYNTHETIC
signifie que la classe
Java8$1
été générée et la liste des interfaces qu'elle contient contient le
Java8$Logger
.
Cette classe représente notre lambda. Si vous regardez l'implémentation de la méthode
log
, vous ne verrez pas le corps du lambda.
… [00026c] Java8$1.log:(Ljava/lang/String;)V 0000: invoke-static {v1}, LJava8;.lambda$main$0:(Ljava/lang/String;)V 0003: return-void …
Au lieu de cela, la méthode
static
de la classe
Java8
est
Java8
-
lambda$main$0
. Je répète, cette méthode est présentée uniquement en bytecode.
… #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
L'indicateur
SYNTHETIC
nous indique Ă nouveau que cette mĂ©thode a Ă©tĂ© gĂ©nĂ©rĂ©e et que son bytecode contient simplement le corps lambda: un appel Ă
System.out.println
. La raison pour laquelle le corps lambda est à l'intérieur de
Java8.class est simple - il peut avoir besoin d'accéder
private
membres
private
de la classe, auxquels la classe générée n'aura pas accès.
Tout ce dont vous avez besoin pour comprendre comment fonctionne la
désaccharization est décrit ci-dessus. Cependant, en le regardant dans le bytecode Dalvik, vous pouvez voir que tout y est beaucoup plus compliqué et effrayant.
Transformation de la source
Pour mieux comprendre comment se produit la
désaccharization , essayons pas à pas de convertir notre classe en quelque chose qui fonctionnera sur toutes les versions de l'API.
Prenons la mĂŞme classe avec lambda comme base:
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!"); } }
Tout d'abord, le corps lambda est déplacé vers la méthode
package private
du
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); + }
Ensuite, une classe est implémentée qui implémente l'interface
Logger
, à l'intérieur de laquelle un bloc de code du corps lambda est exécuté.
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); + } +}
Ensuite, une instance singleton de
Java8$1
, qui est stockée dans la variable
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) {
Voici la dernière classe
doublée qui peut être utilisée sur toutes les versions de l'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); } }
Si vous regardez la classe générée dans le bytecode Dalvik, vous ne trouverez pas de noms comme Java8 $ 1 - il y aura quelque chose comme
-$$Lambda$Java8$QkyWJ8jlAksLjYziID4cZLvHwoY
. La raison pour laquelle une telle dénomination est générée pour la classe, et quels sont ses avantages, tire à un article distinct.
Prise en charge native de lambda
Lorsque nous avons utilisé l'
dx tool
pour compiler une classe contenant des lambdas, un message d'erreur a déclaré que cela ne fonctionnerait qu'avec 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
Par conséquent, il semble logique que si nous essayons de compiler cela avec l'
—min-api 26
, alors la désaccharization ne se produira pas.
$ java -jar d8.jar \ --lib $ANDROID_HOME/platforms/android-28/android.jar \ --release \ --min-api 26 \ --output . \ *.class
Cependant, si nous
.dex
fichier
.dex
, il peut toujours y être trouvé
-$$Lambda$Java8$QkyWJ8jlAksLjYziID4cZLvHwoY
. Pourquoi Est-ce un bug D8?
Pour répondre à cette question, et aussi pourquoi la désaccharization
se produit toujours , nous devons regarder à l'intérieur du bytecode Java de la classe
Java8
.
$ javap -v Java8.class class Java8 { public static void main(java.lang.String...); Code: 0: invokedynamic
À l'intérieur de la méthode
main
, nous voyons Ă nouveau
invokedynamic Ă l'index
0
. Le deuxième argument de l'appel est
0
- l'index de la méthode d'
amorçage qui lui est associée.
Voici une liste de méthodes d'
amorçage :
… 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
Ici, la méthode d'
amorçage est appelée
metafactory
dans la classe
java.lang.invoke.LambdaMetafactory
. Il
vit dans le JDK et crée des classes anonymes à la volée lors de l'exécution pour les lambdas, tout comme D8 les génère en temps de calcul.
Si vous regardez la
Android java.lang.invoke
ou aux
AOSP java.lang.invoke
, nous voyons que cette classe n'est pas dans le runtime. C'est pourquoi le jonglage se produit toujours au moment de la compilation, quel que soit le minApi que vous avez. La machine virtuelle prend en charge les instructions de bytecode similaires Ă
invokedynamic
, mais le
invokedynamic
intégré au JDK
LambdaMetafactory
pas disponible pour utilisation.
Références de méthode
Avec lambdas, Java 8 a ajouté des références de méthode - c'est un moyen efficace de créer un lambda dont le corps référence une méthode existante.
Notre interface
Logger
est un exemple. Le corps lambda fait rĂ©fĂ©rence Ă
System.out.println
. Transformons le lambda en méthode de référence:
public static void main(String... args) { - sayHi(s -> System.out.println(s)); + sayHi(System.out::println); }
Lorsque nous le compilons et jetons un œil au bytecode, nous verrons une différence avec la version précédente:
[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
Au lieu d'appeler le
Java8.lambda$main$0
gĂ©nĂ©rĂ©, qui contient un appel Ă
System.out.println
, maintenant
System.out.println
est appelé directement.
Une classe avec un lambda n'est plus un singleton
static
, mais par l'index
0000
dans le bytecode, nous voyons que nous obtenons un lien vers
PrintStream
-
System.out
, qui est ensuite utilisé pour appeler
println
dessus.
En conséquence, notre classe s'est transformée en ceci:
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); + } +}
Méthodes Default
et static
dans les interfaces
Un autre changement important et majeur apporté par Java 8 a été la possibilité de déclarer
static
méthodes
default
et
static
dans les interfaces.
interface Logger { void log(String s); default void log(String tag, String s) { log(tag + ": " + s); } static Logger systemOut() { return System.out::println; } }
Tout cela est également pris en charge par D8. En utilisant les mêmes outils qu'auparavant, il est facile de voir une version connectée de Logger avec
static
méthodes par
default
et
static
. L'une des différences avec les lambdas et les
method references
est que les méthodes par défaut et statiques sont implémentées dans la machine virtuelle Android et, à partir de l'API 24, D8 ne les
découplera pas.
Peut-ĂŞtre utiliser simplement Kotlin?
En lisant l'article, la plupart d'entre vous ont probablement pensé à Kotlin. Oui, il prend en charge toutes les fonctionnalités de Java 8, mais elles sont implémentées par
kotlinc
de la même manière que D8, à l'exception de certains détails.
Par conséquent, le support Android pour les nouvelles versions de Java est toujours très important, même si votre projet est écrit à 100% en Kotlin.
Il est possible qu'Ă l'avenir Kotlin ne prenne plus en charge le bytecode Java 6 et Java 7.
IntelliJ IDEA , Gradle 5.0 est passé à Java 8. Le nombre de plates-formes fonctionnant sur des machines virtuelles Java plus anciennes diminue.
Desugaring APIs
Pendant tout ce temps, j'ai parlé des fonctionnalités de Java 8, mais je n'ai rien dit sur les nouvelles API - flux,
CompletableFuture
, date / heure, etc.
Pour revenir à l'exemple de l'enregistreur, nous pouvons utiliser la nouvelle API date / heure pour savoir quand les messages ont été envoyés.
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!"); } }
Compilez- le Ă nouveau avec
javac
et convertissez-le en bytecode Dalvik avec D8, qui le
dissocie pour le prendre en charge sur toutes les versions de l'API.
$ javac *.java $ java -jar d8.jar \ --lib $ANDROID_HOME/platforms/android-28/android.jar \ --release \ --output . \ *.class
Vous pouvez même l'exécuter sur votre appareil pour vous assurer qu'il fonctionne.
$ 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
Si l'API 26 et les versions ultérieures se trouvent sur cet appareil, le message Hello apparaît. Sinon, nous verrons ce qui suit:
java.lang.NoClassDefFoundError: Failed resolution of: Ljava/time/LocalDateTime; at Java8.sayHi(Java8.java:13) at Java8.main(Java8.java:9)
D8 a traité des lambdas, une méthode de référence, mais n'a rien fait pour fonctionner avec
LocalDateTime
, et c'est très triste.
Les développeurs doivent utiliser leurs propres implémentations ou wrappers sur l'api date / heure, ou utiliser des bibliothèques comme
ThreeTenBP
pour travailler avec le temps, mais pourquoi ne pouvez-vous pas faire D8 de vos propres mains?
Épilogue
Le manque de prise en charge de toutes les nouvelles API Java 8 reste un gros problème dans l'écosystème Android. Après tout, il est peu probable que chacun de nous puisse nous permettre de spécifier l'API de 26 min dans notre projet. Les bibliothèques prenant en charge Android et JVM ne peuvent pas se permettre d'utiliser l'API qui nous a été présentée il y a 5 ans!
Et même si la prise en charge de Java 8 fait désormais partie de D8, chaque développeur doit toujours spécifier explicitement la compatibilité des sources et des cibles dans Java 8. Si vous écrivez vos propres bibliothèques, vous pouvez renforcer cette tendance en disposant des bibliothèques qui utilisent le bytecode Java 8. (même si vous n'utilisez pas de nouvelles fonctionnalités linguistiques).
Beaucoup de travail est en cours sur D8, il semble donc que tout ira bien à l'avenir avec la prise en charge des fonctionnalités linguistiques. Même si vous écrivez uniquement sur Kotlin, il est très important de forcer l'équipe de développement Android à prendre en charge toutes les nouvelles versions de Java, à améliorer le bytecode et les nouvelles API.
Ce message est une version écrite de mon discours
Creuser dans D8 et R8 .