Register now for Android Dev Summit 2019!

Sugerencias sobre el rendimiento

En este documento, se abarcan las microoptimizaciones que, combinadas, pueden mejorar el rendimiento general de las apps. Sin embargo, es poco probable que estos cambios generen efectos notables en el rendimiento. Siempre debería ser tu prioridad elegir el algoritmo y las estructuras de datos correctas, pero ese tema no está incluido en este documento. Deberías usar estas sugerencias como prácticas de código genérico que puedas transformar en hábitos para mejorar el rendimiento de tus códigos en general.

Hay dos reglas básicas para escribir códigos eficientes:

  • No hacer trabajo innecesario
  • No asignar memoria si no es necesario

Uno de los problemas más difíciles que enfrentarás cuando realices una microoptimización de una app de Android es que la app seguramente se ejecutará en varios tipos de hardware. Es decir, en diferentes versiones de la VM que se ejecutan en diferentes procesadores y a distintas velocidades. En general, ni siquiera es posible establecer que "el dispositivo X es un factor F más rápido/lento que el dispositivo Y", y escalar los resultados de un dispositivo a otro. En particular, las mediciones en el emulador te indican muy poco sobre el rendimiento en cualquier dispositivo. También hay grandes diferencias entre los dispositivos con y sin JIT. Esto se debe a que el mejor código para un dispositivo con JIT no siempre resulta la mejor elección para un dispositivo que no lo tiene.

Para garantizar que tu app tenga un buen rendimiento en una amplia variedad de dispositivos, asegúrate de que el código sea eficiente en todos los niveles y que optimice el rendimiento de forma activa.

Evita crear objetos innecesarios

La creación de objetos nunca es gratis. Es posible que, si usas un recolector generacional de elementos no utilizados con grupos de asignación por subprocesos para los objetos temporales, la asignación sea más económica. Sin embargo, asignar memoria siempre resulta más costoso que no hacerlo.

A medida que asignas más objetos en tu app, forzarás la recolección de elementos no utilizados de forma periódica, lo que provocará pequeños fallos en la experiencia del usuario. El recolector de elementos no utilizados simultáneo que se incorporó en Android 2.3 es útil, pero siempre se debe evitar el trabajo innecesario.

Por lo tanto, debes evitar crear instancias de objetos que no necesites. Por ejemplo, las siguientes sugerencias pueden resultarte útiles:

  • Si tienes un método que muestra una string y sabes que el resultado siempre se agregará a una clase StringBuffer, cambia la implementación y firma para que la función se agregue directamente, en lugar de crear un objeto temporal con una duración relativamente corta.
  • Cuando extraigas strings de un conjunto de datos de entrada, intenta mostrar una substring de los datos originales, en lugar de crear una copia. Crearás un nuevo objeto String, pero compartirá char[] con los datos. (La desventaja de este método es que si solo usas una pequeña parte de la entrada original, de todas formas conservarás los datos en la memoria).

Una idea más radical es dividir los arreglos de varias dimensiones en arreglos paralelos de una sola dimensión:

  • Un arreglo de int es una mejor alternativa que un arreglo de objetos Integer. Sin embargo, esto se debe a que, como regla general, dos arreglos de int paralelos son mucho más eficaces que un arreglo de objetos (int,int). Lo mismo sucede con cualquier combinación de tipos primitivos.
  • Si necesitas implementar un contenedor que almacene tuplas de objetos (Foo,Bar), recuerda que, en general, es mucho más eficaz utilizar dos arreglos paralelos Foo[] y Bar[] en lugar de un arreglo de objetos (Foo,Bar) personalizados. (La excepción a esta generalización se presenta cuando estás diseñando una API para que otros códigos puedan acceder a ella. En esos casos, generalmente, es mejor sacrificar un poco de velocidad para lograr un buen diseño de API. Sin embargo, en tu propio código interno, debes intentar ser lo más eficiente posible).

En términos generales, si puedes, evita crear objetos temporales a corto plazo. Una menor cantidad de objetos creados requerirá una recolección de elementos no utilizados menos frecuente, lo que tiene un impacto directo en la experiencia del usuario.

Usa métodos estáticos en lugar de virtuales

Si no necesitas acceder a los campos de un objeto, haz que tu método sea estático. Las invocaciones serán, aproximadamente, entre un 15% y un 20% más rápidas. También es una práctica recomendada, ya que se puede ver en la firma del método que llamarlo no alterará el estado del objeto.

Usa un final estático para las constantes

Ten en cuenta la siguiente declaración en la parte superior de una clase:

    static int intVal = 42;
    static String strVal = "Hello, world!";
    

El compilador genera un método de inicialización de clase, llamado <clinit>, que se ejecuta la primera vez que se usa la clase. El método almacena el valor 42 en intVal y extrae una referencia de la tabla de constantes de strings correspondiente al archivo de clase strVal. Más adelante, cuando se haga referencia a estos valores, se accederá a ellos mediante búsquedas de campo.

Se puede mejorar este método agregando la palabra clave "final":

    static final int intVal = 42;
    static final String strVal = "Hello, world!";
    

La clase ya no requiere un método <clinit>, ya que las constantes entran en inicializadores de campo estático en el archivo dex. El código que hace referencia a intVal usará el valor entero 42 directamente, y los accesos a strVal usarán una instrucción de "constante de string" que tiene un costo relativamente bajo, en lugar de una búsqueda de campo.

Nota: Esta optimización se aplica solamente a tipos primitivos y constantes de String, no a tipos de referencia arbitrarios. Aun así, es recomendable declarar constantes static final siempre que sea posible.

Usa la sintaxis mejorada de bucle "for"

El bucle for mejorado (también conocido como bucle "for-each") se puede usar para arreglos y colecciones que implementen la interfaz Iterable. En el caso de las colecciones, se asigna un iterador para que la interfaz invoque a hasNext() y next(). En el caso de ArrayList, un bucle contado escrito a manos es, aproximadamente, 3 veces más rápido (con o sin JIT). Sin embargo, en otras colecciones, la sintaxis mejorada de bucle "for" tendrá el mismo resultado que el uso del iterador explícito.

Existen varias alternativas para la iteración mediante una matriz:

    static class Foo {
        int splat;
    }

    Foo[] array = ...

    public void zero() {
        int sum = 0;
        for (int i = 0; i < array.length; ++i) {
            sum += array[i].splat;
        }
    }

    public void one() {
        int sum = 0;
        Foo[] localArray = array;
        int len = localArray.length;

        for (int i = 0; i < len; ++i) {
            sum += localArray[i].splat;
        }
    }

    public void two() {
        int sum = 0;
        for (Foo a : array) {
            sum += a.splat;
        }
    }
    

zero() es más lento, ya que JIT todavía no puede optimizar el costo que le produce obtener la longitud del arreglo una vez por cada iteración a través del bucle.

one() es más rápido, ya que extrae todos los datos y los almacena en variables locales. De esta forma, evita las búsquedas. Solo la longitud de la matriz ofrece un beneficio de rendimiento.

two() es el más rápido para dispositivos sin JIT y no puede distinguirse de one() en dispositivos con JIT. Usa la sintaxis mejorada de bucle "for" que se incorporó en la versión 1.5 del lenguaje de programación Java.

Por lo tanto, deberías usar el bucle mejorado for de forma predeterminada, pero considera el uso de un bucle contado escrito a mano para la iteración ArrayList de rendimiento crítico.

Sugerencia: Consulta también el artículo 46 de Effective Java de Josh Bloch.

Usa un paquete en lugar de acceso privado con clases internas privadas

Ten en cuenta la siguiente definición de clase:

    public class Foo {
        private class Inner {
            void stuff() {
                Foo.this.doStuff(Foo.this.mValue);
            }
        }

        private int mValue;

        public void run() {
            Inner in = new Inner();
            mValue = 27;
            in.stuff();
        }

        private void doStuff(int value) {
            System.out.println("Value is " + value);
        }
    }

Hay que resaltar que lo importante es definir una clase interna privada (Foo$Inner) que acceda directamente a un método privado y un campo de instancia privada en la clase externa. Esta optimización es legal, y el código imprime "El valor es 27", como se esperaba.

El problema reside en que la VM considera que el acceso directo de Foo$Inner a los miembros privados de Foo es ilegal, debido a que Foo y Foo$Inner son distintas clases, a pesar de que el lenguaje Java permite que una clase interna acceda a los miembros privados de una externa. Para evitar este problema, el compilador genera varios métodos sintéticos:

    /*package*/ static int Foo.access$100(Foo foo) {
        return foo.mValue;
    }
    /*package*/ static void Foo.access$200(Foo foo, int value) {
        foo.doStuff(value);
    }

El código de clase interno llama a estos métodos estáticos cuando necesita acceder al campo mValue o invocar el método doStuff() en la clase externa. En otras palabras, el código anterior se utiliza solamente para acceder a campos de miembro mediante métodos de acceso. Anteriormente, indicamos que los métodos de acceso son más lentos que los accesos directos a campos, por lo que este es un ejemplo de un lenguaje determinado cuyo impacto de rendimiento es "invisible".

Si estás usando un código similar a este en un hotspot de rendimiento, puedes declarar que los campos y métodos a los que acceden las clases internas tienen acceso a paquetes, en lugar de acceso privado y, de esta forma, evitar gastos generales. Lamentablemente, esto significa que otras clases contenidas en el mismo paquete pueden acceder directamente a los campos, por lo que no deberías usar esta declaración en una API pública.

Evita usar puntos flotantes

Por regla general, los puntos flotantes son 2 veces más lentos que los valores enteros en dispositivos con tecnología de Android.

En lo referente a la velocidad, no hay ninguna diferencia entre float y double en el hardware más moderno. En cuanto al espacio, double es 2 veces más grande. En computadoras de escritorio, suponiendo que el espacio no representa ningún problema, debes priorizar double antes que float.

Además, incluso para los valores enteros, algunos procesadores tienen la capacidad de multiplicar hardware, pero no de dividirlo. En estos casos, las operaciones de división y de módulo de valores enteros se realizan en el software (es importante que lo tengas en cuenta si estás diseñando una tabla de hash o realizando muchos cálculos).

Familiarízate con las bibliotecas y úsalas

Además de todas las razones habituales por las que es preferible usar el código de biblioteca en lugar de crear código propio, ten en cuenta que el sistema puede reemplazar las llamadas a los métodos de biblioteca con un ensamblador codificado a mano (puede resultar más eficaz que el mejor código que JIT puede producir para el equivalente de Java). El ejemplo típico es String.indexOf() y las API relacionadas, que Dalvik reemplaza con un valor intrínseco integrado. De manera similar, el método System.arraycopy() es, aproximadamente, 9 veces más rápido que un bucle codificado a mano en un Nexus One con JIT.

Sugerencia: Consulta también el artículo 47 de Effective Java de Josh Bloch.

Usa métodos nativos con cuidado

Desarrollar tu app con código nativo usando el NDK de Android no es necesariamente más eficiente que la programación con el lenguaje Java. Por un lado, hay un costo asociado con la transición nativa de Java, y el JIT no puede optimizarse dentro de estos límites. Si estás asignando recursos nativos (memoria en la pila nativa, descriptores de archivos o algún otro método), puede ser mucho más difícil organizar la recopilación oportuna de estos recursos. También debes compilar el código para cada arquitectura que quieras ejecutar (en lugar de confiar en que tenga JIT). Es posible que incluso debas compilar varias versiones para la que consideras la misma arquitectura: el código nativo compilado para el procesador ARM en G1 no puede aprovechar al máximo el ARM en Nexus One, y el código compilado para el ARM en Nexus One no se ejecutará en el ARM en G1.

El código nativo resulta útil, principalmente, cuando ya tienes una base de código nativa que deseas transmitir a Android, no para "acelerar" partes de tu app de Android escritas con el lenguaje Java.

Si necesitas usar código nativo, es recomendable que leas nuestras Sugerencias sobre JNI.

Sugerencia: Consulta también el artículo 54 de Effective Java de Josh Bloch.

Mitos sobre el rendimiento

En dispositivos sin JIT, invocar métodos mediante una variable con un tipo exacto en lugar de una interfaz es ligeramente más eficiente. (Por ejemplo, resultó más eficiente invocar métodos en un HashMap map, en lugar de Map map, aunque en ambos casos el mapa era un HashMap). En este caso, el rendimiento no fue 2 veces más lento. La diferencia real fue de alrededor del 6% más lento. Además, el JIT hace que no se pueda distinguir entre ambos métodos en lo relacionado con la eficiencia.

En los dispositivos sin JIT, el acceso al campo de caché es aproximadamente un 20% más rápido que el acceso reiterado al campo. Con un JIT, el acceso al campo tiene el mismo costo que el local, por lo que esta optimización no vale la pena, a menos que facilite la lectura del código. Lo mismo se aplica a los campos finales, estáticos y finales estáticos.

Siempre realiza mediciones

Antes de comenzar a realizar optimizaciones, asegúrate de que haya un problema que requiera una solución. Verifica que podrás medir con precisión el rendimiento actual; de lo contrario, no podrás medir el beneficio de las alternativas que pruebes.

También es posible que Traceview te resulte útil para la creación del perfiles. Sin embargo, es importante que tengas en cuenta que, por el momento, inhabilita el JIT, que podría provocar que no se atribuya correctamente el código que el JIT podría recuperar. Es importante que, después de hacer los cambios sugeridos por los datos de Traceview, te asegures de que el código resultante realmente se ejecute más rápido cuando lo haga fuera de Traceview.

Para obtener más ayuda sobre la creación de perfiles y la depuración de tus apps, consulta los siguientes documentos: