Composición de UIViewControllers y navegación entre ellos (y no solo)


En este artículo quiero compartir la experiencia que hemos estado utilizando con éxito durante varios años en nuestras aplicaciones iOS, 3 de las cuales se encuentran actualmente en la tienda de aplicaciones. Este enfoque ha funcionado bien y recientemente lo separamos del resto del código y lo diseñamos en una biblioteca RouteComposer separada, que se discutirá de hecho .


https://github.com/ekazaev/route-composer


Pero, para empezar, intentemos averiguar qué significa la composición de los controladores de vista en iOS.


Antes de continuar con la explicación en sí, le recuerdo que en iOS se entiende con mayor frecuencia como un controlador de vista o UIViewController . Esta es una clase heredada del UIViewController estándar, que es el controlador de patrón MVC base que Apple recomienda usar para desarrollar aplicaciones iOS.


Puede usar patrones arquitectónicos alternativos como MVVM, VIP, VIPER, pero en ellos el UIViewController estará involucrado de una forma u otra, lo que significa que esta biblioteca se puede usar con ellos. La esencia del UIViewController usa para controlar el UIView , que con mayor frecuencia representa una pantalla o una parte importante de la pantalla, procesa eventos desde ella y muestra algunos datos en ella.



Todos los UIViewController se pueden dividir condicionalmente en controladores de vista normal , que son responsables de un área visible en la pantalla, y controladores de vista de contenedor , que, además de mostrarse a sí mismos y algunos de sus controles, también pueden mostrar controladores de vista secundarios integrados en ellos de una forma u otra .


Los controladores de vista de contenedor estándar suministrados con Cocoa Touch incluyen: UINavigationConroller , UITabBarController , UISplitController , UIPageController y algunos otros. Además, el usuario puede crear sus propios controladores de vista de contenedor personalizados siguiendo las reglas de Cocoa Touch que se describen en la documentación de Apple.


El proceso de introducción de controladores de vista estándar en los controladores de vista de contenedor, así como la integración de los controladores de vista en la pila de controladores, llamaremos a la composición en este artículo.


Por qué, entonces, la solución estándar para la composición de los controladores de vista resultó no ser óptima para nosotros, y desarrollamos una biblioteca que facilita nuestro trabajo.


Echemos un vistazo a la composición de algunos controladores de vista de contenedor estándar como ejemplo:


Ejemplos de composición en contenedores estándar.


UINavigationController



 let tableViewController = UITableViewController(style: .plain) //        let navigationController = UINavigationController(rootViewController: tableViewController) // ... //        let detailViewController = UIViewController(nibName: "DetailViewController", bundle: nil) navigationController.pushViewController(detailViewController, animated: true) // ... //     navigationController.popToRootViewController(animated: true) 

UITabBarController



 let firstViewController = UITableViewController(style: .plain) let secondViewController = UIViewController() //   let tabBarController = UITabBarController() //         tabBarController.viewControllers = [firstViewController, secondViewController] //        tabBarController.selectedViewController = secondViewController 

UISplitViewController



 let firstViewController = UITableViewController(style: .plain) let secondViewController = UIViewController() //   let splitViewController = UISplitViewController() //        splitViewController.viewControllers = [firstViewController] //        splitViewController.showDetailViewController(secondViewController, sender: nil) 

Ejemplos de integración (composición) de controladores de vista en la pila


Instalación de la raíz del controlador de vista


 let window: UIWindow = //... window.rootViewController = viewController window.makeKeyAndVisible() 

Presentación modal del controlador de vista


 window.rootViewController.present(splitViewController, animated: animated, completion: nil) 

¿Por qué decidimos crear una biblioteca para la composición?


Como puede ver en los ejemplos anteriores, no existe una forma única de integrar los controladores de vista convencionales en contenedores, al igual que no hay una forma única de construir una pila de controladores de vista. Y, si desea cambiar ligeramente el diseño de su aplicación o la forma en que navega en ella, necesitará cambios significativos en el código de la aplicación, también necesitará enlaces a objetos de contenedor para poder insertar sus controladores de vista, etc. Es decir, el método estándar en sí implica una gran cantidad de trabajo, así como la presencia de enlaces para ver los controladores para generar acciones y presentaciones de otros controladores.


Todo esto agrega un dolor de cabeza a varios métodos de vinculación por inmersión a la aplicación (por ejemplo, usando enlaces universales), ya que debe responder la pregunta: ¿qué pasa si el controlador debe mostrarse al usuario ya que hizo clic en el enlace en el safari ya se muestra, o estoy viendo el controlador? lo que debería mostrar que aún no se ha creado , lo que le obliga a caminar a través de los controladores del árbol de visión y escribir código del que a veces sus ojos comienzan a sangrar y que cualquier desarrollador de iOS intenta ocultar. Además, a diferencia de la arquitectura de Android donde cada pantalla se construye por separado, en iOS, para mostrar una parte de la aplicación inmediatamente después del lanzamiento, puede ser necesario construir una pila bastante grande de controladores que estarán ocultos debajo de la que se muestra a pedido.


Sería genial llamar a métodos como goToAccount() , goToMenu() o goToProduct(withId: "012345") cuando un usuario hace clic en un botón o cuando una aplicación goToProduct(withId: "012345") enlace universal de otra aplicación y no piensa en integrar este controlador de vista en la pila, sabiendo que el creador de este controlador de vista ya ha proporcionado esta implementación.


Además, a menudo, nuestras aplicaciones consisten en una gran cantidad de pantallas desarrolladas por diferentes equipos, y para acceder a una de las pantallas durante el proceso de desarrollo, debe pasar por otra pantalla que aún no se ha creado. En nuestra empresa, utilizamos el enfoque que llamamos la placa de Petri . Es decir, en el modo de desarrollo, el desarrollador y el probador tienen acceso a una lista de todas las pantallas de aplicaciones y puede acceder a cualquiera de ellas (por supuesto, algunas de ellas pueden requerir algunos parámetros de entrada).



Puede interactuar con ellos y probarlos individualmente, y luego ensamblarlos en la aplicación final para la producción. Este enfoque facilita en gran medida el desarrollo, pero, como viste en los ejemplos anteriores, la composición comienza cuando necesitas mantener en el código varias formas de integrar el controlador de vista en la pila.


Queda por agregar que todo esto se multiplicará por N tan pronto como su equipo de marketing exprese su deseo de realizar pruebas A / B en usuarios en vivo y verificar qué método de navegación funciona mejor, por ejemplo, una barra de pestañas o un menú de hamburguesas.


  • Vamos a cortar las piernas de Susanin Vamos a mostrar el 50% de los usuarios de la barra de pestañas y al otro menú de hamburguesas, y en un mes le diremos qué usuarios ven más de nuestras ofertas especiales.

Trataré de decirle cómo abordamos la solución a este problema y finalmente lo asignamos a la biblioteca RouteComposer.


Susanin Compositor de ruta


Después de analizar todos los escenarios de composición y navegación, tratamos de abstraer el código dado en los ejemplos anteriores e identificamos 3 entidades principales de las cuales opera la biblioteca RouteComposer : Factory , Finder , Action . Además, la biblioteca contiene 3 entidades auxiliares que son responsables de un pequeño ajuste que puede ser necesario durante el proceso de navegación: RoutingInterceptor , ContextTask , PostRoutingTask . Todas estas entidades deben configurarse en una cadena de dependencias y transferirse al Router y, el objeto que construirá su pila de controladores.


Pero, sobre cada uno de ellos en orden:


La fábrica


Como su nombre lo indica, Factory es responsable de crear el controlador de vista.


 public protocol Factory { associatedtype ViewController: UIViewController associatedtype Context func build(with context: Context) throws -> ViewController } 

Aquí es importante hacer una reserva sobre el concepto de contexto . Al contexto dentro de la biblioteca, llamamos todo lo que el espectador pueda necesitar para ser creado. Por ejemplo, para mostrar un controlador de vista que muestre los detalles del producto, debe pasarle un determinado ID de producto, por ejemplo, en forma de una String . La esencia del contexto puede ser cualquier cosa: un objeto, estructura, bloque o tupla. Si su controlador no necesita nada para ser creado, ¿se puede especificar el contexto como Any? e instalar en nil .


Por ejemplo:


 class ProductViewControllerFactory: Factory { func build(with productID: UUID) throws -> ProductViewController { let productViewController = ProductViewController(nibName: "ProductViewController", bundle: nil) productViewController.productID = productID //  ,      `ContextAction`,     return productViewController } } 

A partir de la implementación anterior, queda claro que esta fábrica cargará la imagen del controlador del archivo XIB e instalará el productID transferido en él. Además del protocolo estándar de Factory , la biblioteca proporciona varias implementaciones estándar de este protocolo para evitar que escriba código banal (en particular, el ejemplo anterior).


Además, me abstendré de proporcionar descripciones de protocolos y ejemplos de sus implementaciones, ya que puede familiarizarse con ellos en detalle descargando el ejemplo que viene con la biblioteca. Existen diversas implementaciones de fábricas para controladores y contenedores de vista convencionales, así como formas de configurarlas.


Acción


La entidad Action es una descripción de cómo integrar un controlador de vista, que será construido por la fábrica, en la pila. El controlador de vista después de la creación no puede simplemente colgarse en el aire y, por lo tanto, cada fábrica debe contener Action como se puede ver en el ejemplo anterior.


La implementación más común de Action es la presentación modal del controlador:


 class PresentModally: Action { func perform(viewController: UIViewController, on existingController: UIViewController, animated: Bool, completion: @escaping (_: ActionResult) -> Void) { guard existingController.presentedViewController == nil else { completion(.failure("\(existingController) is already presenting a view controller.")) return } existingController.present(viewController, animated: animated, completion: { completion(.continueRouting) }) } } 

La biblioteca contiene la implementación de la mayoría de las formas estándar para integrar controladores de vista en la pila, y probablemente no tendrá que crear los suyos propios hasta que utilice algún tipo de controlador de vista de contenedor personalizado o método de presentación. Pero crear acciones personalizadas no debería causar problemas si lee los ejemplos.


Buscador


La esencia de Finder responde al enrutador a la pregunta : ¿ya se ha creado un controlador de este tipo y ya está en la pila? ¿Quizás no se necesita crear nada y es suficiente para mostrar lo que ya está allí? .


 public protocol Finder { associatedtype ViewController: UIViewController associatedtype Context func findViewController(with context: Context) -> ViewController? } 

Si almacena enlaces a todos los controladores de vista que creó, en la implementación del Finder simplemente puede devolver un enlace al controlador de vista deseado. Pero a menudo esto no es así, porque la pila de aplicaciones, especialmente si es grande, cambia de forma bastante dinámica. Además, puede tener varios controladores de vista idénticos en la pila que muestran diferentes entidades (por ejemplo, varios ProductViewControllers que muestran diferentes productos con diferentes ID de producto), por lo que la implementación del Finder puede requerir una implementación personalizada y buscar el controlador de vista correspondiente en la pila. La biblioteca facilita esta tarea al proporcionar StackIteratingFinder como una extensión del Finder , un protocolo con la configuración adecuada para simplificar esta tarea. En la implementación de StackIteratingFinder solo necesita responder la pregunta: ¿es este controlador de vista el que el enrutador está buscando a pedido?


Un ejemplo de tal implementación:


 class ProductViewControllerFinder: StackIteratingFinder { let options: SearchOptions init(options: SearchOptions = .currentAndUp) { self.options = options } func isTarget(_ productViewController: ProductViewController, with productID: UUID) -> Bool { return productViewController.productID == productID } } 

Entidades auxiliares


RoutingInterceptor


RoutingInterceptor permite realizar algunas acciones antes de comenzar la composición de los controladores de vista y decirle al enrutador si es posible integrar los controladores de vista en la pila. El ejemplo más común de una tarea de este tipo es la autenticación (pero nada común en la implementación). Por ejemplo, desea mostrar un controlador de vista con los detalles de una cuenta de usuario, pero, para esto, el usuario debe iniciar sesión en el sistema. Puede implementar un RoutingInterceptor y agregarlo a la configuración de la vista del controlador de detalles del usuario y verificar desde adentro: si el usuario ha iniciado sesión, permita que el enrutador continúe la navegación, si no, muestre el controlador de vista que le indica que inicie sesión y si esta acción es exitosa, permita que el enrutador continúe la navegación o cancele ella si el usuario se niega a iniciar sesión.


 class LoginInterceptor: RoutingInterceptor { func execute(for destination: AppDestination, completion: @escaping (_: InterceptorResult) -> Void) { guard !LoginManager.sharedInstance.isUserLoggedIn else { // ... //  LoginViewController       completion(.success)  completion(.failure("User has not been logged in.")) // ... return } completion(.success) } } 

Una implementación de tal RoutingInterceptor con comentarios está contenida en el ejemplo suministrado con la biblioteca.


ContextTask


La entidad ContextTask , si la proporciona, se puede aplicar por separado a cada controlador de vista en la configuración, independientemente de si fue creada por un enrutador o se encontró en la pila, y solo desea actualizar los datos y establecer algunos valores predeterminados parámetros (por ejemplo, mostrar botón de cierre o no mostrar).


PostRoutingTask


El PostRoutingTask llamará a la implementación de PostRoutingTask después de completar con éxito la integración del controlador de vista solicitado en la pila. En su implementación, es conveniente agregar varios análisis o extraer varios servicios.


Más detalladamente con la implementación de todas las entidades descritas se puede encontrar en la documentación de la biblioteca, así como en el ejemplo adjunto.


PD: el número de entidades auxiliares que se pueden agregar a la configuración no está limitado.


Configuracion


Todas las entidades descritas son buenas porque dividen el proceso de composición en bloques pequeños, intercambiables y confiables.


Ahora pasemos a lo más importante: a la configuración, es decir, la conexión de estos bloques entre sí. Para recopilar estos bloques entre ellos y combinarlos en una cadena de pasos, la biblioteca proporciona una clase de generador StepAssembly (para contenedores - ContainerStepAssembly ). Su implementación le permite encadenar los bloques de composición en un único objeto de configuración, como cuentas en una cadena, y también indicar las dependencias en las configuraciones de otros controladores de vista. Qué hacer con la configuración en el futuro depende de usted. Puede alimentarlo al enrutador con los parámetros necesarios y creará una pila de controladores para usted, puede guardarlo en el diccionario y usarlo más tarde por clave; depende de su tarea específica.


Considere un ejemplo trivial: supongamos que, al hacer clic en una celda de la lista o cuando la aplicación recibe un enlace universal de un cliente de safari o correo electrónico, necesitamos mostrar modalmente el controlador del producto con un determinado ID de producto. En este caso, el controlador del producto debe estar integrado dentro del UINavigationController para que pueda mostrar su nombre y el botón de cierre en su panel de control. Además, este producto solo se puede mostrar a los usuarios que han iniciado sesión; de lo contrario, invítelos a iniciar sesión.


Si analiza este ejemplo sin usar una biblioteca, se verá así:


 class ProductArrayViewController: UITableViewController { let products: [UUID]? let analyticsManager = AnalyticsManager.sharedInstance //  UITableViewControllerDelegate override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { guard let productID = products[indexPath.row] else { return } //   LoginInterceptor guard !LoginManager.sharedInstance.isUserLoggedIn else { //    LoginViewController         `showProduct(with: productID)` return } showProduct(with: productID) } func showProduct(with productID: String) { //   ProductViewControllerFactory let productViewController = ProductViewController(nibName: "ProductViewController", bundle: nil) //   ProductViewControllerContextTask productViewController.productID = productID //   NavigationControllerStep  PushToNavigationAction let navigationController = UINavigationController(rootViewController: productViewController) //   GenericActions.PresentModally present(alertController, animated: navigationController) { [weak self]   . ProductViewControllerPostTask self?.analyticsManager.trackProductView(productID: productID) } } } 

Este ejemplo no incluye la implementación de enlaces universales, que requerirán aislar el código de autorización y mantener el contexto al que debe dirigirse el usuario, además de buscar, de repente el usuario hace clic en un enlace, y este producto ya se le muestra, lo que finalmente hará que el código sea muy Difícil de leer.


Considere la configuración de este ejemplo usando la biblioteca:


 let productScreen = StepAssembly(finder: ProductViewControllerFinder(), factory: ProductViewControllerFactory()) //  : .adding(LoginInterceptor()) .adding(ProductViewControllerContextTask()) .adding(ProductViewControllerPostTask(analyticsManager: AnalyticsManager.sharedInstance)) //  : .using(PushToNavigationAction()) .from(NavigationControllerStep()) // NavigationControllerStep -> StepAssembly(finder: NilFinder(), factory: NavigationControllerFactory()) .using(GeneralAction.presentModally()) .from(GeneralStep.current()) .assemble() 

Si traduces esto al lenguaje humano:


  • Compruebe que el usuario haya iniciado sesión y, si no, ofrézcale una entrada.
  • Si el usuario ha iniciado sesión correctamente, continúe
  • Buscar controlador de vista de producto proporcionado por Finder
  • Si se encontró, haga visible y termine
  • Si no se encontró, cree un UINavigationController , integre en él el controlador de vista creado por ProductViewControllerFactory utilizando PushToNavigationAction
  • UINavigationController GenericActions.PresentModally UINavigationController GenericActions.PresentModally usando GenericActions.PresentModally desde el controlador de vista actual

La configuración requiere algún estudio, como muchas soluciones complejas, por ejemplo, el concepto de AutoLayout y, a primera vista, puede parecer complicado y redundante. Sin embargo, la cantidad de tareas que se resolverán con el fragmento de código dado cubre todos los aspectos, desde la autorización hasta la vinculación profunda, y dividir en una secuencia de acciones hace posible cambiar fácilmente la configuración sin la necesidad de realizar cambios en el código. Además, la implementación de StepAssembly lo ayudará a evitar problemas con una cadena de pasos incompleta y al control de tipos, problemas con la incompatibilidad de los parámetros de entrada para diferentes controladores de vista.


Considere el pseudocódigo de una aplicación completa en la que un ProductArrayViewController muestra una lista de productos y, si el usuario selecciona este producto, lo muestra dependiendo de si el usuario inició sesión o no, u ofrece iniciar sesión y se muestra después de un inicio de sesión exitoso:


Objetos de configuración


 // `RoutingDestination`    .          . struct AppDestination: RoutingDestination { let finalStep: RoutingStep let context: Any? } struct Configuration { //     ,             static func productDestination(with productID: UUID) -> AppDestination { let productScreen = StepAssembly(finder: ProductViewControllerFinder(), factory: ProductViewControllerFactory()) .add(LoginInterceptor()) .add(ProductViewControllerContextTask()) .add(ProductViewControllerPostTask(analyticsManager: AnalyticsManager.sharedInstance)) .using(PushToNavigationAction()) .from(NavigationControllerStep()) .using(GenericActions.PresentModally()) .from(CurrentControllerStep()) .assemble() return AppDestination(finalStep: productScreen, context: productID) } } 


 class ProductArrayViewController: UITableViewController { let products: [UUID]? //... // DefaultRouter -  Router   ,   UIViewController   let router = DefaultRouter() override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { guard let productID = products[indexPath.row] else { return } router.navigate(to: Configuration.productDestination(with: productID)) } } 


 @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { //... func application(_ application: UIApplication, open url: URL, sourceApplication: String?, annotation: Any) -> Bool { guard let productID = UniversalLinksManager.parse(url: url) else { return false } return DefaultRouter().navigate(to: Configuration.productDestination(with: productID)) == .handled } } 

.


. , , , — ProductArrayViewController, UINavigationController HomeViewController — StepAssembly from() . RouteComposer , ( ). , Configuration . , A/B , .


En lugar de una conclusión


, 3 . , , . Fabric , Finder Action . , — , , . , .


, , objective c Cocoa Touch, . iOS 9 12.


UIViewController (MVC, MVVM, VIP, RIB, VIPER ..)


, , , . . .


.

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


All Articles