В этой лаборатории кода вы узнаете, как использовать Kotlin Coroutines в приложении для Android — новый способ управления фоновыми потоками, который может упростить код за счет уменьшения потребности в обратных вызовах. Сопрограммы — это функция Kotlin, которая преобразует асинхронные обратные вызовы для длительных задач, таких как доступ к базе данных или сети, в последовательный код.
Вот фрагмент кода, чтобы дать вам представление о том, что вы будете делать.
// Async callbacks
networkRequest { result ->
// Successful network request
databaseSave(result) { rows ->
// Result saved
}
}
Код на основе обратного вызова будет преобразован в последовательный код с помощью сопрограмм.
// The same code with coroutines
val result = networkRequest()
// Successful network request
databaseSave(result)
// Result saved
Вы начнете с существующего приложения, созданного с использованием компонентов архитектуры , которое использует стиль обратного вызова для длительных задач.
К концу этой лаборатории у вас будет достаточно опыта, чтобы использовать сопрограммы в вашем приложении для загрузки данных из сети, и вы сможете интегрировать сопрограммы в приложение. Вы также познакомитесь с передовыми практиками для сопрограмм и узнаете, как написать тест для кода, использующего сопрограммы.
Предпосылки
- Знакомство с архитектурными компонентами
ViewModel
,LiveData
,Repository
иRoom
. - Опыт работы с синтаксисом Kotlin, включая функции расширения и лямбда-выражения.
- Базовое понимание использования потоков на Android, включая основной поток, фоновые потоки и обратные вызовы.
Что ты будешь делать
- Вызов кода, написанного с помощью сопрограмм, и получение результатов.
- Используйте функции приостановки, чтобы сделать асинхронный код последовательным.
- Используйте
launch
иrunBlocking
для управления выполнением кода. - Изучите методы преобразования существующих API в сопрограммы с помощью
suspendCoroutine
. - Используйте сопрограммы с компонентами архитектуры.
- Изучите передовые методы тестирования сопрограмм.
Что вам понадобится
- Android Studio 3.5 (лаборатория может работать с другими версиями, но некоторые вещи могут отсутствовать или выглядеть иначе).
Если вы столкнетесь с какими-либо проблемами (ошибки в коде, грамматические ошибки, нечеткие формулировки и т. д.) при работе с этой лабораторией кода, сообщите о проблеме по ссылке Сообщить об ошибке в левом нижнем углу лаборатории кода.
Скачать код
Щелкните следующую ссылку, чтобы загрузить весь код для этой лаборатории кода:
... или клонируйте репозиторий GitHub из командной строки с помощью следующей команды:
$ git clone https://github.com/googlecodelabs/kotlin-coroutines.git
Часто задаваемые вопросы
Во-первых, давайте посмотрим, как выглядит начальный образец приложения. Следуйте этим инструкциям, чтобы открыть пример приложения в Android Studio.
- Если вы загрузили zip-файл
kotlin-coroutines
, разархивируйте его. - Откройте проект
coroutines-codelab
в Android Studio. - Выберите модуль
start
приложения. - Нажмите на кнопку « Выполнить » и либо выберите эмулятор, либо подключите свое устройство Android, которое должно поддерживать Android Lollipop (минимальный поддерживаемый пакет SDK — 21). Должен появиться экран Kotlin Coroutines:
Это начальное приложение использует потоки для увеличения счетчика с небольшой задержкой после нажатия на экран. Он также получит новый заголовок из сети и отобразит его на экране. Попробуйте прямо сейчас, и вы увидите, что счетчик и сообщение изменились после небольшой задержки. В этой кодовой лаборатории вы преобразуете это приложение для использования сопрограмм.
Это приложение использует компоненты архитектуры, чтобы отделить код пользовательского интерфейса в MainActivity
от логики приложения в MainViewModel
. Найдите минутку, чтобы ознакомиться со структурой проекта.
-
MainActivity
отображает пользовательский интерфейс, регистрирует прослушиватели кликов и может отображатьSnackbar
. Он передает события вMainViewModel
и обновляет экран на основеLiveData
вMainViewModel
. -
MainViewModel
обрабатывает события вonMainViewClicked
и связывается сMainActivity
с помощьюLiveData.
-
Executors
определяетBACKGROUND,
который может запускать вещи в фоновом потоке. -
TitleRepository
извлекает результаты из сети и сохраняет их в базе данных.
Добавление сопрограмм в проект
Чтобы использовать сопрограммы в Kotlin, вы должны включить библиотеку coroutines-core
в build.gradle (Module: app)
вашего проекта. Проекты кодовой лаборатории уже сделали это за вас, поэтому вам не нужно делать это, чтобы завершить кодовую лабораторию.
Корутины на Android доступны в виде основной библиотеки и специальных расширений для Android:
- kotlinx-corouuntines-core — Основной интерфейс для использования сопрограмм в Kotlin
- kotlinx-coroutines-android — Поддержка основного потока Android в сопрограммах
Стартовое приложение уже включает зависимости в build.gradle.
При создании нового проекта приложения вам потребуется открыть build.gradle (Module: app)
и добавить в проект зависимости сопрограмм.
dependencies { ... implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:x.x.x" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:x.x.x" }
В Android очень важно избегать блокировки основного потока. Основной поток — это единственный поток, который обрабатывает все обновления пользовательского интерфейса. Это также поток, который вызывает все обработчики кликов и другие обратные вызовы пользовательского интерфейса. Таким образом, он должен работать бесперебойно, чтобы гарантировать отличный пользовательский опыт.
Чтобы ваше приложение отображалось пользователю без видимых пауз, основной поток должен обновлять экран каждые 16 мс или чаще , что составляет около 60 кадров в секунду. Многие распространенные задачи занимают больше времени, например анализ больших наборов данных JSON, запись данных в базу данных или получение данных из сети. Таким образом, вызов подобного кода из основного потока может привести к приостановке, зависанию или даже зависанию приложения. И если вы заблокируете основной поток слишком долго, приложение может даже вылететь и отобразить диалоговое окно « Приложение не отвечает ».
Посмотрите видео ниже, чтобы узнать, как сопрограммы решают эту проблему для нас на Android, вводя main-safety.
Шаблон обратного вызова
Одним из шаблонов для выполнения длительных задач без блокировки основного потока являются обратные вызовы. Используя обратные вызовы, вы можете запускать длительные задачи в фоновом потоке. Когда задача завершается, вызывается обратный вызов, чтобы сообщить вам о результате в основном потоке.
Взгляните на пример шаблона обратного вызова.
// Slow request with callbacks
@UiThread
fun makeNetworkRequest() {
// The slow network request runs on another thread
slowFetch { result ->
// When the result is ready, this callback will get the result
show(result)
}
// makeNetworkRequest() exits after calling slowFetch without waiting for the result
}
Поскольку этот код снабжен аннотацией @UiThread
, он должен выполняться достаточно быстро, чтобы выполняться в основном потоке. Это означает, что он должен вернуться очень быстро, чтобы следующее обновление экрана не задерживалось. Однако, поскольку slowFetch
потребуется несколько секунд или даже минут, основной поток не может дождаться результата. Обратный вызов show(result)
позволяет slowFetch
работать в фоновом потоке и возвращать результат, когда он будет готов.
Использование сопрограмм для удаления обратных вызовов
Обратные вызовы — отличный шаблон, однако у них есть несколько недостатков. Код, который интенсивно использует обратные вызовы, может стать трудным для чтения и трудным для понимания. Кроме того, обратные вызовы не позволяют использовать некоторые функции языка, например исключения.
Сопрограммы Kotlin позволяют преобразовывать код на основе обратного вызова в последовательный код. Код, написанный последовательно, обычно легче читать, и в нем могут даже использоваться языковые функции, такие как исключения.
В конце концов, они делают одно и то же: ждут, пока не будет доступен результат от долго выполняющейся задачи, и продолжают выполнение. Однако в коде они выглядят совсем иначе.
Ключевое слово suspend
— это способ Kotlin пометить функцию или тип функции, доступный для сопрограмм. Когда сопрограмма вызывает функцию, помеченную как suspend
, вместо блокировки до тех пор, пока эта функция не вернется, как при обычном вызове функции, она приостанавливает выполнение до тех пор, пока результат не будет готов, а затем возобновляет работу с того места, где остановилась, с результатом. Пока он приостанавливается в ожидании результата, он разблокирует поток, в котором он работает, чтобы могли выполняться другие функции или сопрограммы.
Например, в приведенном ниже коде обе функции makeNetworkRequest()
и slowFetch()
являются функциями suspend
.
// Slow request with coroutines
@UiThread
suspend fun makeNetworkRequest() {
// slowFetch is another suspend function so instead of
// blocking the main thread makeNetworkRequest will `suspend` until the result is
// ready
val result = slowFetch()
// continue to execute after the result is ready
show(result)
}
// slowFetch is main-safe using coroutines
suspend fun slowFetch(): SlowResult { ... }
Как и в случае с версией с обратным вызовом, makeNetworkRequest
должен немедленно вернуться из основного потока, поскольку он помечен @UiThread
. Это означает, что обычно он не мог вызывать блокирующие методы, такие как slowFetch
. Вот где ключевое слово suspend
творит чудеса.
По сравнению с кодом, основанным на обратном вызове, код сопрограммы достигает того же результата, разблокируя текущий поток с меньшим количеством кода. Благодаря последовательному стилю легко объединить несколько длительных задач, не создавая несколько обратных вызовов. Например, код, который извлекает результат из двух конечных точек сети и сохраняет его в базе данных, может быть написан как функция в сопрограммах без обратных вызовов. Вот так:
// Request data from network and save it to database with coroutines
// Because of the @WorkerThread, this function cannot be called on the
// main thread without causing an error.
@WorkerThread
suspend fun makeNetworkRequest() {
// slowFetch and anotherFetch are suspend functions
val slow = slowFetch()
val another = anotherFetch()
// save is a regular function and will block this thread
database.save(slow, another)
}
// slowFetch is main-safe using coroutines
suspend fun slowFetch(): SlowResult { ... }
// anotherFetch is main-safe using coroutines
suspend fun anotherFetch(): AnotherResult { ... }
Вы познакомитесь с сопрограммами в примере приложения в следующем разделе.
В этом упражнении вы напишете сопрограмму для отображения сообщения после задержки. Для начала убедитесь, что модуль start
открыт в Android Studio.
Понимание CoroutineScope
В Kotlin все сопрограммы выполняются внутри CoroutineScope
. Область действия контролирует время жизни сопрограмм через свою работу. Когда вы отменяете задание области, она отменяет все сопрограммы, запущенные в этой области. В Android вы можете использовать область для отмены всех запущенных сопрограмм, когда, например, пользователь уходит из Activity
или Fragment
. Области также позволяют указать диспетчера по умолчанию. Диспетчер контролирует, какой поток запускает сопрограмму.
Для сопрограмм, запускаемых пользовательским интерфейсом, обычно правильно запускать их в Dispatchers.Main
, который является основным потоком на Android. Сопрограмма, запущенная в Dispatchers.Main
, не будет блокировать основной поток во время приостановки. Поскольку сопрограмма ViewModel
почти всегда обновляет пользовательский интерфейс в основном потоке, запуск сопрограмм в основном потоке избавляет вас от дополнительных переключений потока. Сопрограмма, запущенная в основном потоке, может переключать диспетчеров в любое время после ее запуска. Например, он может использовать другой диспетчер для анализа большого результата JSON вне основного потока.
Использование вьюмоделскопе
Библиотека AndroidX lifecycle-viewmodel-ktx
добавляет CoroutineScope в ViewModels, настроенную для запуска сопрограмм, связанных с пользовательским интерфейсом. Чтобы использовать эту библиотеку, вы должны включить ее в build.gradle (Module: start)
вашего проекта. Этот шаг уже сделан в проектах codelab.
dependencies { ... implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:x.x.x" }
Библиотека добавляет viewModelScope
в качестве функции расширения класса ViewModel
. Эта область привязана к Dispatchers.Main
и будет автоматически отменена при очистке ViewModel
.
Переключиться с потоков на сопрограммы
В MainViewModel.kt
найдите следующий TODO вместе с этим кодом:
MainViewModel.kt
/**
* Wait one second then update the tap count.
*/
private fun updateTaps() {
// TODO: Convert updateTaps to use coroutines
tapCount++
BACKGROUND.submit {
Thread.sleep(1_000)
_taps.postValue("$tapCount taps")
}
}
Этот код использует BACKGROUND ExecutorService
(определенный в util/Executor.kt
) для запуска в фоновом потоке. Поскольку sleep
блокирует текущий поток, пользовательский интерфейс зависает, если он вызывается в основном потоке. Через одну секунду после того, как пользователь нажмет на основное представление, он запрашивает закусочную.
Вы можете увидеть это, удалив BACKGROUND из кода и запустив его снова. Счетчик загрузки не будет отображаться, и через секунду все «перейдет» в конечное состояние.
MainViewModel.kt
/**
* Wait one second then update the tap count.
*/
private fun updateTaps() {
// TODO: Convert updateTaps to use coroutines
tapCount++
Thread.sleep(1_000)
_taps.postValue("$tapCount taps")
}
Замените updateTaps
этим кодом на основе сопрограммы, который делает то же самое. Вам придется импортировать launch
и delay
.
MainViewModel.kt
/**
* Wait one second then display a snackbar.
*/
fun updateTaps() {
// launch a coroutine in viewModelScope
viewModelScope.launch {
tapCount++
// suspend this coroutine for one second
delay(1_000)
// resume in the main dispatcher
// _snackbar.value can be called directly from main thread
_taps.postValue("$tapCount taps")
}
}
Этот код делает то же самое, ожидая одну секунду, прежде чем показать закусочную. Однако есть несколько важных отличий:
-
viewModelScope.
launch
запустит сопрограмму вviewModelScope
. Это означает, что когда задание, которое мы передали вviewModelScope
, отменяется, все сопрограммы в этом задании/области действия будут отменены. Если пользователь покинул действие до возвратаdelay
, эта сопрограмма будет автоматически отменена, когдаonCleared
вызывается после уничтожения ViewModel. - Так как
viewModelScope
имеет диспетчерDispatchers.Main
по умолчанию, эта сопрограмма будет запущена в основном потоке. Позже мы увидим, как использовать разные потоки. -
delay
функции является функциейsuspend
. Это показано в Android Studio с помощью значок в левом желобе. Несмотря на то, что эта сопрограмма выполняется в основном потоке,delay
не блокирует поток ни на одну секунду. Вместо этого диспетчер запланирует возобновление сопрограммы через одну секунду при следующем операторе.
Давай, запусти. Когда вы нажимаете на основной вид, вы должны увидеть закусочную через секунду.
В следующем разделе мы рассмотрим, как протестировать эту функцию.
В этом упражнении вы напишете тест для только что написанного кода. В этом упражнении показано, как тестировать сопрограммы, работающие в Dispatchers.Main
, с помощью библиотеки kotlinx-coroutines-test . Позже в этой кодовой лаборатории вы реализуете тест, напрямую взаимодействующий с сопрограммами.
Просмотрите существующий код
Откройте MainViewModelTest.kt
в папке androidTest
.
MainViewModelTest.kt
class MainViewModelTest {
@get:Rule
val coroutineScope = MainCoroutineScopeRule()
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
lateinit var subject: MainViewModel
@Before
fun setup() {
subject = MainViewModel(
TitleRepository(
MainNetworkFake("OK"),
TitleDaoFake("initial")
))
}
}
Правило — это способ запуска кода до и после выполнения теста в JUnit. Два правила используются, чтобы мы могли протестировать MainViewModel в тесте вне устройства:
-
InstantTaskExecutorRule
— это правило JUnit, которое настраиваетLiveData
для синхронного выполнения каждой задачи. -
MainCoroutineScopeRule
— это пользовательское правило в этой кодовой базе, которое настраиваетDispatchers.Main
на использованиеTestCoroutineDispatcher
изkotlinx-coroutines-test
. Это позволяет тестам продвигать виртуальные часы для тестирования и позволяет коду использоватьDispatchers.Main
в модульных тестах.
В методе setup
новый экземпляр MainViewModel
создается с использованием тестовых подделок — это поддельные реализации сети и базы данных, предоставленные в начальном коде, чтобы помочь писать тесты без использования реальной сети или базы данных.
Для этого теста подделки нужны только для удовлетворения зависимостей MainViewModel
. Позже в этой лаборатории кода вы обновите подделки для поддержки сопрограмм.
Напишите тест, управляющий сопрограммами
Добавьте новый тест, который гарантирует, что касания обновляются через одну секунду после нажатия на основной вид:
MainViewModelTest.kt
@Test
fun whenMainClicked_updatesTaps() {
subject.onMainViewClicked()
Truth.assertThat(subject.taps.getValueForTest()).isEqualTo("0 taps")
coroutineScope.advanceTimeBy(1000)
Truth.assertThat(subject.taps.getValueForTest()).isEqualTo("1 taps")
}
При вызове onMainViewClicked
будет запущена только что созданная нами сопрограмма. Этот тест проверяет, что текст касания остается «0 касаний» сразу после onMainViewClicked
, а затем через 1 секунду он обновляется до «1 касания» .
Этот тест использует виртуальное время для управления выполнением сопрограммы, запущенной onMainViewClicked
. MainCoroutineScopeRule
позволяет приостанавливать, возобновлять или контролировать выполнение сопрограмм, запущенных в Dispatchers.Main
. Здесь мы вызываем advanceTimeBy(1_000)
, что заставит главный диспетчер немедленно выполнить сопрограммы, возобновление которых запланировано на 1 секунду позже.
Этот тест полностью детерминирован, что означает, что он всегда будет выполняться одинаково. А поскольку он имеет полный контроль над выполнением сопрограмм, запущенных в Dispatchers.Main
, ему не нужно ждать одну секунду, пока значение будет установлено.
Запустите существующий тест
- Щелкните правой кнопкой мыши имя класса
MainViewModelTest
в вашем редакторе, чтобы открыть контекстное меню. - В контекстном меню выбрать Запустите «MainViewModelTest»
- Для будущих запусков вы можете выбрать эту тестовую конфигурацию в конфигурациях рядом с кнопку на панели инструментов. По умолчанию конфигурация будет называться MainViewModelTest .
Вы должны увидеть прохождение теста! И это должно занять чуть меньше одной секунды.
В следующем упражнении вы узнаете, как преобразовать существующие API-интерфейсы обратного вызова в использование сопрограмм.
На этом шаге вы начнете преобразовывать репозиторий для использования сопрограмм. Для этого мы добавим сопрограммы во ViewModel
, Repository
, Room
и Retrofit
.
Это хорошая идея, чтобы понять, за что отвечает каждая часть архитектуры, прежде чем мы переключим их на использование сопрограмм.
-
MainDatabase
реализует базу данных с помощью Room, которая сохраняет и загружаетTitle
. -
MainNetwork
реализует сетевой API, который извлекает новый заголовок. Он использует Retrofit для получения заголовков.Retrofit
настроен на случайный возврат ошибок или имитацию данных, но в остальном ведет себя так, как если бы выполнял настоящие сетевые запросы. -
TitleRepository
реализует единый API для получения или обновления заголовка путем объединения данных из сети и базы данных. -
MainViewModel
представляет состояние экрана и обрабатывает события. Он скажет репозиторию обновить заголовок, когда пользователь нажимает на экран.
Поскольку сетевой запрос управляется UI-событиями, и мы хотим запустить сопрограмму на их основе, естественным местом для начала использования сопрограмм является ViewModel
.
Версия обратного вызова
Откройте MainViewModel.kt
, чтобы увидеть объявление refreshTitle
.
MainViewModel.kt
/**
* Update title text via this LiveData
*/
val title = repository.title
// ... other code ...
/**
* Refresh the title, showing a loading spinner while it refreshes and errors via snackbar.
*/
fun refreshTitle() {
// TODO: Convert refreshTitle to use coroutines
_spinner.value = true
repository.refreshTitleWithCallbacks(object: TitleRefreshCallback {
override fun onCompleted() {
_spinner.postValue(false)
}
override fun onError(cause: Throwable) {
_snackBar.postValue(cause.message)
_spinner.postValue(false)
}
})
}
Эта функция вызывается каждый раз, когда пользователь щелкает по экрану, и это приводит к тому, что репозиторий обновляет заголовок и записывает новый заголовок в базу данных.
Эта реализация использует обратный вызов, чтобы сделать несколько вещей:
- Прежде чем начать запрос, он отображает счетчик загрузки с
_spinner.value = true
- Когда он получает результат, он очищает счетчик загрузки с помощью
_spinner.value = false
- Если он получает ошибку, он указывает закусочной отображать и очищает счетчик.
Обратите внимание, что обратному вызову onCompleted
не передается title
. Поскольку мы записываем все заголовки в базу данных Room
, пользовательский интерфейс обновляется до текущего заголовка, наблюдая за LiveData
, который обновляется Room
.
В обновлении сопрограмм мы сохраним точно такое же поведение. Рекомендуется использовать наблюдаемый источник данных, например базу данных Room
, для автоматического обновления пользовательского интерфейса.
Версия сопрограмм
Давайте перепишем refreshTitle
с помощью сопрограмм!
Поскольку она нам понадобится прямо сейчас, давайте сделаем пустую функцию приостановки в нашем репозитории ( TitleRespository.kt
). Определите новую функцию, которая использует оператор suspend
, чтобы сообщить Kotlin, что он работает с сопрограммами.
TitleRepository.kt
suspend fun refreshTitle() {
// TODO: Refresh from network and write to database
delay(500)
}
Когда вы закончите с этой лабораторией кода, вы обновите ее, чтобы использовать Retrofit и Room для получения нового заголовка и записи его в базу данных с помощью сопрограмм. На данный момент он просто потратит 500 миллисекунд, притворяясь, что выполняет работу, а затем продолжит работу.
В MainViewModel
замените версию обратного вызова refreshTitle
на ту, которая запускает новую сопрограмму:
MainViewModel.kt
/**
* Refresh the title, showing a loading spinner while it refreshes and errors via snackbar.
*/
fun refreshTitle() {
viewModelScope.launch {
try {
_spinner.value = true
repository.refreshTitle()
} catch (error: TitleRefreshError) {
_snackBar.value = error.message
} finally {
_spinner.value = false
}
}
}
Давайте пройдемся по этой функции:
viewModelScope.launch {
Как и в случае с сопрограммой для обновления количества нажатий, начните с запуска новой сопрограммы в viewModelScope
. Это будет использовать Dispatchers.Main
, что нормально. Несмотря на то, что refreshTitle
будет делать сетевой запрос и запрос к базе данных, он может использовать сопрограммы для предоставления основного безопасного интерфейса. Это означает, что будет безопасно вызывать его из основного потока.
Поскольку мы используем viewModelScope
, когда пользователь уходит с этого экрана, работа, начатая этой сопрограммой, будет автоматически отменена. Это означает, что он не будет делать дополнительных сетевых запросов или запросов к базе данных.
Следующие несколько строк кода фактически вызывают refreshTitle
в repository
.
try {
_spinner.value = true
repository.refreshTitle()
}
Прежде чем эта сопрограмма что-либо сделает, она запускает счетчик загрузки, а затем вызывает refreshTitle
, как обычную функцию. Однако, поскольку refreshTitle
является функцией приостановки, она выполняется не так, как обычная функция.
Нам не нужно передавать обратный вызов. Сопрограмма будет приостановлена до тех пор, пока не будет возобновлена с помощью refreshTitle
. Хотя это выглядит как обычный вызов блокирующей функции, он будет автоматически ждать завершения запроса к сети и базе данных, прежде чем возобновить работу, не блокируя основной поток.
} catch (error: TitleRefreshError) {
_snackBar.value = error.message
} finally {
_spinner.value = false
}
Исключения в функциях приостановки работают точно так же, как ошибки в обычных функциях. Если вы выдаете ошибку в функции приостановки, она будет передана вызывающей стороне. Таким образом, несмотря на то, что они выполняются совершенно по-разному, для их обработки можно использовать обычные блоки try/catch. Это полезно, потому что позволяет вам полагаться на встроенную поддержку языка для обработки ошибок вместо создания собственной обработки ошибок для каждого обратного вызова.
И если вы выбросите исключение из сопрограммы — эта сопрограмма по умолчанию отменит своего родителя. Это означает, что можно легко отменить несколько связанных задач одновременно.
И затем, в блоке finally, мы можем убедиться, что счетчик всегда выключен после выполнения запроса.
Запустите приложение еще раз, выбрав стартовую конфигурацию и нажав , вы должны увидеть индикатор загрузки при нажатии в любом месте. Название останется прежним, потому что мы еще не подключили нашу сеть или базу данных.
В следующем упражнении вы обновите репозиторий, чтобы он действительно работал.
В этом упражнении вы узнаете, как переключить поток, в котором работает сопрограмма, чтобы реализовать рабочую версию TitleRepository
.
Просмотрите существующий код обратного вызова в refreshTitle.
Откройте TitleRepository.kt
и просмотрите существующую реализацию на основе обратного вызова.
TitleRepository.kt
// TitleRepository.kt
fun refreshTitleWithCallbacks(titleRefreshCallback: TitleRefreshCallback) {
// This request will be run on a background thread by retrofit
BACKGROUND.submit {
try {
// Make network request using a blocking call
val result = network.fetchNextTitle().execute()
if (result.isSuccessful) {
// Save it to database
titleDao.insertTitle(Title(result.body()!!))
// Inform the caller the refresh is completed
titleRefreshCallback.onCompleted()
} else {
// If it's not successful, inform the callback of the error
titleRefreshCallback.onError(
TitleRefreshError("Unable to refresh title", null))
}
} catch (cause: Throwable) {
// If anything throws an exception, inform the caller
titleRefreshCallback.onError(
TitleRefreshError("Unable to refresh title", cause))
}
}
}
В TitleRepository.kt
метод refreshTitleWithCallbacks
реализован с обратным вызовом для сообщения вызывающей стороне о загрузке и состоянии ошибки.
Эта функция делает несколько вещей для реализации обновления.
- Переключиться на другой поток с помощью
BACKGROUND
ExecutorService
- Запустите сетевой запрос
fetchNextTitle
, используя блокирующий методexecute()
. Это запустит сетевой запрос в текущем потоке, в данном случае в одном из потоков вBACKGROUND
. - Если результат успешен, сохраните его в базе данных с помощью
insertTitle
и вызовите методonCompleted()
. - Если результат не удался или есть исключение, вызовите метод onError, чтобы сообщить вызывающей стороне о неудачном обновлении.
Эта реализация на основе обратного вызова безопасна для основного потока, поскольку она не блокирует основной поток. Но он должен использовать обратный вызов, чтобы информировать вызывающую сторону о завершении работы. Он также вызывает обратные вызовы в потоке BACKGROUND
, на который он также переключился.
Вызов блокировки вызовов из сопрограмм
Не внедряя сопрограммы в сеть или базу данных, мы можем сделать этот код безопасным для основной сети, используя сопрограммы. Это позволит нам избавиться от обратного вызова и передать результат обратно в поток, который первоначально его вызвал.
Вы можете использовать этот шаблон в любое время, когда вам нужно выполнить блокировку или работу с интенсивным использованием ЦП внутри сопрограммы, например, сортировку и фильтрацию большого списка или чтение с диска.
Для переключения между любыми диспетчерами сопрограммы используют withContext
. Вызов withContext
переключается на другой диспетчер только для лямбды, а затем возвращается к диспетчеру, вызвавшему его, с результатом этой лямбды.
По умолчанию сопрограммы Kotlin предоставляют три диспетчера: Main
, IO
и Default
. Диспетчер ввода-вывода оптимизирован для операций ввода-вывода, таких как чтение из сети или с диска, в то время как диспетчер по умолчанию оптимизирован для задач с интенсивным использованием ЦП.
TitleRepository.kt
suspend fun refreshTitle() {
// interact with *blocking* network and IO calls from a coroutine
withContext(Dispatchers.IO) {
val result = try {
// Make network request using a blocking call
network.fetchNextTitle().execute()
} catch (cause: Throwable) {
// If the network throws an exception, inform the caller
throw TitleRefreshError("Unable to refresh title", cause)
}
if (result.isSuccessful) {
// Save it to database
titleDao.insertTitle(Title(result.body()!!))
} else {
// If it's not successful, inform the callback of the error
throw TitleRefreshError("Unable to refresh title", null)
}
}
}
В этой реализации используются блокирующие вызовы для сети и базы данных, но она все же немного проще, чем версия с обратным вызовом.
Этот код по-прежнему использует блокировку вызовов. Вызов execute()
и insertTitle(...)
заблокирует поток, в котором выполняется эта сопрограмма. Однако, переключаясь на Dispatchers.IO
с помощью withContext
, мы блокируем один из потоков в диспетчере ввода-вывода. Вызвавшая это сопрограмма, возможно работающая на Dispatchers.Main
, будет приостановлена до тех пор, пока лямбда withContext
не будет завершена.
По сравнению с версией с обратным вызовом есть два важных отличия:
-
withContext
возвращает результат обратно вызывавшему его Dispatcher, в данном случаеDispatchers.Main
. Версия обратного вызова вызывала обратные вызовы в потоке службы исполнителяBACKGROUND
. - Вызывающий не должен передавать обратный вызов этой функции. Они могут полагаться на приостановку и возобновление, чтобы получить результат или ошибку.
Запустите приложение снова
Если вы снова запустите приложение, вы увидите, что новая реализация на основе сопрограмм загружает результаты из сети!
На следующем шаге вы интегрируете сопрограммы в Room и Retrofit.
Чтобы продолжить интеграцию сопрограмм, мы воспользуемся поддержкой функций приостановки в стабильной версии Room и Retrofit, а затем существенно упростим код, который мы только что написали, используя функции приостановки.
Корутины в комнате
Сначала откройте MainDatabase.kt
и сделайте insertTitle
функцией приостановки:
MainDatabase.kt
// add the suspend modifier to the existing insertTitle
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertTitle(title: Title)
Когда вы это сделаете, Room сделает ваш запрос безопасным и автоматически выполнит его в фоновом потоке. Однако это также означает, что вы можете вызывать этот запрос только из сопрограммы.
И это все, что вам нужно сделать, чтобы использовать сопрограммы в Room. Довольно изящно.
Корутины в модификации
Далее давайте посмотрим, как интегрировать сопрограммы с Retrofit. Откройте MainNetwork.kt
и измените fetchNextTitle
на функцию приостановки.
MainNetwork.kt
// add suspend modifier to the existing fetchNextTitle
// change return type from Call<String> to String
interface MainNetwork {
@GET("next_title.json")
suspend fun fetchNextTitle(): String
}
Чтобы использовать функции приостановки с Retrofit, вам нужно сделать две вещи:
- Добавьте модификатор приостановки в функцию
- Удалите оболочку
Call
из возвращаемого типа. Здесь мы возвращаемString
, но вы также можете вернуть сложный тип на основе json. Если вы по-прежнему хотите предоставить доступ к полномуResult
модификации, вы можете вернутьResult<String>
вместоString
из функции приостановки.
Retrofit автоматически сделает функции приостановки безопасными для основной среды , чтобы вы могли вызывать их непосредственно из Dispatchers.Main
.
Использование комнаты и модернизации
Теперь, когда Room и Retrofit поддерживают функции приостановки, мы можем использовать их из нашего репозитория. Откройте TitleRepository.kt
и посмотрите, как использование приостанавливающих функций значительно упрощает логику, даже по сравнению с блокирующей версией:
Заголовок Repository.kt
suspend fun refreshTitle() {
try {
// Make network request using a blocking call
val result = network.fetchNextTitle()
titleDao.insertTitle(Title(result))
} catch (cause: Throwable) {
// If anything throws an exception, inform the caller
throw TitleRefreshError("Unable to refresh title", cause)
}
}
Вау, это намного короче. Что случилось? Оказывается, использование приостановки и возобновления позволяет сделать код намного короче. Модернизация позволяет нам использовать возвращаемые типы, такие как String
или объект User
, вместо Call
. Это безопасно, потому что внутри функции приостановки Retrofit
может запускать сетевой запрос в фоновом потоке и возобновлять сопрограмму после завершения вызова.
Более того, мы избавились от withContext
. Поскольку и Room, и Retrofit предоставляют основные безопасные функции приостановки, можно безопасно организовать эту асинхронную работу из Dispatchers.Main
.
Исправление ошибок компилятора
Переход к сопрограммам требует изменения подписи функций, поскольку вы не можете вызывать функцию приостановки из обычной функции. Когда вы добавили модификатор suspend
на этом шаге, было сгенерировано несколько ошибок компилятора, которые показывают, что произойдет, если вы измените функцию на приостановку в реальном проекте.
Пройдитесь по проекту и исправьте ошибки компилятора, изменив функцию на приостановку созданной. Вот быстрые решения для каждого:
TestingFakes.kt
Обновите подделки тестирования, чтобы они поддерживали новые модификаторы приостановки.
НазваниеDaoFake
- Нажмите alt-enter, чтобы добавить модификаторы приостановки ко всем функциям в иерархии.
ГлавнаяNetworkFake
- Нажмите alt-enter, чтобы добавить модификаторы приостановки ко всем функциям в иерархии.
- Замените
fetchNextTitle
этой функцией
override suspend fun fetchNextTitle() = result
MainNetworkCompletedFake
- Нажмите alt-enter, чтобы добавить модификаторы приостановки ко всем функциям в иерархии.
- Замените
fetchNextTitle
этой функцией
override suspend fun fetchNextTitle() = completable.await()
TitleRepository.kt
- Удалите функцию
refreshTitleWithCallbacks
, так как она больше не используется.
Запустите приложение
Run the app again, once it compiles, you will see that it's loading data using coroutines all the way from the ViewModel to Room and Retrofit!
Congratulations, you've completely swapped this app to using coroutines! To wrap up we'll talk a bit about how to test what we just did.
In this exercise, you'll write a test that calls a suspend
function directly.
Since refreshTitle
is exposed as a public API it will be tested directly, showing how to call coroutines functions from tests.
Here's the refreshTitle
function you implemented in the last exercise:
TitleRepository.kt
suspend fun refreshTitle() {
try {
// Make network request using a blocking call
val result = network.fetchNextTitle()
titleDao.insertTitle(Title(result))
} catch (cause: Throwable) {
// If anything throws an exception, inform the caller
throw TitleRefreshError("Unable to refresh title", cause)
}
}
Write a test that calls a suspend function
Open TitleRepositoryTest.kt
in the test
folder which has two TODOS.
Try to call refreshTitle
from the first test whenRefreshTitleSuccess_insertsRows
.
@Test
fun whenRefreshTitleSuccess_insertsRows() {
val subject = TitleRepository(
MainNetworkFake("OK"),
TitleDaoFake("title")
)
subject.refreshTitle()
}
Since refreshTitle
is a suspend
function Kotlin doesn't know how to call it except from a coroutine or another suspend function, and you will get a compiler error like, "Suspend function refreshTitle should be called only from a coroutine or another suspend function."
The test runner doesn't know anything about coroutines so we can't make this test a suspend function. We could launch
a coroutine using a CoroutineScope
like in a ViewModel
, however tests need to run coroutines to completion before they return. Once a test function returns, the test is over. Coroutines started with launch
are asynchronous code, which may complete at some point in the future. Therefore to test that asynchronous code, you need some way to tell the test to wait until your coroutine completes. Since launch
is a non-blocking call, that means it returns right away and can continue to run a coroutine after the function returns - it can't be used in tests. Например:
@Test
fun whenRefreshTitleSuccess_insertsRows() {
val subject = TitleRepository(
MainNetworkFake("OK"),
TitleDaoFake("title")
)
// launch starts a coroutine then immediately returns
GlobalScope.launch {
// since this is asynchronous code, this may be called *after* the test completes
subject.refreshTitle()
}
// test function returns immediately, and
// doesn't see the results of refreshTitle
}
This test will sometimes fail. The call to launch
will return immediately and execute at the same time as the rest of the test case. The test has no way to know if refreshTitle
has run yet or not – and any assertions like checking that the database was updated would be flakey. And, if refreshTitle
threw an exception, it will not be thrown in the test call stack. It will instead be thrown into GlobalScope
's uncaught exception handler.
The library kotlinx-coroutines-test
has the runBlockingTest
function that blocks while it calls suspend functions. When runBlockingTest
calls a suspend function or launches
a new coroutine, it executes it immediately by default. You can think of it as a way to convert suspend functions and coroutines into normal function calls.
In addition, runBlockingTest
will rethrow uncaught exceptions for you. This makes it easier to test when a coroutine is throwing an exception.
Implement a test with one coroutine
Wrap the call to refreshTitle
with runBlockingTest
and remove the GlobalScope.launch
wrapper from subject.refreshTitle().
TitleRepositoryTest.kt
@Test
fun whenRefreshTitleSuccess_insertsRows() = runBlockingTest {
val titleDao = TitleDaoFake("title")
val subject = TitleRepository(
MainNetworkFake("OK"),
titleDao
)
subject.refreshTitle()
Truth.assertThat(titleDao.nextInsertedOrNull()).isEqualTo("OK")
}
This test uses the fakes provided to check that "OK" is inserted to the database by refreshTitle
.
When the test calls runBlockingTest
, it will block until the coroutine started by runBlockingTest
completes. Then inside, when we call refreshTitle
it uses the regular suspend and resume mechanism to wait for the database row to be added to our fake.
After the test coroutine completes, runBlockingTest
returns.
Write a timeout test
We want to add a short timeout to the network request. Let's write the test first then implement the timeout. Create a new test:
TitleRepositoryTest.kt
@Test(expected = TitleRefreshError::class)
fun whenRefreshTitleTimeout_throws() = runBlockingTest {
val network = MainNetworkCompletableFake()
val subject = TitleRepository(
network,
TitleDaoFake("title")
)
launch {
subject.refreshTitle()
}
advanceTimeBy(5_000)
}
This test uses the provided fake MainNetworkCompletableFake
, which is a network fake that's designed to suspend callers until the test continues them. When refreshTitle
tries to make a network request, it'll hang forever because we want to test timeouts.
Then, it launches a separate coroutine to call refreshTitle
. This is a key part of testing timeouts, the timeout should happen in a different coroutine than the one runBlockingTest
creates. By doing so, we can call the next line, advanceTimeBy(5_000)
which will advance time by 5 seconds and cause the other coroutine to timeout.
This is a complete timeout test, and it will pass once we implement timeout.
Run it now and see what happens:
Caused by: kotlinx.coroutines.test.UncompletedCoroutinesError: Test finished with active jobs: ["...]
One of the features of runBlockingTest
is that it won't let you leak coroutines after the test completes. If there are any unfinished coroutines, like our launch coroutine, at the end of the test, it will fail the test.
Add a timeout
Open up TitleRepository
and add a five second timeout to the network fetch. You can do this by using the withTimeout
function:
TitleRepository.kt
suspend fun refreshTitle() {
try {
// Make network request using a blocking call
val result = withTimeout(5_000) {
network.fetchNextTitle()
}
titleDao.insertTitle(Title(result))
} catch (cause: Throwable) {
// If anything throws an exception, inform the caller
throw TitleRefreshError("Unable to refresh title", cause)
}
}
Run the test. When you run the tests you should see all tests pass!
In the next exercise you'll learn how to write higher order functions using coroutines.
In this exercise you'll refactor refreshTitle
in MainViewModel
to use a general data loading function. This will teach you how to build higher order functions that use coroutines.
The current implementation of refreshTitle
works, but we can create a general data loading coroutine that always shows the spinner. This might be helpful in a codebase that loads data in response to several events, and wants to ensure the loading spinner is consistently displayed.
Reviewing the current implementation every line except repository.refreshTitle()
is boilerplate to show the spinner and display errors.
// MainViewModel.kt
fun refreshTitle() {
viewModelScope.launch {
try {
_spinner.value = true
// this is the only part that changes between sources
repository.refreshTitle()
} catch (error: TitleRefreshError) {
_snackBar.value = error.message
} finally {
_spinner.value = false
}
}
}
Using coroutines in higher order functions
Add this code to MainViewModel.kt
MainViewModel.kt
private fun launchDataLoad(block: suspend () -> Unit): Job {
return viewModelScope.launch {
try {
_spinner.value = true
block()
} catch (error: TitleRefreshError) {
_snackBar.value = error.message
} finally {
_spinner.value = false
}
}
}
Now refactor refreshTitle()
to use this higher order function.
MainViewModel.kt
fun refreshTitle() {
launchDataLoad {
repository.refreshTitle()
}
}
By abstracting the logic around showing a loading spinner and showing errors, we've simplified our actual code needed to load data. Showing a spinner or displaying an error is something that's easy to generalize to any data loading, while the actual data source and destination needs to be specified every time.
To build this abstraction, launchDataLoad
takes an argument block
that is a suspend lambda. A suspend lambda allows you to call suspend functions. That's how Kotlin implements the coroutine builders launch
and runBlocking
we've been using in this codelab.
// suspend lambda
block: suspend () -> Unit
To make a suspend lambda, start with the suspend
keyword. The function arrow and return type Unit
complete the declaration.
You don't often have to declare your own suspend lambdas, but they can be helpful to create abstractions like this that encapsulate repeated logic!
In this exercise you'll learn how to use coroutine based code from WorkManager.
What is WorkManager
There are many options on Android for deferrable background work. This exercise shows you how to integrate WorkManager with coroutines. WorkManager is a compatible, flexible and simple library for deferrable background work. WorkManager is the recommended solution for these use cases on Android.
WorkManager is part of Android Jetpack , and an Architecture Component for background work that needs a combination of opportunistic and guaranteed execution. Opportunistic execution means that WorkManager will do your background work as soon as it can. Guaranteed execution means that WorkManager will take care of the logic to start your work under a variety of situations, even if you navigate away from your app.
Because of this, WorkManager is a good choice for tasks that must complete eventually.
Some examples of tasks that are a good use of WorkManager:
- Uploading logs
- Applying filters to images and saving the image
- Periodically syncing local data with the network
Using coroutines with WorkManager
WorkManager provides different implementations of its base ListanableWorker
class for different use cases.
The simplest Worker class allows us to have some synchronous operation executed by WorkManager. However, having worked so far to convert our codebase to use coroutines and suspend functions, the best way to use WorkManager is through the CoroutineWorker
class that allows to define our doWork()
function as a suspend function.
To get started, open up RefreshMainDataWork
. It already extends CoroutineWorker
, and you need to implement doWork
.
Inside the suspend
doWork
function, call refreshTitle()
from the repository and return the appropriate result!
After you've completed the TODO, the code will look like this:
override suspend fun doWork(): Result {
val database = getDatabase(applicationContext)
val repository = TitleRepository(network, database.titleDao)
return try {
repository.refreshTitle()
Result.success()
} catch (error: TitleRefreshError) {
Result.failure()
}
}
Note that CoroutineWorker.doWork()
is a suspending function. Unlike the simpler Worker
class, this code does NOT run on the Executor specified in your WorkManager configuration, but instead use the dispatcher in coroutineContext
member (by default Dispatchers.Default
).
Testing our CoroutineWorker
No codebase should be complete without testing.
WorkManager makes available a couple of different ways to test your Worker
classes, to learn more about the original testing infrastructure, you can read the documentation .
WorkManager v2.1 introduces a new set of APIs to support a simpler way to test ListenableWorker
classes and, as a consequence, CoroutineWorker. In our code we're going to use one of these new API: TestListenableWorkerBuilder
.
To add our new test, update the RefreshMainDataWorkTest
file under the androidTest
folder.
The content of the file is:
package com.example.android.kotlincoroutines.main
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.work.ListenableWorker.Result
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.work.ListenableWorker.Result
import androidx.work.testing.TestListenableWorkerBuilder
import com.example.android.kotlincoroutines.fakes.MainNetworkFake
import com.google.common.truth.Truth.assertThat
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.JUnit4
@RunWith(JUnit4::class)
class RefreshMainDataWorkTest {
@Test
fun testRefreshMainDataWork() {
val fakeNetwork = MainNetworkFake("OK")
val context = ApplicationProvider.getApplicationContext<Context>()
val worker = TestListenableWorkerBuilder<RefreshMainDataWork>(context)
.setWorkerFactory(RefreshMainDataWork.Factory(fakeNetwork))
.build()
// Start the work synchronously
val result = worker.startWork().get()
assertThat(result).isEqualTo(Result.success())
}
}
Before we get to the test, we tell WorkManager
about the factory so we can inject the fake network.
The test itself uses the TestListenableWorkerBuilder
to create our worker that we can then run calling the startWork()
method.
WorkManager is just one example of how coroutines can be used to simplify APIs design.
In this codelab we have covered the basics you'll need to start using coroutines in your app!
We covered:
- How to integrate coroutines to Android apps from both the UI and WorkManager jobs to simplify asynchronous programming,
- How to use coroutines inside a
ViewModel
to fetch data from the network and save it to a database without blocking the main thread. - And how to cancel all coroutines when the
ViewModel
is finished.
For testing coroutine based code, we covered both by testing behavior as well as directly calling suspend
functions from tests.
Learn more
Check out the " Advanced Coroutines with Kotlin Flow and LiveData " codelab to learn more advanced coroutines usage on Android.
Kotlin coroutines have many features that weren't covered by this codelab. If you're interested in learning more about Kotlin coroutines, read the coroutines guides published by JetBrains. Also check out " Improve app performance with Kotlin coroutines " for more usage patterns of coroutines on Android.