Лучшие практики для сопрограмм в 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) { /* ... */ }
}

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

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

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

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

    /* ... */
}

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

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

Внедрение TestDispatcher в тесты

Экземпляр 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,
            testDispatcher
        )
        val article = Article()
        repository.bookmarkArticle(article)
        assertThat(articlesDataSource.isBookmarked(article)).isTrue()
    }
}

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

Избегайте GlobalScope

Это похоже на передовую практику Inject Dispatchers . Используя 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)
                // Notify view user logged in successfully
            } catch (exception: IOException) {
                // Notify view login attempt failed
            }
        }
    }
}

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

Узнайте больше о сопрограммах

Дополнительные ресурсы по сопрограммам см. на странице Дополнительные ресурсы для сопрограмм и потоков Kotlin .