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) {
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)
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) {
"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) {
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) {
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)
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 .