Trabajar con patrones estáticos: un tutorial de Swift MVVM
Publicado: 2022-03-11Hoy vamos a ver cómo las nuevas posibilidades técnicas y las expectativas de nuestros usuarios para las aplicaciones basadas en datos en tiempo real crean nuevos desafíos en la forma en que estructuramos nuestros programas, especialmente nuestras aplicaciones móviles. Si bien este artículo trata sobre iOS y Swift, muchos de los patrones y conclusiones son igualmente aplicables a Android y aplicaciones web.
Ha habido una evolución importante en el funcionamiento de las aplicaciones móviles modernas en los últimos años. Gracias al acceso a Internet más generalizado y tecnologías como las notificaciones automáticas y los WebSockets, el usuario ya no es la única fuente de eventos en tiempo de ejecución, y ya no es necesariamente la más importante, en muchas de las aplicaciones móviles actuales.
Echemos un vistazo más de cerca a qué tan bien funcionan dos patrones de diseño Swift con una aplicación de chat moderna: el patrón clásico modelo-vista-controlador (MVC) y un patrón simplificado inmutable modelo-vista-modelo de vista (MVVM, a veces estilizado "el patrón ViewModel ”). Las aplicaciones de chat son un buen ejemplo porque tienen muchas fuentes de datos y necesitan actualizar sus IU de muchas maneras diferentes cada vez que se reciben datos.
Nuestra aplicación de chat
La aplicación que usaremos como guía en este tutorial de Swift MVVM tendrá la mayoría de las funciones básicas que conocemos de las aplicaciones de chat como WhatsApp. Repasemos las características que implementaremos y comparemos MVVM vs MVC. La aplicación:
- Cargará los chats recibidos previamente desde el disco.
- Sincronizará los chats existentes a través de una solicitud
GETcon el servidor - Recibirá notificaciones automáticas cuando se envíe un nuevo mensaje al usuario
- Se conectará a un WebSocket una vez que estemos en una pantalla de chat
- Puede
POSTun nuevo mensaje en un chat - Mostrará una notificación en la aplicación cuando se reciba un nuevo mensaje de un chat en el que no estamos actualmente
- Mostrará un nuevo mensaje inmediatamente cuando recibamos un nuevo mensaje para el chat actual
- Enviará un mensaje leído cuando leamos un mensaje no leído
- Recibirá un mensaje de lectura cuando alguien lea nuestro mensaje
- Actualiza la insignia del contador de mensajes no leídos en el ícono de la aplicación
- Sincroniza todos los mensajes que se reciben o cambian de nuevo a Core Data
En esta aplicación de demostración, no habrá una implementación real de API, WebSocket o Core Data para mantener la implementación del modelo un poco más simple. En cambio, agregué un chatbot que comenzará a responderte una vez que inicies una conversación. Sin embargo, todos los demás enrutamientos y llamadas se implementan como si el almacenamiento y las conexiones fueran reales, incluidas pequeñas pausas asincrónicas antes de regresar.
Se han construido las siguientes tres pantallas:
MVC clásico
En primer lugar, está el patrón MVC estándar para crear una aplicación iOS. Esta es la forma en que Apple estructura todo su código de documentación y la forma en que las API y los elementos de la interfaz de usuario esperan que funcionen. Es lo que le enseñan a la mayoría de las personas cuando toman un curso de iOS.
A menudo, se culpa a MVC por conducir a UIViewController s inflados de unas pocas miles de líneas de código. Pero si se aplica bien, con una buena separación entre cada capa, podemos tener ViewController s bastante delgados que actúan solo como administradores intermedios entre View s, Model s y otros Controller s.
Aquí está el diagrama de flujo para la implementación de MVC de la aplicación ( CreateViewController para mayor claridad):
Repasemos las capas en detalle.
Modelo
La capa del modelo suele ser la capa menos problemática en MVC. En este caso, opté por usar ChatWebSocket , ChatModel y PushNotificationController para mediar entre los objetos Chat y Message , las fuentes de datos externas y el resto de la aplicación. ChatModel es la fuente de la verdad dentro de la aplicación y solo funciona en memoria en esta aplicación de demostración. En una aplicación de la vida real, probablemente estaría respaldada por Core Data. Por último, ChatEndpoint maneja todas las llamadas HTTP.
Vista
Las vistas son bastante grandes, ya que tiene que manejar muchas responsabilidades, ya que separé cuidadosamente todo el código de vista de UIViewController s. He hecho lo siguiente:
- Usó el patrón de
enumde estado (muy recomendable) para definir en qué estado se encuentra actualmente la vista. - Se agregaron las funciones que se conectan a los botones y otros elementos de la interfaz que desencadenan acciones (como tocar Retorno al ingresar el nombre de un contacto).
- Configure las restricciones y vuelva a llamar al delegado cada vez.
Una vez que agrega un UITableView en la mezcla, las vistas ahora son mucho más grandes que los UIViewController s, lo que lleva a más de 300 líneas de código preocupantes y muchas tareas mixtas en ChatView .
Controlador
Como toda la lógica de manejo de modelos se ha trasladado a ChatModel . Todo el código de la vista, que podría acechar aquí en proyectos separados menos óptimos, ahora vive en la vista, por lo que los UIViewController son bastante reducidos. El controlador de vista es completamente ajeno a cómo se ven los datos del modelo, cómo se obtienen o cómo se deben mostrar, solo coordina. En el proyecto de ejemplo, ninguno de los UIViewController supera las 150 líneas de código.
Sin embargo, el ViewController todavía hace las siguientes cosas:
- Ser un delegado para la vista y otros controladores de vista
- Crear instancias y empujar (o hacer estallar) controladores de vista si es necesario
- Enviar y recibir llamadas hacia y desde
ChatModel - Iniciar y detener el WebSocket según la etapa del ciclo del controlador de vista
- Tomar decisiones lógicas como no enviar un mensaje si está vacío
- Actualizando la vista
Todavía es mucho, pero se trata principalmente de coordinación, procesamiento de bloques de devolución de llamada y reenvío.
Beneficios
- Este patrón lo entiende todo el mundo y lo promueve Apple
- Funciona con toda la documentación.
- No se necesitan marcos adicionales
Desventajas
- Los controladores de vista tienen muchas tareas; muchos de ellos básicamente están pasando datos de un lado a otro entre la vista y la capa del modelo
- No es muy apto para manejar múltiples fuentes de eventos.
- Las clases tienden a saber mucho sobre otras clases.
Definición del problema
Esto funciona muy bien siempre que la aplicación siga las acciones del usuario y responda a ellas, como te imaginas que funcionaría una aplicación como Adobe Photoshop o Microsoft Word. El usuario realiza una acción, la interfaz de usuario se actualiza, repite.
Pero las aplicaciones modernas están conectadas, a menudo en más de una forma. Por ejemplo, interactúa a través de una API REST, recibe notificaciones automáticas y, en algunos casos, también se conecta a un WebSocket.
Con eso, de repente, el controlador de vista necesita lidiar con más fuentes de información, y cada vez que se recibe un mensaje externo sin que el usuario lo active, como recibir un mensaje a través de WebSocket, las fuentes de información deben encontrar el camino de regreso a la derecha. ver controladores. Esto necesita mucho código solo para unir cada parte para realizar lo que es básicamente la misma tarea.
Fuentes de datos externas
Echemos un vistazo a lo que sucede cuando recibimos un mensaje push:
class PushNotificationController { class func received(notification: UNNotification, whenProcessed result: (_ shouldShow: Bool) -> Void) { let shouldShowNotification: Bool defer { result(shouldShowNotification) } let content = notification.request.content let date = DateParser.date(from: content.subtitle) ?? Date() let sender: Message.Sender = .other(name: content.title) let pushedMessage = Message(with: sender, message: content.body, state: .sent, sendDate: date) ChatModel.received(message: pushedMessage, by: content.title) if let chatViewController = chatViewController(handling: content.title) { chatViewController.received(message: pushedMessage) shouldShowNotification = false } else { shouldShowNotification = true } updateChats(for: content.title) } private static func updateChats(for contact: String) { guard let chat = ChatModel.loadedChats.first(where: { (chat) -> Bool in chat.contact == contact }) else { return assertionFailure("Chat for received message should always exist") } BaseNavigationViewController.navigationController?.viewControllers.forEach({ (viewController) in switch viewController { case let chatsViewController as UpdatedChatDelegate: chatsViewController.updated(chat: chat) default: break } }) } private static func chatViewController(handling contact: String) -> ChatViewController? { guard let lastViewController = BaseNavigationViewController.navigationController?.viewControllers.last as? ChatViewController, lastViewController.chat.contact == contact else { return nil } return lastViewController } } Tenemos que buscar manualmente en la pila de controladores de vista para averiguar si hay un controlador de vista que necesita actualizarse solo después de recibir una notificación automática. En este caso, también queremos actualizar las pantallas que implementan UpdatedChatDelegate , que, en este caso, es solo ChatsViewController . También hacemos esto para saber si debemos suprimir la notificación porque ya estamos viendo el Chat para el que estaba destinada. En ese caso, finalmente entregamos el mensaje al controlador de vista. Está bastante claro que PushNotificationController necesita saber demasiado sobre la aplicación para poder hacer su trabajo.
Si ChatWebSocket también enviara mensajes a otras partes de la aplicación, en lugar de tener una relación uno a uno con ChatViewController , nos enfrentaríamos al mismo problema allí.
Está claro que tenemos que escribir un código bastante invasivo cada vez que agregamos otra fuente externa. Este código también es bastante frágil, ya que depende en gran medida de la estructura de la aplicación y delega el paso de datos a la jerarquía para que funcione.
delegados
El patrón MVC también agrega complejidad adicional a la mezcla una vez que agregamos otros controladores de vista. Esto se debe a que los controladores de vista tienden a conocerse entre sí a través de delegados, inicializadores y, en el caso de guiones gráficos prepareForSegue al pasar datos y referencias. Cada controlador de vista maneja sus propias conexiones con el modelo o los controladores de mediación, y ambos envían y reciben actualizaciones.
Además, las vistas se comunican con los controladores de vista a través de delegados. Si bien esto funciona, significa que hay muchos pasos que debemos seguir para pasar los datos, y siempre me encuentro refactorizando muchas devoluciones de llamada y verificando si los delegados están realmente configurados.
Es posible romper un controlador de vista cambiando el código en otro, como datos obsoletos en ChatsListViewController porque ChatViewController ya no está llamando al updated(chat: Chat) . Especialmente en escenarios más complejos, es un fastidio mantener todo sincronizado.
Separación entre Vista y Modelo
Al eliminar todo el código relacionado con la vista del controlador de vista a customView s y mover todo el código relacionado con el modelo a controladores especializados, el controlador de vista es bastante simple y separado. Sin embargo, todavía queda un problema: hay una brecha entre lo que la vista quiere mostrar y los datos que residen en el modelo. Un buen ejemplo es el ChatListView . Lo que queremos mostrar es una lista de celdas que nos diga con quién estamos hablando, cuál fue el último mensaje, la fecha del último mensaje y cuántos mensajes quedan sin leer en el Chat :
Sin embargo, estamos pasando un modelo que no sabe lo que queremos ver. En cambio, es solo un Chat con un contacto que contiene mensajes:
class Chat { let contact: String var messages: [Message] init(with contact: String, messages: [Message] = []) { self.contact = contact self.messages = messages } }Ahora es posible agregar rápidamente código adicional que nos dará el último mensaje y el conteo de mensajes, pero formatear fechas en cadenas es una tarea que pertenece firmemente a la capa de visualización:
var unreadMessages: Int { return messages.filter { switch ($0.sender, $0.state) { case (.user, _), (.other, .read): return false case (.other, .sending), (.other, .sent): return true } }.count } var lastMessage: Date? { return messages.last?.sendDate } Finalmente, formateamos la fecha en ChatItemTableViewCell cuando la mostramos:
func configure(with chat: Chat) { participant.text = chat.contact lastMessage.text = chat.messages.last?.message ?? "" lastMessageDate.text = chat.lastMessage.map { lastMessageDate in DateRenderer.string(from: lastMessageDate) } ?? "" show(unreadMessageCount: chat.unreadMessages) }Incluso en un ejemplo bastante simple, es bastante claro que existe una tensión entre lo que necesita la vista y lo que proporciona el modelo.
MVVM basado en eventos estáticos, también conocido como una versión basada en eventos estáticos del "patrón ViewModel"
Static MVVM funciona con modelos de vista, pero en lugar de crear tráfico bidireccional a través de ellos, al igual que solíamos tener a través de nuestro controlador de vista con MVC, creamos modelos de vista inmutables que actualizan la interfaz de usuario cada vez que la interfaz de usuario necesita cambiar en respuesta a un evento. .
Casi cualquier parte del código puede desencadenar un evento, siempre que pueda proporcionar los datos asociados que requiere la enum del evento. Por ejemplo, la recepción del evento received(new: Message) se puede desencadenar mediante una notificación automática, el WebSocket o una llamada de red normal.
Veámoslo en un diagrama:
A primera vista, parece un poco más complejo que el ejemplo clásico de MVC, ya que hay muchas más clases involucradas para lograr exactamente lo mismo. Pero en una inspección más cercana, ninguna de las relaciones es bidireccional.
Aún más importante es que cada actualización de la interfaz de usuario se desencadena por un evento, por lo que solo hay una ruta a través de la aplicación para todo lo que sucede. Inmediatamente queda claro qué eventos puede esperar. También está claro dónde debe agregar uno nuevo si es necesario, o agregar un nuevo comportamiento al responder a eventos existentes.
Después de la refactorización, terminé con muchas clases nuevas, como mostré arriba. Puede encontrar mi implementación de la versión estática de MVVM en GitHub. Sin embargo, cuando comparo los cambios con la herramienta cloc , queda claro que en realidad no hay mucho código adicional:
| Patrón | archivos | Vacío | Comentario | Código |
|---|---|---|---|---|
| MVC | 30 | 386 | 217 | 1807 |
| MVVM | 51 | 442 | 359 | 1981 |
Solo hay un aumento del 9 por ciento en las líneas de código. Más importante aún, el tamaño promedio de estos archivos se redujo de 60 líneas de código a solo 39.
También de manera crucial, las caídas más grandes se pueden encontrar en los archivos que suelen ser los más grandes en MVC: las vistas y los controladores de vista. Las vistas tienen solo el 74 por ciento de su tamaño original y los controladores de vista ahora tienen solo el 53 por ciento de su tamaño original.
También se debe tener en cuenta que gran parte del código adicional es código de biblioteca que ayuda a adjuntar bloques a botones y otros objetos en el árbol visual, sin requerir el clásico @IBAction de MVC o patrones delegados.
Exploremos las diferentes capas de este diseño una por una.
Evento
El evento siempre es una enum , generalmente con valores asociados. A menudo se superpondrán con una de las entidades de su modelo, pero no necesariamente. En este caso, la aplicación se divide en dos enum de eventos principales: ChatEvent y MessageEvent . ChatEvent es para todas las actualizaciones de los propios objetos de chat:
enum ChatEvent { case started case loaded(chats: [Chat]) case creating(chat: Chat) case created(chat: Chat) case createChatFailed(reason: String) }El otro se ocupa de todos los eventos relacionados con el mensaje:
enum MessageEvent { case sending(message: Message, contact: String, previousMessages: [Message]) case sent(message: Message, contact: String) case failedSending(message: Message, contact: String, reason: String) case received(message: Message, contact: String) case userReads(messagesSentBy: String) case userRead(othersMessages: [Message], sentBy: String) case otherRead(yourMessage: Message, reader: String) } Es importante limitar sus *Event enum s a un tamaño razonable. Si necesita 10 o más casos, generalmente es una señal de que está tratando de cubrir más de un tema.
Nota: El concepto de enum es extremadamente poderoso en Swift. Tiendo a usar mucho enum s con valores asociados, ya que pueden eliminar mucha ambigüedad que de otro modo tendría con valores opcionales.
Tutorial de Swift MVVM: enrutador de eventos
El enrutador de eventos es el punto de entrada para cada evento que ocurre en la aplicación. Cualquier clase que pueda proporcionar el valor asociado puede crear un evento y enviarlo al enrutador de eventos. Por lo tanto, pueden ser activados por cualquier tipo de fuente, por ejemplo:
- El usuario pasando a un controlador de vista en particular
- El usuario tocando un botón determinado
- La aplicación comenzando
- Eventos externos como:
- Una solicitud de red que regresa con una falla o nuevos datos
- Notificaciones push
- Mensajes de WebSocket
El enrutador de eventos debe saber lo menos posible sobre la fuente del evento y, preferiblemente, nada en absoluto. Ninguno de los eventos en esta aplicación de ejemplo tiene ningún indicador de dónde provienen, por lo que es muy fácil mezclar cualquier tipo de fuente de mensaje. Por ejemplo, el WebSocket activa el mismo evento received(message: Message, contact: String) como una nueva notificación de inserción.

Los eventos (ya lo adivinó) se enrutan a las clases que necesitan procesar más estos eventos. Por lo general, las únicas clases que se llaman son la capa del modelo (si es necesario agregar, cambiar o eliminar datos) y el controlador de eventos. Discutiré ambos un poco más adelante, pero la característica principal del enrutador de eventos es brindar un punto de acceso fácil a todos los eventos y reenviar el trabajo a otras clases. Aquí está el ChatEventRouter como ejemplo:
class ChatEventRouter { static func route(event: ChatEvent) { switch event { case .loaded(let chats): ChatEventHandler.loaded(chats: chats) case .creatingChat(let contact): let chat = ChatModel.create(chatWith: contact) ChatEndpoint.create(chat: chat) ChatEventHandler.creatingChat() case .created(let chat): ChatEventHandler.created(chat: chat) case .createChatFailed(let reason): ChatEventHandler.failedCreatingChat(reason: reason) } } } Hay muy poco que hacer aquí: lo único que estamos haciendo es actualizar el modelo y reenviar el evento a ChatEventHandler para que la interfaz de usuario se actualice.
Tutorial de Swift MVVM: controlador de modelos
Esta es exactamente la misma clase que usamos en MVC, ya que funcionaba bastante bien. Representa el estado de la aplicación y, por lo general, estaría respaldado por Core Data o una biblioteca de almacenamiento local.
Las capas del modelo, si se implementan correctamente en MVC, rara vez necesitan una refactorización para adaptarse a diferentes patrones. El mayor cambio es que el cambio de modelo se produce a partir de menos clases, lo que deja un poco más claro dónde se producen los cambios.
En una versión alternativa de este patrón, podría observar los cambios en el modelo y asegurarse de que se manejen. En este caso, elegí simplemente dejar que solo las *EventRouter y *Endpoint cambien el modelo, por lo que existe una responsabilidad clara de dónde y cuándo se actualiza el modelo. Por el contrario, si estuviéramos observando cambios, tendríamos que escribir código adicional para propagar eventos que no cambian el modelo, como errores, a través de ChatEventHandler , lo que haría menos obvio cómo fluyen los eventos a través de la aplicación.
Tutorial de Swift MVVM: controlador de eventos
El controlador de eventos es el lugar donde las vistas o los controladores de vista pueden registrarse (y cancelar el registro) como oyentes para recibir modelos de vista actualizados, que se crean cada vez que ChatEventRouter llama a una función en ChatEventHandler .
Puede ver que refleja aproximadamente todos los estados de vista que usamos en MVC antes. Si desea otros tipos de actualizaciones de la interfaz de usuario, como sonido o activación del motor Taptic, también se pueden hacer desde aquí.
protocol ChatListListening: class { func updated(list: ChatListViewModel) } protocol CreateChatListening: class { func updated(create: CreateChatViewModel) } class ChatEventHandler { private static var chatListListening: [ChatListListening?] = [] private static var createChatListening: [CreateChatListening?] = [] class func add(listener: ChatListListening) { weak var weakListener = listener chatListListening.append(weakListener) } class func remove(listener: ChatListListening) { chatListListening = chatListListening.filter { $0 !== listener } } class func add(listener: CreateChatListening) { weak var weakListener = listener createChatListening.append(weakListener) listener.updated(create: CreateChatViewModelBuilder.build(isSending: false, error: nil)) } class func remove(listener: CreateChatListening) { createChatListening = createChatListening.filter { $0 !== listener } } class func started() { ChatEndpoint.fetchChats() let loadingViewModel = ChatListViewModelBuilder.buildLoading() chatListListening.forEach { $0?.updated(list: loadingViewModel) } } class func loaded(chats: [Chat]) { let chatList = ChatListViewModelBuilder.build(for: chats) chatListListening.forEach { $0?.updated(list: chatList) } } class func creatingChat() { let createChat = CreateChatViewModelBuilder.build(isSending: true, error: nil) createChatListening.forEach { $0?.updated(create: createChat) } } class func failedCreatingChat(reason: String) { let createChat = CreateChatViewModelBuilder.build(isSending: false, error: reason) createChatListening.forEach { $0?.updated(create: createChat) } } class func created(chat: Chat) { let createChat = CreateChatViewModelBuilder.build(isSending: false, error: nil) createChatListening.forEach { $0?.updated(create: createChat) } updateAllChatLists() let chatViewController = ChatViewController(for: chat) BaseNavigationViewController.pushViewController(chatViewController, animated: true, removePreviousFromStack: true) } class func updateAllChatLists() { let chatListViewModel = ChatListViewModelBuilder.build(for: ChatModel.allChats()) chatListListening.forEach { $0?.updated(list: chatListViewModel) } } } Esta clase no hace más que asegurarse de que el oyente correcto pueda obtener el modelo de vista correcto cada vez que ocurra un evento determinado. Los nuevos oyentes pueden obtener un modelo de vista inmediatamente cuando se agregan si es necesario para configurar su estado inicial. Asegúrese siempre de agregar una referencia weak a la lista para evitar ciclos de retención.
Tutorial de Swift MVVM: Ver modelo
Esta es una de las mayores diferencias entre lo que hacen muchos patrones MVVM y lo que hace la variante estática. En este caso, el modelo de vista es inmutable en lugar de establecerse como un intermediario permanente de dos vías entre el modelo y la vista. Por que hariamos eso? Hagamos una pausa para explicarlo un momento.
Uno de los aspectos más importantes para crear una aplicación que funcione bien en todos los casos posibles es asegurarse de que el estado de la aplicación sea el correcto. Si la interfaz de usuario no coincide con el modelo o tiene datos desactualizados, todo lo que hagamos podría provocar que se guarden datos erróneos o que la aplicación se bloquee o se comporte de manera inesperada.
Uno de los objetivos de aplicar este patrón es que no tengamos ningún estado en la aplicación a menos que sea absolutamente necesario. ¿Qué es el estado, exactamente? El estado es básicamente cada lugar donde almacenamos una representación de un tipo particular de datos. Un tipo especial de estado es el estado en el que se encuentra actualmente su interfaz de usuario, que, por supuesto, no podemos evitar con una aplicación basada en la interfaz de usuario. Los otros tipos de estado están todos relacionados con los datos. Si tenemos una copia de una matriz de Chat que respalda nuestro UITableView en la pantalla Lista de chat, ese es un ejemplo de estado duplicado. Un modelo de vista bidireccional tradicional sería otro ejemplo de un duplicado de los mensajes de Chat de nuestro usuario.
Al pasar un modelo de vista inmutable que se actualiza con cada cambio de modelo, eliminamos este tipo de estado duplicado, porque después de que se aplica a la interfaz de usuario, ya no se usa. Entonces solo tenemos los únicos dos tipos de estado que no podemos evitar, la interfaz de usuario y el modelo, y están perfectamente sincronizados entre sí.
Entonces, el modelo de vista aquí es bastante diferente de algunas aplicaciones MVVM. Solo sirve como un almacén de datos inmutable para todas las banderas, valores, bloques y otros valores que la vista requiere para reflejar el estado del modelo, pero la Vista no puede actualizarlo de ninguna manera.
Por lo tanto, puede ser una struct inmutable simple. Para mantener esta struct lo más simple posible, la instanciaremos con un generador de modelos de vista. Una de las cosas interesantes de un modelo de vista es que obtiene indicadores de comportamiento como shouldShowBusy y shouldShowError que reemplazan el mecanismo de enum de estado que se encontraba anteriormente en la vista. Aquí están los datos de ChatItemTableViewCell que habíamos analizado antes:
struct ChatListItemViewModel { let contact: String let message: String let lastMessageDate: String let unreadMessageCount: Int let itemTapped: () -> Void }Debido a que el generador de modelos de vista ya se encarga de los valores y acciones exactos que necesita la vista, todos los datos tienen un formato previo. También es nuevo un bloque que se activará una vez que se toque un elemento. Veamos cómo lo hace el generador de modelos de vista.
Ver generador de modelos
El generador de modelos de vista puede crear instancias de modelos de vista, transformando entradas como Chat s o Message s en modelos de vista que se adaptan perfectamente a una determinada vista. Una de las cosas más importantes que suceden en el generador de modelos de vista es determinar qué sucede realmente dentro de los bloques en el modelo de vista. Los bloques adjuntos por el generador de modelos de vista deben ser extremadamente cortos, llamando a funciones de otras partes de la arquitectura lo antes posible. Dichos bloques no deberían tener ninguna lógica comercial.
class ChatListItemViewModelBuilder { class func build(for chat: Chat) -> ChatListItemViewModel { let lastMessageText = chat.messages.last?.message ?? "" let lastMessageDate = (chat.messages.last?.sendDate).map { DateRenderer.string(from: $0) } ?? "" let unreadMessageCount = ChatModel.unreadMessages(for: chat.contact).count return ChatListItemViewModel(contact: chat.contact, message: lastMessageText, lastMessageDate: lastMessageDate, unreadMessageCount: unreadMessageCount, itemTapped: { show(chat: chat) }) } private class func show(chat: Chat) { let chatViewController = ChatViewController(for: chat) BaseNavigationViewController.pushViewController(chatViewController, animated: true) } }Ahora todo el formato previo ocurre en el mismo lugar y el comportamiento también se decide aquí. Es una clase bastante importante en esta jerarquía y puede ser interesante ver cómo se han implementado los diferentes constructores en la aplicación de demostración y cómo se enfrentan a escenarios más complicados.
Tutorial de Swift MVVM: controlador de vista
El controlador de vista en esta arquitectura hace muy poco. Montará y derribará todo lo relacionado con su vista. Lo mejor es hacer esto porque obtiene todas las devoluciones de llamada del ciclo de vida que se requieren para agregar y eliminar oyentes en el momento adecuado.
A veces, necesita actualizar un elemento de la interfaz de usuario que no está cubierto por la vista raíz, como el título o un botón en la barra de navegación. Es por eso que normalmente sigo registrando el controlador de vista como oyente del enrutador de eventos si tengo un modelo de vista que cubre toda la vista para el controlador de vista dado; Reenvío el modelo de vista a la vista posterior. Pero también está bien registrar cualquier UIView como oyente directamente si hay una parte de la pantalla que tiene una frecuencia de actualización diferente, por ejemplo, un tablero de cotizaciones en vivo en la parte superior de una página sobre una determinada empresa.
El código para ChatsViewController ahora es tan corto que ocupa menos de una página. Lo que queda es anular la vista base, agregar y eliminar el botón Agregar de la barra de navegación, establecer el título, agregarse como oyente e implementar el protocolo ChatListListening :
class ChatsViewController: UIViewController { private lazy var customView: ChatsView = { let customView = ChatsView() return customView }() private var addButton: UIBarButtonItem = UIBarButtonItem(barButtonSystemItem: .add, target: nil, action: nil) override func loadView() { view = customView } override func viewDidLoad() { super.viewDidLoad() ChatEventHandler.add(listener: self) ChatEventRouter.route(event: .started) title = "Chats" } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) navigationItem.rightBarButtonItem = addButton } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) navigationItem.rightBarButtonItem = nil } } extension ChatsViewController: ChatListListening { func updated(list: ChatListViewModel) { addButton.action(block: { _ in list.addChat() }) customView.display(viewModel: list) } } No queda nada que se pueda hacer en otro lugar, ya que ChatsViewController se reduce al mínimo.
Tutorial de Swift MVVM: Ver
La vista en la arquitectura MVVM inmutable aún puede ser bastante pesada, ya que todavía tiene una lista de tareas, pero logré despojarla de las siguientes responsabilidades en comparación con la arquitectura MVC:
- Determinar lo que debe cambiar en respuesta a un nuevo estado
- Implementación de delegados y funciones para acciones.
- Manejar disparadores de vista a vista como gestos y animaciones activadas
- Transformar datos de tal manera que se puedan mostrar (como
Dates aStrings)
Especialmente el último punto tiene una gran ventaja. En MVC, cuando la vista o el controlador de vista es responsable de transformar los datos para mostrarlos, siempre lo hará en el subproceso principal, ya que es muy difícil separar los cambios verdaderos en la interfaz de usuario que deben ocurrir en este subproceso de las cosas que son no es necesario para ejecutar en él. Y tener un código que no cambia la interfaz de usuario ejecutándose en el subproceso principal puede conducir a una aplicación menos receptiva.
En cambio, con este patrón MVVM, todo, desde el bloque que se activa con un toque hasta el momento en que se construye el modelo de vista y se pasará al oyente; podemos ejecutar todo esto en un subproceso separado y solo sumergirnos en el subproceso principal en el end para hacer actualizaciones de UI. Si nuestra aplicación pasa menos tiempo en el hilo principal, funcionará mejor.
Una vez que el modelo de vista aplica el nuevo estado a la vista, se permite que se evapore en lugar de quedarse como otra capa de estado. Todo lo que podría desencadenar un evento se adjunta a un elemento en la vista y no nos comunicaremos con el modelo de vista.
Es importante recordar una cosa: no está obligado a asignar un modelo de vista a través de un controlador de vista a una vista. Como se mencionó anteriormente, partes de la vista pueden ser administradas por otros modelos de vista, especialmente cuando las tasas de actualización varían. Considere una Hoja de cálculo de Google que está siendo editada por diferentes personas mientras mantiene un panel de chat abierto para los colaboradores; no es muy útil actualizar el documento cada vez que llega un mensaje de chat.
Un ejemplo bien conocido es una implementación de tipo para encontrar donde el cuadro de búsqueda se actualiza con resultados más precisos a medida que ingresamos más texto. Así es como implementaría el autocompletado en la clase CreateAutocompleteView : el CreateViewModel sirve a toda la pantalla, pero el cuadro de texto está escuchando el AutocompleteContactViewModel en su lugar.
Otro ejemplo es el uso de un validador de formularios, que puede construirse como un "bucle local" (adjuntando o eliminando estados de error de los campos y declarando que un formulario es válido) o mediante la activación de un evento.
Los modelos de vista estática inmutable proporcionan una mejor separación
Mediante el uso de una implementación estática de MVVM, finalmente logramos separar todas las capas por completo porque el modelo de vista ahora sirve de puente entre el modelo y la vista. También facilitamos la administración de eventos que no fueron causados por la acción del usuario y eliminamos muchas de las dependencias entre las diferentes partes de nuestra aplicación. Lo único que hace un controlador de vista es registrarse (y cancelar el registro) en los controladores de eventos como detector de los eventos que desea recibir.
Beneficios:
- Las implementaciones de vista y controlador de vista tienden a ser mucho más ligeras
- Las clases son más especializadas y separadas.
- Los eventos se pueden activar fácilmente desde cualquier lugar
- Los eventos siguen un camino predecible a través del sistema
- El estado solo se actualiza desde un lugar
- App can be more performant as it's easier to do work off the main thread
- Views receive tailor-made view models and are perfectly separated from the models
Downsides:
- A full view model is created and sent every time the UI needs to update, often overwriting the same button text with the same button text, and replacing blocks with blocks that do exactly the same
- Requires some helper extensions to make button taps and other UI events work well with the blocks in the view model
- Event
enums can easily grow pretty large in complex scenarios and might be hard to split up
The great thing is that this is a pure Swift pattern: It does not require a third-party Swift MVVM framework, nor does it exclude the use of classic MVC, so you can easily add new features or refactor problematic parts of your application today without being forced to rewrite your whole application.
There are other approaches to combat large view controllers that provide better separation as well. I couldn't include them all in full detail to compare them, but let's take a brief look at some of the alternatives:
- Some form of the MVVM pattern
- Some form of Reactive (using RxSwift, sometimes combined with MVVM)
- The model-view-presenter pattern (MVP)
- The view-interactor-presenter-entity-router pattern (VIPER)
Traditional MVVM replaces most of the view controller code with a view model that is just a regular class and can be tested more easily in isolation. Since it needs to be a bi-directional bridge between the view and the model it often implements some form of Observables. That's why you often see it used together with a framework like RxSwift.
MVP and VIPER deal with extra abstraction layers between the model and the view in a more traditional way, while Reactive really remodels the way data and events flow through your application.
The Reactive style of programming is gaining a lot of popularity lately and actually is pretty close to the static MVVM approach with events, as explained in this article. The major difference is that it usually requires a framework, and a lot of your code is specifically geared towards that framework.
MVP is a pattern where both the view controller and the view are considered to be the view layer. The presenter transforms the model and passes it to the view layer, while I transform the data into a view model first. Since the view can be abstracted to a protocol, it's much easier to test.
VIPER takes the presenter from MVP, adds a separate “interactor” for business logic, calls the model layer “entity,” and has a router for navigation purposes (and to complete the acronym). It can be considered a more detailed and decoupled form of MVP.
So there you have it: static event-driven MVVM explained. I look forward to hearing from you in the comments below!
