De l'erreur à l'alerte avec des actions

Bonjour, Habr! Pour l'utilisateur, les messages d'erreur ressemblent souvent à «quelque chose ne va pas, AAAA!». Bien sûr, il aimerait au lieu d'erreurs voir l'erreur magique «Réparer tout». Eh bien, ou d'autres options. Nous avons commencé à les ajouter activement à nous-mêmes, et je veux parler de la façon dont vous pouvez le faire.



Tout d'abord, présentez-moi - mon nom est Alexander, les six dernières années, j'ai consacré le développement iOS. Maintenant, je suis responsable de l'application mobile ManyChat et je vais résoudre les problèmes en utilisant son exemple.

Formulons immédiatement ce que nous allons faire:

  • Ajouter une fonctionnalité au type d'erreur
  • Transformez les erreurs en alertes conviviales
  • Nous affichons les autres actions possibles dans l'interface et traitons leurs clics

Et tout cela sera sur Swift :)

Nous allons résoudre le problème avec un exemple. Le serveur a renvoyé une erreur avec le code 500 au lieu du 200 attendu. Que doit faire le développeur? À tout le moins, avec tristesse d'informer l'utilisateur - le message attendu avec des sceaux n'a pas pu être téléchargé. Dans Apple, le modèle standard est alerte, écrivons donc une fonction simple:

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

PS Pour plus de simplicité, la plupart du code sera dans le contrôleur. Vous êtes libre d'utiliser les mêmes approches dans votre architecture. Le code de l'article sera disponible dans le référentiel , à la fin de l'article, ce lien sera également.

Nous obtenons l'image suivante:



Théoriquement, nous avons terminé la tâche. Mais plusieurs choses sont immédiatement évidentes:

  • Nous n'avons pas donné l'occasion de passer d'une manière ou d'une autre d'un scénario erroné à un scénario réussi. OK dans le cas actuel, cela cache simplement l'alerte - et ce n'est pas une solution
  • Du point de vue de l'expérience utilisateur, le texte doit être rendu plus clair et neutre. Pour que l'utilisateur n'ait pas peur et ne s'exécute pas pour mettre une étoile dans l'AppStore à votre application. Dans ce cas, un texte détaillé nous serait utile lors du débogage
  • Et, pour être honnête, les alertes sont quelque peu dépassées comme solution (de plus en plus, des écrans ou des toasts factices apparaissent dans les applications). Mais c'est déjà une question qui devrait être discutée séparément avec l'équipe

D'accord, l'option présentée ci-dessous semble beaucoup plus sympathique.



Quelle que soit l'option que vous choisissez, pour chacun d'entre eux, vous devrez réfléchir à un tel mécanisme pour afficher un message qui aura fière allure lorsqu'une erreur arbitraire se produira, offrira à l'utilisateur un script clair pour d'autres travaux dans l'application et fournira un ensemble d'actions. La solution est:

  • Doit être extensible. Nous connaissons tous la variabilité de conception inhérente. Notre mécanisme doit être prêt à tout
  • Il est ajouté à l'objet (et supprimé) dans quelques lignes de code
  • Bien testé

Mais avant cela, plongeons-nous dans le minimum théorique pour les erreurs dans Swift.

Erreur dans Swift


Ce paragraphe est un aperçu de haut niveau des erreurs en général. Si vous utilisez déjà activement vos erreurs dans l'application, vous pouvez passer au paragraphe suivant en toute sécurité.

Qu'est-ce qu'une erreur? Une sorte de mauvaise action ou un résultat incorrect. Souvent, nous pouvons supposer des erreurs possibles et les décrire à l'avance dans le code.

Dans ce cas, Apple nous donne le type Erreur. Si nous ouvrons la documentation Apple, alors Erreur ressemblera à ceci (pertinent pour Swift 5.1):

public protocol Error {
}

Juste un protocole sans exigences supplémentaires. La documentation explique aimablement - le manque de paramètres requis permet à tout type d'être utilisé dans le système de gestion des erreurs Swift. Avec un protocole aussi doux, nous travaillerons simplement.

L'idée d'utiliser enum me vient immédiatement à l'esprit: il existe un nombre fini d'erreurs connues, elles peuvent avoir une sorte de paramètres. C'est ce que fait Apple. Par exemple, vous pourriez envisager d'implémenter une 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)
    
    ...

Profitez des meilleures pratiques d'Apple. Imaginez un groupe d'erreurs réseau possibles sous une forme simplifiée:

enum NetworkError: Error {
       //  500 
	case serverError
        //   ,   
	case responseError
        //  ,   ,  
	case internetError
}

Maintenant, n'importe où dans notre application où l'erreur se produit, nous pouvons utiliser notre Network.Error.

Comment travailler avec les bugs? Il existe un mécanisme do catch. Si une fonction peut renvoyer une erreur, elle est marquée avec le mot clé throws. Désormais, chacun de ses utilisateurs doit y accéder via la construction do catch. S'il n'y a pas d'erreur, nous tomberons dans le bloc do, avec une erreur, dans le bloc catch. Les fonctions menant à l'erreur peuvent être n'importe quel nombre dans le bloc do. Le seul point négatif est que dans catch nous obtenons une erreur de type Error. Vous devrez convertir l'erreur dans le type souhaité.

Comme alternative, nous pouvons utiliser l'option, c'est-à-dire obtenir zéro en cas d'erreur et se débarrasser de la conception volumineuse. Parfois, c'est plus pratique: disons quand nous obtenons une variable facultative, puis appliquez-lui une fonction throws. Le code peut être placé dans un bloc if / guard, et il restera concis.

Voici un exemple de travail avec la fonction 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()

PS Ne pas confondre avec do catch dans d'autres langues. Swift ne lance pas d'exception, mais écrit la valeur de l'erreur (si elle s'est produite) dans un registre spécial. S'il y a une valeur, elle va au bloc d'erreur, sinon, le bloc do continue. Sources pour les plus curieux: www.mikeash.com/pyblog/friday-qa-2017-08-25-swift-error-handling-implementation.html

Cette méthode est bonne pour gérer des événements synchrones et n'est pas très pratique pour de longues opérations (par exemple, demande de données sur le réseau), ce qui peut potentiellement prendre beaucoup de temps. Ensuite, vous pouvez utiliser la complétion simple.

Comme alternative à Swift 5, Result a été introduit - une énumération préparée qui contient deux options - succès et échec. En soi, il ne nécessite pas l'utilisation d'Erreur. Et cela n'a pas de relation directe avec l'asynchronie. Mais le retour précis de ce type à l'achèvement est plus pratique pour les événements asynchrones (sinon vous devrez faire deux achèvement, succès et échec, ou retourner deux paramètres). Écrivons un exemple:

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

Ces informations nous suffisent pour travailler.

Encore une fois, brièvement:

  • Les erreurs dans Swift est un protocole
  • Il est commode de présenter les erreurs sous forme d'énumération
  • Il y a deux façons de traiter les erreurs - synchrone (faire un rattrapage) et asynchrone (votre propre compétition ou résultat)

Texte d'erreur


Revenons au sujet de l'article. Dans le paragraphe ci-dessus, nous avons créé notre propre type d'erreurs. Le voilà:

enum NetworkError: Error {
        //  500 
	case serverError
        //   ,   
	case responseError
        //  ,   ,  
	case internetError
}

Maintenant, nous devons faire correspondre chaque erreur avec un texte qui sera compréhensible pour l'utilisateur. Nous l'afficherons dans l'interface en cas d'erreur. LocalizedError Protocol se dépêche de nous aider. Il hérite du protocole Error et le complète avec 4 propriétés:

protocol LocalizedError : Error {
    var errorDescription: String? { get }
    var failureReason: String? { get }
    var recoverySuggestion: String? { get }
    var helpAnchor: String? { get }
}

Nous mettons en œuvre le protocole:

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

L'affichage d'erreur ne changera guère:

	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)

Super, tout était facile avec le texte. Passons aux boutons.

Récupération d'erreur


Présentons l'algorithme de gestion des erreurs dans un diagramme simple. Pour une situation où, à la suite d'une erreur, nous affichons une boîte de dialogue avec les options Réessayer, Annuler et, éventuellement, certaines spécifiques, nous obtenons le schéma:



Nous commencerons à résoudre le problème à la fin. Nous avons besoin d'une fonction qui affiche une alerte avec n + 1 options. Nous lançons, comme nous aimerions montrer une erreur:

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

Une fonction qui détermine le type d'erreur et transmet un signal pour afficher une alerte:

func handleError(error: Error) {
	if error is RecovableError {
		showRecovableOptions(actions: error.actions, from: viewController)
		return
	}
	showErrorAlert(...)
} 


Et un type d'erreur étendu, qui a un contexte et une compréhension de ce qu'il faut faire avec telle ou telle option.

struct RecovableError: Error {
	let recovableACtions: [RecovableAction]
	let context: Context
}

La tête dessine immédiatement un schéma de votre vélo. Mais d'abord, vérifions les docks Apple. Peut-être qu'une partie du mécanisme est déjà entre nos mains.

Implémentation native?


Un peu de recherche sur Internet se traduira par le protocole 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
}

Il semble que nous recherchions:

  • recoveryOptions: [String] - une propriété qui stocke les options de récupération
  • func tentativeRecovery (optionIndex: Int) -> Bool - restaure à partir d'une erreur, de manière synchrone. Vrai - En cas de succès
  • func tentRecovery (optionIndex: Int, resultHandler: (Bool) -> Void) - Option asynchrone, l'idée est la même

Avec les guides d'utilisation, tout est plus modeste. Une petite recherche sur le site Apple et ses environs conduit à un article sur la gestion des erreurs écrit avant les annonces publiques de Swift.

Brièvement:

  • Le mécanisme est pensé pour les applications MacOs et affiche une boîte de dialogue
  • Il a été initialement construit autour de NSError.
  • L'objet RecoveryAttempter est encapsulé dans l'erreur dans userInfo, qui connaît les conditions de l'erreur et peut choisir la meilleure solution au problème. L'objet ne doit pas être nul
  • RecoveryAttempter doit prendre en charge le protocole informel NSErrorRecoveryAttempting
  • Également dans userInfo devrait être une option de récupération
  • Et tout est lié à l'appel de la méthode presentError, qui ne se trouve que dans le SDK macOS. Il montre une alerte
  • Si l'alerte est affichée via presentError, alors lorsque vous sélectionnez une option dans la fenêtre contextuelle d'AppDelegate, une fonction intéressante se déclenche:

func attemptRecovery(fromError error: Error, optionIndex recoveryOptionIndex: Int, delegate: Any?, didRecoverSelector: Selector?, contextInfo: UnsafeMutableRawPointer?)

Mais comme nous n'avons pas presentError, nous ne pouvons pas le retirer.



À ce stade, on a l'impression d'avoir déterré un cadavre plutôt qu'un trésor. Nous devrons transformer Error en NSError et écrire notre propre fonction pour afficher l'alerte par l'application. Un tas de connexions implicites. C'est possible, difficile et pas tout à fait clair - «Pourquoi?».

Pendant que la prochaine tasse de thé se prépare, on peut se demander pourquoi la fonction ci-dessus utilise délégué comme Tout et passe le sélecteur. La réponse est ci-dessous:

Réponse
iOS 2. ! ( , ). :)

Construire un vélo


Implémentons le protocole, cela ne nous fera pas de mal:

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

La dépendance d'index n'est pas la solution la plus pratique (nous pouvons facilement aller au-delà du tableau et planter l'application). Mais pour MVP fera l'affaire. Prenez l'idée d'Apple, modernisez-la. Nous avons besoin d'un objet Attempter séparé et d'options de bouton que nous lui donnerons:

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
    }

Vous devez maintenant afficher l'erreur. J'aime vraiment les protocoles, je vais donc résoudre le problème à travers eux. Créons un protocole universel pour créer un UIAlertController à partir d'erreurs:

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

Et le protocole pour afficher les alertes créées:

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

Il s'est avéré être lourd, mais gérable. Nous pouvons créer de nouvelles façons d'afficher une erreur (par exemple, toast ou afficher une vue personnalisée) et enregistrer l'implémentation par défaut sans rien changer dans la méthode appelée.

Supposons que notre point de vue soit couvert par un protocole:

protocol ViewControllerInput: class {
     //  
    }
    extension ViewControllerInput: ErrorAlertPresentable { }
    
    extension ViewController: ErrorAlertCreatable { }
    //  ,         ,    
    //    
    
    //       "",    ErrorAlertPresentable      . 
    extension ViewController: ErrorToastCreatable { }

Mais notre exemple est beaucoup plus simple, nous prenons donc en charge les deux protocoles et exécutons l'application:

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



Il semble que tout a fonctionné. L'une des conditions initiales était en 2-3 lignes. Nous élargirons notre attempter avec un constructeur pratique:

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

Nous avons obtenu la solution MVP, et il ne sera pas difficile pour nous de nous connecter et de l'appeler n'importe où dans notre application. Commençons par vérifier les cas limites et l'évolutivité.

Et si nous avons plusieurs scénarios de sortie?


Supposons qu'un utilisateur dispose d'un référentiel dans notre application. Le coffre-fort a une limite de place. Dans ce cas, l'utilisateur a deux scénarios pour quitter l'erreur: l'utilisateur peut soit libérer de l'espace, soit en acheter plus. Nous écrirons le code suivant:

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



Cela a été facilement résolu.

Si nous voulons afficher non pas une alerte, mais une vue d'information au milieu de l'écran?




Quelques nouveaux protocoles par analogie résoudront notre problème:

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

Nous pouvons maintenant afficher les erreurs sous la forme d'une vue d'informations. De plus, nous pouvons décider comment les montrer. Par exemple, la première fois que vous accédez à l'écran et à l'erreur - affichez les informations. Et si l'écran s'est chargé avec succès, mais l'action à l'écran a renvoyé une erreur - affichez une alerte.

S'il n'y a pas d'accès à la vue?


Parfois, vous devez renvoyer une erreur, mais il n'y a pas d'accès à la vue. Ou nous ne savons pas quelle vue est actuellement active, et nous voulons afficher une alerte par-dessus tout. Comment résoudre ce problème?

Une des façons les plus simples (à mon avis) de faire la même chose qu'Apple avec le clavier. Créez une nouvelle fenêtre en haut de l'écran actuel. Faisons le:

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

Créez une nouvelle alerte qui peut s'afficher par dessus tout:

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



En apparence, rien n'a changé, mais maintenant nous nous sommes débarrassés de la hiérarchie des contrôleurs de vue. Je recommande fortement de ne pas vous laisser emporter par cette opportunité. Il est préférable d'appeler le code d'affichage dans un routeur ou une entité avec les mêmes droits. Au nom de la transparence et de la clarté.

Nous avons donné aux utilisateurs un excellent outil pour spammer les serveurs lors de dysfonctionnements, de maintenance, etc. Que pouvons-nous améliorer?

Temps de demande minimum


Supposons que nous désactivions Internet et réessayions. Exécutez le chargeur. La réponse viendra instantanément et obtiendra un mini-jeu "Clicker". Avec animation clignotante. Pas trop sympa.



Transformons une erreur instantanée en processus. L'idée est simple - nous ferons le temps de demande minimum. Ici, la mise en œuvre dépend de votre approche du réseautage. Supposons que j'utilise Operation, et pour moi, cela ressemble à ceci:

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

Pour le cas général, je peux proposer cette conception:

//  global      
DispatchQueue.global().asyncAfter(deadline: .now() + 0.15) {
    // your code here
}

Ou nous pouvons faire une abstraction sur nos actions asynchrones et y ajouter une gérabilité:

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

Maintenant, notre animation ne semblera pas aussi nette, même hors ligne. Je recommande d'utiliser cette approche dans la plupart des endroits avec animation.



Pour le mode avion, il est bon d'afficher une invite d'alerte (l'utilisateur peut oublier de désactiver le mode pour commencer à travailler avec l'application). Comme, disons, fait un télégramme. Et pour les requêtes importantes, il est bon de répéter plusieurs fois sous le capot avant d'afficher une alerte ... Mais plus à ce sujet une autre fois :)

Testabilité


Lorsque toute la logique est vidée dans le viewController (comme nous l'avons maintenant), il est difficile à tester. Cependant, si votre viewController est partagé avec la logique métier, le test devient une tâche triviale. En un clin d' œil, la logique métier se transforme en:

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
}

Avec cet article, nous:

  • Fabriqué un mécanisme pratique pour afficher les alertes
  • A donné aux utilisateurs la possibilité de réessayer une opération infructueuse
  • Et essayé d'améliorer l'expérience utilisateur avec notre application

→  Lien vers le code

Merci à tous pour votre temps, je serai heureux de répondre à vos questions dans les commentaires.

All Articles