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

Cómo mejorar el rendimiento de la app con las corrutinas de Kotlin

Una corrutina es un patrón de diseño de simultaneidad que puedes usar en Android para simplificar el código que se ejecuta de manera asíncrona. Las corrutinas se agregaron a Kotlin en la versión 1.3 y están basadas en conceptos establecidos de otros idiomas.

En Android, las corrutinas ayudan a solucionar dos problemas principales:

  • Administran tareas prolongadas que, de lo contrario, podrían bloquear el subproceso principal y provocar que tu app quede inmovilizada.
  • Proporcionan seguridad del subproceso principal o llaman de manera segura a operaciones de red o disco desde el subproceso principal.

En este tema, se describe cómo puedes usar las corrutinas de Kotlin para solucionar estos problemas, lo que te permite escribir código de apps más limpio y conciso.

Cómo administrar tareas prolongadas

En Android, cada app tiene un subproceso principal que se ocupa de la interfaz de usuario y administra las interacciones del usuario. Si tu app asigna demasiado trabajo al subproceso principal, puede parecer que tu app queda inmovilizada o que la velocidad disminuye considerablemente. Las solicitudes de red, el análisis de JSON, leer o escribir desde una base de datos o incluso iterar listas de gran tamaño puede provocar que tu app se ejecute con suficiente lentitud como para ocasionar bloqueos visibles, como IU lentas o inmóviles que no responden a los eventos táctiles con rapidez. Estas operaciones prolongadas deberían ejecutarse fuera del subproceso principal.

En el siguiente ejemplo, se muestra la implementación de corrutinas simples para 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) { /* ... */ }
    

Las corrutinas se basan en funciones regulares y, para ello, agregan dos operaciones que se ocupan de las tareas prolongadas. 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 ejemplo anterior, get() todavía se ejecuta 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, el marco de pila actual se copia y se guarda para más tarde. Al reanudar, el marco de pila se copia desde el lugar en el que se guardó y la función vuelve a ejecutarse. 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 marco de trabajo de la IU de Android y actualizar objetos LiveData.
  • Dispatchers.IO: Este despachador está optimizado para realizar I/O 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 siempre se ejecuta 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 este 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 aumenta la sobrecarga 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 designar un CoroutineScope

Cuando defines una corrutina, también debes designar su CoroutineScope. CoroutineScope administra una o más corrutinas relacionadas. También puedes usar CoroutineScope para iniciar una corrutina nueva dentro de ese alcance. Sin embargo, a diferencia de un despachador, CoroutineScope no ejecuta las corrutinas.

Una función importante de CoroutineScope consiste en detener la ejecución de corrutinas cuando un usuario deja un área de contenido dentro de tu app. Si usas CoroutineScope, puedes garantizar que las operaciones en ejecución se detengan correctamente.

Cómo utilizar CoroutineScope con los componentes de la arquitectura de Android

En Android, puedes asociar implementaciones de CoroutineScope con el ciclo de vida de un componente. Esta medida te permite evitar fugas de memoria o la realización de trabajo adicional para actividades o fragmentos que ya no son relevantes para el usuario. Con los componentes Jetpack, se ajustan naturalmente en un objeto ViewModel. Debido a que un objeto ViewModel no se destruye durante los cambios de configuración (como la rotación de pantalla), no necesitas preocuparte de que tus corrutinas se cancelen o se reinicien.

Los alcances están al tanto de cada corrutina que inician. Por lo tanto, puedes cancelar todo lo que se inició en el alcance en cualquier momento. Como los alcances se propagan a sí mismos, si una corrutina inicia otra corrutina, ambas tienen el mismo alcance. Eso significa que, incluso si otras bibliotecas inician una corrutina desde tu alcance, puedes cancelarlas en cualquier momento. Esta función es importante, sobre todo, si ejecutas corrutinas en un objeto ViewModel. Si tu ViewModel se destruye debido a que el usuario abandonó la pantalla, se debe detener todo el trabajo asíncrono que esté llevando a cabo. De lo contrario, desperdiciarás recursos y es probable que se produzcan fugas de memoria. Si tienes trabajo asíncrono que debería continuar después de destruir tu ViewModel, debería realizarse en una capa inferior de la arquitectura de tu app.

Mediante la biblioteca de KTX para los componentes de la arquitectura de Android, también puedes usar una propiedad de extensión, viewModelScope, a fin de crear corrutinas que se puedan ejecutar hasta la destrucción del objeto ViewModel.

Cómo iniciar una corrutina

Puedes iniciar corrutinas de dos maneras:

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

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

En función de los ejemplos anteriores, la siguiente es una corrutina con la propiedad de extensión de KTX de viewModelScope que utiliza launch para cambiar de funciones regulares a corrutinas:

fun onDocsNeeded() {
        viewModelScope.launch {    // Dispatchers.Main
            fetchDocs()            // Dispatchers.Main (suspend function call)
        }
    }
    

Descomposición paralela

Todas las corrutinas que se inician por 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 las corrutinas muestran y las enruta al emisor.

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

Componentes de la arquitectura con compatibilidad integrada

Algunos componentes de la arquitectura, como ViewModel y Lifecycle, incluyen compatibilidad integrada para corrutinas a través de sus propios miembros de CoroutineScope.

Por ejemplo, ViewModel incluye un viewModelScope integrado. De esta manera, se proporciona un modo estándar para lanzar corrutinas dentro del alcance del ViewModel, como se muestra en el siguiente ejemplo:

class MyViewModel : ViewModel() {

        fun launchDataLoad() {
            viewModelScope.launch {
                sortList()
                // Modify UI
            }
        }

        /**
        * Heavy operation that cannot be done in the Main Thread
        */
        suspend fun sortList() = withContext(Dispatchers.Default) {
            // Heavy work
        }
    }
    

LiveData también utiliza corrutinas con un bloqueo de liveData:

liveData {
        // runs in its own LiveData-specific scope
    }
    

Si deseas obtener más información sobre los componentes de la arquitectura que ofrecen compatibilidad con corrutinas integradas, consulta Cómo usar corrutinas de Kotlin con los componentes de la arquitectura.

Más información

Para obtener más información relacionada con las corrutinas, consulta los siguientes vínculos: