Roteamento para iOS: navegação universal sem reescrever o aplicativo

Em qualquer aplicativo que consiste em mais de uma tela, é necessário implementar a navegação entre seus componentes. Parece que isso não deve ser um problema, porque no UIKit existem componentes de contêiner bastante convenientes, como UINavigationController e UITabBarController, além de métodos flexíveis de exibição de tela modal: basta usar a navegação certa no momento certo.

No entanto, assim que o aplicativo alterna para uma tela usando uma notificação ou link push, tudo se torna um pouco mais complicado. Imediatamente há muitas perguntas:

  • O que fazer com o controlador de exibição, que agora está na tela?
  • como mudar o contexto (por exemplo, guia ativo no UITabBarController)?
  • A pilha de navegação atual tem a tela certa?
  • quando a navegação deve ser ignorada?




No desenvolvimento do iOS, nós no Badoo encontramos todos esses problemas. Como resultado, formalizamos nossos métodos de solução em uma biblioteca de componentes para navegação, que usamos em todos os novos produtos. Neste artigo, falarei sobre nossa abordagem em mais detalhes. Um exemplo da aplicação das práticas descritas pode ser visto em um pequeno projeto de demonstração .

Nosso problema


Muitas vezes, os problemas de navegação são resolvidos adicionando um componente global que conhece a estrutura das telas no aplicativo e decide o que fazer em um caso específico. A estrutura das telas significa informações sobre a presença de um contêiner na hierarquia atual de controladores e seções do aplicativo.

O Badoo tinha um componente semelhante. Funcionou de maneira semelhante com a biblioteca bastante antiga do Facebook, que agora não pode mais ser encontrada em seu repositório público. A navegação foi baseada em URLs associados às telas de aplicativos. Basicamente, toda a lógica estava contida em uma classe, ligada à presença de uma barra de guias e a outras funções específicas do Badoo. A complexidade e a conectividade desse componente eram tão altas que a solução de tarefas que exigiam uma alteração na lógica de navegação poderia demorar várias vezes mais do que o planejado. A testabilidade dessa classe também levantou grandes questões.

Este componente foi criado quando tínhamos apenas um aplicativo. Não podíamos imaginar que, no futuro, desenvolveríamos vários produtos bastante diferentes um do outro ( Bumble , Lumen e outros). Por esse motivo, o navegador do nosso aplicativo mais antigo - o Badoo - era impossível de usar em outros produtos e cada equipe precisava criar algo novo.

Infelizmente, novas abordagens também foram aprimoradas para aplicativos específicos. Com o crescente número de projetos, o problema tornou-se óbvio e surgiu a idéia de criar uma biblioteca que forneceria um certo conjunto de componentes, incluindo a lógica de navegação universal. Isso ajudaria a minimizar o tempo de implementação de funcionalidades semelhantes em novos produtos.

Implementamos um roteador universal


As principais tarefas resolvidas pelo navegador global não são tantas:

  1. Encontre a tela ativa atual.
  2. De alguma forma, compare o tipo de tela ativa e seu conteúdo com o que precisa ser mostrado.
  3. Execute a transição conforme necessário (sequência de transições).

Talvez a formulação das tarefas pareça um pouco abstrata, mas é essa abstração que torna possível universalizar a lógica.

1. Pesquisa ativa na tela


A primeira tarefa parece bastante simples: você só precisa percorrer toda a hierarquia de telas e encontrar o UIViewController superior.



A interface do nosso objeto pode ser algo como isto:

protocol TopViewControllerProvider { var topViewController: UIViewController? { get } } 

No entanto, não está claro como determinar o elemento raiz da hierarquia e o que fazer com as telas do contêiner, como o UIPageViewController e os contêineres específicos do aplicativo.

A opção mais fácil para determinar o elemento raiz é tirar o controlador raiz da tela ativa:

 UIApplication.shared.windows.first { $0.isKeyWindow }?.rootViewController 

Essa abordagem nem sempre funciona com aplicativos em que há várias janelas. Mas este é um caso bastante raro, e o problema pode ser resolvido passando explicitamente a janela desejada como parâmetro.

O problema com telas de contêiner pode ser resolvido criando um protocolo especial para eles, que conterá um método para obter uma tela ativa, ou você pode usar o protocolo anunciado acima. Todos os controladores de contêiner usados ​​no aplicativo devem implementar esse protocolo. Por exemplo, para um UITabBarController, uma implementação pode ser assim:

 extension UITabBarController: TopViewControllerProvider { var topViewController: UIViewController? { return self.selectedViewController } } 

Resta apenas percorrer toda a hierarquia e obter a tela superior. Se o próximo controlador implementar o TopViewControllerProvider, obteremos a tela mostrada através do método declarado. Caso contrário, o controlador mostrado nele será verificado modalmente (se houver).

2. Contexto atual


A tarefa de determinar o contexto atual parece muito mais complicada. Queremos determinar o tipo de tela e, possivelmente, as informações mostradas nela. Parece lógico criar uma estrutura contendo essas informações.

Mas que tipos devem ter propriedades de objetos? Nosso objetivo final é comparar o contexto com o que precisa ser mostrado, para que eles implementem o protocolo Equatable . Isso pode ser implementado através de tipos genéricos:

 struct ViewControllerContext<ScreenType: Equatable, InfoType: Equatable>: Equatable { let screenType: ScreenType let info: InfoType? } 

No entanto, devido às especificidades do Swift, isso impõe certas restrições ao uso desse tipo. Para evitar problemas, essa estrutura em nossos aplicativos tem uma aparência um pouco diferente:

 protocol ViewControllerContextInfo { func isEqual(to info: ViewControllerContextInfo?) -> Bool } struct ViewControllerContext: Equatable { public let screenType: String public let info: ViewControllerContextInfo? } 

Outra opção é aproveitar o novo recurso Swift, Opaque Types , mas ele só está disponível a partir do iOS 13, o que ainda é inaceitável para muitos produtos.

A implementação da comparação de contexto é bastante óbvia. Para não escrever a função isEqual para tipos que já implementam o Equatable, você pode fazer um truque simples, desta vez usando as vantagens do Swift:

 extension ViewControllerContextInfo where Self: Equatable { func isEqual(to info: ViewControllerContextInfo?) -> Bool { guard let info = info as? Self else { return false } return self == info } } 

Ótimo, temos um objeto para comparar. Mas como você pode associá-lo a um UIViewController ? Uma das maneiras é usar objetos associados , uma função útil da linguagem Objective C. Em alguns casos, mas em primeiro lugar, não é muito explícito e, em segundo lugar, geralmente queremos comparar o contexto de apenas algumas telas de aplicativos. Portanto, a criação de um protocolo parece ter boas idéias:

 protocol ViewControllerContextHolder { var currentContext: ViewControllerContext? { get } } 


e sua implementação apenas nas telas necessárias. Se a tela ativa não implementar esse protocolo, seu conteúdo poderá ser considerado insignificante e não levado em consideração ao mostrar um novo.

3. Execução de transição


Vamos ver o que já temos. A capacidade, a qualquer momento, de obter informações sobre a tela ativa na forma de uma estrutura de dados específica. Informações recebidas externamente por meio de um URL aberto, uma notificação por push ou outra maneira de iniciar a navegação, que podem ser convertidas em uma estrutura do mesmo tipo e servem como intenção de navegação. Se a tela superior já mostrar as informações necessárias, você poderá simplesmente ignorar a navegação ou atualizar o conteúdo da tela.



Mas e a própria transição?

É lógico criar um componente (vamos chamá-lo de roteador ), que aceitará o que precisa ser mostrado, comparará com o que já foi mostrado e executará uma transição ou sequência de transições. Além disso, o roteador pode conter lógica geral para processar e validar informações e status do aplicativo. O principal é que você não deve incluir lógica específica para um domínio ou função de aplicativo nesse componente. Se você seguir esta regra, ela permanecerá reutilizável para diferentes aplicativos e fácil de manter.

A declaração básica da interface desse protocolo é semelhante a esta:

 protocol ViewControllerContextRouterProtocol { func navigateToContext(_ context: ViewControllerContext, animated: Bool) } 

Você pode generalizar a função acima passando uma sequência de contextos. Isso não terá um impacto significativo na implementação.

É bastante óbvio que o roteador precisará de uma fábrica de controladores, porque apenas os dados de navegação são recebidos em sua entrada. É necessário criar telas separadas dentro da fábrica e talvez até módulos inteiros com base no contexto transferido. No campo screenType , é possível determinar qual tela você deseja criar, no campo info - com quais dados você precisa preenchê-lo previamente:

 protocol ViewControllersByContextFactory { func viewController(for context: ViewControllerContext) -> UIViewController? } 

Se o aplicativo não for um clone do Snapchat, provavelmente o número de métodos usados ​​para exibir o novo controlador será pequeno. Portanto, para a maioria dos aplicativos, atualizar a pilha UINavigationController e exibir uma tela modal é suficiente. Nesse caso, você pode definir uma enumeração com tipos possíveis, por exemplo:

 enum NavigationType { case modal case navigationStack case rootScreen } 

O tipo de tela depende de como é exibido. Se essa é uma notificação de bloqueio, ela precisa ser mostrada modalmente. Pode ser necessário adicionar outra tela a uma pilha de navegação existente por meio do UINavigationController .

Decidir como mostrar uma tela específica é melhor não no próprio roteador. Se adicionarmos a dependência do roteador ao protocolo ViewControllerNavigationTypeProvider e implementarmos o conjunto desejado de métodos específicos para cada aplicativo, alcançaremos este objetivo:

 protocol ViewControllerNavigationTypeProvider { func navigationType(for context: ViewControllerContext) -> NavigationType } 

Mas e se quisermos introduzir um novo tipo de navegação em um dos aplicativos? Precisa adicionar uma nova opção ao enum, e todos os outros aplicativos saberão sobre isso? Provavelmente, em alguns casos, é exatamente isso que estamos buscando, mas se você seguir o princípio de aberto-fechado , para obter mais flexibilidade, poderá inserir o protocolo de um objeto que pode executar transições:

 protocol ViewControllerContextTransition { func navigate(from source: UIViewController?, to destination: UIViewController, animated: Bool) } 

O ViewControllerNavigationTypeProvider se tornará o seguinte:

 protocol ViewControllerContextTransitionProvider { func transition(for context: ViewControllerContext) -> ViewControllerContextTransition } 

Agora, não estamos limitados a um conjunto fixo de tipos de exibição na tela e podemos expandir os recursos de navegação sem alterações no próprio roteador.

Às vezes, você não precisa criar um novo UIViewController para mudar para alguma tela - basta mudar para uma existente. O exemplo mais óbvio é alternar guias em um UITabBarController . Outro exemplo é a transição para um elemento existente na pilha de controladores mostrados, em vez de criar uma nova tela com o mesmo conteúdo. Para fazer isso, no roteador, antes de criar um novo UIViewController, você pode primeiro verificar se o contexto pode ser simplesmente alternado.

Como resolver este problema? Mais abstrações!

 protocol ViewControllerContextSwitcher { func canSwitch(to context: ViewControllerContext) -> Bool func switchContext(to context: ViewControllerContext, animated: Bool) } 

No caso de guias, esse protocolo pode ser implementado por um componente que sabe o que está contido no UITabBarViewController e pode mapear o ViewControllerContext para uma guia específica e alternar guias.



Um conjunto desses objetos pode ser passado para o roteador como uma dependência.

Para resumir, o algoritmo de processamento de contexto terá a seguinte aparência:

 func navigateToContext(_ context: ViewControllerContext, animated: Bool) { let topViewController = self.topViewControllerProvider.topViewController if let contextHolder = topViewController as? ViewControllerContextHolder, contextHolder.currentContext == context { return } if let switcher = self.contextSwitchers.first(where: { $0.canSwitch(to: context) }) { switcher.switchContext(to: context, animated: animated) return } guard let viewController = self.viewControllersFactory.viewController(for: context) else { return } let navigation = self.transitionProvider.navigation(for: context) navigation.navigate(from: self.topViewControllerProvider.topViewController, to: viewController, animated: true) } 


É conveniente apresentar o diagrama de dependência do roteador na forma de um diagrama UML:



O roteador resultante pode ser usado para transições iniciadas automaticamente ou por meio de ações do usuário. Em nossos produtos, se a navegação não ocorrer automaticamente, as funções padrão do sistema são usadas e a maioria dos módulos não tem conhecimento da existência de um roteador global. É importante lembrar apenas sobre a implementação do protocolo ViewControllerContextHolder , quando necessário, para que o roteador possa sempre descobrir as informações que o usuário vê no momento atual.

Vantagens e desvantagens


Recentemente, começamos a introduzir o método de gerenciamento de navegação descrito nos produtos Badoo. Apesar de a implementação ter sido um pouco mais complicada do que a opção apresentada no projeto de demonstração , estamos satisfeitos com os resultados. Vamos avaliar as vantagens e desvantagens da abordagem descrita.

Entre os benefícios estão:

  • universalidade
  • relativa facilidade de implementação, quando comparado com as opções apresentadas na seção alternativas,
  • falta de restrições na arquitetura do aplicativo e na implementação da navegação convencional entre telas.

As desvantagens são em parte uma conseqüência das vantagens.

  • Os controladores precisam saber quais informações eles mostram. Se considerarmos a arquitetura do aplicativo, o UIViewController deve ser atribuído à camada de exibição e a lógica de negócios não deve ser armazenada nessa camada. A estrutura de dados que contém o contexto de navegação deve ser implementada lá a partir da camada de lógica de negócios, mas, no entanto, os controladores armazenam essas informações, o que não é muito correto.
  • A fonte da verdade sobre o estado do aplicativo é a hierarquia das telas mostradas, o que em alguns casos pode ser uma limitação.


Alternativas


Uma alternativa a essa abordagem poderia ser construir manualmente uma hierarquia de módulos ativos. Um exemplo dessa solução é a implementação do padrão Coordenador, em que os coordenadores formam uma estrutura em árvore que serve como fonte de verdade para determinar a tela ativa, e a lógica da decisão de mostrar essa ou aquela tela está ou não contida nos próprios coordenadores.

Idéias semelhantes podem ser encontradas na arquitetura do RIBs , usada pela nossa equipe do Android.

Essas alternativas fornecem uma abstração mais flexível, mas exigem uniformidade na arquitetura e podem ser muito complicadas para muitos aplicativos.

Se você adotou uma abordagem diferente para solucionar esses problemas, não hesite em falar sobre isso nos comentários.

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


All Articles