Рекомендации по использованию сопрограмм в Android

На этой странице приведены рекомендации, которые помогут вам сделать приложение более масштабируемым и удобным для тестирования при использовании сопрограмм.

Внедрение диспетчеров

Не указывайте Dispatchers в коде при создании новых сопрограмм или вызове 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) { /* ... */ }
}

Этот шаблон внедрения зависимостей упрощает тестирование, поскольку в модульных и инструментальных тестах можно заменить диспетчеры на тестовый диспетчер, чтобы сделать тесты более детерминированными.

Функции suspend можно безопасно вызывать из основного потока

Функции приостановки должны быть безопасны для основного потока, то есть их можно вызывать из него. Если класс выполняет длительные блокирующие операции в сопрограмме, он должен перенести выполнение из основного потока с помощью withContext. Это относится ко всем классам в приложении, независимо от того, к какой части архитектуры они относятся.

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(): Result<List<ArticleWithAuthor>> {
        val news = newsRepository.fetchLatestNews()

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

Такой подход позволяет масштабировать приложение, поскольку классам, вызывающим функции suspend, не нужно беспокоиться о том, какой Dispatcher использовать для определенного типа работы. Эта ответственность лежит на классе, который выполняет работу.

ViewModel должна создавать сопрограммы

Классы ViewModel должны создавать корутины, а не предоставлять функции приостановки для выполнения бизнес-логики. Функции приостановки в ViewModel могут быть полезны, если вместо того, чтобы передавать состояние с помощью потока данных, нужно передать только одно значение.

// 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()
}

Представления не должны напрямую запускать какие-либо сопрограммы для выполнения бизнес-логики. Вместо этого передайте ответственность за это ViewModel. Это упрощает тестирование бизнес-логики, поскольку объекты ViewModel можно тестировать по отдельности, а не с помощью инструментальных тестов, которые требуются для тестирования представлений.

Кроме того, если работа запущена в viewModelScope, ваши сопрограммы автоматически переживут изменения конфигурации. Если вы создаете сопрограммы с помощью lifecycleScope, вам придется обрабатывать это вручную. Если сопрограмма должна существовать дольше области действия ViewModel, ознакомьтесь с разделом Создание сопрограмм на уровне бизнеса и данных.

Не используйте изменяемые типы

Предпочтительнее использовать неизменяемые типы в других классах. Таким образом, все изменения в изменяемом типе централизованы в одном классе, что упрощает отладку при возникновении проблем.

// 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)

    /* ... */
}

Уровень данных и бизнес-логики должен предоставлять функции приостановки и потоки.

Классы в слоях данных и бизнеса обычно предоставляют функции для выполнения однократных вызовов или получения уведомлений об изменениях данных с течением времени. Классы в этих слоях должны предоставлять функции suspend для однократных вызовов и Flow для уведомлений об изменениях данных.

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

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

Этот подход позволяет вызывающей стороне (обычно уровню представления) контролировать выполнение и жизненный цикл работы, выполняемой на этих уровнях, и при необходимости отменять ее.

Создание сопрограмм в бизнес- и уровне данных

Для классов в слое данных или бизнес-логики, которым нужно создавать сопрограммы по разным причинам, есть разные варианты.

Если задачи, выполняемые в этих сопрограммах, актуальны только тогда, когда пользователь находится на текущем экране, они должны следовать жизненному циклу вызывающего объекта. В большинстве случаев вызывающим объектом будет ViewModel, а вызов будет отменен, когда пользователь перейдет на другой экран и ViewModel будет очищен. В этом случае следует использовать coroutineScope или 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())
        }
    }
}

Если задача актуальна, пока приложение открыто, и не связана с определенным экраном, она должна выполняться дольше, чем жизненный цикл вызывающего ее объекта. В этом случае следует использовать внешний CoroutineScope, как описано в записи блога о корутинах и шаблонах для задач, которые нельзя отменять.

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 должен создаваться и управляться классом, который существует дольше, чем текущий экран. Это может быть класс Application или ViewModel, область действия которого ограничена графом навигации.

Как внедрять TestDispatchers в тесты

В классы в тестах должен быть внедрен экземпляр TestDispatcher. В библиотеке kotlinx-coroutines-test доступны две реализации:

  • StandardTestDispatcher: ставит в очередь сопрограммы, запущенные на нем с помощью планировщика, и выполняет их, когда поток тестирования не занят. Вы можете приостановить тестовый поток, чтобы другие поставленные в очередь сопрограммы могли выполняться с помощью таких методов, как advanceUntilIdle.

  • UnconfinedTestDispatcher: немедленно запускает новые сопрограммы, блокируя выполнение. Это упрощает написание тестов, но дает меньше контроля над тем, как выполняются сопрограммы во время тестирования.

Дополнительную информацию можно найти в документации по каждой реализации диспетчера.

Чтобы протестировать сопрограммы, используйте конструктор сопрограмм runTest. runTest использует TestCoroutineScheduler, чтобы пропускать задержки в тестах и управлять виртуальным временем. Вы также можете использовать этот планировщик, чтобы при необходимости создавать дополнительных диспетчеров тестирования.

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,
            defaultDispatcher = testDispatcher
        )
        val article = Article()
        repository.bookmarkArticle(article)
        assertThat(articlesDataSource.isBookmarked(article)).isTrue()
    }
}

Все TestDispatchers должны использовать один планировщик. Это позволяет выполнять весь код сопрограмм в одном тестовом потоке, чтобы тесты были детерминированными. runTest будет ждать завершения всех сопрограмм, которые находятся в том же планировщике или являются дочерними по отношению к тестовой сопрограмме, прежде чем возвращать результат.

Не используйте GlobalScope

Это похоже на рекомендацию Внедряйте диспетчеры. Используя GlobalScope, вы жестко кодируете CoroutineScope, который использует класс, что влечет за собой некоторые недостатки:

  • Пропагандирует жесткое кодирование значений. Если вы зададите в коде значение GlobalScope, то, возможно, также зададите в коде значение Dispatchers.

  • Это затрудняет тестирование, поскольку код выполняется в неконтролируемой области и вы не можете управлять его выполнением.

  • Встроенная в область видимости функция CoroutineContext не может выполняться для всех сопрограмм.

Вместо этого добавьте CoroutineScope для задач, которые должны быть выполнены за пределами текущей области. Подробнее о том, как создавать сопрограммы на уровне бизнеса и данных…

// 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
    }
}

Подробнее о функции GlobalScope и ее альтернативах можно прочитать в статье о корутинах и шаблонах для задач, которые нельзя отменять.

Как сделать сопрограмму отменяемой

Отмена в сопрограммах является кооперативной, то есть когда сопрограмма Job отменяется, она не отменяется, пока не приостановится или не проверит, отменена ли она. Если вы выполняете блокирующие операции в сопрограмме, убедитесь, что она может быть отменена.

Например, если вы считываете несколько файлов с диска, перед началом считывания каждого файла проверьте, была ли отменена сопрограмма. Один из способов проверить, отменена ли операция, – вызвать функцию ensureActive.

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

Все функции блокировки из kotlinx.coroutines, такие как withContext и delay, можно отменить. Если их вызывает ваша сопрограмма, вам не нужно выполнять никаких дополнительных действий.

Подробнее об отмене в сопрограммах можно прочитать в записи блога.

Исключения

Необработанные исключения, возникающие в сопрограммах, могут приводить к сбоям в работе приложения. Если исключения могут возникнуть, перехватывайте их в теле любых сопрограмм, созданных с помощью viewModelScope или lifecycleScope.

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

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

Подробнее об этом можно прочитать в записи блога Исключения в сопрограммах или в разделе Обработка исключений в сопрограммах документации Kotlin.

Подробнее о сопрограммах

Дополнительную информацию о сопрограммах можно найти в руководстве по сопрограммам в документации по Kotlin.