O mundo da programação moderna é rico em tendências, e isso é duplamente verdadeiro para o mundo da programação de
aplicativos "iOS" . Espero não estar muito enganado em afirmar que um dos padrões arquitetônicos mais "elegantes" dos últimos anos é o "coordenador". Então, nossa equipe, há algum tempo, percebeu um desejo irresistível de experimentar essa técnica por conta própria. Além disso, um caso muito bom apareceu - uma mudança significativa na lógica e um novo planejamento total da navegação no aplicativo.
O problema
Muitas vezes acontece que os controladores começam a assumir muito: "dê comandos" diretamente ao
UINavigationController
, "comunique" com os controladores "irmãos" (até inicialize-os e passe-os para a pilha de navegação) - em geral, há muito o que fazer do que eles nem deveriam suspeitar.
Uma das maneiras possíveis de evitar isso é precisamente o "coordenador". Além disso, é bastante conveniente trabalhar e muito flexível: o modelo é capaz de gerenciar eventos de navegação dos dois pequenos módulos (representando, talvez, apenas uma única tela) e de todo o aplicativo (iniciando seu próprio "fluxo", relativamente falando, diretamente do
UIApplicationDelegate
).
A história
Martin Fowler, em seu livro
Patterns of Enterprise Application Architecture, chamou esse padrão de
Application Controller . E seu primeiro popularizador no ambiente "iOS" é considerado
Sorush Khanlu : tudo começou com
seu relatório sobre "NSSpain" em 2015. Em seguida, um
artigo de revisão apareceu
em seu site , com várias sequências (por exemplo,
essa ).
E, em seguida, muitas revisões foram seguidas (a consulta "ios coordenadores" fornece dezenas de resultados de diferentes qualidades e graus de detalhes), incluindo até mesmo um
guia sobre Ray Wenderlich e
um artigo de Paul Hudson sobre seu "Hacking with Swift" como parte de uma série de materiais sobre como se livrar do problema Controlador "maciço".
Olhando para o futuro, o tópico mais notável da discussão é o problema do botão voltar no
UINavigationController
, cujo clique não é processado pelo nosso código, mas só podemos receber um
retorno de chamada .
Na verdade, por que isso é um problema? Os coordenadores, como qualquer objeto, para existir na memória, precisam de algum outro objeto para “possuí-los”. Como regra, ao criar um sistema de navegação usando coordenadores, alguns coordenadores geram outros e mantêm um forte vínculo com eles. Ao "sair da zona de responsabilidade" do coordenador de origem, o controle retorna ao coordenador de origem e a memória ocupada pelo originador deve ser liberada.
Sorush tem
sua própria visão para resolver esse problema e também observa algumas
abordagens dignas . Mas vamos voltar a isso.
Primeira abordagem
Antes de começar a mostrar o código real, gostaria de esclarecer que, embora os princípios sejam totalmente consistentes com os que criamos no projeto, trechos do código e exemplos de seu uso são simplificados e reduzidos sempre que isso não interfere na percepção deles.Quando começamos a experimentar os coordenadores da equipe, não tínhamos muito tempo e liberdade de ação para isso: era necessário considerar os princípios existentes e o dispositivo de navegação. A primeira opção de implementação para coordenadores foi baseada em um "roteador" comum, de propriedade e operado pelo
UINavigationController
. Ele sabe como fazer com as instâncias do
UIViewController
tudo o que é necessário em relação à navegação - push / pop, present / dispens e mais manipulações com
o controlador raiz . Um exemplo da interface desse roteador:
import UIKit protocol Router { func present(_ module: UIViewController, animated: Bool) func dismissModule(animated: Bool, completion: (() -> Void)?) func push(_ module: UIViewController, animated: Bool, completion: (() -> Void)?) func popModule(animated: Bool) func setAsRoot(_ module: UIViewController) func popToRootModule(animated: Bool) }
Uma implementação específica é inicializada com uma instância de
UINavigationController
e não contém nada de particularmente complicado. A única limitação: você não pode passar outras instâncias do
UINavigationController
como argumentos para os métodos de interface (por razões óbvias: o
UINavigationController
não pode conter o
UINavigationController
em sua pilha - essa é uma restrição do
UIKit
).
O coordenador, como qualquer objeto, precisa de um proprietário - outro objeto que armazene um link para ele. Um link para a raiz pode ser armazenado pelo objeto que a gera, mas cada coordenador também pode gerar outros coordenadores. Portanto, uma interface base foi escrita para fornecer um mecanismo de gerenciamento para os coordenadores gerados:
class Coordinator { private var childCoordinators = [Coordinator]() func add(dependency coordinator: Coordinator) {
Uma das vantagens implícitas dos coordenadores é o encapsulamento do conhecimento sobre subclasses específicas do
UIViewController
. Para garantir a interação do roteador e dos coordenadores, introduzimos a seguinte interface:
protocol Presentable { func presented() -> UIViewController }
Cada coordenador específico deve herdar do
Coordinator
e implementar a interface
Presentable
, e a interface do roteador deve assumir o seguinte formato:
protocol Router { func present(_ module: Presentable, animated: Bool) func dismissModule(animated: Bool, completion: (() -> Void)?) func push(_ module: Presentable, animated: Bool, completion: (() -> Void)?) func popModule(animated: Bool) func setAsRoot(_ module: Presentable) func popToRootModule(animated: Bool) }
(A abordagem com
Presentable
também permite que você use coordenadores dentro de módulos escritos para interagir diretamente com instâncias do
UIViewController
, sem
UIViewController
los (módulos) a processamento radical.)
Um breve exemplo de tudo isso em ação:
final class FirstCoordinator: Coordinator, Presentable { func presented() -> UIViewController { return UIViewController() } } final class SecondCoordinator: Coordinator, Presentable { func presented() -> UIViewController { return UIViewController() } } let nc = UINavigationController() let router = RouterImpl(navigationController: nc)
Próxima aproximação
E então um dia chegou o momento de uma total alteração da navegação e absoluta liberdade de expressão! O momento em que nada nos impediu de tentar implementar a navegação nos coordenadores usando o cobiçado método
start()
- uma versão que cativou originalmente com sua simplicidade e concisão.
Os recursos do
Coordinator
mencionados acima obviamente não serão supérfluos. Mas o mesmo método precisa ser adicionado à interface geral:
protocol Coordinator { func add(dependency coordinator: Coordinator) func remove(dependency coordinator: Coordinator) func start() } class BaseCoordinator: Coordinator { private var childCoordinators = [Coordinator]() func add(dependency coordinator: Coordinator) {
"Swift" não oferece a capacidade de declarar classes abstratas (uma vez que é mais orientado para uma abordagem orientada a protocolos do que para uma abordagem mais clássica
e orientada a objetos ), portanto, o método
start()
pode ser deixado com uma implementação ou impulso vazio existe algo como
fatalError(_:file:line:)
(forçando a substituir esse método por herdeiros). Pessoalmente, prefiro a primeira opção.
Mas o Swift tem uma grande oportunidade de adicionar métodos de implementação padrão aos métodos de protocolo; portanto, o primeiro pensamento, é claro, não foi declarar uma classe base, mas fazer algo assim:
extension Coordinator { func add(dependency coordinator: Coordinator) {
Mas as extensões de protocolo não podem declarar campos armazenados, e as implementações desses dois métodos devem obviamente se basear em uma propriedade de tipo armazenado particular.
A base de qualquer coordenador específico será assim:
final class SomeCoordinator: BaseCoordinator { override func start() {
Quaisquer dependências necessárias para o coordenador funcionar podem ser adicionadas ao inicializador. Como um caso típico, uma instância de
UINavigationController
.
Se esse for o coordenador raiz cuja responsabilidade é mapear o
UIViewController
raiz, o coordenador poderá, por exemplo, aceitar uma nova instância do
UINavigationController
com uma pilha vazia.
Ao processar eventos (mais sobre isso posteriormente), o coordenador pode passar esse
UINavigationController
ainda mais para outros coordenadores que ele gerar. E eles também podem fazer com o estado atual da navegação o que precisam: "push", "present" e, pelo menos, substituir toda a pilha de navegação.
Possíveis melhorias na interface
Como se viu mais adiante, nem todos os coordenadores geram outros coordenadores; portanto, nem todos devem depender dessa classe base. Portanto, um dos colegas da equipe relacionada sugeriu se livrar da herança e introduzir a interface do gerenciador de dependências como uma dependência externa:
protocol CoordinatorDependencies { func add(dependency coordinator: Coordinator) func remove(dependency coordinator: Coordinator) } final class DefaultCoordinatorDependencies: CoordinatorDependencies { private let dependencies = [Coordinator]() func add(dependency coordinator: Coordinator) {
Manipulando Eventos Gerados pelo Usuário
Bem, o coordenador criou e de alguma forma iniciou um novo mapeamento. Provavelmente, o usuário olha para a tela e vê um certo conjunto de elementos visuais com os quais ele pode interagir: botões, campos de texto, etc. Alguns deles provocam eventos de navegação e devem ser controlados pelo coordenador que gerou esse controlador. Para resolver esse problema, usamos a
delegação tradicional.
Suponha que exista uma subclasse de
UIViewController
:
final class SomeViewController: UIViewController { }
E o coordenador que o adiciona à pilha:
final class SomeCoordinator: Coordinator { private let dependencies: CoordinatorDependencies private weak var navigationController: UINavigationController? init(navigationController: UINavigationController, dependenciesManager: CoordinatorDependencies = DefaultCoordinatorDependencies()) { self.navigationController = navigationController dependencies = dependenciesManager } func start() { let vc = SomeViewController() navigationController?.pushViewController(vc, animated: true) } }
Delegamos o processamento dos eventos correspondentes do controlador no mesmo coordenador. Aqui, de fato, o esquema clássico é usado:
protocol SomeViewControllerRoute: class { func onSomeEvent() } final class SomeViewController: UIViewController { private weak var route: SomeViewControllerRoute? init(route: SomeViewControllerRoute) { self.route = route super.init(nibName: nil, bundle: nil) } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } @IBAction private func buttonAction() { route?.onSomeEvent() } } final class SomeCoordinator: Coordinator { private let dependencies: CoordinatorDependencies private weak var navigationController: UINavigationController? init(navigationController: UINavigationController, dependenciesManager: CoordinatorDependencies = DefaultCoordinatorDependencies()) { self.navigationController = navigationController dependencies = dependenciesManager } func start() { let vc = SomeViewController(route: self) navigationController?.pushViewController(vc, animated: true) } } extension SomeCoordinator: SomeViewControllerRoute { func onSomeEvent() {
Manipulando o botão de retorno
Outra
boa revisão do modelo de arquitetura discutido foi publicada por Paul Hudson em seu site "Hacking with Swift", pode-se até dizer um guia. Ele também contém uma explicação simples e direta de uma de suas possíveis soluções para o problema do botão de retorno mencionado acima: o coordenador (se necessário) se declara um delegado da instância
UINavigationController
passada a ele e monitora o evento de seu interesse.
Essa abordagem tem uma pequena desvantagem: somente o
NSObject
do
NSObject
pode ser um delegado do
UINavigationController
.
Portanto, há um coordenador que gera outro coordenador. Esse outro, chamando
start()
adiciona algum tipo de
UIViewController
à pilha
UINavigationController
. Ao clicar no botão voltar na
UINavigationBar
tudo o que você precisa fazer é informar ao coordenador de origem que o coordenador gerado terminou seu trabalho ("fluxo"). Para isso, introduzimos outra ferramenta de delegação: um delegado é alocado para cada coordenador gerado, cuja interface é implementada pelo coordenador gerador:
protocol CoordinatorFlowListener: class { func onFlowFinished(coordinator: Coordinator) } final class MainCoordinator: NSObject, Coordinator { private let dependencies: CoordinatorDependencies private let navigationController: UINavigationController init(navigationController: UINavigationController, dependenciesManager: CoordinatorDependencies = DefaultCoordinatorDependencies()) { self.navigationController = navigationController dependencies = dependenciesManager super.init() } func start() { let someCoordinator = SomeCoordinator(navigationController: navigationController, flowListener: self) dependencies.add(someCoordinator) someCoordinator.start() } } extension MainCoordinator: CoordinatorFlowListener { func onFlowFinished(coordinator: Coordinator) { dependencies.remove(coordinator)
No exemplo acima, o
MainCoordinator
não faz nada: simplesmente lança o fluxo de outro coordenador - na vida real, é claro, é inútil. Em nosso aplicativo, o
MainCoordinator
recebe dados de fora, de acordo com os quais determina em que estado o aplicativo está - autorizado, não autorizado, etc. - e qual tela precisa ser exibida. Dependendo disso, ele lança um fluxo do coordenador correspondente. Se o coordenador de origem terminou seu trabalho, o coordenador principal recebe um sinal sobre isso por meio do
CoordinatorFlowListener
e, por exemplo, inicia o fluxo de outro coordenador.
Conclusão
A solução habitual, é claro, tem várias desvantagens (como qualquer solução para qualquer problema).
Sim, você precisa usar muita delegação, mas é simples e tem uma única direção: do gerado para o gerado (do controlador para o coordenador, do coordenador gerado para o gerado).
Sim, para evitar vazamentos de memória, você deve adicionar um método delegado
UINavigationController
com implementação quase idêntica a cada coordenador. (A primeira abordagem não tem essa desvantagem, mas compartilha mais generosamente seu conhecimento interno sobre a nomeação de um coordenador específico.)
Mas a maior desvantagem dessa abordagem é que, na vida real, os coordenadores, infelizmente, conhecerão um pouco mais sobre o mundo ao seu redor do que gostaríamos. Mais precisamente, eles terão que adicionar elementos lógicos que dependem de condições externas, das quais o coordenador não está diretamente ciente. Basicamente, é isso que acontece quando o método
start()
é
onFlowFinished(coordinator:)
ou o retorno de
onFlowFinished(coordinator:)
. E tudo pode acontecer nesses locais, e sempre será um comportamento "codificado": adicionar um controlador à pilha, substituir a pilha, retornar ao controlador raiz - seja o que for. E tudo isso não depende das competências do controlador atual, mas de condições externas.
No entanto, o código é "bonito" e conciso, é muito bom trabalhar com ele, e a navegação pelo código é muito mais fácil. Pareceu-nos que, com as deficiências mencionadas, estando cientes delas, é bem possível existir.
Obrigado por ler neste lugar! Espero que eles tenham aprendido algo útil para si. E se de repente você quer "mais do que eu", aqui está um link para o meu Twitter .