Часть 1, Часть 2
Мы, разработчики, обычно тратим много времени на полировку наилучшего сценария работы нашего приложения. Однако не менее важно обеспечить надлежащий пользовательский опыт, когда все идет не так, как ожидалось. С одной стороны, наблюдать сбой приложения — это плохой опыт для пользователя; с другой, необходимо показать ему правильное сообщение, когда действие не удалось.
Правильная обработка исключений оказывает огромное влияние на то, как пользователи воспринимают ваше приложение. В этой статье мы объясним, как исключения распространяются в корутинах и как вы всегда можете держать ситуацию под контролем, включая различные способы их обработки.
Если вы предпочитаете видео, посмотрите это обсуждение KotlinConf’19 от Флорины Мунтенеску и меня (англ):
⚠️ Для того, чтобы понимать остальную часть статьи без каких-либо проблем, необходимо прочитать и понять часть 1 серии.
Корутина внезапно завершилась! И что теперь? ?
Когда корутина терпит неудачу с исключением, она распространит его до своего родителя! Затем родитель 1) отменит остальные свои дочерние элементы, 2) отменит себя и 3) распространит исключение до своего родителя.
Исключение достигнет корня иерархии, и все сопрограммы, которые запустил CoroutineScope
, также будут отменены.
В то время как распространение исключения может иметь смысл в некоторых случаях, есть и другие, когда это нежелательно. Представьте себе связанный с пользовательским интерфейсом CoroutineScope
, который обрабатывает взаимодействия пользователей. Если дочерняя корутина создает исключение, область пользовательского интерфейса будет отменена и весь его компонент перестанет отвечать, поскольку отмененная область не может запустить больше корутин.
А что, если вы не хотите такого поведения? Кроме того, возможно использовать другую реализацию Job
, а именно SupervisorJob
, в CoroutineContext CoroutineScope
который и создает эти корутины.
SupervisorJob на помощь
С SupervisorJob
неудача одного дочернего элемента не влияет на других. SupervisorJob
не отменит ни саму себя, ни остальных своих “детей”. Кроме того, SupervisorJob
также не будет распространять исключение и позволит дочерней корутине обрабатывать его.
Вы можете создать CoroutineScope
, подобный этому val uiScope = CoroutineScope(SupervisorJob())
, чтобы не распространять отмену при сбое корутины, как показано на этом изображении:
Если исключение не обрабатывается и у CoroutineContext
нет обработчика CoroutineExceptionHandler
(как мы увидим позже), то оно достигнет ExceptionHandler
потока по умолчанию. В JVM исключение будет регистрироваться в консоли, а в Android оно приведет к сбою вашего приложения независимо от Диспетчера, на котором это происходит.
?Неотловленные исключения всегда будут отбрасываться независимо от того, какой тип Job
вы используете
То же самое поведение применяется к конструкторам областей CoroutineScope
и supervisorScope
. Они создадут подпространство (с Job
или SupervisorJob
соответственно в качестве родителя), с помощью которого вы можете логически сгруппировать корутины (например, если вам надо выполнять параллельные вычисления или же вы хотите, чтобы они были или не были затронуты друг другом).
Предупреждение: SupervisorJob
работает только так, как описано, являясь при этом частью области видимости: то есть создано с помощью supervisorScope
, либо CoroutineScope(SupervisorJob())
.
Job
или SupervisorJob
? ?
Когда вы должны использовать Job
или SupervisorJob
? Используйте SupervisorJob
или supervisorScope
, если вы не хотите, чтобы сбой отменял родителя и его сиблингов.
Примеры:
// Область обработки корутин для конкретного слоя моего приложения
val scope = CoroutineScope(SupervisorJob())scope.launch {
// Потомок 1
}scope.launch {
// Потомок 2
}
В этом случае, если child#1
не сработает, ни область действия, ни child#2
не будут отменены. Еще пример:
// Область обработки корутин для конкретного слоя моего приложения
val scope = CoroutineScope(Job())scope.launch {
supervisorScope {
launch {
// Child 1
}
launch {
// Child 2
}
}
}
В этом случае, поскольку supervisorScope
создает подпространство с заданием SupervisorJob
, если child#1
завершится неудачей,child#2
не будет отменен. Если вместо этого вы используете CoroutineScope
, сбой будет распространяться дальше и в конечном итоге приведет к отмене области.
Итак, викторина! А кто же мой родитель? ?
Учитывая следующий фрагмент кода, можете ли вы определить, какой тип Job child#1
имеет в качестве родителя?
val scope = CoroutineScope(Job())scope.launch(SupervisorJob()) {
// новая корутина -> возможно приостановить
launch {
// Child 1
}
launch {
// Child 2
}
}
parentJob child#1
— это работа типа Job
! Надеюсь, все понятно! Даже если на первый взгляд можно подумать, что это может быть SupervisorJob
, это не потому, что новой корутине всегда назначается новое Job()
, которое в данном случае переопределяет SupervisorJob
. SupervisorJob
является родителем корутины, созданной с помощью scope.launch
, так что SupervisorJob
ничего не делает в этом коде!
Таким образом, если child#1
или child#2
завершится неудачно, то ошибка достигнет области действия и вся работа, начатая ей, будет отменена.
Помните, что SupervisorJob
работает только так, как он описан, являясь при этом частью области: то есть создан с помощью supervisorScope
, либо CoroutineScope(SupervisorJob())
. Передача SupervisorJob
в качестве параметра конструктора корутины не будет иметь желаемого эффекта, который можно ожидать от отмены.
Что касается исключений, то если какой-либо дочерний элемент создает исключение, то SupervisorJob
не будет распространять его вверх по иерархии и позволит корутине обработать его.
Под капотом
Если вам интересно, как работает Job
под капотом, ознакомьтесь с реализацией функций childCancelled
и notifyCancelling в файлеJobSupport.kt
.
В реализации SupervisorJob
метод childCancelled
просто возвращает false
, что означает, что он не распространяет отмену, но и не обрабатывает исключение.
Работа с исключениями ? ?
Корутины используют обычный синтаксис Kotlin для обработки исключений: try/catch
либо встроенные вспомогательные функции, такие как runCatching
( который использует try/catch
внутренне).
Ранее упоминалось, что всегда будут появляться неучтенные исключения. Однако разные сборщики корутин трактуют исключения по-разному.
Запуск
При запуске исключения будут появляться сразу же, как только они произойдут. Таким образом, вы можете обернуть код, который может создавать исключения внутри try/catch
, как в этом примере:
scope.launch {
try {
codeThatCanThrowExceptions()
} catch(e: Exception) {
// Обработать исключение
}
}
Async
Когда async
используется в качестве корневой корутины (являющейся прямым потомком экземпляра CoroutineScope
или supervisorScope
), исключения создаются не автоматически, а при вызове .await()
.
Чтобы обрабатывать исключения, создаваемые async
всякий раз корневой корутиной, можно обернуть ее .await()
вызова внутри try/catch
:
supervisorScope {
val deferred = async {
codeThatCanThrowExceptions()
} try {
deferred.await()
} catch(e: Exception) {
// Обработка исключения, появившегося в async
}
}
В этом случае обратите внимание, что вызов async
никогда не вызовет исключение, поэтому его также не нужно оборачивать. await
же вызовет исключение, которое произошло внутри асинхронной корутины.
Также обратите внимание, что мы используем supervisorScope
для вызова async
и await
. Как мы уже говорили, SupervisorJob
позволяет корутине обрабатывать исключение, в отличие от Job
, которое автоматически распространит его вверх по иерархии, поэтому блок catch
не будет вызван:
coroutineScope {
try {
val deferred = async {
codeThatCanThrowExceptions()
}
deferred.await()
} catch(e: Exception) {
// Исключение, вызванное в async, не будет отловлено здесь и // распространится до области действия
}
}
Кроме того, исключения, которые происходят в корутинах, созданных другими, всегда будут распространяться независимо от их конструктора. Например:
val scope = CoroutineScope(Job())scope.launch {
async {
// Если исключение выбрасывает async, launch вызывается без вызова .await()
}
}
В этом случае, если async
создает исключение, оно будет вызвано сразу же, потому что launch
— это корутина, являющаяся прямым дочерним элементом области. Причина заключается в том, что async
(с Job
в его CoroutineContext
) автоматически распространит исключение до его родителя (launch
), который его и вызовет.
⚠️Исключения, вызванные в конструкторе CoroutineScope
или в корутинах, созданных другими, не будут пойманы в try/catch
!
В разделе SupervisorJob
мы упоминаем о существовании CoroutineExceptionHandler
. Давайте погрузимся в него!
CoroutineExceptionHandler
CoroutineExceptionHandler
— это необязательный элемент CoroutineContext
, позволяющий обрабатывать неучтенные исключения.
Вот как вы можете определить CoroutineExceptionHandler
: всякий раз, когда ловится исключение, у вас есть информация о CoroutineContext
, где оно произошло, а также непосредственно само исключение:
val handler = CoroutineExceptionHandler {
context, exception -> println("Caught $exception")
}
Исключения будут пойманы, если требования будут выполнены:
- Когда⏰: исключение создается корутиной, которая автоматически создает другие исключения (работает с
launch
, а не сasync
). - Где ?: если он находится в
CoroutineContext CoroutineScope
или корневой корутине (прямой потомокCoroutineScope
илиsupervisorScope
).
Давайте рассмотрим некоторые примеры использования CoroutineExceptionHandler
, определенного выше. В следующем примере исключение будет перехвачено обработчиком:
val scope = CoroutineScope(Job())
scope.launch(handler) {
launch {
throw Exception("Failed coroutine")
}
}
В другом случае, когда обработчик установлен во внутренней корутине, он не будет пойман:
val scope = CoroutineScope(Job())
scope.launch {
launch(handler) {
throw Exception("Failed coroutine")
}
}
Исключение не перехватывается, поскольку обработчик не установлен в правильном CoroutineContext
. Внутренний запуск распространит его до родителя и , поскольку он ничего не знает об обработчике, вызовется другое исключение.
Изящно справляясь с исключениями в вашем приложении, вы сможете предоставлять хороший пользовательский опыт, даже если все идет не так, как ожидалось.
Не забудьте использовать SupervisorJob
, если хотите избежать распространения отмены при возникновении исключения, в противном случае используйте Job
.
Неучтенные исключения будут увеличиваться в числе. Отлавливайте их, чтобы обеспечить отличный UX!
Читайте также:
Перевод статьи Manuel Vivo: Exceptions in coroutines