В этой статье расскажу о том, как работают корутины и как их создать. Рассмотрим применение при последовательном, параллельном выполнении. Поговорим об обработке ошибок, отладке и способах тестирования корутин. В конце я подведу итог и расскажу о впечатлениях, которые остались после применения данного подхода.
Статья подготовлена по материалам моего доклада на MBLT DEV 2018, в конце поста — линк на видеозапись.
Последовательный стиль
Рис. 2.1
Какую цель преследовали разработчики корутин? Они хотели, чтобы асинхронное программирование было как можно проще. Нет ничего проще, чем исполнение кода «строка за строкой» с применением синтаксических конструкций языка: try-catch-finally, циклов, условных операторов и так далее.
Рассмотрим две функции. Каждая выполняется на своем потоке (рис.2.1). Первая выполняется на потоке B и возвращает некий результат dataB, затем нам нужно передать этот результат во вторую функцию, которая принимает dataB в качестве аргумента и уже выполняется на потоке А. С помощью корутин мы можем написать наш код так, как показано на рис. 2.1. Рассмотрим, как можно этого достичь.
Функции longOpOnB, longOpOnA — так называемые suspend-функции, перед выполнением которых поток освобождается, а после завершения их работы снова становится занят.
Чтобы эти две функции действительно выполнялись в другом потоке относительно вызываемого, и при этом сохранялся «последовательный» стиль написания кода, мы должны погрузить их в контекст корутины.
Это делается путём создания корутины с помощью так называемого Coroutine Builder. На рисунке это launch, но существуют и другие, например, async, runBlocking. О них расскажу позже.
В качестве последнего аргумента передаётся блок исполняемого в контексте корутины кода: вызов suspend-функций, а значит, и всё вышеописанное поведение, возможно только в контексте корутины или же в другой suspend-функции.
В методе Coroutine Builder есть и другие параметры, например, тип запуска, поток, в котором будет выполняться блок и другие.
Управление жизненным циклом
Coroutine Builder в качестве возвращаемого значения отдаёт нам джобу — подкласс класса Job (Рис.2.2). С её помощью мы можем управлять жизненным циклом корутины.
Стартовать методом start(), отменять методом cancel(), ждать завершения джобы с помощью метода join(), подписываться на событие завершения джобы и другое.
Рис. 2.2
Смена потока
Поменять поток выполнения корутины можно с помощью изменения элемента контекста корутины, отвечающего за диспетчеризацию. (Рис. 2.3)
Например корутина 1, выполнится в UI-потоке, в то время как корутина 2 в потоке, взятом из пула Dispatchers.IO.
Рис.2.3
Библиотека корутин также предоставляет suspend-функцию withContext(CoroutineContext), с помощью которой можно переключаться между потоками в контексте корутины. Таким образом, прыгать между потоками можно довольно просто:
Рис. 2.4.
Запускаем нашу корутину на UI-потоке 1 → показываем индикатор загрузки → переключаемся на рабочий поток 2, освобождая при этом главный → выполняем там долгую операцию, которую нельзя выполнять в UI-потоке → возвращаем результат обратно в UI-поток 3 → и уже там работаем с ним, отрисовывая полученные данные и скрывая индикатор загрузки.
Пока что выглядит довольно удобно, идём дальше.
Suspend-функция
Рассмотрим работу корутин на примере самого частого случая — работы с сетевыми запросами с использованием библиотеки Retrofit 2.
Первое, что нам нужно сделать — преобразовать callback-вызов в suspend-функцию, чтобы воспользоваться возможностью корутин:
Рис. 2.5
Для управления состоянием корутины билиотека даёт функции вида suspendXXXXCoroutine, которые предоставляют аргумент, реализующий интерфейс Continuation, с помощью методов resumeWithException и resume которого мы можем возобновлять корутину в случае ошибки и успеха соответственно.
Далее мы разберёмся, что происходит в случае вызова метода resumeWithException, а для начала позаботимся о том, что нам нужно как-то отменять вызов сетевого запроса.
Suspend-функция. Отмена вызова
Для отмены вызова и других действий, касающихся освобождения неиспользуемых ресурсов, при реализации suspend-функции можно использовать идущий из коробки метод suspendCancellableCoroutine (рис. 2.6). Здесь аргумент блока уже реализует интерфейс CancellableContinuation, один из дополнительных методов которого — invokeOnCancellation — позволяет подписаться как на ошибочное, так и успешное событие отмены корутины. Следовательно, здесь и нужно отменять вызов метода.
Рис. 2.6
Отобразим изменения в UI
Теперь, когда suspend-функция для сетевых запросов подготовлена, можно использовать её вызов в UI-потоке корутины как последовательный, при этом во время выполнения запроса поток будет свободен, а для работы запроса будет задействован поток ретрофита.
Таким образом мы реализуем асинхронное относительно UI-потока поведение, но пишем его в последовательном стиле (Рис. 2.6).
Если после получения ответа нужно выполнить тяжелую работу, например, записать полученные данные в базу, то эту функцию, как было уже показано, можно легко выполнить с помощью withContext на пуле бэкгрануд-потоков и продолжить выполнение на UI без единой строчки кода.
Рис. 2.7
К сожалению, это не всё, что нам нужно для разработки приложений. Рассмотрим обработку ошибок.
Обработка ошибок: try-catch-finally. Отмена корутины: CancellationException
Исключение, которое не было поймано внутри корутины, считается необработанным и может вести к падению приложения. Помимо обычных ситуаций, к выбросу исключения приводит возобновление корутины с использованием метода resumeWithException на соответствующей строке вызова suspend-функции. При этом исключение, переданное в качестве аргумента, выбрасывается в неизмененном виде. (Рис. 2.8)
Рис. 2.8
Для обработки исключений становится доступна стандартная конструкция языка try catch finally. Теперь код, который умеет отображать ошибку в UI принимает следующий вид:
Рис. 2.9
В случае отмены корутины, которой можно добиться путём вызова метода Job#cancel, кидается исключение CancellationException. Это исключение по умолчанию обрабатывается и не приводит к крашам или другим негативным последствиям.
Однако, при использовании конструкции try/catch оно будет поймано в блоке catch, и с ним нужно считаться в случаях, если вы хотите обрабатывать только действительно «ошибочные» ситуации. Например, обработку ошибки в UI, когда есть возможность «отмены» запросов или предусмотрено логирование ошибок. В первом случае ошибка будет отображена пользователю, хотя её на самом деле и нет, а во втором — будет логироваться бесполезное исключение и захламлять отчёты.
Чтобы игнорировать ситуацию отмены корутины, необходимо немного модифицировать код:
Рис. 2.10
Логирование ошибок
Рассмотрим ситуацию со стэктрейсом исключений.
Если выкинуть исключение прямо в блоке кода корутины (Рис. 2.11), то стэктрейс выглядит аккуратно, со всего несколькими вызовами от корутин, корректно указывает строку и информацию об исключении. В этом случае из стектрейса можно легко понять, где конкретно, в каком классе и в какой функции было выброшено исключение.
Рис. 2.11
Однако исключения, которые передаются в метод resumeWithException suspend-функций, как правило, не содержат информации о корутине, в которой оно произошло. Например (Рис. 2.12), если из реализованной ранее suspend-функции, возобновить корутину с тем же исключением, что и в предыдущем примере, то стэктрейс не даст информации о том, где конкретно искать ошибку.
Рис. 2.12
Чтобы понять, какая корутина возобновилась с исключением, можно воспользоваться элементом контекста CoroutineName. (Рис. 2.13)
Элемент CoroutineName используется для отладки, передав в него имя корутины, можно его извлечь в suspend-функции и, например, дополнить сообщение исключения. То есть как минимум будет понятно, где искать ошибку.
Этот подход будет работать только в случае с исключением из данной suspend-функции:
Рис. 2.13
Логирование ошибок. ExceptionHandler
Для изменения логирования исключений для конкретной корутины можно установить свой ExceptionHandler, который является одним из элементов контекста корутины. (Рис. 2.14)
Обработчик должен реализовывать интерфейс CoroutineExceptionHandler. С помощью переопределённого оператора + для контекста корутин можно подменить стандартный обработчик исключений на собственный. Необработанное исключение попадёт в метод handleException, где с ним можно сделать всё, что нужно. Например, полностью проигнорировать. Это произойдёт, если оставить обработчик пустым или дополнить собственной информацией:
Рис. 2.14
Посмотрим, как может выглядеть логирование нашего исключения:
- Нужно помнить про CancellationException, который хотим проигнорировать.
- Добавить собственные логи.
- Помнить про дефолтное поведение, в которое входит логирование и завершение приложения, иначе исключение просто «исчезнет» и будет не понятно, что произошло.
Теперь для случая выкидывания исключения будет приходить распечатка стэктрейса в логкат с дополнённой информацией:
Рис. 2.15
Параллельное выполнение. async
Рассмотрим параллельную работу suspend-функций.
Для организации параллельного получения результатов от нескольких функций лучше всего подходит async. Async, как и launch — Coroutine Builder. Его удобство состоит в том, что он, используя метод await(), возвращает данные в случае успеха или бросает исключение, возникшее в процессе выполнения корутины. Метод await будет дожидаться завершения выполнения корутины, если она ещё не завершена, в противном случае сразу отдаст результат работы. Обратите внимание, что await является suspend-функцией, поэтому не может выполняться вне контекста корутины или другой suspend-функции.
Используя async, параллельное получение данных из двух функций будет выглядеть примерно так:
Рис. 2.16
Представим, что перед нами стоит задача параллельного получения данных из двух функций. Затем, нужно их объединять и отображать. В случае возникновения ошибки необходимо отрисовывать UI, отменяя при этом все текущие запросы. Такой кейс часто встречается на практике.
В этом случае обрабатывать ошибку нужно следующим образом:
- Заносим обработку ошибок внутрь каждой из async-корутин.
- В случае ошибки отменяем все корутины. К счастью, для этого существует возможность указать родительскую джобу, при отмене которой отменяются и все её дочерние.
- Придумываем дополнительную реализацию чтобы понять, все ли данные успешно загрузились. Например, будем считать, что если await вернул null, то при получении данных произошла ошибка.
С учётом всего этого, реализация родительской корутины становится несколько сложнее. Также усложняется реализация async-корутин:
Рис. 2.17
Данный подход не является единственно возможным. Например, вы можете реализовать параллельное выполнение с обработкой ошибок, используя ExceptionHandler или SupervisorJob.
Вложенные корутины
Посмотрим на работу вложенных корутин.
По умолчанию вложенная корутина создаётся с использованием скоупа внешней и наследует её контекст. Как следствие, вложенная корутина становится дочерней, а внешняя — родительской.
Если мы отменим внешнюю корутину, то отменятся и созданные таким образом вложенные корутины, которые были использованы в примере ранее. Также это будет полезно при уходе с экрана, когда нужно отменить текущие запросы. Кроме того, родительская корутина всегда будет дожидаться завершения дочерних.
Создать корутину, не зависящую от внешней, можно с использованием глобального скоупа. В этом случае, при отмене внешней корутины вложенная продолжит работать как ни в чём не бывало:
Рис. 2.18
Сделать из глобальной вложенной корутины дочернюю можно, заменив элемент контекста с ключом Job на родительскую джобу, либо полностью использовать контекст родительской корутины. Но в этом случае стоит помнить, перенимаются все элементы родительской корутины: пул потоков, exception handler и так далее:
Рис. 2.19
Теперь ясно, что в случае использования корутин извне нужно предоставлять им возможность установки либо экземляра джобы, либо контекста родителя. А разработчикам библиотек нужно учитывать возможность установки её как дочерней, что вызывает неудобства.
Точки останова
Корутины влияют на просмотр значений объектов в режиме отладки. Если поставить точку останова внутри следующей корутины на функции logData, то при её срабатывании увидим, что здесь всё хорошо и значения отображаются корректно:
Рис. 2.20
Теперь получим dataA с помощью вложенной корутины, оставив точку останова на logData:
Рис. 2.21
Попытка раскрыть блок this, чтобы попытаться найти нужные значения, оборачивается неудачей. Таким образом, отладка при наличии suspend-функций становится затруднительной.
Unit-тестирование
Unit-тестирование реализовать довольно просто. Для этого можно использовать Coroutine Builder runBlocking. runBlocking блокирует поток до тех пор, пока не завершаться все его вложенные корутины, а это именно то, что нужно для тестирования.
Например, если известно, что где-то внутри метода для его реализации используется корутина, то для тестирования метода нужно лишь обернуть его в runBlocking.
runBlocking можно использовать для тестирования suspend-функции:
Рис. 2.22
Примеры
Напоследок хотелось бы показать несколько примеров использования корутин.
Представим, что нам нужно выполнить параллельно три запроса A, B и C, показать их завершение и отразить момент завершения запросов A и B.
Для этого можно просто обернуть корутины запросов A и B в одну общую и работать с ней, как с единым целым:
Рис. 2.23
Следующий пример демонстрирует, как с помощью обычного цикла for можно выполнять периодические запросы с интервалом в 5 секунд:
Рис. 2.24
Выводы
Из минусов отмечу, что корутины — относительно молодой инструмент, поэтому если хотите использовать их на проде, то делать это стоит с осторожностью. Есть сложности отладки, небольшой бойлерплэйт в реализации очевидных вещей.
В целом, корутины довольно просты в использовании, особенно для реализации не сложных асинхронных задач. В частности из-за того, что можно применять стандартные конструкции языка. Корутины легко поддаются unit-тестированию и всё это идет из коробки от той же компании, что разрабатывает язык.
Видеозапись доклада
Получилось много букв. Для тех, кому больше нравиться слушать — видео с моего доклада на MBLT DEV 2018:
Полезные материалы по теме:
- Introduction to Coroutines
- Deep dive into Coroutines
- Guide to kotlinx.coroutines by example
- Guide to UI programming with coroutines
- Coroutines design document (KEEP)
Автор: mixslo