Experiencia en el uso de "coordinadores" en un verdadero proyecto "iOS"

El mundo de la programación moderna es rico en tendencias, y esto es doblemente cierto para el mundo de la programación de aplicaciones "iOS" . Espero no equivocarme mucho al afirmar que uno de los patrones arquitectónicos más "de moda" de los últimos años es el "coordinador". Entonces, nuestro equipo hace un tiempo se dio cuenta de un deseo irresistible de probar esta técnica en ellos mismos. Además, apareció un caso muy bueno: un cambio significativo en la lógica y la planificación total de la navegación en la aplicación.

El problema


A menudo sucede que los controladores comienzan a asumir demasiado: "dar comandos" directamente al UINavigationController , "comunicarse" con sus controladores "hermanos" (incluso inicializarlos y pasarlos a la pila de navegación) - en general, hay mucho que hacer al respecto de lo que ni siquiera deberían sospechar.

Una de las formas posibles de evitar esto es precisamente el "coordinador". Además, resultó que es bastante conveniente para trabajar y muy flexible: la plantilla puede administrar eventos de navegación de ambos módulos pequeños (que representan, tal vez, solo una sola pantalla) y la aplicación completa (que lanza su propio "flujo", relativamente hablando, directamente desde UIApplicationDelegate ).

La historia


Martin Fowler, en su libro Patrones de arquitectura de aplicaciones empresariales, llamó a este patrón Controlador de aplicaciones . Y su primer divulgador en el entorno "iOS" es Sorush Khanlu : todo comenzó con su informe sobre "NSSpain" en 2015. Luego apareció un artículo de revisión en su sitio web , que tenía varias secuelas (por ejemplo, esto ).

Y luego siguieron muchas revisiones (la consulta de “coordinadores de ios” brinda docenas de resultados de diferente calidad y grado de detalle), incluyendo incluso una guía sobre Ray Wenderlich y un artículo de Paul Hudson sobre su “Hackear con Swift” como parte de una serie de materiales sobre cómo deshacerse del problema Controlador "masivo".

Mirando hacia el futuro, el tema de discusión más notable es el problema del botón de retroceso en UINavigationController , UINavigationController clic no es procesado por nuestro código, pero solo podemos recibir una devolución de llamada .

En realidad, ¿por qué es esto un problema? Los coordinadores, como cualquier objeto, para existir en la memoria, necesitan algún otro objeto para "poseerlos". Como regla general, cuando se construye un sistema de navegación usando coordinadores, algunos coordinadores generan otros y mantienen un fuerte vínculo con ellos. Al "salir de la zona de responsabilidad" del coordinador de origen, el control vuelve al coordinador de origen, y la memoria ocupada por el originador debe ser liberada.

Sorush tiene su propia visión para resolver este problema , y también observa un par de enfoques valiosos . Pero volveremos a esto.

Primer acercamiento


Antes de comenzar a mostrar el código real, me gustaría aclarar que, aunque los principios son totalmente consistentes con los que se nos ocurrieron en el proyecto, los extractos del código y los ejemplos de su uso se simplifican y reducen siempre que no interfieran con su percepción.

Cuando comenzamos a experimentar con los coordinadores del equipo, no teníamos mucho tiempo y libertad de acción para esto: era necesario tener en cuenta los principios existentes y el dispositivo de navegación. La primera opción de implementación para los coordinadores se basó en un "enrutador" común, que es propiedad y está operado por UINavigationController . Él sabe cómo hacer con las instancias del UIViewController todo lo que se necesita con respecto a la navegación: push / pop, presente / descartar más manipulaciones con el controlador raíz . Un ejemplo de la interfaz de dicho enrutador:

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

Una implementación específica se inicializa con una instancia de UINavigationController y no contiene nada particularmente complicado en sí mismo. La única limitación: no puede pasar otras instancias del UINavigationController como argumentos a los métodos de la interfaz (por razones obvias: el UINavigationController no puede contener el UINavigationController en su pila; esta es una restricción de UIKit ).

El coordinador, como cualquier objeto, necesita un propietario, otro objeto que almacenará un enlace a él. El objeto que lo genera puede almacenar un enlace a la raíz, pero cada coordinador también puede generar otros coordinadores. Por lo tanto, se escribió una interfaz base para proporcionar un mecanismo de gestión para los coordinadores generados:

 class Coordinator { private var childCoordinators = [Coordinator]() func add(dependency coordinator: Coordinator) { // ... } func remove(dependency coordinator: Coordinator) { // ... } } 

Una de las ventajas implícitas de los coordinadores es la encapsulación del conocimiento sobre subclases específicas de UIViewController . Para garantizar la interacción del enrutador y los coordinadores, presentamos la siguiente interfaz:

 protocol Presentable { func presented() -> UIViewController } 

Luego, cada coordinador específico debe heredar del Coordinator e implementar la interfaz Presentable , y la interfaz del enrutador debe adoptar la siguiente forma:

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

(El enfoque con Presentable también le permite usar coordinadores dentro de módulos que están escritos para interactuar directamente con instancias del UIViewController , sin someterlos (módulos) a un procesamiento radical).

Un breve ejemplo de todo esto en acción:

 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 aproximación


¡Y entonces un día llegó el momento de una alteración total de la navegación y absoluta libertad de expresión! El momento en que nada nos impedía intentar implementar la navegación en los coordinadores utilizando el codiciado método start() , una versión que cautivó originalmente con su simplicidad y concisión.

Las características del Coordinator mencionadas anteriormente obviamente no serán superfluas. Pero se debe agregar el mismo método a la interfaz general:

 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" no ofrece la capacidad de declarar clases abstractas (ya que está más orientado a un enfoque orientado al protocolo que a un enfoque más clásico y orientado a objetos ), por lo tanto, el método start() puede dejarse con una implementación o empuje vacío Hay algo como fatalError(_:file:line:) (obligando a anular este método con los herederos). Personalmente, prefiero la primera opción.

Pero Swift tiene una gran oportunidad para agregar métodos de implementación predeterminados a los métodos de protocolo, por lo que el primer pensamiento, por supuesto, no fue declarar una clase base, sino hacer algo como esto:

 extension Coordinator { func add(dependency coordinator: Coordinator) { // ... } func remove(dependency coordinator: Coordinator) { // ... } } 

Pero las extensiones de protocolo no pueden declarar campos almacenados, y las implementaciones de estos dos métodos obviamente deberían basarse en una propiedad privada de tipo almacenado.

La base de cualquier coordinador particular se verá así:

 final class SomeCoordinator: BaseCoordinator { override func start() { // ... } } 

Cualquier dependencia que sea necesaria para que funcione el coordinador se puede agregar al inicializador. Como un caso típico, una instancia de UINavigationController .

Si este es el coordinador raíz cuya responsabilidad es mapear el UIViewController raíz, el coordinador puede, por ejemplo, aceptar una nueva instancia del UINavigationController con una pila vacía.

Al procesar eventos (más sobre eso más adelante), el coordinador puede pasar este UINavigationController más adelante a otros coordinadores que genera. Y también pueden hacer con el estado actual de navegación lo que necesitan: "empujar", "presentar" y al menos reemplazar toda la pila de navegación.

Posibles mejoras de interfaz


Como resultó más tarde, no todos los coordinadores generarán otros coordinadores, por lo que no todos deberían depender de esa clase base. Por lo tanto, uno de los colegas del equipo relacionado sugirió deshacerse de la herencia e introducir la interfaz del administrador de dependencias como una dependencia 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() { // ... } } 

Manejo de eventos generados por el usuario


Bueno, el coordinador creó y de alguna manera inició un nuevo mapeo. Lo más probable es que el usuario mire la pantalla y vea un cierto conjunto de elementos visuales con los que puede interactuar: botones, campos de texto, etc. Algunos de ellos provocan eventos de navegación y deben ser controlados por el coordinador que generó este controlador. Para resolver este problema, utilizamos la delegación tradicional.

Supongamos que hay una subclase de UIViewController :

 final class SomeViewController: UIViewController { } 

Y el coordinador que lo agrega a la pila:

 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 el procesamiento de los eventos de controlador correspondientes al mismo coordinador. Aquí, de hecho, se usa el esquema clásico:

 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() { // ... } } 

Manejando el botón de retorno


Paul Hudson publicó otra buena revisión de la plantilla arquitectónica discutida en su sitio web "Hacking with Swift", incluso se podría decir una guía. También contiene una explicación simple y directa de una de sus posibles soluciones al problema del botón de retorno mencionado anteriormente: el coordinador (si es necesario) se declara delegado de la instancia de UINavigationController que se le pasó y monitorea el evento que nos interesa.

Este enfoque tiene un pequeño inconveniente: solo el NSObject puede ser un delegado de UINavigationController .

Entonces, hay un coordinador que genera otro coordinador. Este otro, al llamar a start() agrega algún tipo de UIViewController a la pila UINavigationController . Al presionar el botón Atrás en la UINavigationBar todo lo que necesita hacer es informar al coordinador de origen que el coordinador generado ha finalizado su trabajo ("flujo"). Para hacer esto, presentamos otra herramienta de delegación: se asigna un delegado a cada coordinador generado, cuya interfaz es implementada por el coordinador generador:

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

En el ejemplo anterior, el MainCoordinator no hace nada: simplemente lanza el flujo de otro coordinador; en la vida real, por supuesto, es inútil. En nuestra aplicación, MainCoordinator recibe datos del exterior, según los cuales determina en qué estado se encuentra la aplicación: autorizado, no autorizado, etc. - y qué pantalla debe mostrarse. Dependiendo de esto, lanza un flujo del coordinador correspondiente. Si el coordinador de origen ha terminado su trabajo, el coordinador principal recibe una señal al respecto a través del CoordinatorFlowListener y, por ejemplo, lanza un flujo de otro coordinador.

Conclusión


La solución habitual, por supuesto, tiene una serie de desventajas (como cualquier solución a cualquier problema).

Sí, debe usar mucha delegación, pero es simple y tiene una sola dirección: de lo generado a lo generado (del controlador al coordinador, del coordinador generado al generado).

Sí, para escapar de las pérdidas de memoria, debe agregar un método delegado UINavigationController con una implementación casi idéntica para cada coordinador. (El primer enfoque no tiene este inconveniente, sino que comparte más generosamente su conocimiento interno sobre el nombramiento de un coordinador específico).

Pero el mayor inconveniente de este enfoque es que, en la vida real, los coordinadores, desafortunadamente, sabrán un poco más sobre el mundo a su alrededor de lo que nos gustaría. Más precisamente, tendrán que agregar elementos lógicos que dependen de condiciones externas, de las cuales el coordinador no tiene conocimiento directo. Básicamente, esto es, de hecho, lo que sucede cuando se onFlowFinished(coordinator:) método start() o cuando se onFlowFinished(coordinator:) la onFlowFinished(coordinator:) . Y cualquier cosa puede suceder en estos lugares, y siempre será un comportamiento "codificado": agregar un controlador a la pila, reemplazar la pila, volver al controlador raíz, lo que sea. Y todo esto no depende de las competencias del controlador actual, sino de las condiciones externas.

Sin embargo, el código es "bonito" y conciso, es realmente agradable trabajar con él, y la navegación a través del código es mucho más fácil. Nos pareció que con las deficiencias mencionadas, siendo conscientes de ellas, es muy posible que exista.
¡Gracias por leer este lugar! Espero que hayan aprendido algo útil para ellos. Y si de repente quieres "más que yo", entonces aquí hay un enlace a mi Twitter .

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


All Articles