Из ошибки в алерт с действиями

Привет, Хабр! Для пользователя сообщения об ошибке часто выглядят как «Что-то не так, АААА!». Конечно, ему бы хотелось вместо ошибок видеть волшебную ошибку «Починить все». Ну или другие варианты действий. Мы начали активно добавлять себе такие, и я хочу рассказать про то, как вы можете это сделать.

6qcfs3uiu0o9ajdlcfi8frjb5pg.jpeg

Сначала представлюсь — меня зовут Александр, последние шесть лет я посвятил 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. Для простоты большая часть кода будет в контролере. Вы вольны использовать те же подходы в вашей архитектуре. Код статьи будет доступен в репозитории, в конце статьи эта ссылка тоже будет.

Получим вот такую картинку:

9oraeel1k7noyxjplcumz1b56jy.png

Теоретически задачу мы выполнили. Но сразу бросается в глаза несколько вещей:

  • Мы не дали возможности как-то перейти из ошибочного сценария в успешный. ОК в текущем кейсе просто скроет алерт — и это не решение
  • С точки зрения UX’а и пользовательского опыта текст нужно сделать понятней, нейтральней. Чтобы пользователь не пугался и не бежал ставить одну звезду в AppStore вашему приложению. При этом подробный текст пригодился бы нам при дебаге
  • И, будем честны — алерты несколько устарели как решение (все чаще в приложениях появляются либо экраны-заглушки, либо тосты). Но это уже вопрос, который стоит обсудить отдельно с командой

Согласитесь, представленный ниже вариант выглядит гораздо симпатиШнее

jv0zozzv3foowtocsqkk_pjdifo.png

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

  • Должно быть расширяемым. Все мы знаем о свойственной дизайну изменчивости. Наш механизм должен быть готов ко всему
  • Добавляется к объекту (и убирается) в пару строчек кода
  • Хорошо тестируется

Но перед этим давайте окунёмся в теоретический минимум по ошибкам в 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(handler: @escaping (Swift.Result) -> Void) {
	handler(.failure(NetworkError.serverError)
}

blah(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 и, возможно, какими-то специфичными, получаем схему:

a9ten3zbrffm7sz8kkupfo8xsuy.png

Начнем решать задачу с конца. Нам нужна функция, которая показывает алерт с 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 (https://developer.apple.com/documentation/foundation/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 — дернуть ее не удается.

vpxocjapqc5ywmiq8hhq3djow6o.gif

На этом моменте чувствуется, что мы откапали скорее труп, чем клад. Нам придется превращать 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 { }

3hflvhmxpfkuzgskzcpe9hodl2k.png

Вроде все получилось. Одно из первоначальных условий было в 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)
    	}
    }

d5egzquyykwxtjorhevcq2pe1mg.png

С этим легко справились.

Если мы захотим показывать не алерт, а информационную вьюшку посреди экрана?

fgxrpqsgg_yqmy4txescavcvdvc.png

Пару новых протоколов по аналогии решат нашу проблему:

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)
    	}
    }

qresuobexdwaxevl3gnmsfjsc6i.png

С виду ничего не изменилось, но теперь мы отвязались от иерархии view controller’ов. Я настоятельно рекомендую не увлекаться этой возможностью. Лучше вызывать код показа в роутере или сущности с аналогичными правами. Во имя прозрачности и понятности.

Мы дали юзерам прекрасный инструмент для спама серверов во время неполадок, техобслуживания и т.д. Что можем улучшить?

Минимальное время запроса


Допустим, мы выключим интернет и будем жать try again. Запустим лоудер. Ответ придёт моментально и получится мини-игра «Кликер». С мигающей анимацией. Не слишком приятно.

uz3nmu7_ag_k4swmsiy8gw2y1z0.gif

Давайте превратим моментальную ошибку в процесс. Идея проста — сделаем минимальное время запроса. Здесь реализация зависит от вашего подхода к 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) -> Void)) -> Task {
    		return Task {
    			completionHandler(.failure(NetworkError.internetError))
    		}
    			.delayedTask(time: 0.5)
    		.run()
    	}

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

9koq46qq0wffoivy0ucs18om1cm.gif

Для авиарежима хорошо показывать алерт-подсказку (пользователь мог забыть выключить режим для старта работы с приложением). Как, допустим, делает телеграмм. А для важных запросов хорошо делать повтор несколько раз под капотом, прежде чем показывать алерт… Но об этом в другой раз :)

Тестируемость


Когда вся логика свалена во 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
}

Вместе с этой статьей мы:

  • Сделали удобный механизм отображения алертов
  • Дали пользователям возможность повторить неуспешную операцию
  • И постарались улучшить пользовательский опыт работы с нашим приложением

Ссылка на код.

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

© Habrahabr.ru