Composição dos UIViewControllers e navegação entre eles (e não apenas)


Neste artigo, quero compartilhar a experiência que usamos com sucesso há vários anos em nossos aplicativos para iOS, 3 dos quais estão atualmente na Appstore. Essa abordagem funcionou bem e nós a separamos recentemente do restante do código e a projetamos em uma biblioteca RouteComposer separada, que será discutida de fato .


https://github.com/ekazaev/route-composer


Mas, para começar, vamos tentar descobrir o que significa a composição dos controladores de exibição no iOS.


Antes de prosseguir com a explicação em si, lembro que no iOS é geralmente entendido como um controlador de exibição ou UIViewController . Essa é uma classe herdada do UIViewController padrão, que é o controlador de padrão MVC básico que a Apple recomenda usar para o desenvolvimento de aplicativos iOS.


Você pode usar padrões arquiteturais alternativos, como MVVM, VIP, VIPER, mas neles o UIViewController estará envolvido de uma maneira ou de outra, o que significa que essa biblioteca pode ser usada com eles. A essência do UIViewController usada para controlar o UIView , que na maioria das vezes representa uma tela ou uma parte significativa da tela, processa eventos a partir dele e exibe alguns dados nele.



Todos os UIViewController podem ser condicionalmente divididos em Controladores de exibição normal , responsáveis ​​por alguma área visível na tela, e Controladores de exibição de contêiner , que, além de exibir a si mesmos e alguns de seus controles, também podem exibir controladores de exibição filho integrados a eles de uma maneira ou de outra .


Os controladores de exibição de contêiner padrão fornecidos com o Cocoa Touch incluem: UINavigationConroller , UITabBarController , UISplitController , UIPageController e alguns outros. Além disso, o usuário pode criar seus próprios controladores de exibição de contêiner personalizados, seguindo as regras do Cocoa Touch descritas na documentação da Apple.


O processo de introdução de controladores de exibição padrão nos controladores de exibição de contêiner, bem como a integração de controladores de exibição na pilha de controladores, chamaremos a composição neste artigo.


Por que, então, a solução padrão para a composição de controladores de exibição acabou não sendo ideal para nós, e desenvolvemos uma biblioteca que facilita nosso trabalho.


Vamos dar uma olhada na composição de alguns controladores de exibição de contêiner padrão como um exemplo:


Exemplos de composição em recipientes padrão


UINavigationController



 let tableViewController = UITableViewController(style: .plain) //        let navigationController = UINavigationController(rootViewController: tableViewController) // ... //        let detailViewController = UIViewController(nibName: "DetailViewController", bundle: nil) navigationController.pushViewController(detailViewController, animated: true) // ... //     navigationController.popToRootViewController(animated: true) 

UITabBarController



 let firstViewController = UITableViewController(style: .plain) let secondViewController = UIViewController() //   let tabBarController = UITabBarController() //         tabBarController.viewControllers = [firstViewController, secondViewController] //        tabBarController.selectedViewController = secondViewController 

UISplitViewController



 let firstViewController = UITableViewController(style: .plain) let secondViewController = UIViewController() //   let splitViewController = UISplitViewController() //        splitViewController.viewControllers = [firstViewController] //        splitViewController.showDetailViewController(secondViewController, sender: nil) 

Exemplos de integração (composição) de controladores de exibição na pilha


Instalando a Raiz do Controlador de Visualização


 let window: UIWindow = //... window.rootViewController = viewController window.makeKeyAndVisible() 

Apresentação modal do controlador de exibição


 window.rootViewController.present(splitViewController, animated: animated, completion: nil) 

Por que decidimos criar uma biblioteca para composição


Como você pode ver nos exemplos acima, não há uma maneira única de integrar controladores de exibição convencionais em contêineres, assim como não há uma maneira única de construir uma pilha de controladores de exibição. E, se você quiser alterar um pouco o layout do seu aplicativo ou a maneira como navega nele, precisará de alterações significativas no código do aplicativo, também precisará de links para objetos de contêiner para poder inserir seus controladores de exibição neles, etc. Ou seja, o próprio método padrão implica uma quantidade bastante grande de trabalho, bem como a presença de links para visualizar controladores para gerar ações e apresentações de outros controladores.


Tudo isso adiciona uma dor de cabeça a vários métodos de links diretos no aplicativo (por exemplo, usando links universais), já que você precisa responder à pergunta: e se o controlador precisar ser mostrado ao usuário, pois ele clicou no link no safari já é mostrado ou estou olhando o controlador que deve mostrar que ainda não foi criado , forçando você a percorrer os controladores da árvore de visão e escrever código a partir do qual às vezes seus olhos começam a sangrar e que qualquer desenvolvedor do iOS tenta ocultar. Além disso, ao contrário da arquitetura Android, em que cada tela é criada separadamente, no iOS, para mostrar parte do aplicativo imediatamente após o lançamento, pode ser necessário criar uma pilha bastante grande de controladores que serão ocultados sob o que você mostra a pedido.


Seria ótimo chamar métodos como goToAccount() , goToMenu() ou goToProduct(withId: "012345") quando um usuário clica em um botão ou quando um aplicativo goToProduct(withId: "012345") link universal de outro aplicativo e não pensa em integrar esse controlador de exibição na pilha, sabendo que o criador deste controlador de exibição já forneceu essa implementação.


Além disso, frequentemente, nossos aplicativos consistem em um grande número de telas desenvolvidas por equipes diferentes e, para acessar uma das telas durante o processo de desenvolvimento, você precisa passar por outra tela que ainda não foi criada. Em nossa empresa, usamos a abordagem que chamamos de placa de Petri . Ou seja, no modo de desenvolvimento, o desenvolvedor e o testador têm acesso a uma lista de todas as telas de aplicativos e ele pode ir a qualquer uma delas (é claro, algumas delas podem exigir alguns parâmetros de entrada).



Você pode interagir com eles e testar individualmente e, em seguida, montá-los no aplicativo final para produção. Essa abordagem facilita muito o desenvolvimento, mas, como você viu nos exemplos acima, o inferno da composição começa quando você precisa manter em código várias maneiras de integrar o controlador de exibição na pilha.


Resta acrescentar que tudo isso será multiplicado por N assim que sua equipe de marketing manifestar o desejo de realizar testes A / B em usuários ativos e verificar qual método de navegação funciona melhor, por exemplo, uma barra de guia ou um menu de hambúrguer?


  • Vamos cortar as pernas de Susanin Vamos mostrar 50% dos usuários da barra de guias e para o outro menu Hambúrguer. Em um mês, informaremos quais usuários veem mais nossas ofertas especiais?

Tentarei dizer como abordamos a solução para esse problema e finalmente a alocamos na biblioteca RouteComposer.


Susanin Compositor de rotas


Após analisar todos os cenários de composição e navegação, tentamos abstrair o código mostrado nos exemplos acima e identificamos três entidades principais das quais a biblioteca RouteComposer opera - Factory , Finder , Action . Além disso, a biblioteca contém três entidades auxiliares responsáveis ​​por um pequeno ajuste que pode ser necessário durante o processo de navegação - RoutingInterceptor , ContextTask , PostRoutingTask . Todas essas entidades devem ser configuradas em uma cadeia de dependências e transferidas para o Router y, o objeto que criará sua pilha de controladores.


Mas, sobre cada um deles em ordem:


Fábrica


Como o nome sugere, o Factory é responsável por criar o controlador de exibição.


 public protocol Factory { associatedtype ViewController: UIViewController associatedtype Context func build(with context: Context) throws -> ViewController } 

Aqui é importante fazer uma reserva sobre o conceito de contexto . O contexto dentro da biblioteca, chamamos de tudo o que o espectador pode precisar para ser criado. Por exemplo, para mostrar um controlador de exibição que exibe detalhes do produto, você precisa passar um determinado productID para ele, por exemplo, na forma de uma String . A essência do contexto pode ser qualquer coisa: um objeto, estrutura, bloco ou tupla. Se o seu controlador não precisar de nada para ser criado - o contexto pode ser especificado como Any? e instale em nil .


Por exemplo:


 class ProductViewControllerFactory: Factory { func build(with productID: UUID) throws -> ProductViewController { let productViewController = ProductViewController(nibName: "ProductViewController", bundle: nil) productViewController.productID = productID //  ,      `ContextAction`,     return productViewController } } 

A partir da implementação acima, fica claro que esta fábrica carregará a imagem do controlador do arquivo XIB e instalará o productID transferido nele. Além do protocolo padrão de Factory , a biblioteca fornece várias implementações padrão desse protocolo para evitar que você escreva código banal (em particular, o exemplo acima).


Além disso, evitarei fornecer descrições de protocolos e exemplos de suas implementações, pois você pode se familiarizar com eles em detalhes fazendo o download do exemplo que acompanha a biblioteca. Existem várias implementações de fábricas para controladores e contêineres convencionais de exibição, bem como maneiras de configurá-los.


Acção


A entidade Action é uma descrição de como integrar um controlador de exibição que será construído pela fábrica na pilha. O controlador de exibição após a criação não pode simplesmente ficar suspenso no ar e, portanto, cada fábrica deve conter Action como pode ser visto no exemplo acima.


A implementação mais comum do Action é a apresentação modal do controlador:


 class PresentModally: Action { func perform(viewController: UIViewController, on existingController: UIViewController, animated: Bool, completion: @escaping (_: ActionResult) -> Void) { guard existingController.presentedViewController == nil else { completion(.failure("\(existingController) is already presenting a view controller.")) return } existingController.present(viewController, animated: animated, completion: { completion(.continueRouting) }) } } 

A biblioteca contém a implementação das formas mais padrão de integrar controladores de exibição na pilha, e você provavelmente não precisará criar seus próprios até usar algum tipo de controlador de exibição de contêiner personalizado ou método de apresentação. Mas a criação de ações personalizadas não deve causar problemas se você ler os exemplos.


Localizador


A essência do Finder responde ao roteador à pergunta - Esse controlador já está criado e já está na pilha? Talvez não seja necessário criar nada e basta mostrar o que já está lá? .


 public protocol Finder { associatedtype ViewController: UIViewController associatedtype Context func findViewController(with context: Context) -> ViewController? } 

Se você armazenar links para todos os controladores de exibição criados, na implementação do Finder poderá simplesmente retornar um link ao controlador de exibição desejado. Mas na maioria das vezes não é assim, porque a pilha de aplicativos, especialmente se for grande, muda bastante dinamicamente. Além disso, você pode ter vários controladores de exibição idênticos na pilha mostrando entidades diferentes (por exemplo, vários ProductViewControllers mostrando produtos diferentes com diferentes IDs de produto), portanto, a implementação do Finder pode exigir implementação personalizada e procurar o controlador de exibição correspondente na pilha. A biblioteca facilita essa tarefa, fornecendo o StackIteratingFinder como uma extensão para o Finder , um protocolo com as configurações apropriadas para simplificar essa tarefa. Na implementação do StackIteratingFinder você só precisa responder à pergunta - esse controlador de exibição é aquele que o roteador está procurando mediante sua solicitação.


Um exemplo dessa implementação:


 class ProductViewControllerFinder: StackIteratingFinder { let options: SearchOptions init(options: SearchOptions = .currentAndUp) { self.options = options } func isTarget(_ productViewController: ProductViewController, with productID: UUID) -> Bool { return productViewController.productID == productID } } 

Entidades auxiliares


RoutingInterceptor


RoutingInterceptor permite executar algumas ações antes de iniciar a composição dos controladores de exibição e informar ao roteador se é possível integrar os controladores de exibição na pilha. O exemplo mais comum de uma tarefa desse tipo é a autenticação (mas nem um pouco comum na implementação). Por exemplo, você deseja mostrar um controlador de exibição com os detalhes de uma conta de usuário, mas, para isso, o usuário deve estar logado no sistema. Você pode implementar o RoutingInterceptor e adicioná-lo à configuração da visualização do controlador de detalhes do usuário e à verificação interna: se o usuário estiver conectado - permita que o roteador continue a navegação, se não - mostre o controlador de visualização que solicitará que o usuário efetue login e se essa ação for bem-sucedida - permita que o roteador continue a navegação ou cancele ela se o usuário se recusar a fazer login.


 class LoginInterceptor: RoutingInterceptor { func execute(for destination: AppDestination, completion: @escaping (_: InterceptorResult) -> Void) { guard !LoginManager.sharedInstance.isUserLoggedIn else { // ... //  LoginViewController       completion(.success)  completion(.failure("User has not been logged in.")) // ... return } completion(.success) } } 

Uma implementação desse RoutingInterceptor com comentários está contida no exemplo fornecido com a biblioteca.


ContextTask


A ContextTask , se você a fornecer, pode ser aplicada separadamente a cada controlador de exibição na configuração, independentemente de ter sido criada apenas por um roteador ou encontrada na pilha, e você deseja atualizar os dados nele e / ou definir alguns padrões. parâmetros (por exemplo, mostrar o botão fechar ou não mostrar).


PostRoutingTask


A implementação do PostRoutingTask será chamada pelo roteador após a conclusão bem-sucedida da integração do controlador de exibição solicitado na pilha. Na sua implementação, é conveniente adicionar várias análises ou obter vários serviços.


Em mais detalhes com a implementação de todas as entidades descritas, pode ser encontrada na documentação da biblioteca e no exemplo em anexo.


PS: O número de entidades auxiliares que podem ser adicionadas à configuração não é limitado.


Configuração


Todas as entidades descritas são boas, pois dividem o processo de composição em blocos pequenos, intercambiáveis ​​e bem confiáveis.


Agora nos voltamos para a coisa mais importante - para a configuração, ou seja, a conexão desses blocos entre si. Para coletar esses blocos entre si e combiná-los em uma cadeia de etapas, a biblioteca fornece uma classe de construtor StepAssembly (para contêineres - ContainerStepAssembly ). Sua implementação permite que você encadeie os blocos de composição em um único objeto de configuração, como contas em uma string, e também indique as dependências nas configurações de outros controladores de exibição. O que fazer com a configuração no futuro depende de você. Você pode alimentá-lo no roteador com os parâmetros necessários e ele criará uma pilha de controladores para você, você pode salvá-lo no dicionário e usá-lo mais tarde por chave - isso depende da sua tarefa específica.


Considere um exemplo trivial: suponha que, ao clicar em uma célula da lista ou quando o aplicativo receba um link universal de um safari ou cliente de email, precisamos mostrar modalmente o controlador do produto com um determinado productID. Nesse caso, o controlador do produto deve ser construído dentro do UINavigationController para poder mostrar seu nome e botão fechar no painel de controle. Além disso, este produto só pode ser exibido para usuários que estão conectados; caso contrário, convide-os para fazer login.


Se você analisar este exemplo sem usar uma biblioteca, será algo parecido com isto:


 class ProductArrayViewController: UITableViewController { let products: [UUID]? let analyticsManager = AnalyticsManager.sharedInstance //  UITableViewControllerDelegate override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { guard let productID = products[indexPath.row] else { return } //   LoginInterceptor guard !LoginManager.sharedInstance.isUserLoggedIn else { //    LoginViewController         `showProduct(with: productID)` return } showProduct(with: productID) } func showProduct(with productID: String) { //   ProductViewControllerFactory let productViewController = ProductViewController(nibName: "ProductViewController", bundle: nil) //   ProductViewControllerContextTask productViewController.productID = productID //   NavigationControllerStep  PushToNavigationAction let navigationController = UINavigationController(rootViewController: productViewController) //   GenericActions.PresentModally present(alertController, animated: navigationController) { [weak self]   . ProductViewControllerPostTask self?.analyticsManager.trackProductView(productID: productID) } } } 

Este exemplo não inclui a implementação de links universais, o que exigirá o isolamento do código de autorização e a manutenção do contexto para o qual o usuário deve ser direcionado, além da pesquisa, de repente o usuário clica em um link e esse produto já é mostrado a ele, o que, em última análise, tornará o código muito difícil de ler.


Considere a configuração deste exemplo usando a biblioteca:


 let productScreen = StepAssembly(finder: ProductViewControllerFinder(), factory: ProductViewControllerFactory()) //  : .adding(LoginInterceptor()) .adding(ProductViewControllerContextTask()) .adding(ProductViewControllerPostTask(analyticsManager: AnalyticsManager.sharedInstance)) //  : .using(PushToNavigationAction()) .from(NavigationControllerStep()) // NavigationControllerStep -> StepAssembly(finder: NilFinder(), factory: NavigationControllerFactory()) .using(GeneralAction.presentModally()) .from(GeneralStep.current()) .assemble() 

Se você traduzir isso para a linguagem humana:


  • Verifique se o usuário está logado e se não oferecer uma entrada
  • Se o usuário efetuou login com êxito, continue
  • Controlador de visualização do produto de pesquisa fornecido pelo Finder
  • Se foi encontrado - torne visível e finalize
  • Se não foi encontrado - crie um UINavigationController , integre nele o controlador de exibição criado por ProductViewControllerFactory usando PushToNavigationAction
  • GenericActions.PresentModally UINavigationController GenericActions.PresentModally usando GenericActions.PresentModally no controlador de exibição atual

A configuração requer algum estudo, como muitas soluções complexas, por exemplo, o conceito de AutoLayout e, à primeira vista, pode parecer complicado e redundante. No entanto, o número de tarefas a serem resolvidas com o fragmento de código fornecido abrange todos os aspectos, desde a autorização até a vinculação profunda, e a quebra em uma sequência de ações torna possível alterar facilmente a configuração sem a necessidade de fazer alterações no código. Além disso, a implementação do StepAssembly ajudará a evitar problemas com uma cadeia de etapas incompleta e o tipo de controle - problemas com incompatibilidade de parâmetros de entrada para diferentes controladores de exibição.


Considere o pseudo-código de um aplicativo completo no qual um ProductArrayViewController exibe uma lista de produtos e, se o usuário seleciona este produto, exibe-o dependendo se o usuário está logado ou não, ou se oferece para efetuar login e é exibido após um login bem-sucedido:


Objetos de configuração


 // `RoutingDestination`    .          . struct AppDestination: RoutingDestination { let finalStep: RoutingStep let context: Any? } struct Configuration { //     ,             static func productDestination(with productID: UUID) -> AppDestination { let productScreen = StepAssembly(finder: ProductViewControllerFinder(), factory: ProductViewControllerFactory()) .add(LoginInterceptor()) .add(ProductViewControllerContextTask()) .add(ProductViewControllerPostTask(analyticsManager: AnalyticsManager.sharedInstance)) .using(PushToNavigationAction()) .from(NavigationControllerStep()) .using(GenericActions.PresentModally()) .from(CurrentControllerStep()) .assemble() return AppDestination(finalStep: productScreen, context: productID) } } 


 class ProductArrayViewController: UITableViewController { let products: [UUID]? //... // DefaultRouter -  Router   ,   UIViewController   let router = DefaultRouter() override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { guard let productID = products[indexPath.row] else { return } router.navigate(to: Configuration.productDestination(with: productID)) } } 


 @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { //... func application(_ application: UIApplication, open url: URL, sourceApplication: String?, annotation: Any) -> Bool { guard let productID = UniversalLinksManager.parse(url: url) else { return false } return DefaultRouter().navigate(to: Configuration.productDestination(with: productID)) == .handled } } 

.


. , , , — ProductArrayViewController, UINavigationController HomeViewController — StepAssembly from() . RouteComposer , ( ). , Configuration . , A/B , .


Em vez de uma conclusão


, 3 . , , . Fabric , Finder Action . , — , , . , .


, , objective c Cocoa Touch, . iOS 9 12.


UIViewController (MVC, MVVM, VIP, RIB, VIPER ..)


, , , . . .


.

Source: https://habr.com/ru/post/pt421097/


All Articles