Улучшите производительность приложения с помощью сопрограмм Kotlin

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

В этом разделе представлен подробный обзор сопрограмм на Android. Если вы не знакомы с сопрограммами, обязательно прочитайте сопрограммы Kotlin для Android, прежде чем читать этот раздел.

Управляйте долго выполняющимися задачами

Сопрограммы основываются на обычных функциях, добавляя две операции для обработки долго выполняющихся задач. Помимо invoke (или call ) и return , сопрограммы добавляют suspend и resume :

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

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

В следующем примере показана простая реализация сопрограммы для гипотетической долго выполняющейся задачи:

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) { /* ... */ }

В этом примере get() по-прежнему выполняется в основном потоке, но приостанавливает сопрограмму перед запуском сетевого запроса. Когда сетевой запрос завершается, get возобновляет приостановленную сопрограмму вместо использования обратного вызова для уведомления основного потока.

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

Используйте сопрограммы для обеспечения основной безопасности

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

Чтобы указать, где должны запускаться сопрограммы, Kotlin предоставляет три диспетчера, которые вы можете использовать:

  • Dispatchers.Main — используйте этот диспетчер для запуска сопрограммы в основном потоке Android. Его следует использовать только для взаимодействия с пользовательским интерфейсом и выполнения быстрой работы. Примеры включают вызов функций suspend , выполнение операций платформы пользовательского интерфейса Android и обновление объектов LiveData .
  • Dispatchers.IO — этот диспетчер оптимизирован для выполнения дискового или сетевого ввода-вывода вне основного потока. Примеры включают использование компонента Room , чтение или запись файлов, а также выполнение любых сетевых операций.
  • Dispatchers.Default — этот диспетчер оптимизирован для выполнения ресурсоемкой работы за пределами основного потока. Примеры использования включают сортировку списка и анализ JSON.

Продолжая предыдущий пример, вы можете использовать диспетчеры для переопределения функции get . Внутри тела get вызовите withContext(Dispatchers.IO) чтобы создать блок, который выполняется в пуле потоков ввода-вывода. Любой код, который вы помещаете внутрь этого блока, всегда выполняется через диспетчер IO . Поскольку withContext сама по себе является функцией приостановки, функция get также является функцией приостановки.

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
}

С помощью сопрограмм вы можете отправлять потоки с детальным контролем. Поскольку withContext() позволяет вам управлять пулом потоков любой строки кода без введения обратных вызовов, вы можете применять его к очень небольшим функциям, таким как чтение из базы данных или выполнение сетевого запроса. Хорошей практикой является использование withContext() чтобы убедиться, что каждая функция безопасна для основного потока , что означает, что вы можете вызвать функцию из основного потока. Таким образом, вызывающему абоненту никогда не придется думать о том, какой поток следует использовать для выполнения функции.

В предыдущем примере fetchDocs() выполняется в основном потоке; однако он может безопасно вызвать get , который выполняет сетевой запрос в фоновом режиме. Поскольку сопрограммы поддерживают suspend и resume , выполнение сопрограммы в основном потоке возобновляется с результатом get , как только блок withContext завершается.

Производительность withContext()

withContext() не добавляет дополнительных затрат по сравнению с эквивалентной реализацией на основе обратного вызова. Более того, в некоторых ситуациях можно оптимизировать вызовы withContext() за пределами эквивалентной реализации на основе обратного вызова. Например, если функция выполняет десять вызовов в сети, вы можете указать Kotlin переключать потоки только один раз, используя внешний withContext() . Тогда, даже если сетевая библиотека использует withContext() несколько раз, она остается в том же диспетчере и избегает переключения потоков. Кроме того, Kotlin оптимизирует переключение между Dispatchers.Default и Dispatchers.IO , чтобы по возможности избегать переключения потоков.

Запустить сопрограмму

Вы можете запустить сопрограммы одним из двух способов:

  • launch запускает новую сопрограмму и не возвращает результат вызывающей стороне. Любую работу, которая считается «выстрелил и забыл», можно начать с помощью launch .
  • async запускает новую сопрограмму и позволяет вам вернуть результат с помощью функции приостановки, называемой await .

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

Параллельная декомпозиция

Все сопрограммы, запускаемые внутри функции suspend , должны быть остановлены, когда эта функция возвращается, поэтому вам, вероятно, необходимо гарантировать, что эти сопрограммы завершатся перед возвратом. Благодаря структурированному параллелизму в Kotlin вы можете определить coroutineScope , который запускает одну или несколько сопрограмм. Затем, используя await() (для одной сопрограммы) или awaitAll() (для нескольких сопрограмм), вы можете гарантировать, что эти сопрограммы завершатся до возврата из функции.

В качестве примера давайте определим coroutineScope , который асинхронно извлекает два документа. Вызывая await() для каждой отложенной ссылки, мы гарантируем, что обе async операции завершатся до возврата значения:

suspend fun fetchTwoDocs() =
    coroutineScope {
        val deferredOne = async { fetchDoc(1) }
        val deferredTwo = async { fetchDoc(2) }
        deferredOne.await()
        deferredTwo.await()
    }

Вы также можете использовать awaitAll() для коллекций, как показано в следующем примере:

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
    }

Несмотря на то, что fetchTwoDocs() запускает новые сопрограммы с помощью async , функция использует awaitAll() для ожидания завершения запущенных сопрограмм перед возвратом. Однако обратите внимание, что даже если бы мы не вызвали awaitAll() , построитель coroutineScope не возобновляет работу сопрограммы, вызвавшей fetchTwoDocs , до тех пор, пока не будут завершены все новые сопрограммы.

Кроме того, coroutineScope перехватывает любые исключения, которые выдают сопрограммы, и направляет их обратно вызывающему объекту.

Дополнительные сведения о параллельной декомпозиции см. в разделе Составление приостанавливающих функций .

Концепции сопрограмм

CoroutineScope

CoroutineScope отслеживает любую сопрограмму, которую он создает с помощью launch или async . Текущую работу (т. е. запущенные сопрограммы) можно отменить, scope.cancel() в любой момент времени. В Android некоторые библиотеки KTX предоставляют собственные CoroutineScope для определенных классов жизненного цикла. Например, у ViewModel есть viewModelScope , а у LifecyclelifecycleScope . Однако, в отличие от диспетчера, CoroutineScope не запускает сопрограммы.

viewModelScope также используется в примерах, приведенных в разделе Фоновая обработка потоков на Android с помощью Coroutines . Однако если вам нужно создать собственный CoroutineScope для управления жизненным циклом сопрограмм на определенном уровне вашего приложения, вы можете создать его следующим образом:

class ExampleClass {

    // Job and Dispatcher are combined into a CoroutineContext which
    // will be discussed shortly
    val scope = CoroutineScope(Job() + Dispatchers.Main)

    fun exampleMethod() {
        // Starts a new coroutine within the scope
        scope.launch {
            // New coroutine that can call suspend functions
            fetchDocs()
        }
    }

    fun cleanUp() {
        // Cancel the scope to cancel ongoing coroutines work
        scope.cancel()
    }
}

Отмененная область не может создавать больше сопрограмм. Поэтому вам следует вызыватьscope.cancel scope.cancel() только тогда, когда уничтожается класс, управляющий его жизненным циклом. При использовании viewModelScope класс ViewModel автоматически отменяет область видимости в методе onCleared() ViewModel.

Работа

Job — это дескриптор сопрограммы. Каждая сопрограмма, которую вы создаете с помощью launch или async возвращает экземпляр Job , который однозначно идентифицирует сопрограмму и управляет ее жизненным циклом. Вы также можете передать Job в CoroutineScope для дальнейшего управления его жизненным циклом, как показано в следующем примере:

class ExampleClass {
    ...
    fun exampleMethod() {
        // Handle to the coroutine, you can control its lifecycle
        val job = scope.launch {
            // New coroutine
        }

        if (...) {
            // Cancel the coroutine started above, this doesn't affect the scope
            // this coroutine was launched in
            job.cancel()
        }
    }
}

СопрограммаКонтекст

CoroutineContext определяет поведение сопрограммы, используя следующий набор элементов:

  • Job : Управляет жизненным циклом сопрограммы.
  • CoroutineDispatcher : Отправляет работу в соответствующий поток.
  • CoroutineName : имя сопрограммы, полезное для отладки.
  • CoroutineExceptionHandler : обрабатывает неперехваченные исключения.

Для новых сопрограмм, созданных в определенной области, новой сопрограмме назначается новый экземпляр Job , а другие элементы CoroutineContext наследуются из содержащейся области. Вы можете переопределить унаследованные элементы, передав новый CoroutineContext в функцию launch или async функцию. Обратите внимание, что передача Job для launch или async не имеет никакого эффекта, поскольку новый экземпляр Job всегда назначается новой сопрограмме.

class ExampleClass {
    val scope = CoroutineScope(Job() + Dispatchers.Main)

    fun exampleMethod() {
        // Starts a new coroutine on Dispatchers.Main as it's the scope's default
        val job1 = scope.launch {
            // New coroutine with CoroutineName = "coroutine" (default)
        }

        // Starts a new coroutine on Dispatchers.Default
        val job2 = scope.launch(Dispatchers.Default + CoroutineName("BackgroundCoroutine")) {
            // New coroutine with CoroutineName = "BackgroundCoroutine" (overridden)
        }
    }
}

Дополнительные ресурсы сопрограмм

Дополнительные ресурсы по сопрограммам можно найти по следующим ссылкам: