Prise en charge de Java 8 sur Android

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 ici

J'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 #2, 0 // InvokeDynamic #0:log:()LJava8$Logger; 5: invokestatic #3 // Method sayHi:(LJava8$Logger;)V 8: return } … 

À 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 .

Source: https://habr.com/ru/post/fr478692/


All Articles