Como o Java 8 é suportado no Android

Olá Habr! Chamo a atenção de você para a tradução de um maravilhoso artigo de uma série de artigos do famoso Jake Worton sobre como o Android 8 é suportado pelo Java.



O artigo original está aqui

Eu trabalhei em casa por vários anos e sempre ouvi meus colegas reclamarem do Android suportando diferentes versões do Java.

Este é um tópico bastante complicado. Primeiro, você precisa decidir o que queremos dizer com “suporte Java no Android”, porque em uma versão da linguagem pode haver muitas coisas: recursos (lambdas, por exemplo), código de bytes, ferramentas, APIs, JVM e assim por diante.

Quando as pessoas falam sobre o suporte a Java 8 no Android, geralmente significam suporte para recursos de linguagem. Então, vamos começar com eles.

Lambdas


Uma das principais inovações do Java 8 foram as lambdas.
O código se tornou mais conciso e simples, os lambdas nos salvaram da necessidade de escrever classes anônimas volumosas usando uma interface com um único método interno.

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!"); } } 

Após compilar isso usando a dx tool javac e legacy dx tool , obtemos o seguinte erro:

 $ 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 

Esse erro ocorre devido ao fato de as lambdas usarem uma nova instrução no bytecode - invokedynamic , que foi adicionada no Java 7. No texto do erro, você pode ver que o Android suporta apenas a partir da API 26 (Android 8).

Não parece muito bom, porque quase ninguém lança um aplicativo com 26 minApi. Para contornar isso, é usado o chamado processo de remoção de açúcar , que possibilita o suporte ao lambda em todas as versões da API.

História da dessacharização


Ela é bem colorida no mundo Android. O objetivo da dessacharização é sempre o mesmo - permitir que novos recursos de idioma funcionem em todos os dispositivos.

Inicialmente, por exemplo, para oferecer suporte a lambdas no Android, os desenvolvedores conectaram o plugin Retrolambda . Ele usou o mesmo mecanismo interno da JVM, convertendo lambdas em classes, mas o fez em tempo de execução e não em tempo de compilação. As classes geradas eram muito caras em termos de número de métodos, mas, com o tempo, após melhorias, esse indicador diminuiu para algo mais ou menos razoável.

Em seguida, a equipe do Android anunciou um novo compilador que suportava todos os recursos do Java 8 e era mais produtivo. Foi construído sobre o compilador Java Eclipse, mas, em vez de gerar um bytecode Java, gerou um bytecode Dalvik. No entanto, seu desempenho ainda deixou muito a desejar.

Quando o novo compilador (felizmente) foi abandonado, o transformador de bytecode Java no bytecode Java, que fez o malabarismo, foi integrado ao Android Gradle Plugin do Bazel , sistema de compilação do Google. E como seu desempenho ainda era baixo, a busca por uma solução melhor continuou em paralelo.

E agora nos foi dexer - D8 , que deveria substituir a dx tool . A dessacharização foi executada durante a conversão dos arquivos JAR compilados em .dex (dexing). O D8 tem desempenho muito melhor em comparação com o dx e, desde o Android Gradle Plugin 3.1, ele se tornou o dexer padrão.

D8


Agora, usando o D8, podemos compilar o código acima.

 $ 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 como o D8 converteu o lambda, você pode usar a dexdump tool , incluída no Android SDK. Ele exibirá muito de tudo, mas focaremos apenas nisso:

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

Se você ainda não leu o código de bytes, não se preocupe: muito do que está escrito aqui pode ser entendido intuitivamente.

No primeiro bloco, nosso método main com o índice 0000 obtém uma referência do campo INSTANCE para a classe INSTANCE Java8$1 . Essa classe foi gerada durante a . O método principal bytecode também não contém nenhuma menção ao corpo do nosso lambda, portanto, provavelmente, está associado à classe Java8$1 . O índice 0002 chama o método estático sayHi usando o link para INSTANCE . O sayHi requer o Java8$Logger , portanto, parece que o Java8$1 implementa essa interface. Podemos verificar isso aqui:

 Class #2 - Class descriptor : 'LJava8$1;' Access flags : 0x1011 (PUBLIC FINAL SYNTHETIC) Superclass : 'Ljava/lang/Object;' Interfaces - #0 : 'LJava8$Logger;' 

O sinalizador SYNTHETIC significa que a classe Java8$1 foi gerada e a lista de interfaces que inclui contém o Java8$Logger .
Esta classe representa nossa lambda. Se você observar a implementação do método log , não verá o corpo do lambda.

 … [00026c] Java8$1.log:(Ljava/lang/String;)V 0000: invoke-static {v1}, LJava8;.lambda$main$0:(Ljava/lang/String;)V 0003: return-void … 

Em vez disso, o método static da classe Java8 é Java8 - lambda$main$0 . Repito, esse método é apresentado apenas no 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 

O sinalizador SYNTHETIC novamente nos diz que esse método foi gerado e seu código de bytes contém apenas o corpo lambda: uma chamada para System.out.println . O motivo pelo qual o corpo lambda está dentro do Java8.class é simples - ele pode precisar de acesso a membros private da classe, aos quais a classe gerada não terá acesso.

Tudo o que você precisa para entender como funciona a desacarificação está descrito acima. No entanto, olhando para o bytecode de Dalvik, você pode ver que tudo é muito mais complicado e assustador lá.

Transformação de Origem


Para entender melhor como ocorre a desacarificação , vamos tentar passo a passo converter nossa classe em algo que funcione em todas as versões da API.

Vamos dar a mesma aula com o 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!"); } } 

Primeiro, o corpo lambda é movido para o método package private do 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); + } 

Em seguida, é implementada uma classe que implementa a interface Logger , dentro da qual um bloco de código do corpo lambda é executado.

  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); + } +} 

Em seguida, Java8$1 uma instância singleton do Java8$1 , que é armazenada na variável 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) { 

Aqui está a classe final dublada que pode ser usada em todas as versões da 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); } } 

Se você observar a classe gerada no bytecode Dalvik, não encontrará nomes como Java8 $ 1 - haverá algo como -$$Lambda$Java8$QkyWJ8jlAksLjYziID4cZLvHwoY . A razão pela qual essa nomeação é gerada para a classe e quais são suas vantagens atrai um artigo separado.

Suporte nativo lambda


Quando usamos a dx tool para compilar uma classe contendo lambdas, uma mensagem de erro informava que isso funcionaria apenas com 26 APIs.

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

Portanto, parece lógico que, se tentarmos compilar isso com o sinalizador –min —min-api 26 , a desaccarização não ocorrerá.

 $ java -jar d8.jar \ --lib $ANDROID_HOME/platforms/android-28/android.jar \ --release \ --min-api 26 \ --output . \ *.class 

No entanto, se despejarmos o arquivo .dex , ele ainda poderá ser encontrado -$$Lambda$Java8$QkyWJ8jlAksLjYziID4cZLvHwoY . Porque Isso é um bug do D8?

Para responder a essa pergunta e também por que a desacarificação sempre ocorre , precisamos procurar dentro do bytecode Java da 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 } … 

Dentro do método main , vemos novamente invokedynamic no índice 0 . O segundo argumento na chamada é 0 - o índice do método de inicialização associado a ele.

Aqui está uma lista de métodos de inicialização :

 … 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 

Aqui, o método de inicialização é chamado de metafactory na classe java.lang.invoke.LambdaMetafactory . Ele mora no JDK e está envolvido na criação de classes on-the-fly anônimas em tempo de execução para lambdas da mesma maneira que o D8 as gera em tempo de computação.

Se você consultar a Android java.lang.invoke do Android java.lang.invoke
ou nas AOSP java.lang.invoke , vemos que essa classe não está no tempo de execução. É por isso que o de-malabarismo sempre acontece em tempo de compilação, independentemente do minApi que você possui. A VM suporta instruções de bytecode semelhantes a invokedynamic , mas o invokedynamic incorporado ao JDK não LambdaMetafactory disponível para uso.

Referências de método


Juntamente com os lambdas, o Java 8 adicionou referências de método - essa é uma maneira eficaz de criar um lambda cujo corpo faz referência a um método existente.

Nossa interface do Logger é apenas um exemplo. O corpo lambda referido em System.out.println . Vamos transformar o lambda em um método de referência:

  public static void main(String... args) { - sayHi(s -> System.out.println(s)); + sayHi(System.out::println); } 

Quando o compilarmos e examinarmos o bytecode, veremos uma diferença com a versão 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 

Em vez de chamar o Java8.lambda$main$0 gerado, que contém uma chamada para System.out.println , agora System.out.println é chamado diretamente.

Uma classe com um lambda não é mais um singleton static , mas pelo índice 0000 no bytecode, vemos que obtemos um link para PrintStream - System.out , que é usado para chamar println nele.

Como resultado, nossa classe se transformou nisso:

  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 e static em interfaces


Outra mudança importante e importante que o Java 8 trouxe foi a capacidade de declarar métodos static e default nas interfaces.

 interface Logger { void log(String s); default void log(String tag, String s) { log(tag + ": " + s); } static Logger systemOut() { return System.out::println; } } 

Tudo isso também é suportado pelo D8. Usando as mesmas ferramentas de antes, é fácil ver uma versão com logon do Logger com métodos static e default . Uma das diferenças entre as lambdas e as method references é que os métodos padrão e estáticos são implementados na Android VM e, começando com as 24 APIs, o D8 não os separa .

Talvez apenas use Kotlin?


Ao ler o artigo, a maioria de vocês provavelmente pensou em Kotlin. Sim, ele suporta todos os recursos do Java 8, mas eles são implementados pelo kotlinc da mesma maneira que o D8, com exceção de alguns detalhes.

Portanto, o suporte do Android para novas versões do Java ainda é muito importante, mesmo que seu projeto seja 100% escrito no Kotlin.

É possível que, no futuro, o Kotlin deixe de suportar o bytecode Java 6 e Java 7. IntelliJ IDEA , Gradle 5.0 mudou para Java 8. O número de plataformas em execução nas JVMs mais antigas está diminuindo.

Desugaring APIs


Todo esse tempo eu falei sobre os recursos do Java 8, mas não disse nada sobre as novas APIs - fluxos, CompletableFuture , data / hora e assim por diante.

Voltando ao exemplo do Logger, podemos usar a nova API de data / hora para descobrir quando as mensagens foram enviadas.

 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!"); } } 

Compile -o novamente com javac e converta-o no bytecode Dalvik com D8, que o desacopla para suporte em todas as versões da API.

 $ javac *.java $ java -jar d8.jar \ --lib $ANDROID_HOME/platforms/android-28/android.jar \ --release \ --output . \ *.class 

Você pode até executar isso no seu dispositivo para garantir que funcione.

 $ 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 

Se a API 26 e superior estiver neste dispositivo, a mensagem Hello será exibida. Caso contrário, veremos o seguinte:

 java.lang.NoClassDefFoundError: Failed resolution of: Ljava/time/LocalDateTime; at Java8.sayHi(Java8.java:13) at Java8.main(Java8.java:9) 

O D8 lidou com lambdas, um método de referência, mas não fez nada para trabalhar com LocalDateTime , e isso é muito triste.

Os desenvolvedores precisam usar suas próprias implementações ou wrappers na API de data / hora ou usar bibliotecas como o ThreeTenBP para trabalhar com o tempo, mas por que você não pode fazer o D8 com suas próprias mãos?

Epílogo


A falta de suporte para todas as novas APIs do Java 8 continua sendo um grande problema no ecossistema do Android. De fato, é improvável que cada um de nós possa nos permitir especificar 26 min API no nosso projeto. As bibliotecas que suportam Android e JVM não podem usar a API que nos foi introduzida há 5 anos!

E mesmo que o suporte ao Java 8 agora faça parte do D8, todo desenvolvedor ainda deve especificar explicitamente a compatibilidade de origem e destino no Java 8. Se você escrever suas próprias bibliotecas, poderá reforçar essa tendência colocando as bibliotecas que usam o bytecode Java 8 (mesmo se você não estiver usando novos recursos de idioma).

Muito trabalho está sendo feito no D8, então parece que tudo ficará bem no futuro, com suporte para recursos de idioma. Mesmo se você escrever apenas no Kotlin, é muito importante forçar a equipe de desenvolvimento do Android a suportar todas as novas versões do Java, melhorar o bytecode e as novas APIs.

Este post é uma versão escrita da minha palestra Cavando em D8 e R8 .

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


All Articles