Experiência de uso de "coordenadores" em um projeto real de "iOS"

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) { // ... } func remove(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) // Router implementation. router.setAsRoot(FirstCoordinator()) router.push(SecondCoordinator(), animated: true, completion: nil) router.popToRootModule(animated: true) 

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) { // ... } func remove(dependency coordinator: Coordinator) { // ... } func start() { } } 

"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) { // ... } func remove(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) { // ... } func remove(dependency coordinator: Coordinator) { // ... } } final class SomeCoordinator: Coordinator { private let dependencies: CoordinatorDependencies init(dependenciesManager: CoordinatorDependencies = DefaultCoordinatorDependencies()) { dependencies = dependenciesManager } func start() { // ... } } 

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) // ... } } final class SomeCoordinator: NSObject, Coordinator { private weak var flowListener: CoordinatorFlowListener? private weak var navigationController: UINavigationController? init(navigationController: UINavigationController, flowListener: CoordinatorFlowListener) { self.navigationController = navigationController self.flowListener = flowListener } func start() { // ... } } extension SomeCoordinator: UINavigationControllerDelegate { func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) { guard let fromVC = navigationController.transitionCoordinator?.viewController(forKey: .from) else { return } if navigationController.viewControllers.contains(fromVC) { return } if fromVC is SomeViewController { flowListener?.onFlowFinished(coordinator: self) } } } 

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 .

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


All Articles