Часто вижу статьи с заголовком наподобие “Exception handling with coroutines made easy”. Дейстрительно easy, только нездорово, бестолково.
val handler = CoroutineExceptionHandler { _, exception ->
println("CoroutineExceptionHandler got $exception")
}
GlobalScope.launch(handler) {
throw AssertionError()
}
Тут корутина выбрасывает uncaught исключение и все хорошо, программы не упала, инфу в лог написала. Магия? Нет, все просто, она под капотом оборачивает всё в один всеобъемлющий try-catch
Uncaught Exceptions
Здорово, правда? Нихрена. Это означает, что разработчик не придусмотрел какой-то случай после которого приложение уже ведет себя не так как запланировано. Мы игнорируем ошибку в одном месте, после чего все остальное рушится как карточный домик. Последствия этой ошибки могут проявиться позже в самом неочевидном месте, а мы будем сидеть с дебаггером, следить за цепочкой вызовов, чтобы в конце обнаружить, что в какой-то момент id пользователя не загрузилось. Вывод: Приложение должно было упасть! PS: Конечно, есть исключения, не принимаем все буквально. Так мы быстро сможем найти и устранить проблему. Как считаете, что легче заметить, краш приложения, или то, что на каком-то экране вместо реальных данных вдруг появилась заглушка?
Clean Architecture
Что насчет чистого кода? У нас есть 3 слоя, Presentation, Domain и Data. Обычно в слое представления у нас есть какой-то scope, привязанный к Android lifecycle. В нем Presenter/ViewModel запускает корутину, которая обращается к какому-то интерактору или usecase, может даже к репозиторию(Тут будет ссылка на статью, где я объясняю почему он не нужен). А тот в свою очередь обращается к Model. Так какого черта, в слое представления мы обрабатываем ошибки из слоя о котором мы вообще не должны ничего знать? Мало того, что мы должны обрабатывать все виды ошибок в классе, ответственном за отображение информации во View, так еще и изменения в Data затрагивают логику Presentation, слоя с которым он вообще никак не контактирует.
Обработка ошибок здорового человека.
Either
Для начала нам нужна структура, способная хранить как загруженные данные, так и описание ошибки, без всратых коллбэков и прочей чепухи, мы же все таки корутины используем, последовательный код, все дела.
// Представляет значение одного из двух возможных типов. Левая часть обычно хранит ошибку, а правая успешный результат.
sealed class Either<out L, out R> {
class Left<out L>(val a: L) : Either<L, Nothing>()
class Right<out R>(val b: R) : Either<Nothing, R>()
val isRight get() = this is Right<R>
val isLeft get() = this is Left<L>
// Сюда передаем ссылки на функции, которые будут вызваны в зависимости от значения Left | Right
fun fold(fnL: (L) -> Any, fnR: (R) -> Any): Any =
when (this) {
is Left -> fnL(a)
is Right -> fnR(b)
}
}
Тут я привел минимально возможный пример, советую посмотреть Either из библиотеки Arrow, так вы сможете взять лучшее от ФП и ООП, и применить в своем проекте.
Failure
Нужен базовый класс-обертка для всех возможных ошибок. Который позже будет обработан в Presentation.
sealed class Failure {
object ServerFailure: Failure()
object InternalFailure: Failure()
object UnknownFailure: Failure()
abstract class FeatureFailure: Failure()
}
По порядку:
- ServerFailure возвращаем по каким-то левым телодвижениям от сервера, типа внутренних ошибок, либо отсутствия интернет соединения.
- InternalFailure может быть чем угодно, начиная от ошибок в базке данных, до ArithmeticException в бизнесс логике
- FeatureFailure служит для создания своих специфических ошибок, конкретной бизнес логики. Например, ошибка от сервера Wrong Password, обрабатывает только на определенном экране.
- UnknownFailure непредвиденная ошибка из-за которой все же падать не стоит.
UseCase
Настройка готова, приступим к бизнес логике. Допустим нам нужно авторизировать пользователя в приложении. Создадим юзкейс, котрый будет получать номер телефона и пароль, а возвращать либо ошибку, либо информацию о пользователе.
class AuthUseCase(private val authApi: AuthApi) {
// Ошибка, для ситуации с неверным паролем, нормальный сервер должен давать эту информацию.
class BadPasswordFailure : Failure.FeatureFailure()
suspend fun run(phoneNumber: String, password: String): Either<Failure, UserCredentials> {
// Body запроса
val params = PhoneAuthorizeRequestParams(phoneNumber, password)
return try {
// Представим, что authApi, уже возвращает нам Either c сериализованным UserCredentials либо FailureResponse(status: Int)
val response = authApi.singUp(params)
if (response.isRight()) {
Either.Right(response)
} else if (response.isLeft()){
if (response.code == Codes.WrongPassword) { Either.Left(BadPasswordFailure()) }
else { Either.Left(ServerFailure) }
} else { Either.Left(UnknownFailure) }
} catch (e: Exception) {
Either.Left(UnknownFailure)
}
}
Presentation
Теперь нужно вызвать UseCase из Presentation слоя, пусть для примера, будет MVP
class AuthPresenter {
fun load() {
uthUseCase.run(view.getPhone(), view.getPassword()).fold(::handleFailure, ::handleCredentials)
}
private fun handleCredentials(credentials: UserCredentials) {
view.showUserInfo(credentials) // Отображаем информацию о пользователе
}
private fun handleFailure(failure: Failure) {
when (failure) {
is ServerFailure -> view.showServerFailure() // Говорим пользователю проверить интернет соединение и перезагрузить страницу
is BadPasswordFailure -> view.showWrongPasswordFailure() // Просим ввести пароль заново
else -> view.showServerFailure() // Говорим пользователю перезагрузить страницу
}
}
}
Можно создать базовую функцию для обработки стандартных ошибок, и переопередлять ее только по необходимости, если возможны какие-то FetureFailure.
Итог
- Изменения в слое Data никак не влияют на слой представления
- Легко протестировать
- Код выглядит пиздато, все по полочкам