Hola Habr! Les traigo a su atención una traducción de un maravilloso artículo de una serie de artículos del
famoso Jake Worton sobre cómo Android 8 es compatible con Java.
El artículo original está aquí.Trabajé desde casa durante varios años, y a menudo escuché a mis colegas quejarse de que Android admite diferentes versiones de Java.
Este es un tema bastante complicado. Primero debe decidir a qué nos referimos con "soporte Java en Android", porque en una versión del lenguaje puede haber muchas cosas: características (lambdas, por ejemplo), código de bytes, herramientas, API, JVM, etc.
Cuando las personas hablan sobre la compatibilidad con Java 8 en Android, generalmente se refieren a la compatibilidad con las características del lenguaje. Entonces, comencemos con ellos.
Lambdas
Una de las principales innovaciones de Java 8 fue lambdas.
El código se ha vuelto más conciso y simple, las lambdas nos han salvado de la necesidad de escribir clases anónimas voluminosas utilizando una interfaz con un único método en su interior.
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!"); } }
Después de compilar esto usando javac y la
dx tool
heredada, obtenemos el siguiente error:
$ 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
Este error se produce debido al hecho de que las lambdas usan una nueva instrucción en el
invokedynamic
:
invokedynamic
, que se agregó en Java 7. Del texto de error puede ver que Android solo lo admite a partir de la API 26 (Android 8).
No suena muy bien, porque casi nadie lanzará una aplicación con 26 minApi. Para evitar esto, se
utiliza el llamado proceso de
desugaring , que hace posible el soporte lambda en todas las versiones de la API.
Historia de la desacarización
Ella es muy colorida en el mundo de Android. El objetivo de la desaccharización es siempre el mismo: permitir que las nuevas funciones de lenguaje funcionen en todos los dispositivos.
Inicialmente, por ejemplo, para admitir lambdas en Android, los desarrolladores conectaron el complemento
Retrolambda . Utilizó el mismo mecanismo incorporado que la JVM, convirtiendo lambdas a clases, pero lo hizo en tiempo de ejecución y no en tiempo de compilación. Las clases generadas eran muy caras en términos de la cantidad de métodos, pero con el tiempo, después de mejoras y mejoras, este indicador disminuyó a algo más o menos razonable.
Luego, el equipo de Android
anunció un nuevo compilador que admitía todas las características de Java 8 y era más productivo. Fue construido sobre el compilador Eclipse Java, pero en lugar de generar un código de bytes Java, generó un código de bytes Dalvik. Sin embargo, su rendimiento aún dejaba mucho que desear.
Cuando se abandonó el nuevo compilador (afortunadamente), el transformador de bytecode de Java en el bytecode de Java, que hizo el malabarismo,
se integró en el complemento Android Gradle de
Bazel , el sistema de compilación de Google. Y su rendimiento aún era bajo, por lo que la búsqueda de una mejor solución continuó en paralelo.
Y ahora nos
dexer
:
D8 , que supuestamente reemplazaría la
dx tool
. La desaccharización ahora se realizó durante la conversión de archivos JAR compilados a
.dex
(dexing). D8 es mucho mejor en rendimiento en comparación con
dx
, y desde Android Gradle Plugin 3.1 se ha convertido en el dexer predeterminado.
D8
Ahora, usando D8, podemos compilar el código anterior.
$ 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
Para ver cómo el D8 convirtió lambda, puede usar la
dexdump tool
, que se incluye en el SDK de Android. Mostrará bastante de todo, pero nos centraremos solo en esto:
$ $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 aún no ha leído el código de bytes, no se preocupe: gran parte de lo que está escrito aquí puede entenderse intuitivamente.
En el primer bloque, nuestro método
main
con el índice
0000
obtiene una referencia del campo
INSTANCE
a la clase
INSTANCE
Java8$1
. Esta clase se generó durante la
. El
Java8$1
método principal tampoco contiene ninguna mención del cuerpo de nuestro lambda, por lo tanto, lo más probable es que esté asociado con la clase
Java8$1
. El índice
0002
llama al método estático
sayHi
usando el enlace a
INSTANCE
. El
sayHi
requiere
Java8$Logger
, por lo que parece que
Java8$1
implementa esta interfaz. Podemos verificar esto aquí:
Class #2 - Class descriptor : 'LJava8$1;' Access flags : 0x1011 (PUBLIC FINAL SYNTHETIC) Superclass : 'Ljava/lang/Object;' Interfaces - #0 : 'LJava8$Logger;'
El indicador
SYNTHETIC
significa que
Java8$1
ha generado la clase
Java8$1
y la lista de interfaces que incluye contiene el
Java8$Logger
.
Esta clase representa nuestra lambda. Si observa la implementación del método de
log
, no verá el cuerpo de la lambda.
… [00026c] Java8$1.log:(Ljava/lang/String;)V 0000: invoke-static {v1}, LJava8;.lambda$main$0:(Ljava/lang/String;)V 0003: return-void …
En cambio, el método
static
de la clase
Java8
se
Java8
-
lambda$main$0
. Repito, este método se presenta solo 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
El indicador
SYNTHETIC
nuevamente nos dice que este método fue generado, y su bytecode solo contiene el cuerpo lambda: una llamada a
System.out.println
. La razón por la que el cuerpo lambda está dentro de
Java8.class es simple: puede necesitar acceso a miembros
private
de la clase, a los que la clase generada no tendrá acceso.
Todo lo que necesita para comprender cómo funciona la
desaccharización se describe anteriormente. Sin embargo, al mirarlo en el código de bytes de Dalvik, puede ver que todo es mucho más complicado y aterrador allí.
Transformación de fuente
Para comprender mejor cómo se produce la
desacrasificación , intentemos paso a paso convertir nuestra clase en algo que funcione en todas las versiones de la API.
Tomemos la misma clase con lambda como 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!"); } }
Primero, el cuerpo lambda se mueve al método
package private
del
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); + }
Luego se implementa una clase que implementa la interfaz
Logger
, dentro de la cual se ejecuta un bloque de código del cuerpo lambda.
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); + } +}
A continuación,
Java8$1
una instancia singleton de
Java8$1
, que se almacena en 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) {
Aquí está la clase
doblada final que se puede usar en todas las versiones de la 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 observa la clase generada en el código de bytes de Dalvik, no encontrará nombres como Java8 $ 1; habrá algo como
-$$Lambda$Java8$QkyWJ8jlAksLjYziID4cZLvHwoY
. La razón por la cual se genera ese nombre para la clase, y cuáles son sus ventajas, se basa en un artículo separado.
Soporte lambda nativo
Cuando utilizamos la
dx tool
para compilar una clase que contiene lambdas, un mensaje de error decía que esto solo funcionaría con 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
Por lo tanto, parece lógico que si tratamos de compilar esto con el
—min-api 26
, no se produzca la desacarización.
$ java -jar d8.jar \ --lib $ANDROID_HOME/platforms/android-28/android.jar \ --release \ --min-api 26 \ --output . \ *.class
Sin embargo, si
.dex
archivo
.dex
, aún se puede encontrar en él
-$$Lambda$Java8$QkyWJ8jlAksLjYziID4cZLvHwoY
. Por qué ¿Es esto un error D8?
Para responder a esta pregunta, y también por qué
siempre se produce la desaccharización, debemos mirar dentro del
Java8
de
Java8
Java de la clase
Java8
.
$ javap -v Java8.class class Java8 { public static void main(java.lang.String...); Code: 0: invokedynamic
Dentro del método
main
, nuevamente vemos
invocados dinámicamente en el índice
0
. El segundo argumento en la llamada es
0
: el índice del método de
arranque asociado con él.
Aquí hay una lista de métodos de
arranque :
… 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
Aquí el método
bootstrap se llama
metafactory
en la clase
java.lang.invoke.LambdaMetafactory
.
Vive en el JDK y crea clases anónimas sobre la marcha en tiempo de ejecución para lambdas, al igual que D8 las genera en el tiempo de cálculo.
Si nos fijamos en la
Android java.lang.invoke
o a las
AOSP java.lang.invoke
, vemos que esta clase no está en tiempo de ejecución. Es por eso que el malabarismo siempre ocurre en el momento de la compilación, sin importar el minApi que tenga. La VM admite instrucciones de
invokedynamic
de
invokedynamic
similares a las
invokedynamic
, pero el
invokedynamic
integrado en el JDK no
LambdaMetafactory
disponible para su uso.
Referencias del método
Junto con lambdas, Java 8 agregó referencias de método: esta es una forma efectiva de crear una lambda cuyo cuerpo hace referencia a un método existente.
Nuestra interfaz
Logger
es solo un ejemplo. El cuerpo lambda se refería a
System.out.println
. Convirtamos la lambda en un método de referencia:
public static void main(String... args) { - sayHi(s -> System.out.println(s)); + sayHi(System.out::println); }
Cuando lo compilamos y echamos un vistazo al código de bytes, veremos una diferencia con la versión anterior:
[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
En lugar de llamar al
Java8.lambda$main$0
generado, que contiene una llamada a
System.out.println
, ahora se llama directamente a
System.out.println
.
Una clase con un lambda ya no es un singleton
static
, pero por el índice
0000
en el bytecode, vemos que obtenemos un enlace a
PrintStream
-
System.out
, que luego se usa para llamar a
println
en él.
Como resultado, nuestra clase se convirtió en esto:
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étodos Default
y static
en interfaces
Otro cambio importante y importante que trajo Java 8 fue la capacidad de declarar métodos
default
y
static
en las interfaces.
interface Logger { void log(String s); default void log(String tag, String s) { log(tag + ": " + s); } static Logger systemOut() { return System.out::println; } }
Todo esto también es compatible con D8. Usando las mismas herramientas que antes, es fácil ver una versión registrada de Logger con métodos
default
y
static
. Una de las diferencias con lambdas y
method references
es que los métodos predeterminados y estáticos se implementan en la VM de Android y, a partir de las 24 API, D8 no los
desacoplará .
¿Quizás solo use Kotlin?
Mientras leían el artículo, la mayoría de ustedes probablemente pensó en Kotlin. Sí, admite todas las funciones de Java 8, pero
kotlinc
implementa de la misma manera que D8, con la excepción de algunos detalles.
Por lo tanto, el soporte de Android para nuevas versiones de Java sigue siendo muy importante, incluso si su proyecto está escrito al 100% en Kotlin.
Es posible que en el futuro Kotlin ya no admita código de bytes Java 6 y Java 7.
IntelliJ IDEA , Gradle 5.0 cambió a Java 8. El número de plataformas que se ejecutan en JVM más antiguas está disminuyendo.
Desugaring APIs
Todo este tiempo hablé sobre las características de Java 8, pero no dije nada sobre las nuevas API: transmisiones,
CompletableFuture
, fecha / hora, etc.
Volviendo al ejemplo de Logger, podemos usar la nueva API de fecha / hora para saber cuándo se enviaron los mensajes.
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!"); } }
Compílelo nuevamente con
javac
y conviértalo al bytecode de Dalvik con D8, que lo
desacopla para brindar soporte en todas las versiones de la API.
$ javac *.java $ java -jar d8.jar \ --lib $ANDROID_HOME/platforms/android-28/android.jar \ --release \ --output . \ *.class
Incluso puede ejecutar esto en su dispositivo para asegurarse de que funciona.
$ 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 API 26 y superior está en este dispositivo, aparecerá el mensaje Hola. Si no, veremos lo siguiente:
java.lang.NoClassDefFoundError: Failed resolution of: Ljava/time/LocalDateTime; at Java8.sayHi(Java8.java:13) at Java8.main(Java8.java:9)
D8 trató con lambdas, un método de referencia, pero no hizo nada para trabajar con
LocalDateTime
, y esto es muy triste.
Los desarrolladores tienen que usar sus propias implementaciones o envoltorios en la API de fecha / hora, o usar bibliotecas como
ThreeTenBP
para trabajar con el tiempo, pero ¿por qué no puedes hacer D8 con tus propias manos?
Epílogo
La falta de soporte para todas las nuevas API de Java 8 sigue siendo un gran problema en el ecosistema de Android. Después de todo, es poco probable que cada uno de nosotros nos permita especificar la API de 26 minutos en nuestro proyecto. ¡Las bibliotecas que admiten Android y JVM no pueden permitirse el lujo de utilizar la API que se nos presentó hace 5 años!
Y a pesar de que el soporte de Java 8 ahora es parte de D8, cada desarrollador debe especificar explícitamente la compatibilidad de origen y destino en Java 8. Si escribe sus propias bibliotecas, puede reforzar esta tendencia al diseñar bibliotecas que usen código de bytes Java 8 (incluso si no está utilizando nuevas funciones de idioma).
Se está trabajando mucho en D8, por lo que parece que todo estará bien en el futuro con soporte para funciones de lenguaje. Incluso si escribe solo en Kotlin, es muy importante obligar al equipo de desarrollo de Android a admitir todas las nuevas versiones de Java, mejorar el código de bytes y las nuevas API.
Esta publicación es una versión escrita de mi charla
Excavando en D8 y R8 .