Do erro ao alerta com ações

Olá Habr! Para o usuário, as mensagens de erro geralmente parecem "Algo está errado, AAAA!". Claro, ele preferiria, em vez de erros, ver o erro mágico "Reparar tudo". Bem, ou outras opções. Começamos a adicioná-los ativamente a nós mesmos e quero falar sobre como você pode fazer isso.



Primeiro, apresento-me - meu nome é Alexander, nos últimos seis anos dediquei o desenvolvimento iOS. Agora sou responsável pelo aplicativo móvel ManyChat e solucionarei problemas usando o exemplo dele.

Vamos formular imediatamente o que faremos:

  • Adicionar funcionalidade ao tipo de erro
  • Transforme erros em alertas amigáveis
  • Exibimos as possíveis ações adicionais na interface e processamos seus cliques

E tudo isso estará no Swift :)

Vamos resolver o problema com um exemplo. O servidor retornou um erro com o código 500 em vez dos 200 esperados. O que o desenvolvedor deve fazer? No mínimo, com tristeza em informar o usuário - o post esperado com selos não pôde ser baixado. Na Apple, o padrão padrão é alerta, então vamos escrever uma função simples:

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 Por simplicidade, a maior parte do código estará no controlador. Você é livre para usar as mesmas abordagens em sua arquitetura. O código do artigo estará disponível no repositório ; no final do artigo, este link também estará.

Temos a seguinte imagem:



Teoricamente, concluímos a tarefa. Mas várias coisas são imediatamente evidentes:

  • Não tivemos a oportunidade de, de alguma forma, mudar de um cenário errôneo para um cenário bem-sucedido. OK, no caso atual, apenas oculta o alerta - e isso não é uma solução
  • Do ponto de vista da experiência do usuário, o texto precisa ser mais claro e neutro. Para que o usuário não tenha medo e não corra para colocar uma estrela na AppStore no seu aplicativo. Nesse caso, um texto detalhado nos seria útil ao depurar
  • E, para ser sincero, os alertas estão um pouco desatualizados como solução (cada vez mais, telas ou brindes simulados aparecem nos aplicativos). Mas essa já é uma pergunta que deve ser discutida separadamente com a equipe





Você deve admitir que a opção apresentada abaixo parece muito mais simpática. Qualquer que seja a opção escolhida, será necessário pensar em um mecanismo para exibir uma mensagem que parecerá ótima quando ocorrer um erro arbitrário, oferecer ao usuário um script claro para trabalhos adicionais no aplicativo e fornecer um conjunto de ações. A solução é:

  • Deve ser extensível. Todos sabemos sobre a variabilidade inerente ao design. Nosso mecanismo deve estar pronto para qualquer coisa
  • Ele é adicionado ao objeto (e removido) em algumas linhas de código
  • Bem testado

Mas antes disso, vamos mergulhar no mínimo teórico para erros no Swift.

Erro no Swift


Este parágrafo é uma visão geral de nível superior dos erros em geral. Se você já estiver usando ativamente seus erros no aplicativo, poderá prosseguir com segurança para o próximo parágrafo.

O que é um erro? Algum tipo de ação errada ou resultado incorreto. Frequentemente, podemos assumir possíveis erros e descrevê-los com antecedência no código.

Nesse caso, a Apple nos fornece o tipo Erro. Se abrirmos a documentação da Apple, o Erro será semelhante a este (relevante para o Swift 5.1):

public protocol Error {
}

Apenas um protocolo sem requisitos adicionais. A documentação explica gentilmente - a falta de parâmetros necessários permite que qualquer tipo seja usado no sistema de tratamento de erros Swift. Com um protocolo tão gentil, simplesmente trabalharemos.

A idéia de usar enum imediatamente vem à minha mente: há um número conhecido finito de erros, eles podem ter algum tipo de parâmetro. É o que a Apple está fazendo. Por exemplo, você pode considerar implementar um 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)
    
    ...

Aproveite as melhores práticas da Apple. Imagine um grupo de possíveis erros de rede de forma simplificada:

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

Agora, em qualquer lugar do aplicativo em que o erro ocorra, podemos usar o nosso Network.Error.

Como trabalhar com bugs? Existe um mecanismo de captura. Se uma função puder gerar um erro, ela será marcada com a palavra-chave throws. Agora, cada um de seus usuários precisa acessá-lo através da construção do catch. Se não houver erro, cairemos no bloco do, com um erro, no bloco catch. As funções que levam ao erro podem ser qualquer número no bloco do. O único aspecto negativo é que, na captura, recebemos um erro do tipo Error. Você precisará converter o erro no tipo desejado.

Como alternativa, podemos usar o opcional, ou seja, nulo em caso de erro e se livrar do design volumoso. Às vezes, é mais conveniente: digamos que quando obtemos uma variável opcional e, em seguida, aplique uma função throws a ela. O código pode ser colocado em um bloco if / guard e permanecerá conciso.

Aqui está um exemplo de como trabalhar com a função 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 Não confunda com do catch em outros idiomas. Swift não lança uma exceção, mas grava o valor do erro (se aconteceu) em um registro especial. Se houver um valor, ele será direcionado para o bloco de erro; caso contrário, o bloco do continuará. Fontes para os mais curiosos: www.mikeash.com/pyblog/friday-qa-2017-08-25-swift-error-handling-implementation.html

Esse método é bom para manipular eventos síncronos e não é tão conveniente para operações longas (por exemplo, solicitando dados pela rede), o que pode ser potencialmente demorado. Então você pode usar a conclusão simples.

Como alternativa ao Swift 5, o Result foi introduzido - uma enumeração preparada que contém duas opções - sucesso e fracasso. Por si só, não requer o uso de Erro. E não tem relação direta com assincronia. Porém, retornar com precisão esse tipo à conclusão é mais conveniente para eventos assíncronos (caso contrário, você precisará executar duas finalizações, êxito e falha ou retornar dois parâmetros). Vamos escrever um exemplo:

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 informação é suficiente para trabalharmos.

Mais uma vez, brevemente:

  • Erros no Swift é um protocolo
  • É conveniente apresentar erros na forma de enum
  • Existem duas maneiras de lidar com erros - síncrona (captura) e assíncrona (sua própria competição ou resultado)

Texto de erro


Vamos voltar ao tópico do artigo. No parágrafo acima, criamos nosso próprio tipo de erro. Ali está ele:

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

Agora, precisamos combinar cada erro com um texto que seja compreensível para o usuário. Vamos exibi-lo na interface em caso de erro. O Protocolo LocalizedError se apressa para nos ajudar. Ele herda o erro de protocolo e o complementa com 4 propriedades:

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

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

A exibição do erro dificilmente mudará:

	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)

Ótimo, tudo foi fácil com o texto. Vamos para os botões.

Recuperação de erro


Vamos apresentar o algoritmo de tratamento de erros em um diagrama simples. Para uma situação em que, como resultado de um erro, mostramos uma caixa de diálogo com as opções Tentar Novamente, Cancelar e, possivelmente, algumas específicas, obtemos o esquema:



Começaremos a resolver o problema a partir do final. Precisamos de uma função que mostre um alerta com n + 1 opções. Jogamos, como gostaríamos de mostrar um erro:

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

Uma função que determina o tipo de erro e transmite um sinal para exibir um alerta:

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


E um tipo de erro estendido, que possui um contexto e entendimento do que fazer com essa ou aquela opção.

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

A cabeça desenha imediatamente um diagrama da sua bicicleta. Mas primeiro, vamos verificar as docas da Apple. Talvez parte do mecanismo já esteja em nossas mãos.

Implementação nativa?


Um pouco de pesquisa na Internet resultará no 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 procurando:

  • recoveryOptions: [String] - uma propriedade que armazena opções de recuperação
  • func tryRecovery (optionIndex: Int) -> Bool - restaura de um erro de forma síncrona. Verdadeiro - com sucesso
  • func tryRecovery (optionIndex: Int, resultHandler: (Bool) -> Void) - opção assíncrona, a ideia é a mesma

Com os guias de uso, tudo é mais modesto. Uma pequena pesquisa no site da Apple e na área circundante leva a um artigo sobre tratamento de erros escrito antes dos anúncios públicos de Swift.

Resumidamente:

  • O mecanismo foi desenvolvido para aplicativos MacOs e mostra uma caixa de diálogo
  • Foi originalmente construído em torno do NSError.
  • O objeto RecoveryAttempter é encapsulado dentro do erro em userInfo, que conhece as condições do erro e pode escolher a melhor solução para o problema. O objeto não deve ser nulo
  • RecoveryAttempter deve oferecer suporte ao protocolo informal NSErrorRecoveryAttempting
  • Também em userInfo deve haver opção de recuperação
  • E tudo está ligado à chamada do método presentError, que está apenas no macOS SDK. Ele mostra um alerta
  • Se o alerta for mostrado através do presentError, quando você selecionar uma opção na janela pop-up no AppDelegate, uma função interessante será alterada:

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

Mas como não temos presentError, não podemos obtê-lo.



Neste ponto, parece que desenterramos um cadáver em vez de um tesouro. Teremos que transformar Error em NSError e escrever nossa própria função para exibir o alerta pelo aplicativo. Um monte de conexões implícitas. É possível, difícil e não totalmente claro - “Por quê?”.

Enquanto a próxima xícara de chá estiver sendo preparada, pode-se perguntar por que a função acima usa delegate como Any e passa no seletor. A resposta está abaixo:

Responda
iOS 2. ! ( , ). :)

Construindo uma bicicleta


Vamos implementar o protocolo, não vai nos machucar:

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

A dependência de índice não é a solução mais conveniente (podemos facilmente ir além da matriz e travar o aplicativo). Mas para MVP vai fazer. Pegue a idéia da Apple, apenas modernize-a. Precisamos de um objeto Attempter separado e de opções de botão que iremos fornecer:

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
    }

Agora você precisa mostrar o erro. Eu realmente gosto de protocolos, então resolverei o problema através deles. Vamos criar um protocolo universal para criar um UIAlertController a partir de erros:

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

E o protocolo para mostrar alertas criados:

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

Acabou sendo complicado, mas gerenciável. Podemos criar novas maneiras de mostrar um erro (por exemplo, brindar ou mostrar uma exibição personalizada) e registrar a implementação padrão sem alterar nada no método chamado.

Suponha que nossa visão fosse coberta por um protocolo:

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

Mas como nosso exemplo é muito mais simples, suportamos os dois protocolos e executamos o aplicativo:

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 tudo deu certo. Uma das condições iniciais estava em 2-3 linhas. Expandiremos nossa tentativa com um construtor 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)
    			}
    		}
    	}

Temos a solução MVP, e não será difícil conectar e chamá-la em qualquer lugar do nosso aplicativo. Vamos começar a verificar casos extremos e escalabilidade.

E se tivermos vários cenários de saída?


Suponha que um usuário tenha um repositório em nosso aplicativo. O cofre tem um limite de local. Nesse caso, o usuário tem dois cenários para sair do erro: ele pode liberar espaço ou comprar mais. Vamos escrever o seguinte 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)
    	}
    }



Isso foi facilmente resolvido.

Se queremos mostrar não um alerta, mas uma exibição de informações no meio da tela?




Alguns novos protocolos por analogia resolverão nosso 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
    	}
    }

Agora podemos mostrar erros na forma de uma exibição de informações. Além disso, podemos decidir como mostrá-los. Por exemplo, a primeira vez que você entra na tela e o erro - mostra a exibição de informações. E se a tela foi carregada com sucesso, mas a ação na tela retornou um erro - mostre um alerta.

Se não houver acesso à exibição?


Às vezes, você precisa gerar um erro, mas não há acesso à exibição. Ou não sabemos qual visualização está ativa no momento e queremos mostrar um alerta sobre tudo. Como resolver este problema?

Uma das maneiras mais fáceis (na minha opinião) de fazer a mesma coisa que a Apple faz com o teclado. Crie uma nova janela na parte superior da tela atual. Vamos fazer isso:

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

Crie um novo alerta que possa ser mostrado acima de tudo:

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



Na aparência, nada mudou, mas agora nos livramos da hierarquia de controladores de exibição. Eu recomendo não se deixar levar por esta oportunidade. É melhor chamar o código de exibição em um roteador ou entidade com os mesmos direitos. Em nome da transparência e clareza.

Fornecemos aos usuários uma ótima ferramenta para enviar spam a servidores durante mau funcionamento, manutenção etc. O que podemos melhorar?

Tempo mínimo de solicitação


Suponha que desligemos a Internet e tente novamente. Execute o carregador. A resposta virá instantaneamente e obterá um mini-jogo "Clicker". Com animação piscando. Não é legal demais.



Vamos transformar um erro instantâneo em um processo. A idéia é simples - faremos o tempo mínimo de solicitação. Aqui a implementação depende da sua abordagem à rede. Suponha que eu use Operation e, para mim, seja assim:

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 o caso geral, posso oferecer este design:

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

Ou podemos fazer uma abstração sobre nossas ações assíncronas e adicionar gerenciamento a ela:

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

Agora, nossa animação não parecerá tão nítida, mesmo quando estiver offline. Eu recomendo usar essa abordagem na maioria dos lugares com animação.



Para o modo avião, é bom exibir um prompt de alerta (o usuário pode esquecer de desativar o modo para começar a trabalhar com o aplicativo). Como, digamos, faz um telegrama. E para consultas importantes, é bom repetir várias vezes antes de mostrar um alerta ... Mas mais sobre isso outra vez :)

Testabilidade


Quando toda a lógica é despejada no viewController (como temos agora), é difícil testar. No entanto, se o seu viewController for compartilhado com a lógica de negócios, o teste se tornará uma tarefa trivial. Com um movimento das calças, a lógica de negócios se transforma em:

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
}

Juntamente com este artigo, nós:

  • Criou um mecanismo conveniente para exibir alertas
  • Deu aos usuários a opção de tentar novamente uma operação malsucedida
  • E tentou melhorar a experiência do usuário com nosso aplicativo

→  Link para o código

Obrigado a todos pelo seu tempo, teremos prazer em responder suas perguntas nos comentários.

All Articles