De error a alerta con acciones

Hola Habr! Para el usuario, los mensajes de error a menudo se ven como "¡Algo está mal, AAAA!". Por supuesto, le gustaría en lugar de errores ver el error mágico "Repararlo todo". Bueno, u otras opciones. Comenzamos a agregarlos activamente a nosotros mismos, y quiero hablar sobre cómo puede hacer esto.



Primero, presénteme: mi nombre es Alexander, los últimos seis años he dedicado el desarrollo de iOS. Ahora soy responsable de la aplicación móvil ManyChat y resolveré problemas usando su ejemplo.

Formulemos de inmediato lo que haremos:

  • Agregar funcionalidad al tipo de error
  • Convierta los errores en alertas fáciles de usar
  • Mostramos las posibles acciones adicionales en la interfaz y procesamos sus clics

Y todo esto estará en Swift :)

Resolveremos el problema con un ejemplo. El servidor devolvió un error con el código 500 en lugar de los 200 esperados. ¿Qué debe hacer el desarrollador? Como mínimo, con tristeza para informar al usuario: no se pudo descargar la publicación esperada con sellos. En Apple, el patrón estándar es alerta, así que escribamos una función 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)
	}
}

PD: Para simplificar, la mayor parte del código estará en el controlador. Usted es libre de usar los mismos enfoques en su arquitectura. El código del artículo estará disponible en el repositorio , al final del artículo también estará este enlace.

Obtenemos la siguiente imagen:



Teóricamente, completamos la tarea. Pero varias cosas son inmediatamente evidentes:

  • No le dimos la oportunidad de cambiar de alguna manera un escenario erróneo a uno exitoso. OK en el caso actual, solo oculta la alerta, y esta no es una solución
  • Desde el punto de vista de la experiencia del usuario, el texto debe hacerse más claro y neutral. Para que el usuario no tenga miedo y no se ejecute para poner una estrella en la AppStore para su aplicación. En este caso, un texto detallado nos sería útil al depurar
  • Y, para ser sincero, las alertas están algo desactualizadas como solución (cada vez más, aparecen pantallas ficticias o brindis en las aplicaciones). Pero esta es una pregunta que debe discutirse por separado con el equipo.

De acuerdo, la opción presentada a continuación se ve mucho más comprensiva.



Cualquiera que sea la opción que elija, para cualquiera de ellos tendrá que pensar en un mecanismo para mostrar un mensaje que se verá genial cuando ocurra un error arbitrario, ofrecerá al usuario un script claro para un mayor trabajo en la aplicación y proporcionará un conjunto de acciones. La solucion es:

  • Debe ser extensible. Todos sabemos acerca de la variabilidad del diseño inherente. Nuestro mecanismo debe estar listo para cualquier cosa.
  • Se agrega al objeto (y se elimina) en un par de líneas de código
  • Bien probado

Pero antes de eso, sumerjámonos en el mínimo teórico para errores en Swift.

Error en Swift


Este párrafo es una descripción general de alto nivel de los errores en general. Si ya está utilizando activamente sus errores en la aplicación, puede proceder con seguridad al siguiente párrafo.

¿Qué es un error? Algún tipo de acción incorrecta o resultado incorrecto. A menudo podemos asumir posibles errores y describirlos de antemano en el código.

Para este caso, Apple nos da el tipo Error. Si abrimos la documentación de Apple, el error se verá así (relevante para Swift 5.1):

public protocol Error {
}

Solo un protocolo sin requisitos adicionales. La documentación explica amablemente: la falta de parámetros necesarios permite utilizar cualquier tipo en el sistema de manejo de errores Swift. Con un protocolo tan gentil, simplemente trabajaremos.

La idea de usar enum me viene a la mente de inmediato: hay un número finito de errores conocidos, pueden tener algún tipo de parámetros. Que es lo que está haciendo Apple. Por ejemplo, podría considerar implementar un 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)
    
    ...

Aproveche las mejores prácticas de Apple. Imagine un grupo de posibles errores de red en una forma simplificada:

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

Ahora, en cualquier lugar de nuestra aplicación donde ocurra el error, podemos usar nuestro Network.Error.

¿Cómo trabajar con errores? Hay un mecanismo de captura. Si una función puede arrojar un error, se marca con la palabra clave throws. Ahora se requiere que cada uno de sus usuarios acceda a él a través de la construcción do catch. Si no hay error, caeremos en el bloque do, con un error, en el bloque catch. Las funciones que conducen al error pueden ser cualquier número en el bloque do. Lo único negativo es que en catch obtenemos un error de tipo Error. Deberá convertir el error en el tipo deseado.

Como alternativa, podemos usar el opcional, es decir, obtener nulo en caso de error y deshacernos del diseño voluminoso. A veces es más conveniente: digamos cuando obtenemos una variable opcional y luego le aplicamos una función de lanzamiento. El código se puede poner en un bloque if / guard, y seguirá siendo conciso.

Aquí hay un ejemplo de trabajo con la función 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()

PD : No confunda con do catch en otros idiomas. Swift no lanza una excepción, pero escribe el valor del error (si sucedió) en un registro especial. Si hay un valor, va al bloque de error; de lo contrario, el bloque do continúa. Fuentes para los más curiosos: www.mikeash.com/pyblog/friday-qa-2017-08-25-swift-error-handling-implementation.html

Este método es bueno para manejar eventos síncronos y no es tan conveniente para operaciones largas (por ejemplo, solicitando datos a través de la red), lo que puede llevar mucho tiempo. Entonces puedes usar la terminación simple.

Como alternativa a Swift 5, se introdujo Result, una enumeración preparada que contiene dos opciones: éxito y fracaso. Por sí solo, no requiere el uso de Error. Y no tiene relación directa con la asincronía. Pero regresar este tipo con precisión a la finalización es más conveniente para eventos asincrónicos (de lo contrario, tendrá que completar dos veces, éxito y falla, o devolver dos parámetros). Escribamos un ejemplo:

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

Esta información es suficiente para que podamos trabajar.

Una vez más, brevemente:

  • Errores en Swift es un protocolo
  • Es conveniente presentar errores en forma de enumeración
  • Hay dos maneras de lidiar con los errores: sincrónico (capturar) y asincrónico (su propia competencia o resultado)

Texto de error


Volvamos al tema del artículo. En el párrafo anterior, creamos nuestro propio tipo de errores. Ahi esta:

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

Ahora necesitamos hacer coincidir cada error con un texto que sea comprensible para el usuario. Lo mostraremos en la interfaz en caso de error. LocalizedError Protocol se apresura a ayudarnos. Hereda el error de protocolo y lo complementa con 4 propiedades:

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

Implementamos el protocolo:

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

La pantalla de error apenas cambiará:

	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)

Genial, todo fue fácil con el texto. Pasemos a los botones.

Error de recuperación


Presentemos el algoritmo de manejo de errores en un diagrama simple. Para una situación en la que, como resultado de un error, mostramos un cuadro de diálogo con las opciones Intentar de nuevo, Cancelar y, posiblemente, algunas específicas, obtenemos el esquema:



Comenzaremos a resolver el problema desde el final. Necesitamos una función que muestre una alerta con n + 1 opciones. Lanzamos, ya que nos gustaría mostrar un error:

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

Una función que determina el tipo de error y transmite una señal para mostrar una alerta:

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


Y un tipo extendido de error, que tiene un contexto y una comprensión de qué hacer con esta o aquella opción.

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

La cabeza dibuja inmediatamente un diagrama de su bicicleta. Pero primero, revisemos los muelles de Apple. Quizás parte del mecanismo ya esté en nuestras manos.

Implementación nativa?


Un poco de búsqueda en Internet dará como resultado el protocolo 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
}

Parece que estamos buscando:

  • recoveryOptions: [String] - una propiedad que almacena opciones de recuperación
  • func intentRecovery (optionIndex: Int) -> Bool - restaura de un error, sincrónicamente. Verdadero: en caso de éxito
  • func intentRecovery (optionIndex: Int, resultHandler: (Bool) -> Void) - Opción asincrónica, la idea es la misma

Con las guías de uso, todo es más modesto. Una pequeña búsqueda en el sitio de Apple y el área circundante lleva a un artículo sobre manejo de errores escrito antes de los anuncios públicos de Swift.

Brevemente:

  • El mecanismo está pensado para aplicaciones MacOs y muestra un cuadro de diálogo
  • Originalmente fue construido alrededor de NSError.
  • El objeto RecoveryAttempter está encapsulado dentro del error en userInfo, que conoce las condiciones del error y puede elegir la mejor solución al problema. El objeto no debe ser nulo
  • RecoveryAttempter debe admitir el protocolo informal NSErrorRecoveryAttempting
  • También en userInfo debería haber una opción de recuperación
  • Y todo está relacionado con la llamada al método presentError, que solo está en el SDK de macOS. Muestra una alerta
  • Si la alerta se muestra a través de presentError, cuando selecciona una opción en la ventana emergente en AppDelegate, una función interesante se contrae:

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

Pero como no tenemos presentError, no podemos extraerlo.



En este punto, parece que desenterramos un cadáver en lugar de un tesoro. Tendremos que convertir Error en NSError y escribir nuestra propia función para mostrar la alerta de la aplicación. Un montón de conexiones implícitas. Es posible, difícil y no del todo claro: "¿Por qué?".

Mientras se prepara la próxima taza de té, uno podría preguntarse por qué la función anterior usa delegar como Cualquiera y pasa el selector. La respuesta está abajo:

Responder
iOS 2. ! ( , ). :)

Construir una bicicleta


Implementemos el protocolo, no nos hará daño:

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 dependencia del índice no es la solución más conveniente (podemos ir más allá de la matriz y bloquear la aplicación). Pero para MVP lo hará. Tome la idea de Apple, solo modernícela. Necesitamos un objeto de intento separado y opciones de botón que le daremos:

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
    }

Ahora necesitas mostrar el error. Realmente me gustan los protocolos, así que resolveré el problema a través de ellos. Creemos un protocolo universal para crear un UIAlertController a partir de errores:

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

Y el protocolo para mostrar alertas creadas:

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

Resultó ser engorroso, pero manejable. Podemos crear nuevas formas de mostrar un error (por ejemplo, brindar o mostrar una vista personalizada) y registrar la implementación predeterminada sin cambiar nada en el método llamado.

Supongamos que nuestra opinión estuviera cubierta por un protocolo:

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

Pero nuestro ejemplo es mucho más simple, por lo que admitimos ambos protocolos y ejecutamos la aplicación:

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



Parece que todo salió bien. Una de las condiciones iniciales fue en 2-3 líneas. Ampliaremos nuestro intento con un constructor conveniente:

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

Obtuvimos la solución MVP, y no será difícil para nosotros conectarnos y llamarlo en cualquier lugar de nuestra aplicación. Comencemos por comprobar los casos límite y la escalabilidad.

¿Qué pasa si tenemos varios escenarios de salida?


Supongamos que un usuario tiene un repositorio en nuestra aplicación. La bóveda tiene un límite de lugar. En este caso, el usuario tiene dos escenarios para salir del error: puede liberar espacio o comprar más. Escribiremos el siguiente código:

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



Esto se solucionó fácilmente.

Si queremos mostrar no una alerta, sino una vista de información en el medio de la pantalla?




Un par de nuevos protocolos por analogía resolverán nuestro problema:

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

Ahora podemos mostrar errores en forma de una vista de información. Además, podemos decidir cómo mostrarlos. Por ejemplo, la primera vez que ingresa a la pantalla y aparece el error: muestra la vista de información. Y si la pantalla se cargó correctamente, pero la acción en la pantalla devolvió un error, muestre una alerta.

Si no hay acceso a la vista?


A veces necesita lanzar un error, pero no hay acceso a la vista. O no sabemos qué vista está actualmente activa, y queremos mostrar una alerta por encima de todo. ¿Cómo resolver este problema?

Una de las formas más fáciles (en mi opinión) de hacer lo mismo que Apple hace con el teclado. Crear una nueva ventana en la parte superior de la pantalla actual. Vamos a hacerlo:

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

Cree una nueva alerta que se pueda mostrar encima de todo:

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 apariencia, nada ha cambiado, pero ahora nos hemos librado de la jerarquía de los controladores de vista. Recomiendo no dejarse llevar por esta oportunidad. Es mejor llamar al código de visualización en un enrutador o entidad con los mismos derechos. En nombre de la transparencia y la claridad.

Les dimos a los usuarios una gran herramienta para enviar spam a servidores durante fallas, mantenimiento, etc. ¿Qué podemos mejorar?

Tiempo mínimo de solicitud


Supongamos que apagamos Internet e intentamos nuevamente. Ejecute el cargador. La respuesta llegará instantáneamente y obtendrá un mini-juego "Clicker". Con animación parpadeante. No muy bien



Convirtamos un error instantáneo en un proceso. La idea es simple: haremos el tiempo mínimo de solicitud. Aquí la implementación depende de su enfoque de redes. Supongamos que uso Operation, y para mí se ve así:

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

Para el caso general, puedo ofrecer este diseño:

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

O podemos hacer una abstracción sobre nuestras acciones asincrónicas y agregarle capacidad de administración:

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

Ahora nuestra animación no parecerá tan nítida, incluso sin conexión. Recomiendo usar este enfoque en la mayoría de los lugares con animación.



Para el modo avión, es bueno mostrar un aviso de alerta (el usuario podría olvidar apagar el modo para comenzar a trabajar con la aplicación). Como, digamos, hace un telegrama. Y para consultas importantes, es bueno repetir varias veces debajo del capó antes de mostrar una alerta ... Pero más sobre eso en otra ocasión :)

Testabilidad


Cuando se descarga toda la lógica en el viewController (como lo hemos hecho ahora), es difícil de probar. Sin embargo, si su viewController se comparte con la lógica empresarial, las pruebas se convierten en una tarea trivial. Con un movimiento de los pantalones, la lógica empresarial se convierte 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
}

Junto con este artículo nosotros:

  • Hecho un mecanismo conveniente para mostrar alertas
  • Dio a los usuarios la opción de volver a intentar una operación fallida
  • Y trató de mejorar la experiencia del usuario con nuestra aplicación

→  Enlace al código

Gracias a todos por su tiempo, con gusto responderé sus preguntas en los comentarios.

All Articles