Ingeniería iOS

[Arquitectura iOS #2] Puntos clave de MVVM y el binding

En el artículo anterior vimos por qué aparecen los Massive View Controller. Como el controlador de vista actúa como View y Controller, todo el código que no tiene dónde ir acaba allí.

4 min de lectura
Imagen de portada de [Arquitectura iOS #2] Puntos clave de MVVM y el binding

En el artículo anterior vimos por qué aparecen los Massive View Controller. Como el controlador de vista actúa como View y Controller, todo el código que no tiene dónde ir acaba allí.

La solución más utilizada es MVVM (Model-View-ViewModel). Sin embargo, al revisar código que supuestamente usa MVVM, es frecuente encontrar esto: una clase que solo se llama ViewModel mientras el controlador de vista sigue extrayendo cada valor y aplicándolo a la pantalla.

Hoy aclararemos cuál es el verdadero papel del ViewModel y por qué un MVVM sin binding está incompleto.


El ViewModel es una fábrica de estado de pantalla

Los tres componentes de MVVM se dividen así.

  • Model: datos y lógica de negocio (igual que en MVC)
  • View: muestra la pantalla. En iOS, tanto UIView como UIViewController pertenecen aquí
  • ViewModel: transforma los datos del Model en una forma apta para mostrarse en pantalla y mantiene el estado de la pantalla

Hay dos puntos clave.

Primero, en MVVM, UIViewController pertenece al lado de View. La posición ambigua del controlador de vista queda definida como View. El controlador solo dibuja la pantalla y delega todas las decisiones en el ViewModel.

Segundo, el ViewModel no debe conocer UIKit. Es decir, import UIKitno debe depender de UIKitDate. Recibir un Date y convertirlo en una cadena como «hace 3 minutos», o guardar en un Bool si está cargando, forma parte del ViewModel. Decidir en qué UILabel colocar esa cadena corresponde a View.

Esta separación permite probar el ViewModel sin pantalla. Puedes verificar con lógica pura, sin UIViewController, que «si no hay publicaciones, aparece un mensaje informativo».


¿Por qué está incompleto sin binding?

Si te detienes aquí, el código queda así.

// «MVVM de nombre» sin binding MVVM"
final class ProfileViewController: UIViewController {
    let viewModel = ProfileViewModel()

    func refresh() {
        viewModel.load()
        nameLabel.text = viewModel.displayName   // Extraer los valores directamente
        statusLabel.text = viewModel.statusText  // Aplicarlos uno por uno
        emptyView.isHidden = !viewModel.isEmpty
    }
}

Cada vez que cambia el estado del ViewModel, el controlador de vista debe recordar llamar a refresh(). Si omite un solo momento de llamada, la pantalla y el estado dejan de coincidir. Solo has movido el estado: la «responsabilidad de sincronizar el estado y la pantalla» sigue en el controlador de vista.

Por eso MVVM se diseñó desde el principio con data binding como premisa cuando nació en WPF de Microsoft. Solo está completo cuando se garantiza automáticamente que «si cambia el ViewModel, la View cambia también».

Configura una suscripción y la pantalla sigue el estado automáticamente
Configura una suscripción y la pantalla sigue el estado automáticamente

Binding con Combine en UIKit

UIKit no incluye binding integrado, así que añadir Combine es prácticamente el enfoque estándar.

final class ProfileViewModel {
    @Published private(set) var displayName = ""
    @Published private(set) var isLoading = false

    func load() { ... }  // Al completarse, @Published  actualizar el valor
}

final class ProfileViewController: UIViewController {
    private var cancellables = Set<AnyCancellable>()

    override func viewDidLoad() {
        super.viewDidLoad()
        viewModel.$displayName
            .assign(to: \.text!, on: nameLabel)
            .store(in: &cancellables)
        viewModel.$isLoading
            .sink { [weak self] in self?.spinner.isAnimating = $0 }
            .store(in: &cancellables)
    }
}

Una vez configurada la suscripción, la pantalla sigue al ViewModel, cambie cuando cambie y como cambie. Desaparece la duda de «¿cuándo debo llamar a refresh?».

En SwiftUI, este binding está integrado en el lenguaje. Si la View solo lee una propiedad de un objeto marcado con @Observable, esa View se vuelve a dibujar automáticamente cuando cambia el valor. Ni siquiera necesitas código de suscripción.


La trampa del Massive ViewModel

Unos meses después de adoptar MVVM aparece un nuevo problema: el ViewModel se vuelve demasiado grande. Si las peticiones de red, la caché y las reglas de negocio entran todas en el ViewModel, solo has cambiado de lugar al monstruo.

El ViewModel debe encargarse únicamente de la lógica de presentación (transformar el estado de pantalla). La obtención de datos corresponde a un Repository o Service, y las reglas de negocio deben bajar a la capa Model. MVVM separa View del resto; no indica que metas todo lo demás en el ViewModel.

Si lo metes todo, solo cambias de lugar al monstruo
Si lo metes todo, solo cambias de lugar al monstruo

Resumen

  • El ViewModel transforma los datos del Model en estado para la pantalla y no debe conocer UIKit. Así puedes probarlo sin pantalla.
  • Si solo mueves el estado y no hay binding, la responsabilidad de sincronización sigue en el controlador de vista. MVVM se completa con binding.
  • En UIKit, Combine se encarga del binding; en SwiftUI, lo hace @Observable.
  • Si colocas toda la lógica en el ViewModel, obtienes un Massive ViewModel. Limítalo a la lógica de presentación.

Sin embargo, en SwiftUI la propia View refleja el estado de forma declarativa, por lo que gana fuerza la idea de que «no hace falta ningún ViewModel». Trataremos este debate directamente en el próximo artículo.

Seguir leyendo