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

Такой подход делает ваше приложение более масштабируемым, поскольку классам, вызывающим функции приостановки, не нужно беспокоиться о том, какой 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,
            defaultDispatcher = 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)
                // Update UI, user logged in successfully
            } catch (exception: IOException) {
                // Update UI, login attempt failed
            }
        }
    }
}

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

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

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