Rendimiento de Kotlin en Android

Hablemos hoy sobre el rendimiento de Kotlin en Android en producción. Veamos debajo del capó, implemente optimizaciones difíciles, compare el código de bytes. Finalmente, abordaremos seriamente la comparación y mediremos los puntos de referencia.

Este artículo se basa en un informe de Alexander Smirnov en AppsConf 2017 y ayudará a determinar si es posible escribir código en Kotlin, que no será inferior a Java en velocidad.


Sobre el orador: Alexander Smirnov CTO en PapaJobs, dirige el video blog Android en Faces , y también es uno de los organizadores de la comunidad Mosdroid.

Comencemos con sus expectativas.

¿Crees que Kotlin en tiempo de ejecución es más lento que Java? O mas rapido? ¿O tal vez no hay mucha diferencia? Después de todo, ambos trabajan en el código de bytes que nos proporciona la máquina virtual.

Vamos a hacerlo bien. Tradicionalmente, cuando surge la cuestión de comparar el rendimiento, todos quieren ver puntos de referencia y números específicos. Desafortunadamente, para Android no hay JMH ( Java Microbenchmark Harness ), por lo que no podemos medir cuán genial se puede hacer en Java. Entonces, ¿qué podemos hacer para hacer la medición, como se describe a continuación?

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

Si alguna vez intentas medir tu código de esta manera, uno de los desarrolladores de JMH estará triste, llorará y vendrá a ti en un sueño, nunca lo hagas.

En Android, puede hacer puntos de referencia, en particular, Google demostró esto en la E / S del año pasado. Dijeron que mejoraron enormemente la máquina virtual, en este caso ART, y si en Android 4.1 una asignación de un objeto tomaba entre 600 y 700 nanosegundos, en la octava versión tardaría unos 60 nanosegundos. Es decir pudieron medirlo con tanta precisión en una máquina virtual. Por qué no podemos hacer tampoco: no tenemos tales herramientas.

Si miramos toda la documentación, lo único que podemos encontrar es la recomendación anterior, cómo medir la IU:

adb shell dumpsys gfxinfo% nombre_paquete%

En realidad, hagámoslo de esta manera y veamos al final lo que dará. Pero primero, determinaremos qué mediremos y qué más podemos hacer.

La siguiente pregunta. ¿Dónde crees que el rendimiento es importante cuando creas una aplicación de primera clase?

  1. Definitivamente en todas partes.
  2. UI Hilo.
  3. Vista personalizada + animaciones.




Lo que más me gusta es la primera opción, pero lo más probable es que se crea que es imposible hacer que todo el código funcione muy, muy rápidamente y es importante que al menos no haya UiThread o una vista personalizada. También estoy de acuerdo con esto: es muy, muy importante. El hecho de que en su secuencia JSON separada se deserialice durante 10 milisegundos más, nadie se dará cuenta.

La psicología de la Gestalt dice que cuando parpadeamos, durante unos 150-300 milisegundos, el ojo humano está fuera de foco y no ve lo que realmente está sucediendo allí. Y luego estos 10 milisegundos de clima no. Pero si volvemos a la psicología gestalt, es importante no lo que realmente veo y lo que realmente sucede, pero lo que entiendo como usuario es importante.

Es decir Si hacemos que el usuario piense que lo tiene todo muy, muy rápido, pero de hecho simplemente será golpeado maravillosamente, por ejemplo, con la ayuda de una hermosa animación, entonces estará satisfecho, incluso si de hecho no lo es.

Los motivos de psicología de la Gestalt en iOS se han estado moviendo durante bastante tiempo. Por lo tanto, si toma dos aplicaciones con el mismo tiempo de procesamiento, pero en plataformas diferentes, y las pone lado a lado, parecerá que en iOS todo es más rápido. La animación en iOS se procesa un poco más rápido, la animación anterior comienza en el inicio y muchas otras animaciones, por lo que es hermosa.

Entonces, la primera regla es pensar en el usuario.

Y para la segunda regla, debes sumergirte en el hardcore.

ESTILO KOTLIN


Para evaluar honestamente el rendimiento de Kotlin, lo compararemos con Java. Por lo tanto, resulta que es imposible medir algunas cosas que solo están en Kotlin, por ejemplo:

  • Colección Api.
  • Parámetros predeterminados del método.
  • Clases de datos.
  • Tipos reificados.
  • Corutinas

La API de colección que nos proporciona Kotlin es muy buena, muy rápida. En Java, esto simplemente no existe, solo hay diferentes implementaciones. Por ejemplo, la biblioteca Liteweight Stream API será más lenta porque hace todo lo mismo que Kotlin, pero con una o dos asignaciones adicionales a la operación, ya que todo se convierte en un objeto adicional.

Si tomamos la API de Stream de Java 8, funcionará más lentamente que la API de Kotlin Collection, pero con una condición: no hay tal parálisis en la API de Collection como en Java 8. Si habilitamos paralelo, en grandes cantidades de datos, la API de Stream en Java omitirá la API de Kotlin Collection. Por lo tanto, no podemos comparar tales cosas, porque llevamos a cabo la comparación precisamente desde el punto de vista de Android.

La segunda cosa, que me parece que no se puede comparar, son los parámetros predeterminados del Método , una característica muy interesante que, por cierto, está en Dart. Cuando llama a algún método, puede tener algunos parámetros que pueden tener algún valor, pero pueden ser NULL. Y por lo tanto, no crea 10 métodos diferentes, sino que hace un método y dice que uno de los parámetros puede ser NULL, y en el futuro lo usará sin ningún parámetro. Es decir él mirará, el parámetro ha llegado o no ha venido. Es muy conveniente porque puede escribir mucho menos código, pero el inconveniente es que tiene que pagar por ello. Este es el azúcar sintáctico: usted, como desarrollador, cree que este es un método API, pero en realidad, debajo del capó, cada variación del método con parámetros faltantes se genera en el código de bytes. Y cada uno de estos métodos también verifica poco a poco si este parámetro ha llegado. Si llegó, entonces está bien, si no fue así, entonces creamos una máscara de bits, y dependiendo de esta máscara de bits, el método original que escribió se llama realmente. Operaciones bit a bit, todo si / de lo contrario cuesta un poco de dinero, pero muy poco, y es normal que tenga que pagar por conveniencia. Me parece que esto es absolutamente normal.

El siguiente elemento que no se puede comparar son las clases de datos .

Todos lloran que en Java hay parámetros para los que hay clases de modelo. Es decir toma parámetros y realiza más métodos, captadores y establecedores para todos estos parámetros. Resulta que para una clase con diez parámetros, todavía necesita un conjunto completo de captadores, setters y un montón más. Además, si no usa generadores, entonces tiene que escribir con las manos, lo que generalmente es terrible.

Kotlin te permite alejarte de todo. Primero, dado que hay propiedades en Kotlin, no necesita escribir getters y setters. No tiene parámetros de clase, todas las propiedades . En cualquier caso, creemos que sí. En segundo lugar, si escribe que se trata de clases de datos, se generará un montón de todo lo demás. Por ejemplo, equals (), toStrung () / hasCode (), etc.

Por supuesto, esto también tiene inconvenientes. Por ejemplo, no necesitaba comparar todos los 20 parámetros de mis clases de datos a la vez en mis iguales (), solo necesitaba comparar 3. A alguien no le gusta todo esto porque el rendimiento se pierde en esto y, además, se genera mucho funciones de servicio, y el código compilado es bastante voluminoso. Es decir, si escribe todo a mano, habrá menos código que si usa clases de datos.

No uso clases de datos por otro motivo. Anteriormente, había restricciones en la expansión de tales clases y algo más. Ahora todos están mejor con esto, pero el hábito permanece.

¿Qué es muy, muy bueno en Kotlin, y qué será siempre más rápido que Java? Se trata de tipos Reified , que, por cierto, también está en Dart.

Sabes que cuando usas genéricos, el borrado de tipo se borra en la etapa de compilación y en tiempo de ejecución ya no sabes qué objeto de este genérico se usa realmente.

Con los tipos Reified, no necesita usar la reflexión en muchos lugares, cuando en Java lo necesitaría, porque con los métodos en línea es con Reified que conoce el tipo y, por lo tanto, resulta que no usa la reflexión y su código funciona más rápido. La magia

Y hay corutinas . Son geniales, me gustan mucho, pero en el momento de la presentación se incluyeron solo en la versión alfa, por lo que no fue posible hacer comparaciones correctas con ellos.

CAMPOS


Así que vamos adelante, pasemos a lo que podemos comparar con Java y a lo que podemos influir en general.

 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 dije, no tenemos parámetros para la clase, tenemos propiedades.

Tenemos var, tenemos val, tenemos una clase externa, una de las propiedades de las cuales es @JvmField, y veremos qué sucede realmente con la función work (): sumamos el valor del campo a y el campo b de nuestra propia clase y valores del campo a y el campo b de la clase externa, que se escribe en el campo inmutable c.

La pregunta es qué, de hecho, se llamará en d = a + b. Todos sabemos que esta propiedad una vez, se llamará al captador de esta clase para este parámetro.

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

Pero si miramos el código de bytes, veremos que realmente se está accediendo a getfield. Es decir, esto en el bytecode no es una llamada a la función InvokeVirtual, sino un acceso directo al campo. No hay nada que se nos prometió inicialmente, que tenemos todas las propiedades, no los campos. Resulta que Kotlin nos está engañando, hay un atractivo directo.

¿Qué sucede si vemos qué bytecode se genera para otra línea: 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, si estaba accediendo a una propiedad privada, siempre tenía una llamada InvokeVirtual. Si se trataba de una propiedad privada, el acceso a ella se realizó a través de GetField. GetField es mucho más rápido que InvokeVirtual, la especificación de Android afirma que acceder a un campo directamente es de 3 a 7 veces más rápido. Por lo tanto, se recomienda que siempre se refiera a Field, y no a través de getters o setters. Ahora, especialmente en la octava máquina virtual ART, ya habrá números diferentes, pero si aún es compatible con 4.1, esto será cierto.

Por lo tanto, resulta que todavía es beneficioso para nosotros tener GetField, y no InvokeVirtual.

Ahora, puede obtener GetField si está accediendo a una propiedad de su propia clase, o si esta es una propiedad pública, debe establecer @JvmField. Entonces, exactamente lo mismo en el bytecode será una llamada GetField, que es 3 a 7 veces más rápida.

Está claro que aquí hablamos en nanosegundos y, con un trono, es muy, muy pequeño. Pero, por otro lado, si lo hace en el hilo de la interfaz de usuario, por ejemplo, en el método ondraw accede a algún tipo de vista, esto afectará la representación de cada cuadro, y puede hacerlo un poco más rápido.

Si sumamos todas las optimizaciones, en resumen, puede dar algo.

¿ESTÁTICA?


¿Qué pasa con la estática? Todos sabemos que en Kotlin, la estática es un objeto complementario. Anteriormente, probablemente haya agregado algún tipo de etiqueta, por ejemplo, estática pública, estática final, etc., si convierte esto al código de Kotlin, obtendrá un objeto complementario, que escribirá algo como lo siguiente:

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

¿Crees que esta entrada es idéntica a la declaración final estática estándar en Java? ¿Es estático o no?

Sí, de hecho, Kotlin declara que aquí está en Kotlin: estático, ese objeto dice que es estático. En realidad, esto no es estático.

Si observamos el bytecode generado, veremos lo siguiente:

  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 

Se genera un Test.Companion; un objeto singleton para el que se crea la instancia, esta instancia se escribe en su propio campo. Después de eso, el acceso a uno de los objetos complementarios se produce a través de este objeto. Toma getstatic, es decir, una instancia estática de esta clase, e invoca la función getK invokevirtual en ella, y exactamente lo mismo para la función work2. Entonces entendemos que no es estático.

Esto es importante, porque en las JVM más antiguas, la invookestatic era aproximadamente un 30% más rápida que la invokevirtual. Ahora, por supuesto, en HotSpot, la virtualización optimizada se está volviendo realmente genial, y es casi invisible. Sin embargo, debe tener esto en cuenta, especialmente porque hay una asignación adicional y una ubicación adicional en 4ST1 es de 700 nanosegundos, demasiado.

Echemos un vistazo al código Java que sale si implementa el código de bytes de forma inversa:

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

Se crea un campo estático, se crea una implementación final estática del objeto Companion, se crean captadores y establecedores y, como puede ver, haciendo referencia al campo estático en el interior, aparece un método estático adicional. Todo es lo suficientemente triste.

¿Qué podemos hacer para asegurarnos de que no sea estático? Podemos intentar agregar @JvmField y @JvmStatic y ver qué sucede.

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

Diré de inmediato que no se alejará de @JvmStatic, será el mismo objeto, ya que este es un objeto complementario, habrá una asignación adicional de este objeto y habrá una llamada adicional.

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

Pero la llamada cambiará solo para k, porque será @JvmField, se tomará directamente como getstatic, ya no se generarán getters y setters. Pero para la función work2 nada cambiará.

  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 

La segunda opción sobre cómo crear estática se propone en la documentación de Kotlin, por lo que se dice que solo podemos crear un objeto, y este será un código estático.

 object A { fun test() = 53 } 

En realidad, esto tampoco es así.

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

Resulta que hacemos una llamada de instancia getstatic desde singletone, que se crea, y llamamos exactamente los mismos métodos virtuales.

La única forma en que podemos lograr invokestatic es Funciones de orden superior. Cuando simplemente escribimos alguna función fuera de la clase, por ejemplo, fun test2 se llamará realmente como static.

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

Además, lo más interesante es que se creará una clase, un objeto, en este caso testKt, generará un objeto para sí mismo, generará una función que se pone en este objeto, y ahora se llamará como invocatático.

Por qué se hizo esto es incomprensible. Muchos no están contentos con esto, pero hay quienes consideran que tal implementación es bastante normal. Desde la máquina virtual, incl. El arte está mejorando, ahora no es tan crítico. En la octava versión de Android, al igual que en HotSpot, todo está optimizado, pero estas pequeñas cosas afectan ligeramente el rendimiento general.

NULABILIDAD


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

Este es el siguiente ejemplo interesante. Parece que notamos que el segundo puede ser anulable, y debe verificarse antes de hacer algo con él. En este caso, espero que tengamos uno si. Cuando este código se implementa si el segundo no es igual a cero, entonces creo que la ejecución irá más allá y solo se generará primero.

¿Cómo se desarrolla todo esto en el código Java? En realidad habrá un cheque.

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

Obtendremos Intrinsics inicialmente. Digamos que yo digo que este

Si se expandirá en un operador ternario. Pero además de esto, aunque incluso arreglamos que el primer parámetro no puede ser anulable, aún se verificará a través de Intrinsics.

Intrinsics es una clase interna en Kotlin que tiene un cierto conjunto de parámetros y verificaciones. Y cada vez que hace que el parámetro del método no sea anulable, lo verifica de todos modos. Por qué Entonces, que trabajamos en Interop Java, y puede suceder que espere que no sea anulable aquí, pero con Java vendrá de algún lado.

Si marca esto, irá más allá en el código, y luego, después de 10-20 llamadas al método, hará algo con un parámetro que, aunque puede no ser anulable, pero por alguna razón resultó ser. Todo se enamorará de ti y no podrás entender lo que realmente sucedió. Para evitar esta situación, cada vez que pase el parámetro nulo, aún tendrá que verificarlo. Y si es anulable, habrá una excepción.

Este cheque también vale algo, y si hay muchos de ellos, entonces no será muy bueno.

Pero, de hecho, si hablamos de HotSpot, 10 llamadas de estos Intrínsecos tomarán alrededor de cuatro nanosegundos. Esto es muy, muy pequeño, y no debe preocuparse por esto, pero este es un factor interesante.

Primitivas


En Java existen los primitivos. En Kotlin, como todos sabemos, no hay primitivas, siempre operamos con objetos. En Java, se utilizan para proporcionar un mayor rendimiento para los objetos en algunos cálculos menores. Agregar dos objetos es mucho más costoso que agregar dos primitivas. Considera un ejemplo.

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

Hay tres números, para los dos primeros se deducirá el tipo no nulo, y alrededor del tercero decimos que puede ser anulable.

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

Si observa el código de bytes y ve qué código Java se genera, los dos primeros números no son nulos y, por lo tanto, pueden ser primitivos. Pero la primitiva no puede contener Nulo, solo un objeto puede hacer esto, por lo que se generará un objeto para el tercer número.

AUTOBOXING


Cuando trabaje con primitivas y realice una operación con primitivas y no primitivas, necesitará traducir una de ellas en una primitiva o en un objeto.

Y, al parecer, no es sorprendente que si realiza operaciones con nullable y no nullable en Kotlin, pierda un poco de rendimiento. Además, si hay muchas operaciones de este tipo, entonces pierde mucho.

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

¿Ves dónde estará aquí el boxeo / unboxing? Tampoco vi hasta que miré el bytecode.

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

En realidad, esperaba que hubiera algo como esta comparación: si la cadena no es nula y está vacía, se establece en verdadero, de lo contrario se establece en falso. Todo parece ser simple, pero en realidad se genera el siguiente código:

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

Miremos adentro. Se toma la variable a , se emite en CharSequence, después de que se ha echado, que también se ha gastado durante algún tiempo, se llama otra verificación - StringsKt.isBlank - así es como se escribe la función de extensión para CharSequence, por lo que se emite y se envía. Como la primera expresión puede ser anulable, la toma y hace Boxeo, y la envuelve todo en Boolean.valueOf. Por lo tanto, la primitiva verdadera también se convierte en un objeto, y solo después de eso, la verificación ya tiene lugar y se llama a Intrinsics.areEqual.

Parecería una operación tan simple, pero un resultado tan inesperado. De hecho, hay muy pocas de esas cosas. Pero cuando puede tener nullable / no nullable, puede generar muchas de esas cosas, y una que nunca hubiera esperado. Por lo tanto, le recomiendo que se aleje de la oscuridad lo antes posible. Es decir llegue a la inmunidad de los valores lo antes posible y aléjese de los valores nulables para que no opere nulo lo más rápido posible.

Bucles


La próxima cosa interesante.

Puede usar el usual for, que está en Java, pero también puede usar la nueva API conveniente: escriba la enumeración de elementos en la lista de inmediato. , 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. Es decir , . 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/es420143/


All Articles