Prácticas recomendadas para corrutinas en Android

En esta página, se presentan varias prácticas recomendadas que generan un impacto positivo, ya que hacen que tu app sea más escalable y más fácil de probar cuando se usan corrutinas.

Inserta despachadores

No codifiques Dispatchers cuando crees corrutinas nuevas o llames a withContext.

// DO inject Dispatchers
class NewsRepository(
    private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default
) {
    suspend fun loadNews() = withContext(defaultDispatcher) { /* ... */ }
}

// DO NOT hardcode Dispatchers
class NewsRepository {
    // DO NOT use Dispatchers.Default directly, inject it instead
    suspend fun loadNews() = withContext(Dispatchers.Default) { /* ... */ }
}

Este patrón de inserción de dependencias facilita las pruebas, ya que podrás reemplazar esos despachadores en pruebas de instrumentación y de unidades mediante un despachador de pruebas a fin de que tus pruebas resulten más determinantes.

Las funciones de suspensión deberían ser seguras para su llamada desde el subproceso principal

Las funciones de suspensión deberían ser seguras para el subproceso principal; es decir, deben ser seguras para su llamada desde ese subproceso. Si una clase realiza operaciones de bloqueo de larga duración en una corrutina, esta se encarga de trasladar la ejecución fuera del subproceso principal mediante withContext. Esto se aplica a todas las clases en tu app, independientemente de la parte de la arquitectura en la que se encuentre cada una.

class NewsRepository(private val ioDispatcher: CoroutineDispatcher) {

    // As this operation is manually retrieving the news from the server
    // using a blocking HttpURLConnection, it needs to move the execution
    // to an IO dispatcher to make it main-safe
    suspend fun fetchLatestNews(): List<Article> {
        withContext(ioDispatcher) { /* ... implementation ... */ }
    }
}

// This use case fetches the latest news and the associated author.
class GetLatestNewsWithAuthorsUseCase(
    private val newsRepository: NewsRepository,
    private val authorsRepository: AuthorsRepository
) {
    // This method doesn't need to worry about moving the execution of the
    // coroutine to a different thread as newsRepository is main-safe.
    // The work done in the coroutine is lightweight as it only creates
    // a list and add elements to it
    suspend operator fun invoke(): List<ArticleWithAuthor> {
        val news = newsRepository.fetchLatestNews()

        val response: List<ArticleWithAuthor> = mutableEmptyList()
        for (article in news) {
            val author = authorsRepository.getAuthor(article.author)
            response.add(ArticleWithAuthor(article, author))
        }
        return Result.Success(response)
    }
}

Este patrón hace que tu app sea más escalable, ya que las clases que llaman a las funciones de suspensión no tienen que preocuparse por el Dispatcher que deberían usar según el tipo de trabajo. Esta tarea recae sobre la clase que realiza el trabajo.

El ViewModel debería crear corrutinas

Las clases ViewModel deberían optar por crear corrutinas en vez de exponer las funciones de suspensión para realizar lógica empresarial. Las funciones de suspensión en ViewModel pueden ser útiles si, en lugar de exponer un estado con un flujo de datos, solo es necesario emitir un único valor.

// DO create coroutines in the ViewModel
class LatestNewsViewModel(
    private val getLatestNewsWithAuthors: GetLatestNewsWithAuthorsUseCase
) : ViewModel() {

    private val _uiState = MutableStateFlow<LatestNewsUiState>(LatestNewsUiState.Loading)
    val uiState: StateFlow<LatestNewsUiState> = _uiState

    fun loadNews() {
        viewModelScope.launch {
            val latestNewsWithAuthors = getLatestNewsWithAuthors()
            _uiState.value = LatestNewsUiState.Success(latestNewsWithAuthors)
        }
    }
}

// Prefer observable state rather than suspend functions from the ViewModel
class LatestNewsViewModel(
    private val getLatestNewsWithAuthors: GetLatestNewsWithAuthorsUseCase
) : ViewModel() {
    // DO NOT do this. News would probably need to be refreshed as well.
    // Instead of exposing a single value with a suspend function, news should
    // be exposed using a stream of data as in the code snippet above.
    suspend fun loadNews() = getLatestNewsWithAuthors()
}

Las vistas no deberían activar directamente ninguna corrutina para realizar lógica empresarial. En su lugar, difiere esa tarea a la ViewModel. Esto hará que tu lógica empresarial resulte más fácil de probar, ya que se podrán realizar pruebas de unidades sobre los objetos ViewModel, en lugar de usar las pruebas de instrumentación necesarias a fin de probar las vistas.

Además, tus corrutinas sobrevivirán automáticamente a los cambios de configuración si se inicia el trabajo en el viewModelScope. Si, en cambio, creas corrutinas con lifecycleScope, tendrás que controlar eso de forma manual. Si la corrutina debe sobrevivir más allá del alcance del ViewModel, consulta la sección Crea corrutinas en la capa empresarial y de datos.

No expongas tipos que mutan

Opta por exponer tipos inmutables a otras clases. De esta manera, todos los cambios en el tipo que muta se centralizarán en una clase, lo que facilitará la depuración cuando se produzca algún error.

// DO expose immutable types
class LatestNewsViewModel : ViewModel() {

    private val _uiState = MutableStateFlow(LatestNewsUiState.Loading)
    val uiState: StateFlow<LatestNewsUiState> = _uiState

    /* ... */
}

class LatestNewsViewModel : ViewModel() {

    // DO NOT expose mutable types
    val uiState = MutableStateFlow(LatestNewsUiState.Loading)

    /* ... */
}

La capa empresarial y de datos debería exponer las funciones de suspensión y los flujos

Por lo general, las clases de las capas empresariales y de datos exponen funciones a fin de realizar llamadas únicas o de recibir notificaciones sobre cambios en los datos a lo largo del tiempo. Las clases de esas capas deberían exponer las funciones de suspensión para llamadas únicas y el Flujo que notifica acerca de cambios en los datos.

// Classes in the data and business layer expose
// either suspend functions or Flows
class ExampleRepository {
    suspend fun makeNetworkRequest() { /* ... */ }

    fun getExamples(): Flow<Example> { /* ... */ }
}

Esta práctica recomendada hace que el llamador (en general, la capa de presentación) pueda controlar la ejecución y el ciclo de vida del trabajo que se produce en esas capas, y cancelarlo cuando sea necesario.

Crea corrutinas en la capa empresarial y de datos

Existen diferentes opciones para las clases en la capa empresarial o de datos que necesiten crear corrutinas por distintos motivos.

Si el trabajo que se debe realizar en esas corrutinas es relevante solo cuando el usuario se encuentra en la pantalla actual, debería seguir el ciclo de vida del llamador. En la mayoría de los casos, el emisor será el ViewModel, y la llamada se cancelará cuando el usuario salga de la pantalla y se borre el ViewModel. En este caso, se debería usar coroutineScope o supervisorScope.

class GetAllBooksAndAuthorsUseCase(
    private val booksRepository: BooksRepository,
    private val authorsRepository: AuthorsRepository,
) {
    suspend fun getBookAndAuthors(): BookAndAuthors {
        // In parallel, fetch books and authors and return when both requests
        // complete and the data is ready
        return coroutineScope {
            val books = async { booksRepository.getAllBooks() }
            val authors = async { authorsRepository.getAllAuthors() }
            BookAndAuthors(books.await(), authors.await())
        }
    }
}

Si el trabajo que se debe llevar a cabo es relevante cuando se abre la app y no está vinculado a una pantalla en particular, entonces este debería sobrevivir el ciclo de vida del llamador. En esta situación, se debería usar un CoroutineScope externo como se explica en la entrada de blog Corrutinas y Patrones para trabajos que no se deben cancelar.

class ArticlesRepository(
    private val articlesDataSource: ArticlesDataSource,
    private val externalScope: CoroutineScope,
) {
    // As we want to complete bookmarking the article even if the user moves
    // away from the screen, the work is done creating a new coroutine
    // from an external scope
    suspend fun bookmarkArticle(article: Article) {
        externalScope.launch { articlesDataSource.bookmarkArticle(article) }
            .join() // Wait for the coroutine to complete
    }
}

externalScope debe ser creado y administrado por una clase que dure más que la pantalla actual. Podría administrarlo la clase Application o un ViewModel con alcance en un gráfico de navegación.

Cómo insertar TestDispatchers en las pruebas

Para las pruebas, deberías insertar una instancia de TestDispatcher en tus clases. Hay dos implementaciones disponibles en la biblioteca kotlinx-coroutines-test:

  • StandardTestDispatcher: Pone en cola las corrutinas que se iniciaron en él con un programador y las ejecuta cuando el subproceso de prueba no está ocupado. Puedes suspender el subproceso de prueba para permitir que otras corrutinas en cola se ejecuten mediante métodos como advanceUntilIdle.

  • UnconfinedTestDispatcher: Ejecuta corrutinas nuevas con anticipación, de forma bloqueada. Por lo general, esto facilita la escritura de pruebas, pero te brinda menos control sobre cómo se ejecutan las corrutinas durante la prueba.

Consulta la documentación de implementación de cada despachador para obtener más detalles.

Para probar corrutinas, usa el compilador de corrutinas runTest. runTest usa un objeto TestCoroutineScheduler para omitir retrasos en las pruebas y permitirte controlar el tiempo virtual. También puedes usar este programador para crear despachadores de pruebas adicionales, según sea necesario.

class ArticlesRepositoryTest {

    @Test
    fun testBookmarkArticle() = runTest {
        // Pass the testScheduler provided by runTest's coroutine scope to
        // the test dispatcher
        val testDispatcher = UnconfinedTestDispatcher(testScheduler)

        val articlesDataSource = FakeArticlesDataSource()
        val repository = ArticlesRepository(
            articlesDataSource,
            testDispatcher
        )
        val article = Article()
        repository.bookmarkArticle(article)
        assertThat(articlesDataSource.isBookmarked(article)).isTrue()
    }
}

Todos los TestDispatchers deben compartir el mismo programador. De esta manera, podrás ejecutar todo el código de corrutinas en el subproceso de prueba único para que las pruebas sean deterministas. runTest esperará a que se completen todas las corrutinas que se encuentren en el mismo programador o que sean secundarias de la corrutina de prueba antes de mostrar resultados.

Evita GlobalScope

Esto es similar a la práctica recomendada de insertar despachadores. Si usas GlobalScope, codificarás el CoroutineScope que usa una clase, y eso traerá algunas desventajas:

  • Promueve los valores hard-coded. Si codificas GlobalScope, es posible que también estés codificando Dispatchers.

  • Hace que las pruebas sean muy difíciles, ya que el código se ejecutará en un alcance sin control, por lo que no podrás controlar su ejecución.

  • No podrás hacer que un CoroutineContext común se ejecute para todas las corrutinas incorporadas en el propio alcance.

En su lugar, considera insertar un CoroutineScope para el trabajo que necesite sobrevivir al alcance actual. Si quieres obtener más información sobre este tema, consulta la sección Crea corrutinas en la capa empresarial y de datos.

// DO inject an external scope instead of using GlobalScope.
// GlobalScope can be used indirectly. Here as a default parameter makes sense.
class ArticlesRepository(
    private val articlesDataSource: ArticlesDataSource,
    private val externalScope: CoroutineScope = GlobalScope,
    private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default
) {
    // As we want to complete bookmarking the article even if the user moves
    // away from the screen, the work is done creating a new coroutine
    // from an external scope
    suspend fun bookmarkArticle(article: Article) {
        externalScope.launch(defaultDispatcher) {
            articlesDataSource.bookmarkArticle(article)
        }
            .join() // Wait for the coroutine to complete
    }
}

// DO NOT use GlobalScope directly
class ArticlesRepository(
    private val articlesDataSource: ArticlesDataSource,
) {
    // As we want to complete bookmarking the article even if the user moves away
    // from the screen, the work is done creating a new coroutine with GlobalScope
    suspend fun bookmarkArticle(article: Article) {
        GlobalScope.launch {
            articlesDataSource.bookmarkArticle(article)
        }
            .join() // Wait for the coroutine to complete
    }
}

Puedes obtener más información sobre GlobalScope y sus alternativas en la entrada de blog Corrutinas y Patrones para trabajos que no se deben cancelar.

Haz que tu corrutina se pueda cancelar

La cancelación en corrutinas es cooperativa. Es decir, cuando se cancela un Job de corrutinas, esta no se cancelará hasta que se suspenda o se verifique su cancelación. Si realizas operaciones de bloqueo en una corrutina, asegúrate de que esa corrutina se pueda cancelar.

Por ejemplo, si vas a leer varios archivos del disco, antes de comenzar a hacerlo, verifica si se canceló la corrutina. Una forma de comprobar la cancelación es llamar a la función ensureActive.

someScope.launch {
    for(file in files) {
        ensureActive() // Check for cancellation
        readFile(file)
    }
}

Se pueden cancelar todas las funciones de suspensión de kotlinx.coroutines, como withContext y delay. Si tu corrutina las llama, no deberías tener que realizar ningún trabajo adicional.

Si deseas obtener más información sobre la cancelación de corrutinas, consulta la entrada de blog Cancelación en corrutinas.

Ten cuidado con las excepciones

Las excepciones no controladas que se producen en las corrutinas pueden hacer que falle la app. Si es probable que ocurran excepciones, captúralas en el cuerpo de alguna corrutina creada con viewModelScope o lifecycleScope.

class LoginViewModel(
    private val loginRepository: LoginRepository
) : ViewModel() {

    fun login(username: String, token: String) {
        viewModelScope.launch {
            try {
                loginRepository.login(username, token)
                // Notify view user logged in successfully
            } catch (exception: IOException) {
                // Notify view login attempt failed
            }
        }
    }
}

Para obtener más información, consulta la entrada de blog Excepciones en corrutinas o Manejo de excepciones de corrutinas en la documentación de Kotlin.

Obtén más información sobre las corrutinas

Si quieres ver más recursos sobre las corrutinas, consulta la página Recursos adicionales sobre corrutinas y flujos de Kotlin.