Tutorial de Swift: una introducción al patrón de diseño de MVVM

Publicado: 2022-03-11

Entonces, está comenzando un nuevo proyecto de iOS, recibió del diseñador todos los documentos .pdf y .sketch necesarios, y ya tiene una visión sobre cómo creará esta nueva aplicación.

Comienza a transferir pantallas de interfaz de usuario de los bocetos del diseñador a los archivos .swift , .xib y .storyboard de ViewController .

UITextField aquí, UITableView allá, algunas UILabels más y una pizca de UIButtons . También se incluyen IBOutlets e IBActions . Todo bien, todavía estamos en la zona de interfaz de usuario.

Sin embargo, es hora de hacer algo con todos estos elementos de la interfaz de usuario; UIButtons recibirán toques con los dedos, UILabels y UITableViews necesitarán que alguien les diga qué mostrar y en qué formato.

De repente, tienes más de 3000 líneas de código.

3.000 líneas de código Swift

Terminaste con un montón de código espagueti.

El primer paso para resolver esto es aplicar el patrón de diseño Modelo-Vista-Controlador (MVC). Sin embargo, este patrón tiene sus propios problemas. Llega el patrón de diseño Model-View-ViewModel (MVVM) que salva el día.

Cómo lidiar con el código espagueti

En poco tiempo, su ViewController inicial se ha vuelto demasiado inteligente y demasiado masivo.

Código de red, código de análisis de datos, código de ajustes de datos para la presentación de la interfaz de usuario, notificaciones de estado de la aplicación, cambios de estado de la interfaz de usuario. Todo ese código aprisionado dentro de la if -ología de un solo archivo que no se puede reutilizar y solo cabría en este proyecto.

Su código ViewController se ha convertido en el infame código espagueti.

¿Cómo sucedió eso?

La razón probable es algo como esto:

Tenía prisa por ver cómo se comportaban los datos de back-end dentro de UITableView , por lo que colocó algunas líneas de código de red dentro de un método temporal de ViewController solo para obtener ese .json de la red. A continuación, necesitaba procesar los datos dentro de ese .json , por lo que escribió otro método temporal para lograrlo. O, peor aún, lo hiciste dentro del mismo método.

ViewController siguió creciendo cuando apareció el código de autorización de usuario. Luego, los formatos de datos comenzaron a cambiar, la interfaz de usuario evolucionó y necesitaba algunos cambios radicales, y siguió agregando más if s en una if -ology ya masiva.

Pero, ¿cómo es que UIViewController es lo que se salió de control?

El UIViewController es el lugar lógico para comenzar a trabajar en su código de interfaz de usuario. Representa la pantalla física que está viendo mientras usa cualquier aplicación con su dispositivo iOS. Incluso Apple usa UIViewControllers en su aplicación de sistema principal cuando cambia entre diferentes aplicaciones y sus IU animadas.

Apple basa su abstracción de UI dentro de UIViewController , ya que es el núcleo del código de UI de iOS y parte del patrón de diseño de MVC .

Relacionado: Los 10 errores más comunes que los desarrolladores de iOS no saben que están cometiendo

Actualización al patrón de diseño MVC

Patrón de diseño MVC

En el patrón de diseño de MVC, se supone que View está inactivo y solo muestra datos preparados bajo demanda.

El controlador debe trabajar en los datos del Modelo para prepararlos para las Vistas , que luego muestran esos datos.

View también es responsable de notificar al Controlador sobre cualquier acción, como los toques del usuario.

Como se mencionó, UIViewController suele ser el punto de partida para crear una pantalla de interfaz de usuario. Tenga en cuenta que en su nombre contiene tanto la "vista" como el "controlador". Esto significa que "controla la vista". No significa que tanto el código de "controlador" como el de "vista" deban ir dentro.

Esta mezcla de código de vista y controlador a menudo ocurre cuando mueve IBOutlets de pequeñas subvistas dentro de UIViewController y manipula esas subvistas directamente desde UIViewController . En su lugar, debería haber envuelto ese código dentro de una subclase UIView personalizada.

Es fácil ver que esto podría llevar a que se crucen las rutas de código de View y Controller.

MVVM al rescate

Aquí es donde el patrón MVVM resulta útil.

Dado que se supone que UIViewController es un controlador en el patrón MVC, y ya está haciendo mucho con las vistas , podemos fusionarlos en la vista de nuestro nuevo patrón: MVVM .

Patrón de diseño MVVM

En el patrón de diseño MVVM, Model es el mismo que en el patrón MVC. Representa datos simples.

La vista está representada por los objetos UIView o UIViewController , acompañados de sus archivos .xib y .storyboard , que solo deben mostrar datos preparados. (No queremos tener código NSDateFormatter , por ejemplo, dentro de la Vista).

Solo una cadena simple y formateada que proviene de ViewModel .

ViewModel oculta todo el código de red asíncrono, el código de preparación de datos para la presentación visual y la escucha de código para los cambios del modelo . Todos estos están ocultos detrás de una API bien definida modelada para adaptarse a esta vista en particular.

Uno de los beneficios de usar MVVM es la prueba. Dado que ViewModel es NSObject puro (o struct , por ejemplo) y no está acoplado con el código UIKit , puede probarlo más fácilmente en sus pruebas unitarias sin que afecte el código de la interfaz de usuario.

Ahora, la Vista ( UIViewController / UIView ) se ha vuelto mucho más simple, mientras que ViewModel actúa como el pegamento entre el Modelo y la Vista .

Aplicación de MVVM en Swift

MVVM en Swift

Para mostrarle MVVM en acción, puede descargar y examinar el proyecto Xcode de ejemplo creado para este tutorial aquí. Este proyecto usa Swift 3 y Xcode 8.1.

Hay dos versiones del proyecto: Starter y Finished.

La versión Finalizada es una mini aplicación completa, donde Starter es el mismo proyecto pero sin los métodos y objetos implementados.

Primero, le sugiero que descargue el proyecto Starter y siga este tutorial. Si necesita una referencia rápida del proyecto para más adelante, descargue el Proyecto terminado .

Tutorial Proyecto Introducción

El proyecto tutorial es una aplicación de baloncesto para el seguimiento de las acciones de los jugadores durante el juego.

aplicación de baloncesto

Se utiliza para el seguimiento rápido de los movimientos del usuario y de la puntuación general en un juego informal.

Dos equipos juegan hasta que se alcanza la puntuación de 15 (con al menos una diferencia de dos puntos). Cada jugador puede anotar de uno a dos puntos, y cada jugador puede ayudar, tomar rebotes y cometer faltas.

La jerarquía del proyecto se ve así:

jerarquía del proyecto

Modelo

  • Game.swift
    • Contiene la lógica del juego, rastrea la puntuación general, rastrea los movimientos de cada jugador.
  • Team.swift
    • Contiene el nombre del equipo y la lista de jugadores (tres jugadores en cada equipo).
  • Player.swift
    • Un solo jugador con un nombre.

Vista

  • HomeViewController.swift
    • Controlador de vista raíz, que presenta GameScoreboardEditorViewController
  • GameScoreboardEditorViewController.swift
    • Complementado con la vista Interface Builder en Main.storyboard .
    • Pantalla de interés para este tutorial.
  • PlayerScoreboardMoveEditorView.swift
    • Complementada con la vista Interface Builder en PlayerScoreboardMoveEditorView.xib
    • Subvista de la vista anterior, también usa el patrón de diseño MVVM.

Ver modelo

  • El grupo ViewModel está vacío, esto es lo que construirá en este tutorial.

El proyecto Xcode descargado ya contiene marcadores de posición para los objetos View ( UIView y UIViewController ). El proyecto también contiene algunos objetos hechos a medida para demostrar una de las formas de proporcionar datos a los objetos ViewModel (grupo Services ).

El grupo Extensions contiene extensiones útiles para el código de la interfaz de usuario que no están dentro del alcance de este tutorial y se explican por sí mismas.

Si ejecuta la aplicación en este punto, mostrará la interfaz de usuario finalizada, pero no sucede nada cuando un usuario presiona los botones.

Esto se debe a que solo ha creado vistas y IBActions sin conectarlas a la lógica de la aplicación y sin llenar los elementos de la interfaz de usuario con los datos del modelo (del objeto Game , como veremos más adelante).

Conexión de vista y modelo con ViewModel

En el patrón de diseño MVVM, View no debería saber nada sobre el Modelo. Lo único que View sabe es cómo trabajar con un ViewModel.

Comience examinando su Vista.

En el archivo GameScoreboardEditorViewController.swift , el método fillUI está vacío en este punto. Este es el lugar donde desea completar la interfaz de usuario con datos. Para lograr esto, debe proporcionar datos para ViewController . Haces esto con un objeto ViewModel.

Primero, cree un objeto ViewModel que contenga todos los datos necesarios para este ViewController .

Vaya al grupo de proyectos ViewModel Xcode, que estará vacío, cree un archivo GameScoreboardEditorViewModel.swift y conviértalo en un protocolo.

 import Foundation protocol GameScoreboardEditorViewModel { var homeTeam: String { get } var awayTeam: String { get } var time: String { get } var score: String { get } var isFinished: Bool { get } var isPaused: Bool { get } func togglePause(); }

El uso de protocolos como este mantiene las cosas limpias y ordenadas; solo debe definir los datos que utilizará.

A continuación, cree una implementación para este protocolo.

Cree un nuevo archivo, llamado GameScoreboardEditorViewModelFromGame.swift , y convierta este objeto en una subclase de NSObject .

Además, haz que se ajuste al protocolo GameScoreboardEditorViewModel :

 import Foundation class GameScoreboardEditorViewModelFromGame: NSObject, GameScoreboardEditorViewModel { let game: Game struct Formatter { static let durationFormatter: DateComponentsFormatter = { let dateFormatter = DateComponentsFormatter() dateFormatter.unitsStyle = .positional return dateFormatter }() } // MARK: GameScoreboardEditorViewModel protocol var homeTeam: String var awayTeam: String var time: String var score: String var isFinished: Bool var isPaused: Bool func togglePause() { if isPaused { startTimer() } else { pauseTimer() } self.isPaused = !isPaused } // MARK: Init init(withGame game: Game) { self.game = game self.homeTeam = game.homeTeam.name self.awayTeam = game.awayTeam.name self.time = GameScoreboardEditorViewModelFromGame.timeRemainingPretty(for: game) self.score = GameScoreboardEditorViewModelFromGame.scorePretty(for: game) self.isFinished = game.isFinished self.isPaused = true } // MARK: Private fileprivate var gameTimer: Timer? fileprivate func startTimer() { let interval: TimeInterval = 0.001 gameTimer = Timer.schedule(repeatInterval: interval) { timer in self.game.time += interval self.time = GameScoreboardEditorViewModelFromGame.timeRemainingPretty(for: self.game) } } fileprivate func pauseTimer() { gameTimer?.invalidate() gameTimer = nil } // MARK: String Utils fileprivate static func timeFormatted(totalMillis: Int) -> String { let millis: Int = totalMillis % 1000 / 100 // "/ 100" <- because we want only 1 digit let totalSeconds: Int = totalMillis / 1000 let seconds: Int = totalSeconds % 60 let minutes: Int = (totalSeconds / 60) return String(format: "%02d:%02d.%d", minutes, seconds, millis) } fileprivate static func timeRemainingPretty(for game: Game) -> String { return timeFormatted(totalMillis: Int(game.time * 1000)) } fileprivate static func scorePretty(for game: Game) -> String { return String(format: "\(game.homeTeamScore) - \(game.awayTeamScore)") } }

Tenga en cuenta que proporcionó todo lo necesario para que ViewModel funcione a través del inicializador.

Le proporcionó el objeto Game , que es el Modelo debajo de este ViewModel.

Si ejecuta la aplicación ahora, aún no funcionará porque no ha conectado estos datos de ViewModel a la Vista en sí.

Entonces, regrese al archivo GameScoreboardEditorViewController.swift y cree una propiedad pública llamada viewModel .

Hágalo del tipo GameScoreboardEditorViewModel .

Colóquelo justo antes del método viewDidLoad dentro de GameScoreboardEditorViewController.swift .

 var viewModel: GameScoreboardEditorViewModel? { didSet { fillUI() } }

A continuación, debe implementar el método fillUI .

Observe cómo se llama a este método desde dos lugares, el observador de la propiedad viewModel ( didSet ) y el método viewDidLoad . Esto se debe a que podemos crear un ViewController y asignarle un ViewModel antes de adjuntarlo a una vista (antes de llamar al método viewDidLoad ).

Por otro lado, podría adjuntar la vista de ViewController a otra vista y llamar a viewDidLoad , pero si viewModel no está configurado en ese momento, no pasará nada.

Es por eso que primero debe verificar si todo está configurado para que sus datos llenen la interfaz de usuario. Es importante proteger su código contra usos inesperados.

Entonces, vaya al método fillUI y reemplácelo con el siguiente código:

 fileprivate func fillUI() { if !isViewLoaded { return } guard let viewModel = viewModel else { return } // we are sure here that we have all the setup done self.homeTeamNameLabel.text = viewModel.homeTeam self.awayTeamNameLabel.text = viewModel.awayTeam self.scoreLabel.text = viewModel.score self.timeLabel.text = viewModel.time let title: String = viewModel.isPaused ? "Start" : "Pause" self.pauseButton.setTitle(title, for: .normal) }

Ahora, implemente el método pauseButtonPress :

 @IBAction func pauseButtonPress(_ sender: AnyObject) { viewModel?.togglePause() }

Todo lo que necesita hacer ahora es establecer la propiedad viewModel real en este ViewController . Haces esto “desde afuera”.

Abra el archivo HomeViewController.swift y descomente el ViewModel; crear y configurar líneas en el método showGameScoreboardEditorViewController :

 // uncomment this when view model is implemented let viewModel = GameScoreboardEditorViewModelFromGame(withGame: game) controller.viewModel = viewModel

Ahora, ejecuta la aplicación. Debería verse algo como esto:

Aplicación iOS

La vista central, que es responsable de la puntuación, el tiempo y los nombres de los equipos, ya no muestra los valores establecidos en Interface Builder.

Ahora, muestra los valores del propio objeto ViewModel, que obtiene sus datos del objeto Model actual (objeto Game ).

¡Excelente! Pero, ¿qué pasa con las vistas de los jugadores? Esos botones todavía no hacen nada.

Sabes que tienes seis vistas para el seguimiento de los movimientos del jugador.

Creó una subvista separada, llamada PlayerScoreboardMoveEditorView para eso, que no hace nada con los datos reales por ahora y muestra valores estáticos que se establecieron a través de Interface Builder dentro del archivo PlayerScoreboardMoveEditorView.xib .

Tienes que darle algunos datos.

Lo harás de la misma manera que lo hiciste con GameScoreboardEditorViewController y GameScoreboardEditorViewModel .

Abra el grupo ViewModel en el proyecto Xcode y defina el nuevo protocolo aquí.

Cree un nuevo archivo llamado PlayerScoreboardMoveEditorViewModel.swift y coloque el siguiente código dentro:

 import Foundation protocol PlayerScoreboardMoveEditorViewModel { var playerName: String { get } var onePointMoveCount: String { get } var twoPointMoveCount: String { get } var assistMoveCount: String { get } var reboundMoveCount: String { get } var foulMoveCount: String { get } func onePointMove() func twoPointsMove() func assistMove() func reboundMove() func foulMove() }

Este protocolo ViewModel fue diseñado para adaptarse a su PlayerScoreboardMoveEditorView , tal como lo hizo en la vista principal, GameScoreboardEditorViewController .

Debe tener valores para los cinco movimientos diferentes que un usuario puede realizar y debe reaccionar cuando el usuario toca uno de los botones de acción. También necesita una String para el nombre del jugador.

Después de hacer esto, cree una clase concreta que implemente este protocolo, tal como lo hizo con la vista principal ( GameScoreboardEditorViewController ).

A continuación, cree una implementación de este protocolo: cree un nuevo archivo, asígnele el nombre PlayerScoreboardMoveEditorViewModelFromPlayer.swift y convierta este objeto en una subclase de NSObject . Además, haz que se ajuste al protocolo PlayerScoreboardMoveEditorViewModel :

 import Foundation class PlayerScoreboardMoveEditorViewModelFromPlayer: NSObject, PlayerScoreboardMoveEditorViewModel { fileprivate let player: Player fileprivate let game: Game // MARK: PlayerScoreboardMoveEditorViewModel protocol let playerName: String var onePointMoveCount: String var twoPointMoveCount: String var assistMoveCount: String var reboundMoveCount: String var foulMoveCount: String func onePointMove() { makeMove(.onePoint) } func twoPointsMove() { makeMove(.twoPoints) } func assistMove() { makeMove(.assist) } func reboundMove() { makeMove(.rebound) } func foulMove() { makeMove(.foul) } // MARK: Init init(withGame game: Game, player: Player) { self.game = game self.player = player self.playerName = player.name self.onePointMoveCount = "\(game.playerMoveCount(for: player, move: .onePoint))" self.twoPointMoveCount = "\(game.playerMoveCount(for: player, move: .twoPoints))" self.assistMoveCount = "\(game.playerMoveCount(for: player, move: .assist))" self.reboundMoveCount = "\(game.playerMoveCount(for: player, move: .rebound))" self.foulMoveCount = "\(game.playerMoveCount(for: player, move: .foul))" } // MARK: Private fileprivate func makeMove(_ move: PlayerInGameMove) { game.addPlayerMove(move, for: player) onePointMoveCount = "\(game.playerMoveCount(for: player, move: .onePoint))" twoPointMoveCount = "\(game.playerMoveCount(for: player, move: .twoPoints))" assistMoveCount = "\(game.playerMoveCount(for: player, move: .assist))" reboundMoveCount = "\(game.playerMoveCount(for: player, move: .rebound))" foulMoveCount = "\(game.playerMoveCount(for: player, move: .foul))" } }

Ahora, debe tener un objeto que cree esta instancia "desde el exterior" y configurarlo como una propiedad dentro de PlayerScoreboardMoveEditorView .

¿Recuerda cómo HomeViewController fue responsable de establecer la propiedad viewModel en GameScoreboardEditorViewController ?

De la misma manera, GameScoreboardEditorViewController es una vista principal de su PlayerScoreboardMoveEditorView y GameScoreboardEditorViewController será responsable de crear los objetos PlayerScoreboardMoveEditorViewModel .

Primero debe expandir su GameScoreboardEditorViewModel .

Abra GameScoreboardEditorViewMode l y agregue estas dos propiedades:

 var homePlayers: [PlayerScoreboardMoveEditorViewModel] { get } var awayPlayers: [PlayerScoreboardMoveEditorViewModel] { get }

Además, actualice GameScoreboardEditorViewModelFromGame con estas dos propiedades justo encima del método initWithGame :

 let homePlayers: [PlayerScoreboardMoveEditorViewModel] let awayPlayers: [PlayerScoreboardMoveEditorViewModel]

Agrega estas dos líneas dentro initWithGame :

 self.homePlayers = GameScoreboardEditorViewModelFromGame.playerViewModels(from: game.homeTeam.players, game: game) self.awayPlayers = GameScoreboardEditorViewModelFromGame.playerViewModels(from: game.awayTeam.players, game: game)

Y, por supuesto, agregue el método playerViewModelsWithPlayers que falta:

 // MARK: Private Init fileprivate static func playerViewModels(from players: [Player], game: Game) -> [PlayerScoreboardMoveEditorViewModel] { var playerViewModels: [PlayerScoreboardMoveEditorViewModel] = [PlayerScoreboardMoveEditorViewModel]() for player in players { playerViewModels.append(PlayerScoreboardMoveEditorViewModelFromPlayer(withGame: game, player: player)) } return playerViewModels }

¡Genial!

Ha actualizado su ViewModel ( GameScoreboardEditorViewModel ) con la matriz de jugadores locales y visitantes. Todavía necesita llenar estas dos matrices.

Lo harás en el mismo lugar en el que viewModel este modelo de vista para llenar la interfaz de usuario.

Abra GameScoreboardEditorViewController y vaya al método fillUI . Agregue estas líneas al final del método:

 homePlayer1View.viewModel = viewModel.homePlayers[0] homePlayer2View.viewModel = viewModel.homePlayers[1] homePlayer3View.viewModel = viewModel.homePlayers[2] awayPlayer1View.viewModel = viewModel.awayPlayers[0] awayPlayer2View.viewModel = viewModel.awayPlayers[1] awayPlayer3View.viewModel = viewModel.awayPlayers[2]

Por el momento, tiene errores de compilación porque no agregó la propiedad viewModel real dentro de PlayerScoreboardMoveEditorView .

Agregue el siguiente código sobre el init method inside the PlayerScoreboardMoveEditorView`.

 var viewModel: PlayerScoreboardMoveEditorViewModel? { didSet { fillUI() } }

E implemente el método fillUI :

 fileprivate func fillUI() { guard let viewModel = viewModel else { return } self.name.text = viewModel.playerName self.onePointCountLabel.text = viewModel.onePointMoveCount self.twoPointCountLabel.text = viewModel.twoPointMoveCount self.assistCountLabel.text = viewModel.assistMoveCount self.reboundCountLabel.text = viewModel.reboundMoveCount self.foulCountLabel.text = viewModel.foulMoveCount }

Finalmente, ejecute la aplicación y vea cómo los datos en los elementos de la interfaz de usuario son los datos reales del objeto Game .

Aplicación iOS

En este punto, tiene una aplicación funcional que usa el patrón de diseño MVVM.

Oculta muy bien el Modelo de la Vista, y su Vista es mucho más simple de lo que está acostumbrado con el MVC.

Hasta este punto, ha creado una aplicación que contiene la vista y su modelo de vista.

Esa Vista también tiene seis instancias de la misma subvista (vista del reproductor) con su ViewModel.

Sin embargo, como puede notar, solo puede mostrar datos en la interfaz de usuario una vez (en el método fillUI ), y esos datos son estáticos.

Si sus datos en las vistas no cambiarán durante la vida útil de esa vista, entonces tiene una solución buena y limpia para usar MVVM de esta manera.

Hacer que el modelo de vista sea dinámico

Debido a que sus datos cambiarán, debe hacer que su ViewModel sea dinámico.

Lo que esto significa es que cuando cambia el modelo, ViewModel debe cambiar sus valores de propiedad pública; propagaría el cambio a la vista, que es la que actualizará la interfaz de usuario.

Hay muchas maneras de hacer esto.

Cuando el modelo cambia, ViewModel recibe una notificación primero.

Necesita algún mecanismo para propagar lo que cambia hasta la Vista.

Algunas de las opciones incluyen RxSwift, que es una biblioteca bastante grande y lleva algún tiempo acostumbrarse.

ViewModel podría activar NSNotification s en cada cambio de valor de propiedad, pero esto agrega una gran cantidad de código que necesita un manejo adicional, como suscribirse a notificaciones y darse de baja cuando la vista se desasigna.

Key-Value-Observing (KVO) es otra opción, pero los usuarios confirmarán que su API no es elegante.

En este tutorial, usará genéricos y cierres de Swift, que se describen muy bien en el artículo Enlaces, genéricos, Swift y MVVM.

Ahora, volvamos a la aplicación de ejemplo.

Vaya al grupo de proyectos ViewModel y cree un nuevo archivo Swift, Dynamic.swift .

 class Dynamic<T> { typealias Listener = (T) -> () var listener: Listener? func bind(_ listener: Listener?) { self.listener = listener } func bindAndFire(_ listener: Listener?) { self.listener = listener listener?(value) } var value: T { didSet { listener?(value) } } init(_ v: T) { value = v } }

Usará esta clase para las propiedades en sus ViewModels que espera cambiar durante el ciclo de vida de View.

Primero, comience con PlayerScoreboardMoveEditorView y su ViewModel, PlayerScoreboardMoveEditorViewModel .

Abra PlayerScoreboardMoveEditorViewModel y observe sus propiedades.

Debido a que no se espera que cambie el nombre del playerName , puede dejarlo como está.

Las otras cinco propiedades (cinco tipos de movimiento) cambiarán, por lo que debe hacer algo al respecto. ¿La solución? La clase Dynamic mencionada anteriormente que acaba de agregar al proyecto.

Dentro PlayerScoreboardMoveEditorViewModel elimine las definiciones de cinco cadenas que representan el conteo de movimientos y reemplácelas con esto:

 var onePointMoveCount: Dynamic<String> { get } var twoPointMoveCount: Dynamic<String> { get } var assistMoveCount: Dynamic<String> { get } var reboundMoveCount: Dynamic<String> { get } var foulMoveCount: Dynamic<String> { get }

Así es como debería verse ahora el protocolo ViewModel:

 import Foundation protocol PlayerScoreboardMoveEditorViewModel { var playerName: String { get } var onePointMoveCount: Dynamic<String> { get } var twoPointMoveCount: Dynamic<String> { get } var assistMoveCount: Dynamic<String> { get } var reboundMoveCount: Dynamic<String> { get } var foulMoveCount: Dynamic<String> { get } func onePointMove() func twoPointsMove() func assistMove() func reboundMove() func foulMove() }

Este tipo Dynamic le permite cambiar el valor de esa propiedad en particular y, al mismo tiempo, notificar el objeto detector de cambios, que, en este caso, será la Vista.

Ahora, actualice la implementación real de ViewModel PlayerScoreboardMoveEditorViewModelFromPlayer .

Reemplace esto:

 var onePointMoveCount: String var twoPointMoveCount: String var assistMoveCount: String var reboundMoveCount: String var foulMoveCount: String

con lo siguiente:

 let onePointMoveCount: Dynamic<String> let twoPointMoveCount: Dynamic<String> let assistMoveCount: Dynamic<String> let reboundMoveCount: Dynamic<String> let foulMoveCount: Dynamic<String>

Nota: está bien declarar estas propiedades como constantes con let ya que no cambiará la propiedad real. Cambiará la propiedad de value en el objeto Dynamic .

Ahora, hay errores de compilación porque no inicializó sus objetos Dynamic .

Dentro del método init de PlayerScoreboardMoveEditorViewModelFromPlayer , reemplace la inicialización de las propiedades de movimiento con esto:

 self.onePointMoveCount = Dynamic("\(game.playerMoveCount(for: player, move: .onePoint))") self.twoPointMoveCount = Dynamic("\(game.playerMoveCount(for: player, move: .twoPoints))") self.assistMoveCount = Dynamic("\(game.playerMoveCount(for: player, move: .assist))") self.reboundMoveCount = Dynamic("\(game.playerMoveCount(for: player, move: .rebound))") self.foulMoveCount = Dynamic("\(game.playerMoveCount(for: player, move: .foul))")

Dentro de PlayerScoreboardMoveEditorViewModelFromPlayer vaya al método makeMove y reemplácelo con el siguiente código:

 fileprivate func makeMove(_ move: PlayerInGameMove) { game.addPlayerMove(move, for: player) onePointMoveCount.value = "\(game.playerMoveCount(for: player, move: .onePoint))" twoPointMoveCount.value = "\(game.playerMoveCount(for: player, move: .twoPoints))" assistMoveCount.value = "\(game.playerMoveCount(for: player, move: .assist))" reboundMoveCount.value = "\(game.playerMoveCount(for: player, move: .rebound))" foulMoveCount.value = "\(game.playerMoveCount(for: player, move: .foul))" }

Como puede ver, ha creado instancias de la clase Dynamic y le ha asignado valores de String . Cuando necesite actualizar los datos, no cambie la propiedad Dynamic en sí; más bien actualice su propiedad de value .

¡Genial! PlayerScoreboardMoveEditorViewModel ahora es dinámico.

Hagámoslo y vayamos a la vista que realmente escuchará estos cambios.

Abra PlayerScoreboardMoveEditorView y su método fillUI (debería ver errores de compilación en este método en este punto, ya que está intentando asignar un valor de String al tipo de objeto Dynamic ).

Reemplace las líneas "erróneas":

 self.onePointCountLabel.text = viewModel.onePointMoveCount self.twoPointCountLabel.text = viewModel.twoPointMoveCount self.assistCountLabel.text = viewModel.assistMoveCount self.reboundCountLabel.text = viewModel.reboundMoveCount self.foulCountLabel.text = viewModel.foulMoveCount

con lo siguiente:

 viewModel.onePointMoveCount.bindAndFire { [unowned self] in self.onePointCountLabel.text = $0 } viewModel.twoPointMoveCount.bindAndFire { [unowned self] in self.twoPointCountLabel.text = $0 } viewModel.assistMoveCount.bindAndFire { [unowned self] in self.assistCountLabel.text = $0 } viewModel.reboundMoveCount.bindAndFire { [unowned self] in self.reboundCountLabel.text = $0 } viewModel.foulMoveCount.bindAndFire { [unowned self] in self.foulCountLabel.text = $0 }

A continuación, implemente los cinco métodos que representan acciones de movimiento (sección Acción de botón ):

 @IBAction func onePointAction(_ sender: Any) { viewModel?.onePointMove() } @IBAction func twoPointsAction(_ sender: Any) { viewModel?.twoPointsMove() } @IBAction func assistAction(_ sender: Any) { viewModel?.assistMove() } @IBAction func reboundAction(_ sender: Any) { viewModel?.reboundMove() } @IBAction func foulAction(_ sender: Any) { viewModel?.foulMove() }

Ejecute la aplicación y haga clic en algunos botones de movimiento. Verá cómo cambian los valores del contador dentro de las vistas del jugador cuando hace clic en el botón de acción.

Aplicación iOS

Ha terminado con PlayerScoreboardMoveEditorView y PlayerScoreboardMoveEditorViewModel .

Esto fue sencillo.

Ahora, debe hacer lo mismo con su vista principal ( GameScoreboardEditorViewController ).

Primero, abra GameScoreboardEditorViewModel y vea qué valores se espera que cambien durante el ciclo de vida de la vista.

Reemplace las definiciones time , score , isFinished , isPaused con las versiones Dynamic :

 import Foundation protocol GameScoreboardEditorViewModel { var homeTeam: String { get } var awayTeam: String { get } var time: Dynamic<String> { get } var score: Dynamic<String> { get } var isFinished: Dynamic<Bool> { get } var isPaused: Dynamic<Bool> { get } func togglePause() var homePlayers: [PlayerScoreboardMoveEditorViewModel] { get } var awayPlayers: [PlayerScoreboardMoveEditorViewModel] { get } }

Vaya a la implementación de ViewModel ( GameScoreboardEditorViewModelFromGame ) y haga lo mismo con las propiedades declaradas en el protocolo.

Reemplace esto:

 var time: String var score: String var isFinished: Bool var isPaused: Bool

con lo siguiente:

 let time: Dynamic<String> let score: Dynamic<String> let isFinished: Dynamic<Bool> let isPaused: Dynamic<Bool>

Obtendrá algunos errores, ahora, porque cambió el tipo de ViewModel de String y Bool a Dynamic<String> y Dynamic<Bool> .

Arreglemos eso.

Arregle el método togglePause reemplazándolo con lo siguiente:

 func togglePause() { if isPaused.value { startTimer() } else { pauseTimer() } self.isPaused.value = !isPaused.value }

Observe cómo el único cambio es que ya no establece el valor de la propiedad directamente en la propiedad. En su lugar, lo establece en la propiedad de value del objeto.

Ahora, arregla el método initWithGame reemplazando esto:

 self.time = GameScoreboardEditorViewModelFromGame.timeRemainingPretty(game) self.score = GameScoreboardEditorViewModelFromGame.scorePretty(game) self.isFinished = game.isFinished self.isPaused = true

con lo siguiente:

 self.time = Dynamic(GameScoreboardEditorViewModelFromGame.timeRemainingPretty(for: game)) self.score = Dynamic(GameScoreboardEditorViewModelFromGame.scorePretty(for: game)) self.isFinished = Dynamic(game.isFinished) self.isPaused = Dynamic(true)

Deberías entender el punto ahora.

Está envolviendo los valores primitivos, como String , Int y Bool , con versiones Dynamic<T> de esos objetos, lo que le brinda el mecanismo de vinculación liviano.

Tienes un error más que corregir.

En el método startTimer , reemplace la línea de error con:

 self.time.value = GameScoreboardEditorViewModelFromGame.timeRemainingPretty(for: self.game)

Ha actualizado su ViewModel para que sea dinámico, tal como lo hizo con el ViewModel del reproductor. Pero aún necesita actualizar su Vista ( GameScoreboardEditorViewController ).

Reemplace todo el método fillUI con esto:

 fileprivate func fillUI() { if !isViewLoaded { return } guard let viewModel = viewModel else { return } self.homeTeamNameLabel.text = viewModel.homeTeam self.awayTeamNameLabel.text = viewModel.awayTeam viewModel.score.bindAndFire { [unowned self] in self.scoreLabel.text = $0 } viewModel.time.bindAndFire { [unowned self] in self.timeLabel.text = $0 } viewModel.isFinished.bindAndFire { [unowned self] in if $0 { self.homePlayer1View.isHidden = true self.homePlayer2View.isHidden = true self.homePlayer3View.isHidden = true self.awayPlayer1View.isHidden = true self.awayPlayer2View.isHidden = true self.awayPlayer3View.isHidden = true } } viewModel.isPaused.bindAndFire { [unowned self] in let title = $0 ? "Start" : "Pause" self.pauseButton.setTitle(title, for: .normal) } homePlayer1View.viewModel = viewModel.homePlayers[0] homePlayer2View.viewModel = viewModel.homePlayers[1] homePlayer3View.viewModel = viewModel.homePlayers[2] awayPlayer1View.viewModel = viewModel.awayPlayers[0] awayPlayer2View.viewModel = viewModel.awayPlayers[1] awayPlayer3View.viewModel = viewModel.awayPlayers[2] }

La única diferencia es que cambió sus cuatro propiedades dinámicas y agregó detectores de cambios a cada una de ellas.

En este punto, si ejecuta su aplicación, alternar el botón Inicio/Pausa iniciará y pausará el temporizador del juego. Esto se utiliza para los tiempos de espera durante el juego.

Ya casi ha terminado, excepto que la puntuación no cambia en la interfaz de usuario, cuando presiona uno de los botones de puntos (botón 1 y 2 puntos).

Esto se debe a que realmente no ha propagado los cambios de puntuación en el objeto del modelo de Game subyacente hasta ViewModel.

Entonces, abra el objeto del modelo de Game para un pequeño examen. Compruebe su método updateScore .

 fileprivate func updateScore(_ score: UInt, withScoringPlayer player: Player) { if isFinished || score == 0 { return } if homeTeam.containsPlayer(player) { homeTeamScore += score } else { assert(awayTeam.containsPlayer(player)) awayTeamScore += score } if checkIfFinished() { isFinished = true } NotificationCenter.default.post(name: Notification.Name(rawValue: GameNotifications.GameScoreDidChangeNotification), object: self) }

Este método hace dos cosas importantes.

Primero, establece la propiedad isFinished en true si el juego finaliza según las puntuaciones de ambos equipos.

Después de eso, publica una notificación de que la puntuación ha cambiado. Escuchará esta notificación en GameScoreboardEditorViewModelFromGame y actualizará el valor de la puntuación dinámica en el método del controlador de notificaciones.

Add this line at the bottom of initWithGame method (don't forget the super.init() call to avoid errors):

 super.init() subscribeToNotifications()

Below initWithGame method, add deinit method, since you want to do the cleanup properly and avoid crashes caused by the NotificationCenter .

 deinit { unsubscribeFromNotifications() }

Finally, add the implementations of these methods. Add this section right below the deinit method:

 // MARK: Notifications (Private) fileprivate func subscribeToNotifications() { NotificationCenter.default.addObserver(self, selector: #selector(gameScoreDidChangeNotification(_:)), name: NSNotification.Name(rawValue: GameNotifications.GameScoreDidChangeNotification), object: game) } fileprivate func unsubscribeFromNotifications() { NotificationCenter.default.removeObserver(self) } @objc fileprivate func gameScoreDidChangeNotification(_ notification: NSNotification){ self.score.value = GameScoreboardEditorViewModelFromGame.scorePretty(for: game) if game.isFinished { self.isFinished.value = true } }

Now, run the app, and click on the player views to change scores. Since you've already connected dynamic score and isFinished in the ViewModel with the View, everything should work when you change the score value inside the ViewModel.

How to Further Improve the App

While there's always room for improvement, this is out of scope of this tutorial.

For example, we do not stop the time automatically when the game is over (when one of the teams reaches 15 points), we just hide the player views.

You can play with the app if you like and upgrade it to have a “game creator” view, which would create a game, assign team names, assign player names and create a Game object that could be used to present GameScoreboardEditorViewController .

We can create another “game list” view that uses the UITableView to show multiple games in progress with some detailed info in the table cell. In cell select, we can show the GameScoreboardEditorViewController with the selected Game .

The GameLibrary has already been implemented. Just remember to pass that library reference to the ViewModel objects in their initializer. For example, “game creator's” ViewModel would need to have an instance of GameLibrary passed through the initializer so that it would be able to insert the created Game object into the library. “Game list's” ViewModel would also need this reference to fetch all games from the library, which will be needed by the UITableView.

The idea is to hide all of the dirty (non-UI) work inside the ViewModel and have the UI (View) only act with prepared presentation data.

What now?

After you get used to the MVVM, you can further improve it by using Uncle Bob's Clean Architecture rules.

An additional good read is a three-part tutorial on Android architecture:

  • Android Architecture: Part 1 – Every new beginning is hard,
  • Android Architecture: Part 2 – The clean architecture,
  • Android Architecture: Part 2 – The clean architecture.

Examples are written in Java (for Android), and if you are familiar with Java (which is much closer to Swift then Objective-C is to Java), you'll get ideas on how to further refactor your code inside the ViewModel objects so that they don't import any iOS modules ( UIKit or CoreLocation eg).

These iOS modules can be hidden behind the pure NSObjects , which is good for code reusability.

MVVM is a good choice for most iOS apps, and hopefully, you will give it a try in your next project. Or, try it in your current project when you are creating a UIViewController .

Related: Working With Static Patterns: A Swift MVVM Tutorial