Únete a ⁠ #Android11: The Beta Launch Show el 3 de junio.

Mejor rendimiento mediante subprocesos

Si usas de manera adecuada los subprocesos en Android, podrás aumentar el rendimiento de tu app. En esta página, se analizan varios aspectos del trabajo con subprocesos: el trabajo con el subproceso de IU, o subproceso principal; la relación entre el ciclo de vida de la app y la prioridad del subproceso; y los métodos que proporciona la plataforma para ayudar a administrar la complejidad del subproceso. En esta página, se describen las dificultades que se pueden encontrar en cada una de estas áreas y las estrategias para evitarlas.

Subproceso principal

Cuando el usuario inicia tu app, Android crea un nuevo proceso de Linux junto con un subproceso de ejecución. Este subproceso principal, también conocido como subproceso de IU, es responsable de todo lo que sucede en la pantalla. Comprender cómo funciona puede ayudarte a diseñar tu app para usar el subproceso principal y obtener el mejor rendimiento posible.

Aspectos internos

El subproceso principal tiene un diseño muy simple: su única tarea es tomar y ejecutar bloques de trabajo desde una cola de trabajos que se puedan usar de forma segura en subprocesos hasta que se cierre la app. El marco de trabajo genera algunos de estos bloques de trabajo a partir de una variedad de lugares. Estos lugares incluyen devoluciones de llamada asociadas con información de ciclos de vida; eventos del usuario, como entradas; o eventos provenientes de otros procesos y apps. Además, la app puede poner bloques en cola de forma explícita sin usar el marco de trabajo.

Casi todos los bloques de código que ejecuta tu app están vinculados a una devolución de llamada de evento, como una entrada, un aumento del diseño o una generación. Cuando algo activa un evento, el subproceso en el que se produjo envía el evento a la cola de mensajes del subproceso principal. Luego, este puede dar servicio al evento.

Mientras se reproduce una animación o una actualización de pantalla, el sistema intenta ejecutar un bloque de trabajo (que es el responsable de generar la pantalla) cada 16 ms a fin de renderizar sin problemas a 60 fotogramas por segundo. Para que el sistema alcance este objetivo, se debe actualizar la jerarquía de IU/vistas en el subproceso principal. Sin embargo, cuando la cola de mensajes del subproceso principal contiene tareas que son demasiado numerosas o demasiado largas para que el subproceso principal complete la actualización con suficiente rapidez, la app debe transferir este trabajo a un subproceso de trabajo. Si el subproceso principal no puede terminar de ejecutar bloques de trabajo en 16 ms, el usuario puede notar pequeños bloqueos, retrasos o falta de respuesta de la IU a la entrada. Si se bloquea el subproceso principal durante aproximadamente cinco segundos, el sistema muestra el diálogo Aplicación no responde (ANR), lo que permite al usuario cerrar directamente la app.

Transferir tareas numerosas o largas desde el subproceso principal, de modo que no interfieran en el renderizado eficaz y la rápida respuesta a la entrada del usuario, es el motivo más importante para que adoptes el uso del subproceso en tu app.

Referencia de objetos de IU y subprocesos

Por diseño, los objetos de Android View no se pueden usar de manera segura con subprocesos. Se espera que una app cree, use y destruya objetos de IU, todo en el subproceso principal. Si intentas modificar un subproceso o, incluso, hacer referencia a un objeto de IU en un subproceso que no sea el subproceso principal, se pueden producir excepciones, fallas silenciosas, bloqueos y otros comportamientos erróneos indefinidos.

Los problemas relacionados con las referencias se dividen en dos categorías: referencias explícitas y referencias implícitas.

Referencias explícitas

Muchas tareas de subprocesos no principales tienen el objetivo final de actualizar objetos de IU. Sin embargo, si uno de estos subprocesos accede a un objeto en la jerarquía de vistas, se puede producir inestabilidad de la app; si un subproceso de trabajo cambia las propiedades de ese objeto al mismo tiempo que cualquier otro subproceso hace referencia al objeto, los resultados son indefinidos.

Por ejemplo, imagina una app que incluya una referencia directa a un objeto de IU en un subproceso de trabajo. El objeto del subproceso de trabajo puede contener una referencia a una View; pero, antes de que se complete el trabajo, se quita la View de la jerarquía de vistas. Cuando estas dos acciones suceden en forma simultánea, la referencia mantiene el objeto View en la memoria y establece propiedades en él. Sin embargo, el usuario nunca ve este objeto, y la app elimina el objeto una vez que la referencia desaparece.

En otro ejemplo, los objetos View contienen referencias a la actividad que los posee. Si se destruye esa actividad, pero queda un bloque de trabajo en un subproceso al que hace referencia, de manera directa o indirecta, el recolector de elementos no utilizados no recolectará la actividad hasta que ese bloque de trabajo termine de ejecutarse.

Este escenario puede causar un problema en situaciones en las que el trabajo del subproceso puede estar en curso mientras se produce algún evento de ciclo de vida de la actividad, como la rotación de la pantalla. El sistema no podría realizar la recolección de elementos no utilizados hasta que se complete el trabajo en curso. En consecuencia, es posible que haya dos objetos Activity en la memoria hasta que se pueda realizar la recolección de elementos no utilizados.

En este tipo de situaciones, te sugerimos no incluir en tu app referencias explícitas a los objetos de IU en las tareas de trabajo con subprocesos. Evitar tales referencias te ayudará a evitar este tipo de pérdidas de memoria y, al mismo tiempo, evitará la contención de subprocesos.

En todos los casos, tu app solo debe actualizar los objetos de IU en el subproceso principal. Esto significa que debes elaborar una política de negociación que permita que varios subprocesos comuniquen el trabajo al subproceso principal, que le indica a la actividad o el fragmento más importante que debe actualizar el objeto de IU real.

Referencias implícitas

En el siguiente fragmento de código, se puede ver una falla común en el diseño de códigos con objetos en subprocesos:

Kotlin

    class MainActivity : Activity() {
        // ...
        inner class MyAsyncTask : AsyncTask<Unit, Unit, String>() {
            override fun doInBackground(vararg params: Unit): String {...}
            override fun onPostExecute(result: String) {...}
        }
    }

Java

    public class MainActivity extends Activity {
      // ...
      public class MyAsyncTask extends AsyncTask<Void, Void, String>   {
        @Override protected String doInBackground(Void... params) {...}
        @Override protected void onPostExecute(String result) {...}
      }
    }
    

La falla en este fragmento se produce porque el código declara el objeto de subprocesamiento MyAsyncTask como una clase interna no estática de alguna actividad (o una clase interna de Kotlin). Esta declaración crea una referencia implícita a la instancia de Activity que la contiene. En consecuencia, el objeto incluye una referencia a la actividad hasta que se completa el trabajo en subproceso, lo que provoca un retraso en la destrucción de la actividad a la que se hace referencia. A su vez, esta demora pone más presión sobre la memoria.

Una posible solución directa a este problema consiste en definir tus instancias de clases sobrecargadas como clases estáticas, o en sus propios archivos, de modo que se elimine la referencia implícita.

Otra solución es declarar el objeto AsyncTask como una clase anidada estática (o quitar el calificador interno de Kotlin). Cuando esto ocurre, se elimina el problema de referencia implícita debido a la diferencia entre una clase anidada estática y una interna: una instancia de una clase interna requiere una instancia de la clase externa para ser creada y tiene acceso directo a los métodos y campos de la instancia que la contiene. Por el contrario, una clase anidada estática no requiere una referencia a una instancia de la clase que la contiene, por lo que no incluye referencias a los miembros de la clase externa.

Kotlin

    class MainActivity : Activity() {
        // ...
        class MyAsyncTask : AsyncTask<Unit, Unit, String>() {
            override fun doInBackground(vararg params: Unit): String {...}
            override fun onPostExecute(result: String) {...}
        }
    }
    

Java

    public class MainActivity extends Activity {
      // ...
      static public class MyAsyncTask extends AsyncTask<Void, Void, String>   {
        @Override protected String doInBackground(Void... params) {...}
        @Override protected void onPostExecute(String result) {...}
      }
    }
    

Ciclos de vida de subprocesos y actividad de la app

El ciclo de vida puede afectar la forma en que funciona el subprocesamiento en tu app. Es posible que tengas que decidir si un subproceso debe o no persistir después de que se cierra una actividad. También debes tener en cuenta la relación entre la priorización de subprocesos y si una actividad se ejecuta en primer plano o en segundo plano.

Subprocesos persistentes

Los subprocesos persisten después de la vida útil de las actividades que los generan. Los subprocesos continúan en ejecución, sin interrupciones, independientemente de la creación o destrucción de actividades. En algunos casos, esta persistencia es conveniente.

Considera un caso en el que una actividad genera un conjunto de bloques de trabajo en subproceso, que luego se destruye antes de que un subproceso de trabajo pueda ejecutar los bloques. ¿Qué debe hacer la app con los bloques que están en curso?

Si los bloques iban a actualizar una IU que ya no existe, no hay razón para que el trabajo continúe. Por ejemplo, si el trabajo consiste en cargar información del usuario desde una base de datos y actualizar las vistas, ya no se necesita un subproceso.

Por el contrario, los paquetes de trabajo pueden tener algunos beneficios que no están completamente relacionados con la IU. En este caso, debes conservar el subproceso. Por ejemplo, es posible que los paquetes estén esperando para descargar una imagen, almacenarla en la memoria caché del disco y actualizar el objeto View asociado. Aunque el objeto ya no existe, los actos de descarga y almacenamiento en caché de la imagen pueden ser útiles en caso de que el usuario vuelva a la actividad cerrada.

Administrar de forma manual las respuestas del ciclo de vida de todos los objetos de subprocesos puede ser muy complejo. Si no las administras correctamente, puede haber conflictos de memoria y problemas de rendimiento en tu app. Combinar ViewModel con LiveData te permite cargar datos y recibir notificaciones cuando estos cambian, sin tener que preocuparte por el ciclo de vida. Los objetos ViewModel son una solución a este problema. Los ViewModels se mantienen independientemente de los cambios de configuración, lo que proporciona una manera fácil de conservar tus datos de vistas. Para obtener más información sobre ViewModels, consulta la guía sobre ViewModel; para obtener más información sobre LiveData, consulta la guía sobre LiveData. Si, además, deseas obtener más información sobre la arquitectura de las apps, lee la Guía de arquitectura de apps.

Prioridad del subproceso

Como se describe en Ciclos de vida de subprocesos y actividad de la app, la prioridad que reciben los subprocesos de la app depende parcialmente de en qué parte de su ciclo de vida se encuentra la app. A medida que creas y administras subprocesos en tu app, es importante establecer prioridades para que los subprocesos correctos obtengan las prioridades apropiadas en los momentos apropiados. Si la prioridad se establece en un valor demasiado alto, es posible que tu subproceso interrumpa el subproceso de IU y RenderThread, lo que hará que la app omita fotogramas. Si el valor es demasiado bajo, puedes hacer que las tareas asíncronas (como la carga de imágenes) sean más lentas de lo necesario.

Cada vez que creas un subproceso, debes llamar a setThreadPriority(). El programador de subprocesos del sistema les da prioridad a los subprocesos con prioridades altas y equilibra esas prioridades con la necesidad de realizar todo el trabajo con el tiempo. Por lo general, los subprocesos del grupo en primer plano obtienen aproximadamente el 95% del tiempo de ejecución total del dispositivo, mientras que el grupo en segundo plano obtiene aproximadamente el 5%.

El sistema también asigna a cada subproceso su propio valor de prioridad y utiliza la clase Process.

De forma predeterminada, el sistema establece la prioridad de un subproceso en las mismas membresías de prioridad y grupo que el subproceso de generación. Sin embargo, tu app puede ajustar de manera explícita la prioridad del subproceso usando setThreadPriority().

La clase Process ayuda a reducir la complejidad en la asignación de valores de prioridad, ya que proporciona un conjunto de constantes que tu app puede usar para establecer prioridades de subprocesos. Por ejemplo, THREAD_PRIORITY_DEFAULT representa el valor predeterminado para un subproceso. Tu app debe establecer la prioridad de subproceso como THREAD_PRIORITY_BACKGROUND para los subprocesos que ejecutan tareas menos urgentes.

Además, la app puede usar las constantes THREAD_PRIORITY_LESS_FAVORABLE y THREAD_PRIORITY_MORE_FAVORABLE como incrementadores para establecer prioridades relativas. Para obtener una lista de prioridades de subprocesos, consulta las constantes THREAD_PRIORITY de la clase Process.

Para obtener más información sobre la administración de subprocesos, consulta la documentación de referencia sobre las clases Thread y Process.

Clases de ayuda para crear subprocesos

El marco de trabajo proporciona las mismas clases y primitivas de Java para facilitar la creación de subprocesos, como las clases Thread, Runnable y Executors. Para ayudar a reducir la carga cognitiva asociada con el desarrollo de apps con subprocesos para Android, el marco de trabajo proporciona un conjunto de asistentes que pueden colaborar en el desarrollo, como AsyncTaskLoader y AsyncTask. Cada clase de asistente tiene un conjunto específico de matices de rendimiento que la hace única para un subconjunto específico de problemas de subprocesos. Usar la clase incorrecta para la situación incorrecta puede generar problemas de rendimiento.

La clase AsyncTask

La clase AsyncTask es una primitiva simple y útil para las apps que necesitan trasladar rápidamente el trabajo del subproceso principal a los subprocesos de trabajo. Por ejemplo, un evento de entrada puede activar la necesidad de actualizar la IU con un mapa de bits cargado. Un objeto AsyncTask puede aligerar la carga del mapa de bits y decodificarla en un subproceso alternativo. Una vez que se completa el procesamiento, el objeto AsyncTask puede volver a administrar la recepción del trabajo en el subproceso principal a fin de actualizar la IU.

Cuando se usa AsyncTask, se deben tener en cuenta algunos aspectos importantes sobre el rendimiento. En primer lugar, de forma predeterminada, una app envía todos los objetos AsyncTask que crea a un solo subproceso. Por lo tanto, se ejecutan en serie y, al igual que en el subproceso principal, un paquete de trabajo especialmente largo puede bloquear la cola. Por este motivo, te sugerimos que solo uses AsyncTask para controlar elementos de trabajo de menos de 5 ms de duración.

Además, los objetos AsyncTask son la fuente más común de problemas relacionados con referencias implícitas. Los objetos AsyncTask presentan riesgos relacionados con referencias explícitas, pero a veces son más fáciles de solucionar. Por ejemplo, AsyncTask puede requerir una referencia a un objeto de IU para actualizarlo correctamente una vez que AsyncTask ejecuta sus devoluciones de llamada en el subproceso principal. En esa situación, puedes usar una WeakReference para almacenar una referencia al objeto de IU requerido y acceder al objeto una vez que AsyncTask esté funcionando en el subproceso principal. Para ser claros, conservar una WeakReference en un objeto no hace que el objeto sea seguro para subprocesos. WeakReference simplemente proporciona un método para controlar problemas relacionados con referencias explícitas y recolección de elementos no utilizados.

La clase HandlerThread

Si bien AsyncTask es útil, puede que no siempre sea la solución correcta para el problema relacionado con los subprocesos. En cambio, es posible que necesites un enfoque más tradicional para ejecutar un bloque de trabajo en un subproceso de ejecución más larga, además de capacidad para administrar ese flujo de trabajo de forma manual.

Un desafío común consiste en obtener marcos de vista previa de tu objeto Camera. Cuando te registras para recibir marcos de vista previa de la cámara, los recibes en la devolución de llamada de onPreviewFrame(), que se invoca en el subproceso del evento desde el que se llamó. Si esta devolución de llamada se invocara en el subproceso de IU, la tarea de procesar enormes conjuntos de píxeles interferiría en el trabajo de renderizado y procesamiento de eventos. El mismo problema se aplica a AsyncTask, que también ejecuta trabajos en serie y puede sufrir bloqueos.

En esta situación, sería apropiado un subproceso de controlador, que es un subproceso de larga duración que toma el trabajo de una cola y opera en él. En este ejemplo, cuando tu app delega el comando Camera.open() a un bloque de trabajo del subproceso de controlador, la devolución de llamada asociada de onPreviewFrame() llega al subproceso de controlador en lugar de a los subprocesos de IU o AsyncTask. Por lo tanto, si vas a realizar un trabajo de larga duración en relación con los píxeles, esta puede ser una mejor solución.

Cuando tu app cree un subproceso usando HandlerThread, no olvides establecer la prioridad del subproceso en función del tipo de trabajo que esté realizando. Recuerda que las CPU solo pueden manejar una pequeña cantidad de subprocesos en paralelo. Establecer la prioridad ayuda al sistema a conocer las formas adecuadas de programar este trabajo cuando todos los demás subprocesos luchan por atraer la atención.

La clase ThreadPoolExecutor

Existen ciertos tipos de trabajo que se pueden reducir a tareas de gran paralelismo y distribución. Por ejemplo, una de esas tareas consiste en calcular un filtro para cada bloque de 8 x 8 de una imagen de 8 megapíxeles. Debido al gran volumen de paquetes de trabajo que se crea, AsyncTask y HandlerThread no son clases apropiadas. La naturaleza de subproceso único de AsyncTask convertiría todo el trabajo subprocesado en un sistema lineal. Por otro lado, el uso de la clase HandlerThread requeriría que el programador gestione de forma manual el balanceo de cargas entre un grupo de subprocesos.

ThreadPoolExecutor es una clase de asistente que sirve para facilitar este proceso. Esta clase administra la creación de un grupo de subprocesos, establece sus prioridades y administra la manera en que se distribuye el trabajo entre esos subprocesos. A medida que aumenta o disminuye la carga de trabajo, la clase lanza o destruye más subprocesos para ajustarse a esta.

Esta clase también ayuda a que tu app genere una cantidad óptima de subprocesos. Cuando la app construye un objeto ThreadPoolExecutor, establece un número mínimo y uno máximo de subprocesos. A medida que aumente la carga de trabajo para el ThreadPoolExecutor, la clase tendrá en cuenta los recuentos de subprocesos mínimos y máximos iniciales, y considerará la cantidad de trabajo pendiente que quede por hacer. En función de estos factores, ThreadPoolExecutor decide cuántos subprocesos deberían estar activos en un momento determinado.

¿Cuántos subprocesos deberías crear?

Aunque desde el nivel del software tu código tiene la capacidad de crear cientos de subprocesos, esta tarea puede generar problemas de rendimiento. Tu app comparte recursos limitados de CPU con servicios en segundo plano, el procesador, el motor de audio, las redes y otros elementos. En realidad, las CPU solo tienen la capacidad de manejar una pequeña cantidad de subprocesos en paralelo. Más allá de este límite, se producirán problemas de prioridad y programación. Por lo tanto, es importante crear solo la cantidad de subprocesos que necesite tu carga de trabajo.

En términos prácticos, hay una serie de variables responsables de esto, pero elegir un valor (como 4, para comenzar) y probarlo con Systrace es una estrategia tan eficaz como cualquier otra. Puedes usar el método de prueba y error para conocer la cantidad mínima de subprocesos que puedes usar sin tener problemas.

Otro elemento que debes tener en cuenta a la hora de decidir la cantidad de subprocesos es que estos ocupan espacio en la memoria. Cada subproceso ocupa un mínimo de 64 K de memoria. Esto se suma a las muchas apps instaladas en un dispositivo, especialmente en situaciones en las que las pilas de llamadas crecen de manera exponencial.

A menudo, muchos procesos del sistema y bibliotecas de terceros crean sus propios grupos de subprocesos. Si tu app puede reutilizar un subproceso existente, esto puede mejorar el rendimiento, ya que se reduce la contención de la memoria y los recursos de procesamiento.