Привет, Хабр! Для пользователя сообщения об ошибке часто выглядят как «Что-то не так, АААА!». Конечно, ему бы хотелось вместо ошибок видеть волшебную ошибку «Починить все». Ну или другие варианты действий. Мы начали активно добавлять себе такие, и я хочу рассказать про то, как вы можете это сделать.
Сначала представлюсь — меня зовут Александр, последние шесть лет я посвятил iOS-разработке. Сейчас отвечаю за мобильное приложение ManyChat и проблемы буду решать на его примере.
Давайте сразу сформулируем, что мы будем делать:
И все это будет на Swift:)
Будем решать проблему с примера. Сервер вернул ошибку с кодом 500 вместо ожидаемых 200. Что стоит сделать разработчику? Как минимум, с грустью сообщить пользователю — ожидаемый пост с котикам не удалось загрузить. В Apple стандартным паттерном является алерт, поэтому напишем простую функцию:
P.S. Для простоты большая часть кода будет в контролере. Вы вольны использовать те же подходы в вашей архитектуре. Код статьи будет доступен в репозитории, в конце статьи эта ссылка тоже будет.
Получим вот такую картинку:
Теоретически задачу мы выполнили. Но сразу бросается в глаза несколько вещей:
Согласитесь, представленный ниже вариант выглядит гораздо симпатиШнее
Какой бы вариант вы ни выбрали, под любой из них нужно будет продумать такой механизм показа сообщения, который будет выглядеть отлично при возникновении произвольной ошибки, предложит пользователю ясный сценарий для дальнейшей работы в приложении и предоставит набор действий. При этом решение:
Но перед этим давайте окунёмся в теоретический минимум по ошибкам в Swift.
Этот параграф — верхнеуровневый обзор ошибок в целом. Если вы уже активно используете свои ошибки в приложении, можете смело переходить к следующему параграфу.
Что такое ошибка? Некое неправильное действие или некорректный результат. Часто мы можем предположить возможные ошибки и заранее описать их в коде.
Для этого случая Apple дают нам тип Error. Если мы откроем документацию Apple, то Error будет выглядеть вот так (актуально для Swift 5.1):
Просто протокол без дополнительных требований. Документация любезно поясняет — отсутствие обязательных параметров позволяет любому типу использоваться в системе обработки ошибок Swift. С таким щадящим протоколом нам будет просто работать.
В голову сразу приходит мысль использовать enum: ошибок конечное известное количество, у них могут быть какие то параметры. Что и делают Apple. Например, можно рассмотреть реализацию DecodingError:
Воспользуемся лучшими практиками Apple. Представим группу возможных ошибок сети в упрощенном виде:
Теперь, в любом месте нашего приложения, где происходит ошибка, мы можем использовать наш Network.Error.
Как работать с ошибками? Есть механизм do catch. Если функция может бросить ошибку, то она помечается ключевым словом throws. Теперь каждый ее пользователь обязан обратиться к ней через конструкцию do catch. При отсутствии ошибки мы попадем в блок do, с ошибкой — в блок catch. Функций, приводящих к ошибке, может быть сколько угодно в блоке do. Единственный минус — мы в catch получим ошибку типа Error. Понадобится скастить ошибку в нужный тип.
В качестве альтернативы мы можем использовать опционал, то есть получить nil в случае ошибки и избавиться от громоздкой конструкции. Иногда это удобней: допустим, когда мы достаем опциональную переменную, а затем к ней применяем throws функцию. Код можно положить в один if/guard блок, и он останется лаконичным.
Вот пример работы с throws функцией:
P.S. Не стоит путать с do catch в других языках. Свифт не бросает исключение, а записывает значение ошибки (если она произошла) в специальный регистр. При наличии там значения идет переход в блок error, при отсутствии — блок do продолжается. Источники для самых любознательных: www.mikeash.com/pyblog/friday-qa-2017-08-25-swift-error-handling-implementation.html
Этот метод хорош для обработки синхронных событий и не так удобен в случае долгих операций (например, запрос данных по сети), которые могут потенциально занять много времени. Тогда можно использовать простой completion.
Как альтернативу в Swift 5 внедрили Result – подготовленный enum, который содержит два варианта – success и failure. Сам по себе он не требует использование Error. Да и к асинхронности он не имеет прямого отношения. Но возвращать именно этот тип в completion удобней для асинхронных событий (иначе придется делать два completion, success и failure, или возвращать два параметра). Напишем пример:
Этой информации нам вполне хватит для работы.
Еще раз, кратко:
Вернемся к теме статьи. В параграфе выше мы создали свой тип ошибок. Вот он:
Теперь каждую ошибку нам надо сопоставить с текстом, который будет понятен пользователю. Его мы выведем в интерфейс в случае ошибки. На помощь нам спешит LocalizedError Protocol. Он наследует protocol Error и дополняет его 4 свойствами:
Реализуем протокол:
Показ ошибки почти не изменится:
Отлично, с текстом все было легко. Перейдем к кнопками.
Давайте представим алгоритм обработки ошибок в виде простой диаграммы. Для ситуации, когда в результате ошибки мы показываем диалоговое окно с вариантами Try Again, Cancel и, возможно, какими-то специфичными, получаем схему:
Начнем решать задачу с конца. Нам нужна функция, которая показывает алерт с n + 1 вариантами. Накидаем, как бы нам хотелось показывать ошибку:
Функция, которая определяет тип ошибки и передает сигнал к показу алерта:
И расширенный тип ошибки, у которой есть контекст и понимание, что делать при том или ином варианте.
В голове сразу рисуется схема своего велосипеда. Но сначала давайте проверим доки Apple. Возможно, часть механизма уже есть у нас в руках.
Немного поиска в Интернете приведет к protocol RecoverableError:
Похоже на то, что мы ищем:
С гайдами по использованию все скромнее. Небольшой поиск по сайту Apple и окрестностям приводит к статье об обработке ошибок, написанной до публичных анонсов Свифт.
Кратко:
Но поскольку у нас нет presentError – дернуть ее не удается.
На этом моменте чувствуется, что мы откопали скорее труп, чем клад. Нам придется превращать Error в NSError и писать свою функцию для показа алерта приложением. Куча неявных связей. Можно, сложно и не совсем понятно – «Зачем?».
Пока заваривается очередная чашка чая, можно подумать, почему в функции выше используется delegate как Any и передается селектор. Ответ ниже:
Давайте все же реализуем протокол, нам не повредит:
Зависимость ошибки от индекса – не самое удобное решение (мы легко можем выйти за пределы массива и крашнуть приложение). Но для MVP сойдёт. Возьмем идею Apple, просто осовременим ее. Нам пригодится отдельный объект Attempter и варианты кнопок, которые мы ему зададим:
Теперь надо показать ошибку. Я очень люблю протоколы, поэтому буду решать задачу через них. Создадим универсальный протокол для создания UIAlertController из ошибок:
И протокол для показа созданных алертов:
Получилось громоздко, но управляемо. Мы можем создавать новые способы показа ошибки (например, toast или показ кастомного вью) и прописывать дефолтную имплементацию, не меняя ничего в вызываемом методе.
Допустим, если бы наш view был прикрыт протоколом:
Но наш пример гораздо проще, поэтому поддержим оба протокола и запустим приложение:
Вроде все получилось. Одно из первоначальных условий было в 2-3 строчки. Расширим наш attempter удобным конструктором:
Мы получили MVP-решение, и нам не будет сложно в любом месте нашего приложения подключить и вызывать его. Давайте начнем проверять edge-кейсы и масштабируемость.
Допустим, в нашем приложении у юзера есть хранилище. У хранилища есть ограничение по месту. В этом случае у пользователя есть два сценария для выхода из ошибки: юзер может либо освободить место, либо купить еще. Напишем следующий код:
С этим легко справились.
Пару новых протоколов по аналогии решат нашу проблему:
Теперь мы можем показывать ошибки в виде информационной вью. Причем, мы можем решать, как их показывать. Например, при первом заходе на экран и ошибке – показать информационный вью. А если экран загрузился успешно, но действие на экране вернуло ошибку – показать алерт.
Иногда нужно бросить ошибку, но доступа к вью нет. Или мы не знаем, какое вью сейчас активно, и хотим показать алерт поверх всего. Как решать эту задачу?
Один из самых простых способов (на мой взгляд) поступить так же, как Apple поступает с клавиатурой. Создать новый Window поверх текущего экрана. Сделаем это:
Создадим новый алерт, который может показывать поверх всего:
С виду ничего не изменилось, но теперь мы отвязались от иерархии view controller'ов. Я настоятельно рекомендую не увлекаться этой возможностью. Лучше вызывать код показа в роутере или сущности с аналогичными правами. Во имя прозрачности и понятности.
Мы дали юзерам прекрасный инструмент для спама серверов во время неполадок, техобслуживания и т.д. Что можем улучшить?
Допустим, мы выключим интернет и будем жать try again. Запустим лоудер. Ответ придёт моментально и получится мини-игра «Кликер». С мигающей анимацией. Не слишком приятно.
Давайте превратим моментальную ошибку в процесс. Идея проста – сделаем минимальное время запроса. Здесь реализация зависит от вашего подхода к networking. Допустим, я использую Operation, и для меня это выглядит так:
Для общего случая могу предложить такую конструкцию:
Или же мы можем сделать абстракцию над нашими асинхронными действиями и добавить ей управляемости:
Теперь наша анимация не будет казаться такой резкой, даже при режиме офлайн. Рекомендую использовать данный подход в большинстве мест с анимацией.
Для авиарежима хорошо показывать алерт-подсказку (пользователь мог забыть выключить режим для старта работы с приложением). Как, допустим, делает телеграмм. А для важных запросов хорошо делать повтор несколько раз под капотом, прежде чем показывать алерт… Но об этом в другой раз :)
Когда вся логика свалена во viewController (как у нас сейчас), протестировать это затруднительно. Однако если у вас viewController разделен с бизнес логикой – тестирование становится тривиальной задачей. Легкими движением рукибрюки бизнес-логика превращается в:
Вместе с этой статьей мы:
> Ссылка на код
Всем спасибо за уделенное время, буду рад ответить на ваши вопросы в комментариях.
Сначала представлюсь — меня зовут Александр, последние шесть лет я посвятил iOS-разработке. Сейчас отвечаю за мобильное приложение ManyChat и проблемы буду решать на его примере.
Давайте сразу сформулируем, что мы будем делать:
- Добавим типу Error функциональности
- Превратим ошибки в понятные для пользователя алерты
- Выведем в интерфейс возможные дальнейшие действия и обработаем их нажатия
И все это будет на Swift:)
Будем решать проблему с примера. Сервер вернул ошибку с кодом 500 вместо ожидаемых 200. Что стоит сделать разработчику? Как минимум, с грустью сообщить пользователю — ожидаемый пост с котикам не удалось загрузить. В Apple стандартным паттерном является алерт, поэтому напишем простую функцию:
final class FeedViewController: UIViewController {
// Где-то в глубине кода
func handleFeedResponse(...) {
/// Где-то внутри функции обработки ответа
if let error = error {
let alertVC = UIAlertController(
title: "Error",
message: "Error connecting to the server",
preferredStyle: .alert)
let action = UIAlertAction(title: "OK", style: .default, handler: nil)
alertVC.addAction(action)
self.present(alertVC, animated: true, completion: nil)
}
}
P.S. Для простоты большая часть кода будет в контролере. Вы вольны использовать те же подходы в вашей архитектуре. Код статьи будет доступен в репозитории, в конце статьи эта ссылка тоже будет.
Получим вот такую картинку:
Теоретически задачу мы выполнили. Но сразу бросается в глаза несколько вещей:
- Мы не дали возможности как-то перейти из ошибочного сценария в успешный. ОК в текущем кейсе просто скроет алерт — и это не решение
- С точки зрения пользовательского опыта текст нужно сделать понятней, нейтральней. Чтобы пользователь не пугался и не бежал ставить одну звезду в AppStore вашему приложению. При этом подробный текст пригодился бы нам при дебаге
- И, будем честны — алерты несколько устарели как решение (все чаще в приложениях появляются либо экраны-заглушки, либо тосты). Но это уже вопрос, который стоит обсудить отдельно с командой
Согласитесь, представленный ниже вариант выглядит гораздо симпатиШнее
Какой бы вариант вы ни выбрали, под любой из них нужно будет продумать такой механизм показа сообщения, который будет выглядеть отлично при возникновении произвольной ошибки, предложит пользователю ясный сценарий для дальнейшей работы в приложении и предоставит набор действий. При этом решение:
- Должно быть расширяемым. Все мы знаем о свойственной дизайну изменчивости. Наш механизм должен быть готов ко всему
- Добавляется к объекту (и убирается) в пару строчек кода
- Хорошо тестируется
Но перед этим давайте окунёмся в теоретический минимум по ошибкам в Swift.
Error в Swift
Этот параграф — верхнеуровневый обзор ошибок в целом. Если вы уже активно используете свои ошибки в приложении, можете смело переходить к следующему параграфу.
Что такое ошибка? Некое неправильное действие или некорректный результат. Часто мы можем предположить возможные ошибки и заранее описать их в коде.
Для этого случая Apple дают нам тип Error. Если мы откроем документацию Apple, то Error будет выглядеть вот так (актуально для Swift 5.1):
public protocol Error {
}
Просто протокол без дополнительных требований. Документация любезно поясняет — отсутствие обязательных параметров позволяет любому типу использоваться в системе обработки ошибок Swift. С таким щадящим протоколом нам будет просто работать.
В голову сразу приходит мысль использовать enum: ошибок конечное известное количество, у них могут быть какие то параметры. Что и делают Apple. Например, можно рассмотреть реализацию DecodingError:
public enum DecodingError : Error {
/// Структура, хранящая дополнительную информацию об ошибке.
/// Отличная идея, стоит взять на заметку
public struct Context {
/// Дополнительные параметры для определения причин ошибки
public let codingPath: [CodingKey]
public let debugDescription: String
/// Ошибка, связанная с текущей ошибкой.
/// Можно использовать для чейна последовательности ошибок. Тоже интересная идея
public let underlyingError: Error?
public init(codingPath: [CodingKey], debugDescription: String, underlyingError: Error? = nil)
}
/// N кейсов для причин ошибки
case typeMismatch(Any.Type, DecodingError.Context)
case valueNotFound(Any.Type, DecodingError.Context)
...
Воспользуемся лучшими практиками Apple. Представим группу возможных ошибок сети в упрощенном виде:
enum NetworkError: Error {
// Любой 500 код
case serverError
// Ответ не такой, как мы ожидаем
case responseError
// Ответа нет, отвалились по таймауту, отсуствует сеть
case internetError
}
Теперь, в любом месте нашего приложения, где происходит ошибка, мы можем использовать наш Network.Error.
Как работать с ошибками? Есть механизм do catch. Если функция может бросить ошибку, то она помечается ключевым словом throws. Теперь каждый ее пользователь обязан обратиться к ней через конструкцию do catch. При отсутствии ошибки мы попадем в блок do, с ошибкой — в блок catch. Функций, приводящих к ошибке, может быть сколько угодно в блоке do. Единственный минус — мы в catch получим ошибку типа Error. Понадобится скастить ошибку в нужный тип.
В качестве альтернативы мы можем использовать опционал, то есть получить nil в случае ошибки и избавиться от громоздкой конструкции. Иногда это удобней: допустим, когда мы достаем опциональную переменную, а затем к ней применяем throws функцию. Код можно положить в один if/guard блок, и он останется лаконичным.
Вот пример работы с throws функцией:
func blah() -> String throws {
throw NetworkError.serverError
}
do {
let string = try blah()
// Эта строчка уже не вызовется, так как верхняя функция генерирует ошибку
let anotherString = try blah()
} catch {
// Распечатает NetworkError.serverError
print(error)
}
// Переменная string = nil
let string = try? blah()
P.S. Не стоит путать с do catch в других языках. Свифт не бросает исключение, а записывает значение ошибки (если она произошла) в специальный регистр. При наличии там значения идет переход в блок error, при отсутствии — блок do продолжается. Источники для самых любознательных: www.mikeash.com/pyblog/friday-qa-2017-08-25-swift-error-handling-implementation.html
Этот метод хорош для обработки синхронных событий и не так удобен в случае долгих операций (например, запрос данных по сети), которые могут потенциально занять много времени. Тогда можно использовать простой completion.
Как альтернативу в Swift 5 внедрили Result – подготовленный enum, который содержит два варианта – success и failure. Сам по себе он не требует использование Error. Да и к асинхронности он не имеет прямого отношения. Но возвращать именно этот тип в completion удобней для асинхронных событий (иначе придется делать два completion, success и failure, или возвращать два параметра). Напишем пример:
func blah<ResultType>(handler: @escaping (Swift.Result<ResultType, Error>) -> Void) {
handler(.failure(NetworkError.serverError)
}
blah<String>(handler { result in
switch result {
case .success(let value):
print(value)
case .failure(let error):
print(error)
}
})
Этой информации нам вполне хватит для работы.
Еще раз, кратко:
- Ошибки в Swift — это протокол
- Удобно представлять ошибки в виде enum
- Есть два способа работы с ошибками – синхронный (do catch) и асинхронный (ваш собственный competion или Result)
Текст ошибки
Вернемся к теме статьи. В параграфе выше мы создали свой тип ошибок. Вот он:
enum NetworkError: Error {
// Любой 500 код
case serverError
// Ответ не такой, как мы ожидаем
case responseError
// Ответа нет, отвалились по таймауту, отсутствует сеть
case internetError
}
Теперь каждую ошибку нам надо сопоставить с текстом, который будет понятен пользователю. Его мы выведем в интерфейс в случае ошибки. На помощь нам спешит LocalizedError Protocol. Он наследует protocol Error и дополняет его 4 свойствами:
protocol LocalizedError : Error {
var errorDescription: String? { get }
var failureReason: String? { get }
var recoverySuggestion: String? { get }
var helpAnchor: String? { get }
}
Реализуем протокол:
extension NetworkError: LocalizedError {
var errorDescription: String? {
switch self {
case .serverError, .responseError:
return "Error"
case .internetError:
return "No Internet Connection"
}
}
var failureReason: String? {
switch self {
case .serverError, .responseError:
return "Something went wrong"
case .internetError:
return nil
}
}
var recoverySuggestion: String? {
switch self {
case .serverError, .responseError:
return "Please, try again"
case .internetError:
return "Please check your internet connection and try again"
}
}
}
Показ ошибки почти не изменится:
if let error = error {
let errorMessage = [error.failureReason, error.recoverySuggestion].compactMap({ $0 }).joined(separator: ". ")
let alertVC = UIAlertController(
title: error.errorDescription,
message: errorMessage,
preferredStyle: .alert)
let action = UIAlertAction(title: "OK", style: .default) { (_) -> Void in }
alertVC.addAction(action)
self.present(alertVC, animated: true, competion: nil)
Отлично, с текстом все было легко. Перейдем к кнопками.
Восстановление из ошибок
Давайте представим алгоритм обработки ошибок в виде простой диаграммы. Для ситуации, когда в результате ошибки мы показываем диалоговое окно с вариантами Try Again, Cancel и, возможно, какими-то специфичными, получаем схему:
Начнем решать задачу с конца. Нам нужна функция, которая показывает алерт с n + 1 вариантами. Накидаем, как бы нам хотелось показывать ошибку:
struct RecovableAction {
let title: String
let action: () -> Void
}
func showRecovableOptions(actions: [RecovableAction], from viewController: UIViewController) {
let alertActions = actions.map { UIAlertAction(name: $0.title, action: $0.action) }
let cancelAction = UIAlertAction(name: "Cancel", action: nil)
let alertController = UIAlertController(actions: alertActions)
viewController.present(alertController, complition: nil)
}
Функция, которая определяет тип ошибки и передает сигнал к показу алерта:
func handleError(error: Error) {
if error is RecovableError {
showRecovableOptions(actions: error.actions, from: viewController)
return
}
showErrorAlert(...)
}
И расширенный тип ошибки, у которой есть контекст и понимание, что делать при том или ином варианте.
struct RecovableError: Error {
let recovableACtions: [RecovableAction]
let context: Context
}
В голове сразу рисуется схема своего велосипеда. Но сначала давайте проверим доки Apple. Возможно, часть механизма уже есть у нас в руках.
Нативная реализация?
Немного поиска в Интернете приведет к protocol RecoverableError:
// A specialized error that may be recoverable by presenting several potential recovery options to the user.
protocol RecoverableError : Error {
var recoveryOptions: [String] { get }
func attemptRecovery(optionIndex recoveryOptionIndex: Int, resultHandler handler: @escaping (Bool) -> Void)
func attemptRecovery(optionIndex recoveryOptionIndex: Int) -> Bool
}
Похоже на то, что мы ищем:
- recoveryOptions: [String] – свойство, хранящее варианты восстановления
- func attemptRecovery(optionIndex: Int) -> Bool – восстанавливает из ошибки, синхронно. True – при успехе
- func attemptRecovery(optionIndex: Int, resultHandler: (Bool) -> Void) – Асинхронный вариант, идея та же
С гайдами по использованию все скромнее. Небольшой поиск по сайту Apple и окрестностям приводит к статье об обработке ошибок, написанной до публичных анонсов Свифт.
Кратко:
- Механизм придуман для MacOs приложений и показывает диалоговое окно
- Он изначально построен вокруг NSError
- Внутрь ошибки в userInfo инкапсулируется объект RecoveryAttempter, который знает об условиях возникновения ошибки и может подобрать наилучший вариант решения проблемы. Объект не должен быть равен nil
- RecoveryAttempter должен поддерживать неформальный протокол NSErrorRecoveryAttempting
- Также в userInfo должны быть recovery option
- И все завязано на вызов метода presentError, который есть только в SDK macOS. Он показывает алерт
- Если алерт показан через presentError, то при выборе варианта в всплывающем окне в AppDelegate дернется интересная функция:
func attemptRecovery(fromError error: Error, optionIndex recoveryOptionIndex: Int, delegate: Any?, didRecoverSelector: Selector?, contextInfo: UnsafeMutableRawPointer?)
Но поскольку у нас нет presentError – дернуть ее не удается.
На этом моменте чувствуется, что мы откопали скорее труп, чем клад. Нам придется превращать Error в NSError и писать свою функцию для показа алерта приложением. Куча неявных связей. Можно, сложно и не совсем понятно – «Зачем?».
Пока заваривается очередная чашка чая, можно подумать, почему в функции выше используется delegate как Any и передается селектор. Ответ ниже:
Ответ
Функция приходит к нам из времен iOS 2. И в то время в языке не было даже протоколов! Единственным способом было вызвать у объекта строковый селектор (предварительно проверив, что он поддерживается). Были времена:)
Строим велосипед
Давайте все же реализуем протокол, нам не повредит:
struct RecoverableError: Foundation.RecoverableError {
let error: Error
var recoveryOptions: [String] {
return ["Try again"]s
}
func attemptRecovery(optionIndex recoveryOptionIndex: Int) -> Bool {
// Делаем заглушку, в будущем будут условия
return true
}
func attemptRecovery(optionIndex: Int, resultHandler: (Bool) -> Void) {
// Индексы не самый безопасный способ работы.
// В будущем попытаемся от них избавиться
switch optionIndex {
case 0:
resultHandler(true)
default:
resultHandler(false)
}
}
Зависимость ошибки от индекса – не самое удобное решение (мы легко можем выйти за пределы массива и крашнуть приложение). Но для MVP сойдёт. Возьмем идею Apple, просто осовременим ее. Нам пригодится отдельный объект Attempter и варианты кнопок, которые мы ему зададим:
struct RecoveryAttemper {
// Массив с вариантами
private let _recoveryOptions: [RecoveryOptions]
var recoveryOptionsText: [String] {
return _recoveryOptions.map({ $0.title })
}
init(options: [RecoveryOptions] {
_recoveryOptions = recoveryOptions
}
// Перетащим сюда обработку ошибок
func attemptRecovery(fromError error: Error, optionIndex: Int) -> Bool {
let option = _recoveryOptions[optionIndex]
switch option {
case .tryAgain(let action)
action()
return true
case .cancel:
return false
}
}
}
// Расширяемый enum, сюда мы будем складировать все варианты ошибок
enum RecoveryOptions {
// Захватим контекст для повторения действия (например, сделать повторно запрос на сервер)
case tryAgain(action: (() -> Void))
case cancel
}
Теперь надо показать ошибку. Я очень люблю протоколы, поэтому буду решать задачу через них. Создадим универсальный протокол для создания UIAlertController из ошибок:
protocol ErrorAlertCreatable: class, ErrorReasonExtractable {
// Создает алерт контролер из ошибки
func createAlert(for error: Error) -> UIAlertController
}
// MARK: - Default implementation
extension ErrorAlertCreatable where Self: UIViewController {
func createAlert(for error: Error) -> UIAlertController {
// Для восстанавливаемой ошибки соберем особый алерт
if let recoverableError = error as? RecoverableError {
return createRecoverableAlert(for: recoverableError)
}
let defaultTitle = "Error"
let description = errorReason(from: error)
// Для ошибки с описанием создадим алерт только с кнопкой ОК
if let localizedError = error as? LocalizedError {
return createAlert(
title: localizedError.errorDescription ?? defaultTitle,
message: description,
actions: [.okAction],
aboveAll: aboveAll)
}
return createAlert(title: defaultTitle, message: description, actions: [.okAction])
}
fileprivate func createAlert(title: String?, message: String?, actions: [UIAlertAction]) -> UIAlertController {
let alertViewController = UIAlertController(title: title, message: message, preferredStyle: .alert)
actions.forEach({ alertViewController.addAction($0) })
return alertViewController
}
fileprivate func createRecoverableAlert(for recoverableError: RecoverableError) -> UIAlertController {
let title = recoverableError.errorDescription
let message = recoverableError.recoverySuggestion
// Создадим кнопки из возможных опций.
let actions = recoverableError.recoveryOptions.enumerated().map { (element) -> UIAlertAction in
let style: UIAlertAction.Style = element.offset == 0 ? .cancel : .default
return UIAlertAction(title: element.element, style: style) { _ in
recoverableError.attemptRecovery(optionIndex: element.offset)
}
}
return createAlert(title: title, message: message, actions: actions)
}
func createOKAlert(with text: String) -> UIAlertController {
return createAlert(title: text, message: nil, actions: [.okAction])
}
}
extension ERror
// Удобный конструктор для действия ok
extension UIAlertAction {
static let okAction = UIAlertAction(title: "OK", style: .cancel) { (_) -> Void in }
}
// Вытаскиваем описание ошибки
protocol ErrorReasonExtractable {
func errorReason(from error: Error) -> String?
}
// MARK: - Default implementation
extension ErrorReasonExtractable {
func errorReason(from error: Error) -> String? {
if let localizedError = error as? LocalizedError {
return localizedError.recoverySuggestion
}
return "Something bad happened. Please try again"
}
}
И протокол для показа созданных алертов:
protocol ErrorAlertPresentable: class {
func presentAlert(from error: Error)
}
// MARK: - Default implementation
extension ErrorAlertPresentable where Self: ErrorAlertCreatable & UIViewController {
func presentAlert(from error: Error) {
let alertVC = createAlert(for: error)
present(alertVC, animated: true, completion: nil)
}
}
Получилось громоздко, но управляемо. Мы можем создавать новые способы показа ошибки (например, toast или показ кастомного вью) и прописывать дефолтную имплементацию, не меняя ничего в вызываемом методе.
Допустим, если бы наш view был прикрыт протоколом:
protocol ViewControllerInput: class {
// Набор методов
}
extension ViewControllerInput: ErrorAlertPresentable { }
extension ViewController: ErrorAlertCreatable { }
// Сама реализация, скрытая за протоколом может легко менять способ создания алерта, реализуя нужный протокол
// или задав свою реализацию
// Мы могли бы добавить протокол для "тостов", добавить имплементацию в ErrorAlertPresentable и заменой строчки кода поменять отображение.
extension ViewController: ErrorToastCreatable { }
Но наш пример гораздо проще, поэтому поддержим оба протокола и запустим приложение:
func requestFeed(...) {
service.requestObject { [weak self] (result) in
guard let `self` = self else { return }
switch result {
case .success:
break
case .failure(let error):
// Захватим контекст с параметрами и сделаем повторный вызов той же функции
// Из-за сильной ссылки захваченный объект (в данном случае viewController) не умрет,
// пока блок не вызовется. Или не умрет объект tryAgainOption
let tryAgainOption = RecoveryOptions.tryAgain {
self.requestFeed(...)
}
let recoveryOptions = [tryAgainOption]
let attempter = RecoveryAttemper(recoveryOptions: recoveryOptions)
let recovableError = RecoverableError(error: error, attempter: attempter)
self.presentAlert(from: recovableError)
}
}
}
// MARK: - ErrorAlertCreatable
extension ViewController: ErrorAlertCreatable { }
// MARK: - ErrorAlertPresentable
extension ViewController: ErrorAlertPresentable { }
Вроде все получилось. Одно из первоначальных условий было в 2-3 строчки. Расширим наш attempter удобным конструктором:
struct RecoveryAttemper {
//
...
//
static func tryAgainAttempter(block: @escaping (() -> Void)) -> Self {
return RecoveryAttemper(recoveryOptions: [.cancel, .tryAgain(action: block)])
}
}
func requestFeed() {
service.requestObject { [weak self] (result) in
guard let `self` = self else { return }
switch result {
case .success:
break
case .failure(let error):
// Создание упростится до строчки
let recovableError = RecoverableError(error: error, attempter: .tryAgainAttempter(block: {
self.requestFeed()
}))
self.presentAlert(from: recovableError)
}
}
}
Мы получили MVP-решение, и нам не будет сложно в любом месте нашего приложения подключить и вызывать его. Давайте начнем проверять edge-кейсы и масштабируемость.
Что если у нас будет несколько сценариев выхода из ошибки?
Допустим, в нашем приложении у юзера есть хранилище. У хранилища есть ограничение по месту. В этом случае у пользователя есть два сценария для выхода из ошибки: юзер может либо освободить место, либо купить еще. Напишем следующий код:
// Внутри контролера
func runOutOfSpace() {
service.runOfSpace { [weak self] (result) in
guard let `self` = self else { return }
switch result {
case .success:
break
case .failure(let error):
let notEnoughSpace = RecoveryOptions.freeSpace {
self.freeSpace()
}
let buyMoreSpace = RecoveryOptions.buyMoreSpace {
self.buyMoreSpace()
}
let options = [notEnoughSpace, buyMoreSpace]
let recovableError = RecoverableError(error: error, attempter: .cancalableAttemter(options: options))
self.presentAlert(from: recovableError)
}
}
}
func freeSpace() {
let alertViewController = createOKAlert(with: "Free space selected")
present(alertViewController, animated: true, completion: nil)
}
func buyMoreSpace() {
let alertViewController = createOKAlert(with: "Buy more space selected")
present(alertViewController, animated: true, completion: nil)
}
struct RecoveryAttemper {
//
...
//
static func cancalableAttemter(options: [RecoveryOptions]) -> Self {
return RecoveryAttemper(recoveryOptions: [.cancel] + options)
}
}
С этим легко справились.
Если мы захотим показывать не алерт, а информационную вьюшку посреди экрана?
Пару новых протоколов по аналогии решат нашу проблему:
protocol ErrorViewCreatable {
func createErrorView(for error: Error) -> ErrorView
}
// MARK: - Default implementation
extension ErrorViewCreatable {
func createErrorView(for error: Error) -> ErrorView {
if let recoverableError = error as? RecoverableError {
return createRecoverableAlert(for: recoverableError)
}
let defaultTitle = "Error"
let description = errorReason(from: error)
if let localizedError = error as? LocalizedError {
return createErrorView(
title: localizedError.errorDescription ?? defaultTitle,
message: description)
}
return createErrorView(title: defaultTitle, message: description)
}
fileprivate func createErrorView(title: String?, message: String?, actions: [ErrorView.Action] = []) -> ErrorView {
// Реализация ErrorView вышла довольно объемной и сильно зависит от дизайна.
// Вариант реализации приложен на github
return ErrorView(title: title, description: message, actions: actions)
}
fileprivate func createRecoverableAlert(for recoverableError: RecoverableError) -> ErrorView {
let title = recoverableError.errorDescription
let message = errorReason(from: recoverableError)
let actions = recoverableError.recoveryOptions.enumerated().map { (element) -> ErrorView.Action in
return ErrorView.Action(title: element.element) {
recoverableError.attemptRecovery(optionIndex: element.offset)
}
}
return createErrorView(title: title, message: message, actions: actions)
}
}
protocol ErrorViewAddable: class {
func presentErrorView(from error: Error)
var errorViewSuperview: UIView { get }
}
// MARK: - Default implementation
extension ErrorViewAddable where Self: ErrorViewCreatable {
func presentErrorView(from error: Error) {
let errorView = createErrorView(for: error)
errorViewSuperview.addSubview(errorView)
errorView.center = errorViewSuperview.center
}
}
// Алгоритм подключения такой же
// MARK: - ErrorAlertCreatable
extension ViewController: ErrorViewCreatable { }
// MARK: - ErrorAlertPresentable
extension ViewController: ErrorViewAddable {
var errorViewSuperview: UIView {
return self
}
}
Теперь мы можем показывать ошибки в виде информационной вью. Причем, мы можем решать, как их показывать. Например, при первом заходе на экран и ошибке – показать информационный вью. А если экран загрузился успешно, но действие на экране вернуло ошибку – показать алерт.
Если доступа к вью нет?
Иногда нужно бросить ошибку, но доступа к вью нет. Или мы не знаем, какое вью сейчас активно, и хотим показать алерт поверх всего. Как решать эту задачу?
Один из самых простых способов (на мой взгляд) поступить так же, как Apple поступает с клавиатурой. Создать новый Window поверх текущего экрана. Сделаем это:
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
// Статическая переменная – достаточно дешевое решение.
// Более сложным и чистым решением будет создание объекта держателя и передача его через DI
static private(set) var errorWindow: UIWindow = {
let alertWindow = UIWindow.init(frame: UIScreen.main.bounds)
alertWindow.backgroundColor = .clear
// Создадим rootViewController, который будет делать present для нового viewController
let viewController = UIViewController()
viewController.view.backgroundColor = .clear
alertWindow.rootViewController = viewController
return alertWindow
}()
Создадим новый алерт, который может показывать поверх всего:
final class AboveAllAlertController: UIAlertController {
var alertWindow: UIWindow {
return AppDelegate.alertWindow
}
func show() {
let topWindow = UIApplication.shared.windows.last
if let topWindow = topWindow {
alertWindow.windowLevel = topWindow.windowLevel + 1
}
alertWindow.makeKeyAndVisible()
alertWindow.rootViewController?.present(self, animated: true, completion: nil)
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
alertWindow.isHidden = true
}
}
protocol ErrorAlertCreatable: class {
// Добавим параметр в функцию создания
func createAlert(for error: Error, aboveAll: Bool) -> UIAlertController
}
// MARK: - Default implementation
extension ErrorAlertCreatable where Self: UIViewController {
...
// И создадим наш новый алерт
fileprivate func createAlert(title: String?, message: String?, actions: [UIAlertAction], aboveAll: Bool) -> UIAlertController {
let alertViewController = aboveAll ?
AboveAllAlertController(title: title, message: message, preferredStyle: .alert) :
UIAlertController(title: title, message: message, preferredStyle: .alert)
actions.forEach({ alertViewController.addAction($0) })
return alertViewController
}
}
// И про показ не забудем
protocol ErrorAlertPresentable: class {
func presentAlert(from error: Error)
func presentAlertAboveAll(from error: Error)
}
// MARK: - Default implementation
extension ErrorAlertPresentable where Self: ErrorAlertCreatable & UIViewController {
func presentAlert(from error: Error) {
let alertVC = createAlert(for: error, aboveAll: false)
present(alertVC, animated: true, completion: nil)
}
func presentAlertAboveAll(from error: Error) {
let alertVC = createAlert(for: error, aboveAll: true)
// Переведем в нужный тип и вызывем кастомную функцию показа
if let alertVC = alertVC as? AboveAllAlertController {
alertVC.show()
return
}
// Поможет нам найти проблему, если что-то отломается
assert(false, "Should create AboveAllAlertController")
present(alertVC, animated: true, completion: nil)
}
}
С виду ничего не изменилось, но теперь мы отвязались от иерархии view controller'ов. Я настоятельно рекомендую не увлекаться этой возможностью. Лучше вызывать код показа в роутере или сущности с аналогичными правами. Во имя прозрачности и понятности.
Мы дали юзерам прекрасный инструмент для спама серверов во время неполадок, техобслуживания и т.д. Что можем улучшить?
Минимальное время запроса
Допустим, мы выключим интернет и будем жать try again. Запустим лоудер. Ответ придёт моментально и получится мини-игра «Кликер». С мигающей анимацией. Не слишком приятно.
Давайте превратим моментальную ошибку в процесс. Идея проста – сделаем минимальное время запроса. Здесь реализация зависит от вашего подхода к networking. Допустим, я использую Operation, и для меня это выглядит так:
final class DelayOperation: AsyncOperation {
private let _delayTime: Double
init(delayTime: Double = 0.3) {
_delayTime = delayTime
}
override func main() {
super.main()
DispatchQueue.global().asyncAfter(deadline: .now() + _delayTime) {
self.state = .finished
}
}
}
// Где-то в коде
let flowListOperation = flowService.list(for: pageID, path: path, limiter: limiter)
let handler = createHandler(for: flowListOperation)
let delayOperation = DelayOperation(delayTime: 0.5)
/// Под >>> прячется addDependency.
[flowListOperation, delayOperation] >>> handler
operationQueue.addOperations([flowListOperation, delayOperation, handler])
Для общего случая могу предложить такую конструкцию:
// Вместо global можно использовать любую нужную очередь
DispatchQueue.global().asyncAfter(deadline: .now() + 0.15) {
// your code here
}
Или же мы можем сделать абстракцию над нашими асинхронными действиями и добавить ей управляемости:
struct Task {
let closure: () -> Void
private var _delayTime: Double?
init(closure: @escaping () -> Void) {
self.closure = closure
}
fileprivate init(closure: @escaping () -> Void, time: Double) {
self.closure = closure
_delayTime = time
}
@discardableResult
func run() -> Self {
if let delayTime = _delayTime {
DispatchQueue.global().asyncAfter(deadline: .now() + delayTime) {
self.closure()
}
return self
}
closure()
return self
}
func delayedTask(time: Double) -> Self {
return Task(closure: closure, time: time)
}
}
// Где то внутри сервиса
func requestObject(completionHandler: @escaping ((Result<Bool, Error>) -> Void)) -> Task {
return Task {
completionHandler(.failure(NetworkError.internetError))
}
.delayedTask(time: 0.5)
.run()
}
Теперь наша анимация не будет казаться такой резкой, даже при режиме офлайн. Рекомендую использовать данный подход в большинстве мест с анимацией.
Для авиарежима хорошо показывать алерт-подсказку (пользователь мог забыть выключить режим для старта работы с приложением). Как, допустим, делает телеграмм. А для важных запросов хорошо делать повтор несколько раз под капотом, прежде чем показывать алерт… Но об этом в другой раз :)
Тестируемость
Когда вся логика свалена во viewController (как у нас сейчас), протестировать это затруднительно. Однако если у вас viewController разделен с бизнес логикой – тестирование становится тривиальной задачей. Легкими движением руки
func requestFeed() {
service.requestObject { [weak self] (result) in
guard let `self` = self else { return }
switch result {
case .success:
break
case .failure(let error):
DispatchQueue.main.async {
let recoverableError = RecoverableError(error: error, attempter: .tryAgainAttempter(block: {
self.requestFeed()
}))
// Делегируем другому объекту показ алерта
self.viewInput?.presentAlert(from: recoverableError)
}
}
}
}
// Где-то в тестах
func testRequestFeedFailed() {
// Put out mock that conform to AlertPresntable protocol
controller.viewInput = ViewInputMock()
// Вызовем метод. Нам понадобится доработать нетворкинг, чтобы он работал синхронно
// Или добавить после expectation
controller.requestFeed()
// Our mocked object should save to true to bool variable when method called
XCTAssert(controller.viewInput.presentAlertCalled)
// Next we could compare recoverable error attempter to expected attempter
}
Вместе с этой статьей мы:
- Сделали удобный механизм отображения алертов
- Дали пользователям возможность повторить неуспешную операцию
- И постарались улучшить пользовательский опыт работы с нашим приложением
> Ссылка на код
Всем спасибо за уделенное время, буду рад ответить на ваши вопросы в комментариях.
lastalexxx
По материалу есть ряд вопросов и замечаний:
1. Можно показывать не обычный Alert, а со стилем actionSheet. Он куда интереснее выглядит, позволяя впихнуть приличный список UIAlertAction.
2. Не вижу проблемы в действиях с обычного Alert, а не с кастомной View (если только менеджеры не заставляют выдерживать дизайн). Алерты — естественный способ взаимодействия с системой.
3. Рисовать отдельное окно — ну, странный способ. Он то рабочий, но почему не найти в стеке ViewController тот, который сейчас активен?
4. У NSError есть прекрасный localizedDescription, который отобразит (в большинстве кейсов) адекватное описание ошибки юзеру.
5. Если сеть не доступна, то смысл тыкать «Try Again». Лишнее действие. Лучше предложить пользователю список действий, а при возвращении в приложение (applicationDidBecomeActive), или на конкретный ViewController, перепроверять состояние сети.
P.S. Если верно помню, проверить Airplane Mode просто так не получится.
aleksiosdev Автор
Добрый день! Спасибо за хороший комментарий. Постараюсь тезисно ответить:
1. Можно, зависит от вас и вашего дизайна.
2. Сам показ алертов — не проблема. История была больше про создание процесса восстановления из ошибок. Это может быть удобно если у вас приложение достаточно слоистое — вы можете спокойно передавать ошибку между разными уровнями, в которой содержится все, чтобы из нее восстановится, когда это понадобится. Возможно, я недостаточно качественно донес эту мысль.
3. Действительно, довольно легко найти активный контролер. Все будет работать. Могут быть следующие корнер кейсы:
— Если вы используете контролер не на полный экран — он может вполне отобразится в нем. Допустим кастомные всплывающее окно. У нас есть кейс с выдвижной по свайпу панелькой. Она занимает 3/4 экрана, это считается верхний контролер и показывается Алерт в нем
— Сейчас не могу проверить, но вроде можно по ошибке найти childViewController как верхний контролер и он тоже будет не на весь экран
Отдельное окно — способ избежать таких проблем. Аналогично рекомендуется поступать при отображении push-уведомлений внутри приложения.
4. Здесь зависит от ваших критериев качества и приоритетов. В некоторых сейчас популярных доставках еды из-за нагрузки я вижу requestTimeOut, ошибки URL и ошибки парсинга данных. Я бы на месте менеджеров хотел бы этого избежать, свести к более нейтральным и общим формулровкам.
5. Звучит разумно для случая полного отсуствия сети/авиарежима. Я под internetError понимал группу ошибок, в которую входит requestTimeOut и всякие ошибки из-за нестабильности сети. Для них tryAgain имеет смысл, так как сеть может вернутся вот прям сейчас.
Проверка applicationDidBecomeActive — является хорошим тоном.
Добавлю — у нас есть обсервинг состояния сети. Допустим мы не смогли загрузить ленту. При восстановлении соединения мы перезапрашвиаем данные и без участия пользователя обновляем интерфейс.
6. Да, удобного API для проверки нет. Тем не менее — есть несколько обходных способов, они за рамками данной статьи.
lastalexxx
Спасибо за ответ! Стало понятнее.
По поводу текста ошибок (вроде requestTimeOut, вместо человеческого языка) — имхо, но это косяк тестеров и менеджмента, пользователь не должен видеть такие ошибки. Плюс, мы используем CodyFire для работы с сетью (надстройка над Alamofire). Очень удобно там обрабатываются ошибки (для наших кейсов — отлично).