Cómo mejorar el rendimiento de una app con corrutinas de Kotlin

Las corrutinas de Kotlin te permiten escribir código asíncrono limpio y simplificado que mantiene la capacidad de respuesta de tu app mientras administras tareas prolongadas, como operaciones de disco o llamadas de red.

En este tema, se explora en detalle cómo funcionan las corrutinas en Android. Si no conoces sobre corrutinas, te recomendamos que leas Corrutinas de Kotlin en Android antes de empezar con este tema.

Cómo administrar tareas prolongadas

Las corrutinas se basan en funciones regulares y, para ello, agregan dos operaciones que se ocupan de las tareas de larga duración. Además de invoke (o call) y return, las corrutinas agregan suspend y resume:

  • suspend pausa la ejecución de la corrutina actual y guarda todas las variables locales.
  • resume continúa la ejecución de una corrutina suspendida desde donde se detuvo.

Solo puedes llamar a funciones suspend desde otras funciones suspend o si utilizas un compilador de corrutinas como launch para comenzar una corrutina nueva.

En el siguiente ejemplo, se muestra una implementación de corrutina simple para realizar una tarea prolongada hipotética:

suspend fun fetchDocs() {                             // Dispatchers.Main
    val result = get("https://developer.android.com") // Dispatchers.IO for `get`
    show(result)                                      // Dispatchers.Main
}

suspend fun get(url: String) = withContext(Dispatchers.IO) { /* ... */ }

En ese ejemplo, todavía se ejecuta get() en el subproceso principal, pero suspende la corrutina antes de iniciar la solicitud de red. Cuando se completa la solicitud de red, get reanuda la corrutina suspendida en lugar de usar una devolución de llamada para notificar al subproceso principal.

Kotlin utiliza un marco de pila para administrar qué función se ejecuta junto con cualquier variable local. Cuando se suspende una corrutina, se copia y se guarda el marco de pila actual para más tarde. Cuando se reanuda, se copia el marco de pila desde el lugar en el que se guardó, y vuelve a ejecutarse la función. Aunque el código podría parecer una solicitud de bloqueo secuencial ordinaria, la corrutina garantiza que la solicitud de red evite bloquear el subproceso principal.

Cómo usar corrutinas para la seguridad del subproceso principal

Las corrutinas de Kotlin utilizan despachadores para determinar qué subprocesos se utilizan para la ejecución de corrutinas. Si deseas ejecutar código fuera del subproceso principal, puedes indicarles a las corrutinas de Kotlin que realicen el trabajo en el despachador predeterminado o de IO. En Kotlin, todas las corrutinas se deben ejecutar en un despachador, incluso cuando se ejecutan en el subproceso principal. La corrutinas se pueden suspender a sí mismas, y el despachador es responsable de reanudarlas.

Para especificar en qué lugar deberían ejecutarse las corrutinas, Kotlin proporciona tres despachadores que puedes utilizar:

  • Dispatchers.Main: Utiliza este despachador para ejecutar una corrutina en el subproceso de Android principal. Solo debes usar este despachador para interactuar con la IU y realizar trabajos rápidos. Por ejemplo, para llamar a funciones suspend, ejecutar operaciones del framework de la IU de Android y actualizar objetos LiveData.
  • Dispatchers.IO: Este despachador está optimizado para realizar E/S de disco o red fuera del subproceso principal. Algunos ejemplos incluyen usar el componente Room, leer desde archivos o escribir en ellos, y ejecutar operaciones de red.
  • Dispatchers.Default: Este despachador está optimizado para realizar trabajo que usa la CPU de manera intensiva fuera del subproceso principal. Algunos casos prácticos de ejemplo son clasificar una lista y analizar JSON.

Continuando con el ejemplo anterior, puedes utilizar despachadores para volver a definir la función get. Dentro del cuerpo de get, llama a withContext(Dispatchers.IO) para crear un bloque que se ejecute en el grupo de subprocesos de IO. Cualquier código que coloques dentro de ese bloque se ejecutará siempre a través del despachador de IO. Debido a que withContext es una función suspendida, get también es una función suspendida.

suspend fun fetchDocs() {                      // Dispatchers.Main
    val result = get("developer.android.com")  // Dispatchers.Main
    show(result)                               // Dispatchers.Main
}

suspend fun get(url: String) =                 // Dispatchers.Main
    withContext(Dispatchers.IO) {              // Dispatchers.IO (main-safety block)
        /* perform network IO here */          // Dispatchers.IO (main-safety block)
    }                                          // Dispatchers.Main
}

Mediante las corrutinas, puedes despachar subprocesos con control detallado. Debido a que withContext() te permite controlar el grupo de subprocesos de cualquier línea de código sin introducir devoluciones de llamada, puedes aplicarlo a funciones muy pequeñas, como leer desde una base de datos o realizar una solicitud de red. Una práctica recomendada consiste en usar withContext() a fin de garantizar que todas las funciones sean seguras para el subproceso principal, lo cual significa que puedes llamar a la función desde el subproceso principal. De esta manera, el emisor nunca tiene que pensar en qué subproceso se debe utilizar para ejecutar la función.

En el ejemplo anterior, aunque fetchDocs() se ejecuta en el subproceso principal, puede llamar de manera segura a get, que realiza una solicitud de red en segundo plano. Debido a que las corrutinas admiten suspend y resume, la corrutina en el subproceso principal se reanuda con el resultado de get tan pronto como se termina el bloqueo de withContext.

Rendimiento de withContext()

withContext() no agrega sobrecarga adicional en comparación con una implementación basada en devoluciones de llamada equivalente. Además, en algunos casos, es posible optimizar las llamadas de withContext() más allá de una implementación basada en devoluciones de llamada equivalente. Por ejemplo, si una función hace diez llamadas a una red, puedes usar un withContext() externo para indicarle a Kotlin que intercambie subprocesos solo una vez. Luego, aunque la biblioteca de red usa withContext() varias veces, permanece en el mismo despachador y evita intercambiar subprocesos. Además, Kotlin optimiza el intercambio entre Dispatchers.Default y Dispatchers.IO a fin de evitar el intercambio de subprocesos siempre que sea posible.

Cómo iniciar una corrutina

Puedes iniciar corrutinas de dos maneras:

  • launch inicia una corrutina nueva y no le muestra el resultado al llamador. Cualquier trabajo que se considere "activar y olvidar" se puede iniciar con launch.
  • async inicia una corrutina nueva y te permite mostrar un resultado con una función de suspensión llamada await.

Por lo general, como una función regular no puede llamar a await, debes usar launch para lanzar una corrutina nueva desde una función regular. Usa el objeto async solo cuando esté dentro de otra corrutina o cuando esté dentro de una función suspendida y realice una descomposición paralela.

Descomposición paralela

Todas las corrutinas que se inician dentro de una función de suspend se deben detener cuando se muestra esa función. Por lo tanto, es probable que debas garantizar que esas corrutinas se completen antes de mostrarlas. Con la simultaneidad estructurada en Kotlin, puedes definir un objeto coroutineScope que inicie una o más corrutinas. Luego, puedes usar await() (para una sola corrutina) o awaitAll() (para varias corrutinas) a fin de garantizar que estas corrutinas se completen antes de mostrarlas desde la función.

Como ejemplo, definamos un objeto coroutineScope que obtiene dos documentos de manera asíncrona. Con las llamadas a await() en cada referencia diferida, garantizamos que las dos operaciones de async se completen antes de mostrar un valor:

suspend fun fetchTwoDocs() =
    coroutineScope {
        val deferredOne = async { fetchDoc(1) }
        val deferredTwo = async { fetchDoc(2) }
        deferredOne.await()
        deferredTwo.await()
    }

También puedes usar awaitAll() en las colecciones, como se muestra en el siguiente ejemplo:

suspend fun fetchTwoDocs() =        // called on any Dispatcher (any thread, possibly Main)
    coroutineScope {
        val deferreds = listOf(     // fetch two docs at the same time
            async { fetchDoc(1) },  // async returns a result for the first doc
            async { fetchDoc(2) }   // async returns a result for the second doc
        )
        deferreds.awaitAll()        // use awaitAll to wait for both network requests
    }

Aunque fetchTwoDocs() lanza corrutinas nuevas con async, la función usa awaitAll() para esperar a que esas corrutinas lanzadas se completen antes de mostrarlas. Sin embargo, ten en cuenta que, incluso si no llamaste a awaitAll(), el constructor de coroutineScope no reanuda la corrutina que llamó a fetchTwoDocs hasta que se completan todas las corrutinas nuevas.

Además, coroutineScope captura todas las excepciones que muestran las corrutinas y las enruta al emisor.

Para obtener más información sobre la descomposición paralela, consulta Cómo componer funciones suspendidas.

Conceptos de corrutinas

CoroutineScope

Un CoroutineScope realiza un seguimiento de cualquier corrutina que crea con launch o async. Para cancelar el trabajo en curso (es decir, las corrutinas en ejecución), se puede llamar a scope.cancel() en cualquier momento. En Android, algunas bibliotecas de KTX proporcionan su propio objeto CoroutineScope para ciertas clases de ciclo de vida. Por ejemplo, ViewModel tiene viewModelScope, y Lifecycle tiene lifecycleScope. Sin embargo, a diferencia de un despachador, un objeto CoroutineScope no ejecuta las corrutinas.

También se usa viewModelScope en los ejemplos de Cómo administrar subprocesos en segundo plano en Android con corrutinas. Sin embargo, si necesitas crear tu propio objeto CoroutineScope para controlar el ciclo de vida de las corrutinas ubicadas en una capa específica de tu app, puedes hacerlo de la siguiente manera:

class ExampleClass {

    // Job and Dispatcher are combined into a CoroutineContext which
    // will be discussed shortly
    val scope = CoroutineScope(Job() + Dispatchers.Main)

    fun exampleMethod() {
        // Starts a new coroutine within the scope
        scope.launch {
            // New coroutine that can call suspend functions
            fetchDocs()
        }
    }

    fun cleanUp() {
        // Cancel the scope to cancel ongoing coroutines work
        scope.cancel()
    }
}

Un permiso cancelado no puede crear más corrutinas. Por lo tanto, solo debes llamar a scope.cancel() cuando se destruye la clase que controla su ciclo de vida. Si usas viewModelScope, la clase ViewModel cancelará automáticamente el permiso en el método onCleared() de ViewModel.

Job

Un Job es un controlador de corrutinas. Cada corrutina que creas con los objetos launch o async muestra una instancia de Job que identifica de forma única la corrutina y administra su ciclo de vida. También puedes pasar un elemento Job a CoroutineScope para administrar más aspectos de su ciclo de vida, como se muestra en el siguiente ejemplo:

class ExampleClass {
    ...
    fun exampleMethod() {
        // Handle to the coroutine, you can control its lifecycle
        val job = scope.launch {
            // New coroutine
        }

        if (...) {
            // Cancel the coroutine started above, this doesn't affect the scope
            // this coroutine was launched in
            job.cancel()
        }
    }
}

CoroutineContext

El objeto CoroutineContext define el comportamiento de una corrutina mediante el siguiente conjunto de elementos:

Para las corrutinas nuevas que se crearon dentro de un permiso, se asigna una nueva instancia Job a la corrutina nueva, y se heredan los otros elementos CoroutineContext del permiso en el que se encuentran. Puedes anular los elementos heredados si pasas un objeto CoroutineContext nuevo a la función launch o async. Ten en cuenta que pasar un objeto Job a un elemento launch o async no tendrá ningún efecto, ya que siempre se asigna una nueva instancia de Job a una corrutina nueva.

class ExampleClass {
    val scope = CoroutineScope(Job() + Dispatchers.Main)

    fun exampleMethod() {
        // Starts a new coroutine on Dispatchers.Main as it's the scope's default
        val job1 = scope.launch {
            // New coroutine with CoroutineName = "coroutine" (default)
        }

        // Starts a new coroutine on Dispatchers.Default
        val job2 = scope.launch(Dispatchers.Default + CoroutineName("BackgroundCoroutine")) {
            // New coroutine with CoroutineName = "BackgroundCoroutine" (overridden)
        }
    }
}

Recursos adicionales de corrutinas

Para obtener más recursos de corrutinas, consulta los siguientes vínculos: