Mejor rendimiento a través de 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 a fin de 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 framework 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 framework.

Casi todos los bloques de código que ejecuta tu app están vinculados con 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 aproximadamente 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.

Mover tareas numerosas o largas desde el subproceso principal, de modo que no interfieran en la renderización eficaz y la rápida respuesta a las entradas del usuario, es el motivo más importante para que adoptes el uso de subprocesos en tu app.

Referencia de objetos de IU y subprocesos

Por diseño, los objetos de Android View no cuentan con protección de 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 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 la IU en un subproceso de trabajo. El objeto del subproceso de trabajo puede contener una referencia a un objeto View; pero, antes de que se complete el trabajo, se quita la View de la jerarquía de vistas. Cuando estas dos acciones suceden de 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 borra 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 que no incluyas 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) {...}
  }
}

En este fragmento, se produce una falla 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 con subprocesos, 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 solución directa a este problema sería definir tus instancias de clases sobrecargadas como clases estáticas, o en sus propios archivos, para quitar así la referencia implícita.

Otra solución sería siempre cancelar y limpiar las tareas en segundo plano de la devolución de llamada de ciclo de vida de la Activity correspondiente, como onDestroy. Sin embargo, este enfoque puede ser tedioso y propenso a errores. Como regla general, no debes poner lógica compleja y que no sea de IU directamente en actividades. Además, AsyncTask dejó de estar disponible y no te recomendamos su uso en un código nuevo. Consulta Cómo ejecutar subprocesos en Android para obtener más detalles sobre las primitivas de simultaneidad disponibles.

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 ejecutándose, sin interrupciones, independientemente de la creación o destrucción de actividades, aunque se finalizarán junto con el proceso de aplicación una vez que no haya más componentes activos de la aplicación. En algunos casos, esta persistencia es conveniente.

Considera un caso en el que una actividad genera un conjunto de bloques de trabajo con subprocesos, 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 destruida.

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. Se mantienen los ViewModels independientemente de los cambios de configuración, lo que proporciona una manera fácil de conservar tus datos de vistas. Si quieres 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 Procesos y ciclo de vida de la aplicación, la prioridad que reciben los subprocesos de tu 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 adecuados. 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 la misma prioridad y pertenencia a un grupo que el subproceso de generación. Sin embargo, tu aplicación 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 trabajos menos urgentes.

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

Si deseas obtener más información para administrar subprocesos, consulta la documentación de referencia sobre las clases Thread y Process.

Clases de ayuda para crear subprocesos

En el caso de desarrolladores que usan Kotlin como su lenguaje principal, recomendamos que se usen corrutinas. Las corrutinas proporcionan una serie de beneficios, incluida la escritura de código asíncrono sin devoluciones de llamada, además de simultaneidad estructurada para establecer alcances, cancelaciones y manejo de errores.

El framework también proporciona las mismas clases y primitivas de Java a fin de facilitar los subprocesos, como las clases Thread, Runnable y Executors, además de otras adicionales, como HandlerThread. Si deseas obtener más información, consulta Cómo ejecutar subprocesos en Android.

La clase HandlerThread

Un subproceso de controlador es un subproceso de larga duración que toma el trabajo de una cola y opera en él.

Un desafío común consiste en obtener fotogramas de vista previa de tu objeto Camera. Cuando te registras para recibir fotogramas 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 arrays de píxeles interferiría en el trabajo de renderizado y procesamiento de eventos.

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 al subproceso de IU. 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 esto crea, HandlerThread no es la clase adecuada para usar.

ThreadPoolExecutor es una clase de ayuda 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 inicia o destruye más subprocesos para ajustarse a esta carga.

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.