Enrutamiento para iOS: navegación universal sin reescribir la aplicación

En cualquier aplicación que consista en más de una pantalla, es necesario implementar la navegación entre sus componentes. Parece que esto no debería ser un problema, porque en UIKit hay componentes de contenedor bastante convenientes como UINavigationController y UITabBarController, así como métodos de visualización modal flexibles: solo use la navegación correcta en el momento correcto.

Sin embargo, tan pronto como la aplicación cambia a una pantalla usando una notificación push o un enlace, todo se vuelve un poco más complicado. Inmediatamente hay muchas preguntas:

  • ¿Qué hacer con el controlador de vista, que ahora está en la pantalla?
  • ¿Cómo cambiar el contexto (por ejemplo, pestaña activa en UITabBarController)?
  • ¿La pila de navegación actual tiene la pantalla correcta?
  • ¿Cuándo se debe ignorar la navegación?




En el desarrollo de iOS, en Badoo nos encontramos con todos estos problemas. Como resultado, formalizamos nuestros métodos de solución en una biblioteca de componentes para la navegación, que usamos en todos los productos nuevos. En este artículo hablaré sobre nuestro enfoque con más detalle. Un ejemplo de la aplicación de las prácticas descritas se puede ver en un pequeño proyecto de demostración .

Nuestro problema


A menudo, los problemas de navegación se resuelven agregando un componente global que conoce la estructura de las pantallas en la aplicación y decide qué hacer en un caso particular. La estructura de las pantallas significa información sobre la presencia de un contenedor en la jerarquía actual de controladores y secciones de la aplicación.

Badoo tenía un componente similar. Funcionó de manera similar con la biblioteca bastante antigua de Facebook, que ahora ya no se puede encontrar en su repositorio público. La navegación se basó en las URL asociadas con las pantallas de la aplicación. Básicamente, toda la lógica estaba contenida en una clase, que estaba vinculada a la presencia de una barra de pestañas y a algunas otras funciones específicas de Badoo. La complejidad y conectividad de este componente era tan alta que resolver tareas que requerían un cambio en la lógica de navegación podría llevar varias veces más de lo planeado. La capacidad de prueba de esta clase también planteó grandes preguntas.

Este componente se creó cuando solo teníamos una aplicación. No podríamos imaginar que en el futuro desarrollemos varios productos que sean bastante diferentes entre sí ( Bumble , Lumen y otros). Por esta razón, el navegador de nuestra aplicación más madura, Badoo, era imposible de usar en otros productos y cada equipo tenía que encontrar algo nuevo.

Desafortunadamente, los nuevos enfoques también se han agudizado para aplicaciones específicas. Con el creciente número de proyectos, el problema se hizo evidente y surgió la idea de crear una biblioteca que proporcionara un cierto conjunto de componentes, incluida la lógica de navegación universal. Esto ayudaría a minimizar el tiempo de implementación de funcionalidades similares en nuevos productos.

Implementamos un enrutador universal


Las tareas principales resueltas por el navegador global no son tantas:

  1. Encuentra la pantalla activa actual.
  2. Compare de alguna manera el tipo de pantalla activa y su contenido con lo que debe mostrarse.
  3. Realice la transición según sea necesario (secuencia de transiciones).

Quizás la formulación de las tareas parezca un poco abstracta, pero es esta abstracción la que hace posible universalizar la lógica.

1. Búsqueda activa de pantalla


La primera tarea parece bastante simple: solo tienes que recorrer toda la jerarquía de pantallas y encontrar el UIViewController superior.



La interfaz de nuestro objeto puede verse así:

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

Sin embargo, no está claro cómo determinar el elemento raíz de la jerarquía y qué hacer con las pantallas de contenedor como UIPageViewController y los contenedores específicos de la aplicación.

La opción más fácil para determinar el elemento raíz es tomar el controlador raíz de la pantalla activa:

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

Es posible que este enfoque no siempre funcione con aplicaciones donde hay varias ventanas. Pero este es un caso bastante raro, y el problema puede resolverse pasando explícitamente la ventana deseada como parámetro.

El problema con las pantallas de contenedor se puede resolver creando un protocolo especial para ellas, que contendrá un método para obtener una pantalla activa, o puede usar el protocolo anunciado anteriormente. Todos los controladores de contenedores utilizados en la aplicación deben implementar este protocolo. Por ejemplo, para un UITabBarController, una implementación podría verse así:

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

Solo queda recorrer toda la jerarquía y obtener la pantalla superior. Si el próximo controlador implementa TopViewControllerProvider, obtendremos la pantalla que se muestra a través del método declarado. De lo contrario, el controlador que se muestra en él se verificará modalmente (si corresponde).

2. Contexto actual


La tarea de determinar el contexto actual parece mucho más complicada. Queremos determinar el tipo de pantalla y, posiblemente, la información que se muestra en ella. Parece lógico crear una estructura que contenga esta información.

Pero, ¿qué tipos deberían tener propiedades de objeto? Nuestro objetivo final es comparar el contexto con lo que se debe mostrar, por lo que deben implementar el protocolo Equatable . Esto se puede implementar a través de tipos genéricos:

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

Sin embargo, debido a los detalles de Swift, esto impone ciertas restricciones sobre el uso de este tipo. Para evitar problemas, esta estructura en nuestras aplicaciones tiene un aspecto ligeramente diferente:

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

Otra opción es aprovechar la nueva función Swift, Tipos opacos , pero solo está disponible a partir de iOS 13, que todavía es inaceptable para muchos productos.

La implementación de la comparación de contexto es bastante obvia. Para no escribir la función isEqual para los tipos que ya implementan Equatable, puede hacer un truco simple, esta vez utilizando las ventajas de Swift:

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

Genial, tenemos un objeto para comparar. Pero, ¿cómo puedes asociarlo con un UIViewController ? Una forma es usar objetos asociados , una característica útil del lenguaje del Objetivo C en algunos casos, pero en primer lugar, no es muy explícito y, en segundo lugar, generalmente queremos comparar el contexto de solo algunas pantallas de aplicaciones. Por lo tanto, crear un protocolo parece buenas ideas:

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


y su implementación solo en las pantallas necesarias. Si la pantalla activa no implementa este protocolo, entonces su contenido puede considerarse insignificante y no tenerse en cuenta al mostrar uno nuevo.

3. Ejecución de transición


Veamos lo que ya tenemos. La capacidad en cualquier momento de obtener información sobre la pantalla activa en forma de una estructura de datos específica. Información recibida externamente a través de una URL abierta, notificación push u otra forma de iniciar la navegación, que se puede convertir en una estructura del mismo tipo y servir como una intención de navegación. Si la pantalla superior ya muestra la información necesaria, simplemente puede ignorar la navegación o actualizar el contenido de la pantalla.



Pero, ¿qué pasa con la transición en sí?

Es lógico hacer un componente (llamémoslo enrutador ), que tomará lo que se necesita mostrar, lo comparará con lo que ya se ha mostrado y realizará una transición o secuencia de transiciones. Además, el enrutador puede contener lógica general para procesar y validar información y el estado de la aplicación. Lo principal es que no debe incluir la lógica específica de un dominio o función de aplicación en este componente. Si cumple con esta regla, seguirá siendo reutilizable para diferentes aplicaciones y fácil de mantener.

La declaración de interfaz básica de dicho protocolo se ve así:

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

Puede generalizar la función anterior al pasar una secuencia de contextos. Esto no tendrá un impacto significativo en la implementación.

Es bastante obvio que el enrutador necesitará un controlador de fábrica, porque solo se reciben datos de navegación en su entrada. Es necesario crear pantallas separadas dentro de la fábrica, y tal vez incluso módulos completos basados ​​en el contexto transferido. Desde el campo screenType , puede determinar qué pantalla desea crear, desde el campo de información , con qué datos necesita completar previamente:

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

Si la aplicación no es un clon de Snapchat, lo más probable es que el número de métodos utilizados para mostrar el nuevo controlador sea pequeño. Por lo tanto, para la mayoría de las aplicaciones, es suficiente actualizar la pila UINavigationController y mostrar una pantalla modal. En este caso, puede definir una enumeración con posibles tipos, por ejemplo:

 enum NavigationType { case modal case navigationStack case rootScreen } 

El tipo de pantalla depende de cómo se muestre. Si se trata de una notificación de bloqueo, debe mostrarse modalmente. Es posible que sea necesario agregar otra pantalla a una pila de navegación existente a través del UINavigationController .

Decidir cómo mostrar una pantalla en particular es mejor no en el enrutador. Si agregamos la dependencia del enrutador bajo el protocolo ViewControllerNavigationTypeProvider e implementamos el conjunto deseado de métodos específicos para cada aplicación, lograremos este objetivo:

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

Pero, ¿qué pasa si queremos introducir un nuevo tipo de navegación en una de las aplicaciones? ¿Necesita agregar una nueva opción para enumerar, y todas las demás aplicaciones lo sabrán? Probablemente, en algunos casos, esto es exactamente lo que estamos buscando, pero si se adhiere al principio abierto-cerrado , entonces, para una mayor flexibilidad, puede ingresar el protocolo de un objeto que puede realizar transiciones:

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

Entonces ViewControllerNavigationTypeProvider se convertirá en esto:

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

Ahora no estamos limitados a un conjunto fijo de tipos de pantallas y podemos ampliar las capacidades de navegación sin cambios en el enrutador.

A veces no es necesario crear un nuevo UIViewController para cambiar a alguna pantalla, simplemente cambie a una existente. El ejemplo más obvio es el cambio de pestañas en un UITabBarController . Otro ejemplo es la transición a un elemento existente en la pila de controladores que se muestra en lugar de crear una nueva pantalla con el mismo contenido. Para hacer esto, en el enrutador, antes de crear un nuevo UIViewController, primero puede verificar si el contexto simplemente se puede cambiar.

¿Cómo resolver este problema? Más abstracciones!

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

En el caso de las pestañas, este protocolo puede ser implementado por un componente que sepa qué contiene el UITabBarViewController y puede asignar ViewControllerContext a una pestaña específica y cambiar pestañas.



Un conjunto de tales objetos se puede pasar al enrutador como una dependencia.

Para resumir, el algoritmo de procesamiento de contexto se verá así:

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


Es conveniente presentar el diagrama de dependencia del enrutador en forma de diagrama UML:



El enrutador resultante se puede usar para transiciones iniciadas automáticamente o mediante acciones del usuario. En nuestros productos, si la navegación no se produce automáticamente, se utilizan las funciones estándar del sistema y la mayoría de los módulos desconocen la existencia de un enrutador global. Solo es importante recordar acerca de la implementación del protocolo ViewControllerContextHolder cuando sea necesario para que el enrutador siempre pueda encontrar la información que el usuario ve en el momento actual.

Ventajas y desventajas.


Recientemente, comenzamos a introducir el método de gestión de navegación descrito en los productos Badoo. A pesar de que la implementación resultó ser algo más complicada que la opción presentada en el proyecto de demostración , estamos satisfechos con los resultados. Vamos a evaluar las ventajas y desventajas del enfoque descrito.

De los beneficios incluyen:

  • universalidad
  • relativa facilidad de implementación, en comparación con las opciones presentadas en la sección de alternativas,
  • falta de restricciones en la arquitectura de la aplicación y la implementación de navegación convencional entre pantallas.

Las desventajas son en parte consecuencia de las ventajas.

  • Los controladores necesitan saber qué información muestran. Si consideramos la arquitectura de la aplicación, el UIViewController debe asignarse a la capa de visualización y la lógica empresarial no debe almacenarse en esta capa. La estructura de datos que contiene el contexto de navegación debe implementarse allí desde la capa de lógica de negocios, sin embargo, los controladores almacenarán esta información, que no es muy correcta.
  • La fuente de la verdad sobre el estado de la aplicación es la jerarquía de las pantallas que se muestran, lo que en algunos casos puede ser una limitación.


Alternativas


Una alternativa a este enfoque podría ser construir una jerarquía de módulos activos manualmente. Un ejemplo de tal solución es la implementación del patrón Coordinador, donde los coordinadores forman una estructura de árbol que sirve como una fuente de verdad para determinar la pantalla activa, y la lógica de la decisión de mostrar esta o aquella pantalla está contenida en los coordinadores mismos.

Se pueden encontrar ideas similares en la arquitectura RIB , que es utilizada por nuestro equipo de Android.

Dichas alternativas proporcionan una abstracción más flexible, pero requieren uniformidad en la arquitectura y pueden ser demasiado engorrosas para muchas aplicaciones.

Si tomó un enfoque diferente para resolver tales problemas, no dude en hablar de ello en los comentarios.

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


All Articles