Desempenho do Kotlin no Android

Vamos falar hoje sobre o desempenho do Kotlin no Android em produção. Vamos dar uma olhada, implementar otimizações complicadas, comparar o código de bytes. Finalmente, abordaremos seriamente a comparação e mediremos os benchmarks.

Este artigo é baseado em um relatório de Alexander Smirnov no AppsConf 2017 e ajudará a descobrir se é possível escrever código no Kotlin, que não será inferior ao Java em velocidade.


Sobre o palestrante: Alexander Smirnov, CTO da PapaJobs, administra o blog de vídeo Android no Faces e também é um dos organizadores da comunidade Mosdroid.

Vamos começar com suas expectativas.

Você acha que o Kotlin em tempo de execução é mais lento que o Java? Ou mais rápido? Ou talvez não haja muita diferença? Afinal, ambos trabalham no bytecode que a máquina virtual nos fornece.

Vamos acertar. Tradicionalmente, quando surge a questão de comparar o desempenho, todo mundo quer ver referências e números específicos. Infelizmente, para o Android não existe JMH ( Java Microbenchmark Harness ), então não podemos medir o quão legal isso pode ser feito em Java. Então, o que podemos fazer para fazer a medição, conforme descrito abaixo?

fun measure() : Long { val startTime = System.nanoTime() work() return System.nanoTime() - startTime } adb shell dumpsys gfxinfo %package_name% 

Se você tentar medir seu código dessa maneira, um dos desenvolvedores do JMH ficará triste, chorará e virá até você em um sonho - nunca faça isso.

No Android, você pode fazer benchmarks, em particular, o Google demonstrou isso na E / S do ano passado. Eles disseram que melhoraram muito a máquina virtual, neste caso, ART, e se no Android 4.1 uma alocação de um objeto levasse de 600 a 700 nanossegundos, na oitava versão levaria cerca de 60 nanossegundos. I.e. eles foram capazes de medi-lo com tanta precisão em uma máquina virtual. Por que não podemos fazer isso - não temos essas ferramentas.

Se examinarmos toda a documentação, a única coisa que podemos encontrar é a recomendação acima, como medir a interface do usuário:

adb shell dumpsys gfxinfo% package_name%

Na verdade, vamos fazer dessa maneira e ver no final o que isso dará. Mas primeiro, determinaremos o que mediremos e o que mais podemos fazer.

A próxima pergunta. Onde você acha que o desempenho é importante quando você cria um aplicativo de primeira classe?

  1. Definitivamente em todo lugar.
  2. Thread da interface do usuário.
  3. Visualização personalizada + animações.




Acima de tudo, gosto da primeira opção, mas é mais provável que seja impossível fazer com que todo o código funcione muito, muito rapidamente e é importante que pelo menos não haja UiThread ou exibição personalizada. Eu também concordo com isso - é muito, muito importante. O fato de que em seu fluxo JSON separado será desserializado por 10 milissegundos a mais será que ninguém notará.

A psicologia da Gestalt diz que, quando piscamos, por cerca de 150 a 300 milissegundos, o olho humano está desfocado e não vê o que realmente está acontecendo ali. E então esses 10 milissegundos de tempo não. Mas se voltarmos à psicologia da gestalt, é importante não o que realmente vejo e o que realmente acontece, mas o que entendo como usuário é importante.

I.e. se fizermos o usuário pensar que ele tem tudo muito, muito rápido, mas na verdade ele será simplesmente superado, por exemplo, com a ajuda de uma bela animação, ele ficará satisfeito, mesmo que de fato não seja.

Os motivos da psicologia da Gestalt no iOS estão mudando há algum tempo. Portanto, se você pegar dois aplicativos com o mesmo tempo de processamento, mas em plataformas diferentes, e colocá-los lado a lado, parece que no iOS tudo é mais rápido. A animação no iOS processa um pouco mais rápido, a animação anterior inicia na inicialização e muitas outras animações, para que fique bonita.

Portanto, a primeira regra é pensar no usuário.

E para a segunda regra, você precisa mergulhar no hardcore.

KOTLIN STYLE


Para avaliar honestamente o desempenho do Kotlin, o compararemos com o Java. Portanto, é impossível medir algumas coisas que estão apenas no Kotlin, por exemplo:

  • Coleção Api.
  • Parâmetros padrão do método.
  • Classes de dados.
  • Tipos reificados.
  • Corotinas.

A API de coleta que o Kotlin nos fornece é muito legal, muito rápida. Em Java, isso simplesmente não existe, existem apenas implementações diferentes. Por exemplo, a biblioteca da API do Liteweight Stream API será mais lenta porque faz tudo igual ao Kotlin, mas com uma ou duas alocações adicionais para a operação, pois tudo se transforma em um objeto adicional.

Se pegarmos a API Stream do Java 8, ela funcionará mais lentamente que a API Kotlin Collection, mas com uma condição - não há paralisia na API Collection, se incluirmos paralelo, em grandes volumes de dados da API Stream, O Java ignorará a API do Kotlin Collection. Portanto, não podemos comparar essas coisas, porque realizamos a comparação precisamente do ponto de vista do Android.

A segunda coisa que, ao que me parece, não pode ser comparada, são os parâmetros padrão do método - um recurso muito interessante, que, aliás, está no Dart. Quando você chama algum método, ele pode ter alguns parâmetros que podem ter algum valor, mas podem ser NULL. E, portanto, você não cria 10 métodos diferentes, mas executa um método e diz que um dos parâmetros pode ser NULL e, no futuro, use-o sem nenhum parâmetro. I.e. ele olhará, o parâmetro chegou ou ele não chegou. É muito conveniente que você possa escrever muito menos código, mas o inconveniente é que você deve pagar por isso. Este é o açúcar sintático: você, como desenvolvedor, pensa que este é um método de API, mas, na realidade, sob o capô, cada variação do método com parâmetros ausentes é gerada no bytecode. E cada um desses métodos também verifica pouco a pouco se esse parâmetro chegou. Se veio, ok, se não, criamos uma máscara de bit e, dependendo dessa máscara de bit, o método original que você escreveu é realmente chamado. Operações bit a bit, todas custam um pouco de dinheiro, mas muito pouco, e é normal que você pague por conveniência. Parece-me que isso é absolutamente normal.

O próximo item que não pode ser comparado é Classes de dados .

Todo mundo chora que em Java existem parâmetros para os quais existem classes de modelo. I.e. você pega parâmetros e faz mais métodos, getters e setters para todos esses parâmetros. Acontece que, para uma turma com dez parâmetros, você ainda precisa de todo um conjunto de getters, setters e muito mais. Além disso, se você não usar geradores, precisará escrever com as mãos, o que geralmente é terrível.

Kotlin permite que você fique longe de tudo. Primeiro, como existem propriedades no Kotlin, você não precisa escrever getters e setters. Não possui parâmetros de classe, todas as propriedades . De qualquer forma, achamos que sim. Em segundo lugar, se você escrever que essas são classes de dados, um monte de tudo o mais será gerado. Por exemplo, equals (), toStrung () / hasCode (), etc.

Obviamente, isso também tem desvantagens. Por exemplo, eu não precisava que todos os 20 parâmetros de minhas classes de dados fossem comparados ao mesmo tempo em meus iguais (), eu só precisava comparar 3. Alguém não gosta de tudo isso porque o desempenho é perdido nisso e, além disso, muito é gerado funções de serviço e o código compilado é bastante volumoso. Ou seja, se você escrever tudo manualmente, haverá menos código do que se você usar classes de dados.

Eu não uso classes de dados por outro motivo. Anteriormente, havia restrições à expansão de tais classes e outras coisas. Agora todo mundo está melhor com isso, mas o hábito permanece.

O que é muito, muito legal no Kotlin e o que sempre será mais rápido que o Java? São os tipos Reified , que, a propósito, também estão em Dart.

Você sabe que quando você usa genéricos, o apagamento de tipo é apagado no estágio de compilação e, em tempo de execução, você não sabe mais qual objeto desse genérico é realmente usado.

Com os tipos Reified, você não precisa usar a reflexão em muitos lugares quando precisaria em Java, porque com os métodos inline é com a Reified que você conhece o tipo e, portanto, verifica-se que você não usa a reflexão e seu código funciona mais rápido. A magia

E há Coroutines . Eles são muito legais, eu gosto muito deles, mas no momento do desempenho eles foram incluídos apenas na versão alfa, portanto não foi possível fazer comparações corretas com eles.

CAMPOS


Então, vamos em frente, vamos para o que podemos comparar com Java e o que podemos influenciar em geral.

 class Test { var a = 5 var b = 6 val c = B() fun work () { val d = a + b val e = ca + cb } } class B (@JvmField var a: Int = 5,var b: Int = 6) 

Como eu disse, não temos parâmetros para a classe, temos propriedades.

Temos var, temos val, temos uma classe externa, uma das propriedades é @JvmField, e veremos o que realmente acontece com a função work (): somamos o valor dos campos a e b de nossa própria classe e valores do campo ae campo b da classe externa, escritos no campo imutável c.

A questão é como, de fato, será chamado em d = a + b. Todos sabemos que, uma vez que essa propriedade, o getter dessa classe será chamado para esse parâmetro.

  L0 LINENUMBER 10 L0 ALOAD 0 GETFIELD kotlin/Test.a : I ALOAD 0 GETFIELD kotlin/Test.b : I IADD ISTORE 1 

Mas se olharmos para o bytecode, veremos que o getfield está realmente sendo acessado. Ou seja, isso no bytecode não é uma chamada para a função InvokeVirtual, mas um acesso direto ao campo. Inicialmente, nada nos foi prometido, que possuímos todas as propriedades, não os campos. Acontece que Kotlin está nos enganando, há um apelo direto.

O que acontece se vermos o bytecode gerado para outra linha: val e = ca + cb?

  L1 LINENUMBER 11 L1 ALOAD 0 GETFIELD kotlin/Test.c : Lkotlin/B; GETFIELD kotlin/Ba : I ALOAD 0 GETFIELD kotlin/Test.c : Lkotlin/B; INVOKEVIRTUAL kotlin/B.getB ()I IADD ISTORE 2 

Anteriormente, se você estava acessando uma propriedade privada, sempre tinha uma chamada InvokeVirtual. Se essa era uma propriedade privada, o acesso a ela era através do GetField. GetField é muito mais rápido que o InvokeVirtual, a especificação do Android afirma que acessar um campo diretamente é 3 a 7 vezes mais rápido. Portanto, é recomendável que você sempre consulte o Field, e não através de getters ou setters. Agora, especialmente na oitava máquina virtual ART, já haverá números diferentes, mas se você ainda suportar o 4.1, isso será verdade.

Portanto, verifica-se que ainda é benéfico termos o GetField, e não o InvokeVirtual.

Agora, você pode obter o GetField se estiver acessando uma propriedade de sua própria classe ou, se for uma propriedade pública, você deve definir @JvmField. Então, exatamente o mesmo no bytecode será uma chamada GetField, que é 3 a 7 vezes mais rápida.

É claro que aqui falamos em nanossegundos e, com um trono, é muito, muito pequeno. Mas, por outro lado, se você fizer isso no thread da interface do usuário, por exemplo, no método ondraw, você acessa algum tipo de exibição, isso afetará a renderização de cada quadro e você poderá fazê-lo um pouco mais rápido.

Se somarmos todas as otimizações, em suma, isso pode dar alguma coisa.

ESTÁTICO!?


E a estática? Todos sabemos que no Kotlin a estática é um objeto complementar. Anteriormente, você provavelmente adicionava algum tipo de tag, por exemplo, estática pública, estática final etc., se converter isso em código Kotlin, você receberá um objeto complementar, que escreverá algo como o seguinte:

  companion object { var k = 5 fun work2() : Int = 42 } 

Você acha que essa entrada é idêntica à declaração final estática padrão em Java? É estático ou não?

Sim, de fato, Kotlin declara que aqui está em Kotlin - estático, esse objeto diz que é estático. Na realidade, isso não é estático.

Se observarmos o bytecode gerado, veremos o seguinte:

  L2 LINENUMBER 21 L2 GETSTATIC kotlin/Test.Companion : Lkotlin/Test$Companion; INVOKEVIRTUAL kotlin/Test$Companion.getK ()I GETSTATIC kotlin/Test.Companion : Lkotlin/Test$Companion; INVOKEVIRTUAL kotlin/Test$Companion.work2 ()I IADD ISTORE 3 

Um Test.Companion é gerado; um objeto singleton para o qual a instância é criada, essa instância é gravada em seu próprio campo. Depois disso, o acesso a um objeto complementar ocorre através desse objeto. Ele pega getstatic, isto é, uma instância estática dessa classe e chama a função getK invokevirtual nela, e exatamente o mesmo para a função work2. Então entendemos que não é estático.

Isso importa, porque nas JVMs mais antigas, o invokestatic era cerca de 30% mais rápido que o invokevirtual. Agora, é claro, no HotSpot, a virtualização otimizada está ficando muito legal e é quase invisível. No entanto, é preciso ter isso em mente, especialmente porque há uma alocação extra, e um local extra no 4ST1 é de 700 nanossegundos em excesso.

Vejamos o código Java que sai se você implantar novamente o bytecode:

 private static int k = 5; public static final Test.Companion Companion = new Test.Companion((DefaultConstructorMarker)null); public static final class Companion { public final int getK() { return Test.k;} public final void setK(int var1) { Test.k = var1; } public final int work2() { return 42; } private Companion() { } // $FF: synthetic method public Companion(DefaultConstructorMarker $constructor_marker) { this(); } } 

Um campo estático é criado, uma implementação final estática do objeto Companion, getters e setters são criados e, como você pode ver, referindo-se ao campo estático interno, um método estático adicional é exibido. Tudo é triste o suficiente.

O que podemos fazer, certificando-se de que não é estático? Podemos tentar adicionar @JvmField e @JvmStatic e ver o que acontece.

 val i = k + work2() companion object { @JvmField var k = 5 JvmStatic fun work2() : Int = 42 } 

Diremos imediatamente que você não vai se afastar do @JvmStatic, será o mesmo objeto, já que esse é um objeto complementar, haverá uma alocação extra desse objeto e uma chamada extra.

 private static int k = 5; public static final Test.Companion Companion = new Test.Companion((DefaultConstructorMarker)null); public static final class Companion { @JvmStatic public final int work2() { return 42; } private Companion() {} // $FF: synthetic method public Companion(DefaultConstructorMarker $constructor_marker) { this(); } } 

Mas a chamada mudará apenas para k, porque será @JvmField, será tomada diretamente como getstatic, getters e setters não serão mais gerados. Mas para a função work2, nada mudará.

  L2 LINENUMBER 21 L2 GETSTATIC kotlin/Test.k : I GETSTATIC kotlin/Test.Companion : Lkotlin/Test$Companion; INVOKEVIRTUAL kotlin/Test$Companion.work2 ()I IADD ISTORE 3 

A segunda opção sobre como criar estática é proposta na documentação do Kotlin, por isso é dito que podemos apenas criar um objeto, e este será um código estático.

 object A { fun test() = 53 } 

Na realidade, isso também não é assim.

 L3 LINENUMBER 23 L3 GETSTATIC kotlin/A.INSTANCE : Lkotlin/A; INVOKEVIRTUAL kotlin/A.test ()I POP 

Acontece que fazemos uma chamada de instância getstatic de singletone, que é criada, e chamamos exatamente os mesmos métodos virtuais.

A única maneira de obtermos invocestáticos são as funções de ordem superior. Quando apenas escrevemos alguma função fora da classe, por exemplo, o divertido teste2 será realmente chamado de estático.

  fun test2() = 99 L4 LINENUMBER 24 L4 INVOKESTATIC kotlin/TestKt.test2 ()I POP 

Além disso, o mais interessante é que uma classe será criada, um objeto, neste caso testKt, gerará um objeto para si, gerará uma função que ele coloca nesse objeto e agora será chamada de invocativa.

Por que isso foi feito é incompreensível. Muitos estão descontentes com isso, mas há quem considere essa implementação bastante normal. Desde a máquina virtual, incl. A arte está melhorando, agora não é tão crítica. Na oitava versão do Android, assim como no HotSpot, tudo é otimizado, mas essas pequenas coisas ainda afetam levemente o desempenho geral.

NULABILIDADE


 fun test(first: String, second: String?) : String { second ?: return first return "$first $second" } 

Este é o próximo exemplo interessante. Parece que observamos que o segundo pode ser anulável e deve ser verificado antes de fazer qualquer coisa com ele. Nesse caso, espero que tenhamos um se. Quando esse código é implantado se o segundo não for igual a zero, acho que a execução irá além e produzirá apenas primeiro.

Como isso realmente se desenrola no código java? Na verdade, haverá um cheque.

 @NotNull public final String test(@NotNull String first,@Nullable String second) { Intrinsics.checkParameterIsNotNull(first, "first"); return second != null ? (first + " " + second) : first; } 

Obteremos Intrinsics inicialmente. Digamos que eu digo que este

Se expandirá para um operador ternário. Além disso, embora tenhamos fixado que o primeiro parâmetro não pode ser anulável, ele ainda será verificado através do Intrinsics.

Intrinsics é uma classe interna no Kotlin que possui um determinado conjunto de parâmetros e verificações. E toda vez que você torna o parâmetro do método não nulo, ele o verifica de qualquer maneira. Porque Então, trabalhamos no Interop Java e pode acontecer que você espere que não seja anulável aqui, mas com o Java ele virá de algum lugar.

Se você marcar isso, ele irá além do código e, depois de 10 a 20 chamadas ao método, você fará algo com um parâmetro que, embora possa não ser nulo, mas por alguma razão acabou sendo. Tudo cairá para você e você não será capaz de entender o que realmente aconteceu. Para evitar essa situação, sempre que você passar o parâmetro nulo, você ainda precisará verificá-lo. E se for anulável, haverá uma exceção.

Essa verificação também vale alguma coisa e, se houver muitas delas, não será muito boa.

Mas, de fato, se falarmos sobre HotSpot, 10 chamadas desses intrínsecos levarão cerca de quatro nanossegundos. Isso é muito, muito pequeno, e você não deve se preocupar com isso, mas esse é um fator interessante.

PRIMITIVOS


Em Java, existem coisas como primitivas. Em Kotlin, como todos sabemos, não existem primitivos, sempre operamos com objetos. Em Java, eles são usados ​​para fornecer maior desempenho para objetos em alguns cálculos menores. Adicionar dois objetos é muito mais caro do que adicionar duas primitivas. Considere um exemplo.

  var a = 5 var b = 6 var bOption : Int? = 6 

Existem três números, para os dois primeiros o tipo não nulo será deduzido e, no terceiro, dizemos que pode ser anulável.

  private int a = 5; private int b = 6; @Nullable private Integer bOption = Integer.valueOf(6); 

Se você olhar para o bytecode e ver qual código Java é gerado, os dois primeiros números não serão nulos e, portanto, poderão ser primitivos. Mas o primitivo não pode conter Nulo, apenas um objeto pode fazer isso; portanto, um objeto será gerado para o terceiro número.

AUTOBOXING


Ao trabalhar com primitivos e executar uma operação com um primitivo e não primitivo, você precisará converter um deles em um primitivo ou em um objeto.

E, ao que parece, não é surpreendente que, se você fizer operações com valor nulo e não com valor nulo no Kotlin, perderá um pouco de desempenho. Além disso, se houver muitas dessas operações, você perderá muito.

  val a: String? = null var b = a?.isBlank() == true 

Veja onde o boxe / unboxing estará aqui? Também não vi até olhar o bytecode.

 if (a != null && a.isBlank()) true else false 

Na verdade, eu esperava que houvesse algo como essa comparação: se a sequência não for nula e estiver vazia, defina como true, caso contrário, defina como false. Tudo parece ser simples, mas, na realidade, o seguinte código é gerado:

 String a = (String)null; boolean b = Intrinsics.areEqual(a != null ? Boolean.valueOf(StringsKt.isBlank((CharSequence)a)) : null, Boolean.valueOf(true)); 

Vamos olhar para dentro. A variável a é obtida, é convertida em CharSequence, depois de lançada, que também foi gasta por algum tempo, outra verificação é chamada - StringsKt.isBlank - é assim que a função de extensão para CharSequence é gravada e, portanto, convertida e enviada. Como a primeira expressão pode ser anulável, ela pega e executa Boxing e agrupa tudo em Boolean.valueOf. Portanto, o verdadeiro primitivo também se torna um objeto, e somente depois disso a verificação já ocorre e o Intrinsics.areEqual é chamado.

Parece uma operação tão simples, mas um resultado inesperado. De fato, existem muito poucas coisas assim. Mas quando você pode ter anulável / não anulável, pode gerar muitas dessas coisas e algo que você nunca esperaria. Portanto, recomendo que você evite a obscuridade o mais rápido possível. I.e. chegue à imunidade de valores o mais cedo possível e afaste-se de anulável para que você não opere o mais rápido possível.

Loops


A próxima coisa interessante.

Você pode usar o usual para, que está em Java, mas também pode usar a nova API conveniente - escrever a enumeração de elementos na lista imediatamente. , work, it - .

 list.forEach { work(it * 2) } 

. , . , Google, , ArrayList for 3 , . .

, ArrayList, — foreach.

 inline fun <reified T> List<T>.foreach(crossinline action: (T) -> Unit): Unit { val size = size var i = 0 while (i < size) { action(get(i)) i++ } } list.foreach { } 

API, - . , Kotlin: extension , «», reified, .. , , , crossinline. , , . 3 , Android Google.

RANGES


Ranges.

 inline fun <reified T> List<T>.foreach(crossinline action: (T) -> Unit): Unit { val size = size for(i in 0..size) { work(i * 2) } } 

: Unit -. −1, until , , . , , ranges. I.e. , . step. .

INTRINSICS


- Intrinsics, :

 class Test { fun concat(first: String, second: String) = "$first $second" } 

Intrinsics — second, first.

 public final class Test { @NotNull public final String concat(@NotNull String first, @NotNull String second) { Intrinsics.checkParameterIsNotNull(first, "first"); Intrinsics.checkParameterIsNotNull(second, "second"); return first + " " + second; } } 

, gradle. , - 4 , . Kotlin UI, , nullable, Kotlin :

kotlinc -Xno-call-assertions -Xno-param-assertions Test.kt

Intrinsics, , .

, , . — Xno-param-assertions — Intrinsics, .

, , , , , . , , , .

REDEX


, , , Proguard. , 99% , , . Android 8.0 , . , .

, Proguard, Facebook, Redex . -, , . , Jvm Fields , .

, Redex . , , , Proguard, , . Redex 7% APK. , .

BENCHMARKS


. , , . , . , dumpsys gfxinfo , . github github.com/smred .

, Huawei.


. — , . , , 0,04 . , , — , .


Kotlin, . , , . - , Kotlin , Java. , , , , . .


, , , , Kotlin Java. , - , , , , , .


, : - Kotlin , .. . , . - - — 2 , Galaxy S6, .


Google Pixel. , 0,1 .



, , ,

  • UI custom view.
  • onmeasure-onlayout-ondraw. autoboxing, not null ..
  • Kotlin, Java , .
  • — .

, , . , , , , Kotlin, . , Kotlin .

, .

brand new AppsConf , Android . , . , 8 9 .

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


All Articles