Хабрахабр

Coroutines :: опыт практического применения

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

Статья подготовлена по материалам моего доклада на MBLT DEV 2018, в конце поста — линк на видеозапись.

Последовательный стиль


Рис. 2.1

Они хотели, чтобы асинхронное программирование было как можно проще. Какую цель преследовали разработчики корутин? Нет ничего проще, чем исполнение кода «строка за строкой» с применением синтаксических конструкций языка: try-catch-finally, циклов, условных операторов и так далее.

Каждая выполняется на своем потоке (рис. Рассмотрим две функции. 1). 2. С помощью корутин мы можем написать наш код так, как показано на рис. Первая выполняется на потоке B и возвращает некий результат dataB, затем нам нужно передать этот результат во вторую функцию, которая принимает dataB в качестве аргумента и уже выполняется на потоке А. 1. 2. Рассмотрим, как можно этого достичь.

Функции longOpOnB, longOpOnA — так называемые suspend-функции, перед выполнением которых поток освобождается, а после завершения их работы снова становится занят.

Чтобы эти две функции действительно выполнялись в другом потоке относительно вызываемого, и при этом сохранялся «последовательный» стиль написания кода, мы должны погрузить их в контекст корутины.

На рисунке это launch, но существуют и другие, например, async, runBlocking. Это делается путём создания корутины с помощью так называемого Coroutine Builder. О них расскажу позже.

В качестве последнего аргумента передаётся блок исполняемого в контексте корутины кода: вызов suspend-функций, а значит, и всё вышеописанное поведение, возможно только в контексте корутины или же в другой suspend-функции.

В методе Coroutine Builder есть и другие параметры, например, тип запуска, поток, в котором будет выполняться блок и другие.

Управление жизненным циклом

Coroutine Builder в качестве возвращаемого значения отдаёт нам джобу — подкласс класса Job (Рис.2.2). С её помощью мы можем управлять жизненным циклом корутины.

Стартовать методом start(), отменять методом cancel(), ждать завершения джобы с помощью метода join(), подписываться на событие завершения джобы и другое.

2.
Рис. 2

Смена потока

Поменять поток выполнения корутины можно с помощью изменения элемента контекста корутины, отвечающего за диспетчеризацию. (Рис. 2.3)

IO. Например корутина 1, выполнится в UI-потоке, в то время как корутина 2 в потоке, взятом из пула Dispatchers.

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

2. Таким образом мы реализуем асинхронное относительно UI-потока поведение, но пишем его в последовательном стиле (Рис. 6).

Если после получения ответа нужно выполнить тяжелую работу, например, записать полученные данные в базу, то эту функцию, как было уже показано, можно легко выполнить с помощью withContext на пуле бэкгрануд-потоков и продолжить выполнение на UI без единой строчки кода.

2.
Рис. 7

Рассмотрим обработку ошибок. К сожалению, это не всё, что нам нужно для разработки приложений.

Обработка ошибок: try-catch-finally. Отмена корутины: CancellationException

Исключение, которое не было поймано внутри корутины, считается необработанным и может вести к падению приложения. Помимо обычных ситуаций, к выбросу исключения приводит возобновление корутины с использованием метода resumeWithException на соответствующей строке вызова suspend-функции. При этом исключение, переданное в качестве аргумента, выбрасывается в неизмененном виде. (Рис. 2.8)

2.
Рис. 8

Теперь код, который умеет отображать ошибку в UI принимает следующий вид: Для обработки исключений становится доступна стандартная конструкция языка try catch finally.

2.
Рис. 9

Это исключение по умолчанию обрабатывается и не приводит к крашам или другим негативным последствиям. В случае отмены корутины, которой можно добиться путём вызова метода Job#cancel, кидается исключение CancellationException.

Например, обработку ошибки в UI, когда есть возможность «отмены» запросов или предусмотрено логирование ошибок. Однако, при использовании конструкции try/catch оно будет поймано в блоке catch, и с ним нужно считаться в случаях, если вы хотите обрабатывать только действительно «ошибочные» ситуации. В первом случае ошибка будет отображена пользователю, хотя её на самом деле и нет, а во втором — будет логироваться бесполезное исключение и захламлять отчёты.

Чтобы игнорировать ситуацию отмены корутины, необходимо немного модифицировать код:

2.
Рис. 10

Логирование ошибок

Рассмотрим ситуацию со стэктрейсом исключений.

2. Если выкинуть исключение прямо в блоке кода корутины (Рис. В этом случае из стектрейса можно легко понять, где конкретно, в каком классе и в какой функции было выброшено исключение. 11), то стэктрейс выглядит аккуратно, со всего несколькими вызовами от корутин, корректно указывает строку и информацию об исключении.

2.
Рис. 11

Например (Рис. Однако исключения, которые передаются в метод resumeWithException suspend-функций, как правило, не содержат информации о корутине, в которой оно произошло. 12), если из реализованной ранее suspend-функции, возобновить корутину с тем же исключением, что и в предыдущем примере, то стэктрейс не даст информации о том, где конкретно искать ошибку. 2.

2.
Рис. 12

(Рис. Чтобы понять, какая корутина возобновилась с исключением, можно воспользоваться элементом контекста CoroutineName. 13) 2.

То есть как минимум будет понятно, где искать ошибку. Элемент CoroutineName используется для отладки, передав в него имя корутины, можно его извлечь в suspend-функции и, например, дополнить сообщение исключения.

Этот подход будет работать только в случае с исключением из данной suspend-функции:

2.
Рис. 13

Логирование ошибок. ExceptionHandler

Для изменения логирования исключений для конкретной корутины можно установить свой ExceptionHandler, который является одним из элементов контекста корутины. (Рис. 2.14)

С помощью переопределённого оператора + для контекста корутин можно подменить стандартный обработчик исключений на собственный. Обработчик должен реализовывать интерфейс CoroutineExceptionHandler. Например, полностью проигнорировать. Необработанное исключение попадёт в метод handleException, где с ним можно сделать всё, что нужно. Это произойдёт, если оставить обработчик пустым или дополнить собственной информацией:

2.
Рис. 14

Посмотрим, как может выглядеть логирование нашего исключения:

  1. Нужно помнить про CancellationException, который хотим проигнорировать.
  2. Добавить собственные логи.
  3. Помнить про дефолтное поведение, в которое входит логирование и завершение приложения, иначе исключение просто «исчезнет» и будет не понятно, что произошло.

Теперь для случая выкидывания исключения будет приходить распечатка стэктрейса в логкат с дополнённой информацией:

2.
Рис. 15

Параллельное выполнение. async

Рассмотрим параллельную работу suspend-функций.

Async, как и launch — Coroutine Builder. Для организации параллельного получения результатов от нескольких функций лучше всего подходит async. Метод await будет дожидаться завершения выполнения корутины, если она ещё не завершена, в противном случае сразу отдаст результат работы. Его удобство состоит в том, что он, используя метод await(), возвращает данные в случае успеха или бросает исключение, возникшее в процессе выполнения корутины. Обратите внимание, что await является suspend-функцией, поэтому не может выполняться вне контекста корутины или другой suspend-функции.

Используя async, параллельное получение данных из двух функций будет выглядеть примерно так:

2.
Рис. 16

Затем, нужно их объединять и отображать. Представим, что перед нами стоит задача параллельного получения данных из двух функций. Такой кейс часто встречается на практике. В случае возникновения ошибки необходимо отрисовывать UI, отменяя при этом все текущие запросы.

В этом случае обрабатывать ошибку нужно следующим образом:

  1. Заносим обработку ошибок внутрь каждой из async-корутин.
  2. В случае ошибки отменяем все корутины. К счастью, для этого существует возможность указать родительскую джобу, при отмене которой отменяются и все её дочерние.
  3. Придумываем дополнительную реализацию чтобы понять, все ли данные успешно загрузились. Например, будем считать, что если await вернул null, то при получении данных произошла ошибка.

С учётом всего этого, реализация родительской корутины становится несколько сложнее. Также усложняется реализация async-корутин:

2.
Рис. 17

Например, вы можете реализовать параллельное выполнение с обработкой ошибок, используя ExceptionHandler или SupervisorJob. Данный подход не является единственно возможным.

Вложенные корутины

Посмотрим на работу вложенных корутин.

Как следствие, вложенная корутина становится дочерней, а внешняя — родительской. По умолчанию вложенная корутина создаётся с использованием скоупа внешней и наследует её контекст.

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

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

2.
Рис. Но в этом случае стоит помнить, перенимаются все элементы родительской корутины: пул потоков, exception handler и так далее: 18

Сделать из глобальной вложенной корутины дочернюю можно, заменив элемент контекста с ключом Job на родительскую джобу, либо полностью использовать контекст родительской корутины.

2.
Рис. 19

А разработчикам библиотек нужно учитывать возможность установки её как дочерней, что вызывает неудобства. Теперь ясно, что в случае использования корутин извне нужно предоставлять им возможность установки либо экземляра джобы, либо контекста родителя.

Точки останова

Корутины влияют на просмотр значений объектов в режиме отладки. Если поставить точку останова внутри следующей корутины на функции logData, то при её срабатывании увидим, что здесь всё хорошо и значения отображаются корректно:

2.
Рис. 20

Теперь получим dataA с помощью вложенной корутины, оставив точку останова на logData:

2.
Рис. 21

Таким образом, отладка при наличии suspend-функций становится затруднительной. Попытка раскрыть блок this, чтобы попытаться найти нужные значения, оборачивается неудачей.

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:

Полезные материалы по теме:

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть