tutoriales.com

Abrazando la Arquitectura MVVM en SwiftUI con Combine: Reactividad y Observables

Este tutorial te guiará a través de la implementación de la arquitectura Model-View-ViewModel (MVVM) en SwiftUI, potenciada por el framework Combine para manejar la reactividad. Aprenderás a desacoplar tu código, mejorar la testabilidad y construir aplicaciones más robustas y escalables.

Intermedio20 min de lectura5 views
Reportar error

La arquitectura Model-View-ViewModel (MVVM) se ha establecido como uno de los patrones de diseño más populares en el desarrollo de aplicaciones modernas, especialmente cuando se trabaja con frameworks declarativos como SwiftUI. Combinado con el poder de Combine para la gestión reactiva del estado, MVVM permite crear aplicaciones con una lógica de negocio clara, UI desacoplada y una excelente testabilidad.

En este tutorial, exploraremos los principios de MVVM en el contexto de SwiftUI, cómo Combine facilita la comunicación entre View y ViewModel, y construiremos una aplicación de ejemplo para solidificar estos conceptos. Al finalizar, tendrás una comprensión profunda de cómo aplicar MVVM para crear aplicaciones Swift robustas y fáciles de mantener.

🚀 ¿Por qué MVVM en SwiftUI con Combine?

SwiftUI ya nos proporciona herramientas para gestionar el estado, como @State, @Binding, @ObservedObject y @StateObject. Sin embargo, a medida que las aplicaciones crecen en complejidad, la lógica de negocio puede empezar a mezclarse con la lógica de la interfaz de usuario dentro de la View, dificultando el mantenimiento y la prueba del código. Aquí es donde MVVM brilla.

💡 Consejo: Piensa en MVVM como una forma de separar las responsabilidades. La `View` sabe *cómo* mostrar algo, el `ViewModel` sabe *qué* mostrar (y cómo obtenerlo), y el `Model` es la fuente de los datos.

Ventajas clave:

  • Separación de Responsabilidades (SoC): Cada componente tiene una única razón para cambiar.
  • Testabilidad Mejorada: La lógica de negocio en el ViewModel se puede probar de forma aislada, sin depender de la UI.
  • Mantenibilidad: Los cambios en la UI o en la lógica de negocio tienen menos impacto en otras partes del código.
  • Colaboración: Permite que diseñadores y desarrolladores de UI trabajen más fácilmente en paralelo con los desarrolladores de lógica de negocio.
  • Reusabilidad: Los ViewModels pueden ser reutilizados en diferentes Views o incluso en diferentes plataformas (si la lógica es agnóstica a la UI).

El Rol de Combine

Combine es el framework de Apple para manejar eventos asíncronos en el tiempo, lo que lo hace perfecto para la comunicación reactiva entre View y ViewModel. Permite que el ViewModel "publique" cambios de estado, a los cuales la View puede "suscribirse" para actualizarse automáticamente.


🛠️ Componentes de MVVM: Desglosando Roles

Entender el papel de cada componente es fundamental para implementar MVVM correctamente.

1. El Modelo (Model)

El Model representa la capa de datos y la lógica de negocio. Es completamente independiente de la UI. Contiene la estructura de tus datos (structs, clases), la lógica para manipular esos datos, y las interacciones con bases de datos, APIs de red o cualquier otra fuente de datos.

  • Ejemplo: Un struct User con propiedades id, name, email. Una clase UserService que carga usuarios de una API.
  • Características: Agnosticismo a la UI, pura lógica de negocio y datos.

2. La Vista (View)

La View es responsable exclusivamente de la presentación de la interfaz de usuario. En SwiftUI, esto significa que tu View contendrá los elementos visuales (Text, Button, List, etc.) y reaccionará a las interacciones del usuario, pasando estos eventos al ViewModel. La View observa el ViewModel y se actualiza cuando este cambia.

  • Ejemplo: Una struct UserListView que muestra una lista de usuarios.
  • Características: Declarativa, pasiva, solo se encarga de la presentación.

3. El ViewModel

El ViewModel actúa como un intermediario entre el Model y la View. Expone los datos del Model en un formato que la View puede consumir fácilmente, y proporciona comandos o métodos para que la View interactúe con el Model (por ejemplo, cargar datos, guardar cambios). El ViewModel contiene la lógica de presentación.

  • Ejemplo: Una clase UserListViewModel que expone una lista de usuarios formateada para la UserListView y métodos para recargar la lista.
  • Características: Observa el Model y expone propiedades observables para la View. Contiene lógica de presentación, pero no de UI directa. Es el "motor" de la View.
🔥 Importante: El `ViewModel` nunca debe tener una referencia directa a la `View`. La comunicación es unidireccional o a través de enlaces de datos y observadores.

✨ Implementando MVVM con Combine en SwiftUI: Un Ejemplo Práctico

Vamos a construir una aplicación simple que muestra una lista de usuarios, con la capacidad de cargar nuevos usuarios y gestionar el estado de carga. Utilizaremos MVVM y Combine para la reactividad.

Paso 1: El Modelo (Model)

Primero, definamos nuestra estructura de datos para un usuario.

// MARK: - Model/User.swift

import Foundation

struct User: Identifiable, Decodable {
    let id: Int
    let name: String
    let email: String
    let username: String

    static let mockUsers: [User] = [
        User(id: 1, name: "Leanne Graham", email: "Sincere@april.biz", username: "Bret"),
        User(id: 2, name: "Ervin Howell", email: "Shanna@melissa.tv", username: "Antonette")
    ]
}

enum APIError: Error, LocalizedError {
    case invalidURL
    case networkError(Error)
    case decodingError(Error)
    case unknownError

    var errorDescription: String? {
        switch self {
        case .invalidURL: return "La URL de la API es inválida."
        case .networkError(let error): return "Error de red: \(error.localizedDescription)"
        case .decodingError(let error): return "Error al decodificar datos: \(error.localizedDescription)"
        case .unknownError: return "Un error desconocido ha ocurrido."
        }
    }
}

protocol UserServiceProtocol {
    func fetchUsers() -> AnyPublisher<[User], APIError>
}

class UserService: UserServiceProtocol {
    func fetchUsers() -> AnyPublisher<[User], APIError> {
        guard let url = URL(string: "https://jsonplaceholder.typicode.com/users") else {
            return Fail(error: APIError.invalidURL).eraseToAnyPublisher()
        }

        return URLSession.shared.dataTaskPublisher(for: url)
            .mapError { APIError.networkError($0) }
            .flatMap { (data, response) -> AnyPublisher<[User], APIError> in
                guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
                    return Fail(error: APIError.networkError(URLError(.badServerResponse)))
                        .eraseToAnyPublisher()
                }
                return Just(data)
                    .decode(type: [User].self, decoder: JSONDecoder())
                    .mapError { APIError.decodingError($0) }
                    .eraseToAnyPublisher()
            }
            .eraseToAnyPublisher()
    }
}

Aquí, User es una simple estructura Decodable y Identifiable. Hemos añadido una enum APIError para manejar errores específicos de la API y un UserService que simula la carga de usuarios desde una URL real usando URLSession y Combine.

Paso 2: El ViewModel

Este es el corazón de nuestra lógica de presentación. El UserListViewModel será una clase ObservableObject para que SwiftUI pueda reaccionar a sus cambios. Utilizaremos propiedades @Published para que Combine notifique automáticamente a las Views suscriptoras cuando estas propiedades cambien.

// MARK: - ViewModel/UserListViewModel.swift

import Foundation
import Combine

class UserListViewModel: ObservableObject {
    @Published var users: [User] = []
    @Published var isLoading: Bool = false
    @Published var errorMessage: String? = nil

    private var cancellables = Set<AnyCancellable>()
    private let userService: UserServiceProtocol

    init(userService: UserServiceProtocol = UserService()) {
        self.userService = userService
        fetchUsers()
    }

    func fetchUsers() {
        isLoading = true
        errorMessage = nil

        userService.fetchUsers()
            .receive(on: DispatchQueue.main) // Asegurarse de que las actualizaciones de UI ocurren en el hilo principal
            .sink { [weak self] completion in
                self?.isLoading = false
                switch completion {
                case .finished: break
                case .failure(let error):
                    self?.errorMessage = error.localizedDescription
                    print("Error al cargar usuarios: \(error.localizedDescription)")
                }
            } receiveValue: { [weak self] fetchedUsers in
                self?.users = fetchedUsers
            }
            .store(in: &cancellables)
    }

    func refreshUsers() {
        // Un simple wrapper para `fetchUsers` para propósitos de UI refresh
        fetchUsers()
    }
}

Explicación del ViewModel:

  • @Published var users: [User]: Lista de usuarios que la View mostrará.
  • @Published var isLoading: Bool: Indica si los datos se están cargando (útil para mostrar un indicador de actividad).
  • @Published var errorMessage: String?: Almacena un mensaje de error si ocurre uno durante la carga.
  • cancellables: Un Set para almacenar las suscripciones de Combine y evitar fugas de memoria.
  • userService: Una dependencia inyectada para obtener los datos de usuario. Esto facilita las pruebas.
  • fetchUsers(): Método principal que utiliza userService para obtener usuarios. Usa receive(on: DispatchQueue.main) para asegurarse de que las actualizaciones de UI ocurran en el hilo principal. El sink maneja tanto el éxito (receiveValue) como el fallo (receiveCompletion).
📌 Nota: Al usar `[weak self]` en los cierres de `sink`, evitamos ciclos de retención fuertes y posibles fugas de memoria. Es una práctica recomendada al trabajar con Combine y objetos de referencia.

Paso 3: La Vista (View)

Ahora, crearemos la View que consumirá nuestro ViewModel. Utilizaremos @StateObject para instanciar y poseer el ViewModel dentro de la View lifecycle.

// MARK: - View/UserListView.swift

import SwiftUI

struct UserListView: View {
    @StateObject var viewModel = UserListViewModel() // Instancia y posee el ViewModel

    var body: some View {
        NavigationView {
            List {
                if viewModel.isLoading {
                    ProgressView("Cargando usuarios...")
                } else if let error = viewModel.errorMessage {
                    Text("Error: \(error)")
                        .foregroundColor(.red)
                    Button("Reintentar") {
                        viewModel.fetchUsers()
                    }
                } else if viewModel.users.isEmpty {
                    Text("No hay usuarios disponibles.")
                        .foregroundColor(.gray)
                    Button("Cargar usuarios") {
                        viewModel.fetchUsers()
                    }
                } else {
                    ForEach(viewModel.users) { user in
                        NavigationLink(destination: UserDetailView(user: user)) {
                            UserRow(user: user)
                        }
                    }
                }
            }
            .navigationTitle("Usuarios")
            .refreshable {
                await viewModel.refreshUsers()
            }
        }
    }
}

struct UserRow: View {
    let user: User

    var body: some View {
        HStack {
            Image(systemName: "person.circle.fill")
                .resizable()
                .frame(width: 40, height: 40)
                .foregroundColor(.blue)
            VStack(alignment: .leading) {
                Text(user.name)
                    .font(.headline)
                Text(user.email)
                    .font(.subheadline)
                    .foregroundColor(.gray)
            }
        }
    }
}

struct UserDetailView: View {
    let user: User

    var body: some View {
        VStack(alignment: .leading, spacing: 10) {
            Text("Detalles del Usuario")
                .font(.largeTitle)
                .fontWeight(.bold)
            
            Divider()
            
            DetailRow(label: "Nombre:", value: user.name)
            DetailRow(label: "Usuario:", value: user.username)
            DetailRow(label: "Email:", value: user.email)

            Spacer()
        }
        .padding()
        .navigationTitle(user.name)
        .navigationBarTitleDisplayMode(.inline)
    }
}

struct DetailRow: View {
    let label: String
    let value: String

    var body: some View {
        HStack {
            Text(label)
                .fontWeight(.medium)
            Spacer()
            Text(value)
                .foregroundColor(.secondary)
        }
    }
}

struct UserListView_Previews: PreviewProvider {
    static var previews: some View {
        UserListView()
    }
}

Explicación de la View:

  • @StateObject var viewModel = UserListViewModel(): Esta propiedad inicializa el ViewModel y le indica a SwiftUI que lo posea y observe. Cuando el ViewModel emita cambios (a través de sus propiedades @Published), la View se invalidará y se redibujará.
  • La List utiliza viewModel.users para mostrar la lista. También usa viewModel.isLoading para mostrar un ProgressView y viewModel.errorMessage para mostrar errores.
  • El modificador .refreshable de SwiftUI invoca viewModel.refreshUsers() cuando el usuario desliza hacia abajo para refrescar.
  • La View no contiene ninguna lógica de negocio o cómo se obtienen los datos; simplemente muestra lo que el ViewModel le proporciona y notifica al ViewModel sobre las interacciones del usuario (como refrescar la lista).

🔄 Flujo de Datos en MVVM con Combine

Comprender cómo fluyen los datos entre los componentes es crucial.

VIEW Interfaz de Usuario (SwiftUI/UIKit) Eventos (Acciones/Taps) Observa @Published VIEWMODEL Lógica con Combine Framework Gestiona Suscripciones (AnyCancellable) Emite valores mediante Publishers Solicitud de Datos Resultados / Entidades MODEL Lógica de Negocio e Independencia

Este diagrama ilustra el ciclo:

  1. View a ViewModel: El usuario interactúa con la View (e.g., pulsa un botón, desliza para refrescar). La View llama a un método del ViewModel (e.g., viewModel.fetchUsers()).
  2. ViewModel a Model: El ViewModel recibe la acción y, si es necesario, interactúa con el Model para solicitar datos o ejecutar lógica de negocio (e.g., userService.fetchUsers()).
  3. Model a ViewModel: El Model realiza su operación (e.g., obtiene datos de la red) y devuelve los resultados (o errores) al ViewModel a través de un Publisher de Combine.
  4. ViewModel a View: El ViewModel procesa los datos del Model, los formatea para la presentación y actualiza sus propiedades @Published. Combine notifica a la View que estas propiedades han cambiado. SwiftUI redibuja automáticamente las partes de la View que dependen de esas propiedades.
⚠️ Advertencia: Evita que la `View` acceda directamente al `Model` o que el `Model` tenga conocimiento de la `View` o el `ViewModel`. Mantén la separación estricta para cosechar los beneficios del patrón.

✅ Testeando tu ViewModel

Una de las mayores ventajas de MVVM es la testabilidad. Dado que la lógica de presentación reside en el ViewModel y no depende de la UI, puedes escribir pruebas unitarias para tu ViewModel fácilmente.

Para facilitar esto, inyectamos UserServiceProtocol en el ViewModel. Esto nos permite usar un mock de UserService en nuestras pruebas.

// MARK: - Tests/UserListViewModelTests.swift

import XCTest
import Combine
@testable import YourAppModuleName // Reemplaza 'YourAppModuleName' con el nombre de tu módulo

// Mock para UserServiceProtocol
class MockUserService: UserServiceProtocol {
    var shouldReturnError = false
    var mockUsers: [User] = User.mockUsers

    func fetchUsers() -> AnyPublisher<[User], APIError> {
        if shouldReturnError {
            return Fail(error: APIError.unknownError).eraseToAnyPublisher()
        } else {
            return Just(mockUsers)
                .setFailureType(to: APIError.self)
                .eraseToAnyPublisher()
        }
    }
}

class UserListViewModelTests: XCTestCase {

    var viewModel: UserListViewModel!
    var mockUserService: MockUserService!
    var cancellables: Set<AnyCancellable>!

    override func setUpWithError() throws {
        mockUserService = MockUserService()
        viewModel = UserListViewModel(userService: mockUserService)
        cancellables = Set<AnyCancellable>()
    }

    override func tearDownWithError() throws {
        viewModel = nil
        mockUserService = nil
        cancellables = nil
    }

    func testFetchUsersSuccess() throws {
        let expectation = XCTestExpectation(description: "Users fetched successfully")

        viewModel.$users
            .dropFirst() // Ignorar el valor inicial vacío
            .sink { users in
                XCTAssertFalse(users.isEmpty)
                XCTAssertEqual(users.count, self.mockUserService.mockUsers.count)
                XCTAssertEqual(users.first?.name, self.mockUserService.mockUsers.first?.name)
                expectation.fulfill()
            }
            .store(in: &cancellables)

        viewModel.$isLoading
            .dropFirst() // Ignorar el valor inicial
            .prefix(2) // isLoading = true, luego isLoading = false
            .collect()
            .sink { values in
                XCTAssertEqual(values, [true, false])
            }
            .store(in: &cancellables)

        viewModel.fetchUsers()

        wait(for: [expectation], timeout: 1.0)
        XCTAssertNil(viewModel.errorMessage)
        XCTAssertFalse(viewModel.isLoading)
    }

    func testFetchUsersFailure() throws {
        mockUserService.shouldReturnError = true
        let expectation = XCTestExpectation(description: "Users fetch failed")

        viewModel.$errorMessage
            .dropFirst() // Ignorar el nil inicial
            .sink { errorMessage in
                XCTAssertNotNil(errorMessage)
                XCTAssertEqual(errorMessage, APIError.unknownError.localizedDescription)
                expectation.fulfill()
            }
            .store(in: &cancellables)

        viewModel.$isLoading
            .dropFirst() // Ignorar el valor inicial
            .prefix(2) // isLoading = true, luego isLoading = false
            .collect()
            .sink { values in
                XCTAssertEqual(values, [true, false])
            }
            .store(in: &cancellables)

        viewModel.fetchUsers()

        wait(for: [expectation], timeout: 1.0)
        XCTAssertNotNil(viewModel.errorMessage)
        XCTAssertFalse(viewModel.isLoading)
        XCTAssertTrue(viewModel.users.isEmpty)
    }
}

Explicación de las Pruebas:

  • MockUserService: Creamos una implementación falsa de UserServiceProtocol que podemos controlar. Podemos simular tanto el éxito como el fallo en la carga de usuarios.
  • setUpWithError() y tearDownWithError(): Métodos para configurar y limpiar el entorno de prueba para cada test.
  • testFetchUsersSuccess():
    • Creamos un XCTestExpectation para esperar el resultado asíncrono.
    • Nos suscribimos a $users y $isLoading del ViewModel para verificar que sus valores cambien como esperamos.
    • dropFirst() es importante para ignorar el estado inicial de la propiedad @Published antes de que la acción de prueba la modifique.
    • Llamamos a viewModel.fetchUsers() y esperamos la expectativa.
    • Finalmente, asertamos que errorMessage es nil y isLoading es false después de un éxito.
  • testFetchUsersFailure(): Similar al éxito, pero configuramos mockUserService.shouldReturnError = true para simular un fallo y verificamos que errorMessage se establezca correctamente.
📌 Nota: Para ejecutar estas pruebas, asegúrate de que el objetivo de tus pruebas tenga acceso al módulo de tu aplicación (normalmente se añade `@testable import YourAppModuleName`).

⚖️ Consideraciones y Patrones Adicionales

MVVM es un patrón potente, pero como todo, tiene sus matices y puede combinarse con otras ideas.

Coordinadores / Flow Controllers

Para gestionar la navegación compleja y desvincular la View y el ViewModel de las decisiones de navegación, puedes introducir el patrón Coordinador. Un Coordinator se encargaría de la creación y presentación de Views y ViewModels, permitiendo que estos últimos se centren puramente en la lógica de presentación.

¿Por qué usar Coordinadores? Un `ViewModel` no debería decidir qué `View` mostrar después de una acción. Esa es una responsabilidad de navegación. Los `Coordinator`s ayudan a centralizar y gestionar esta lógica, haciendo que el código sea más modular y reutilizable. Esto es especialmente útil en aplicaciones con flujos de usuario complejos o múltiples puntos de entrada.

Inyección de Dependencias

Como ya hemos visto con UserServiceProtocol, la inyección de dependencias es clave para la testabilidad y flexibilidad. Puedes usar un contenedor de inyección de dependencias para gestionar las instancias de tus servicios y ViewModels.

Estados Complejos del UI

Para Views con estados de UI muy complejos (e.g., formularios con validación en tiempo real de múltiples campos), podrías considerar un enfoque de Reducer (inspirado en Redux o The Composable Architecture) dentro de tu ViewModel para gestionar el estado de manera más predecible.

90% de compresión de MVVM

Conclusión

Abrazar la arquitectura MVVM con Combine en SwiftUI te proporcionará una base sólida para construir aplicaciones escalables, mantenibles y fáciles de probar. Al separar claramente las responsabilidades, te asegurarás de que tu código sea más limpio y que tu equipo pueda trabajar de manera más eficiente.

Empieza a aplicar estos principios en tus proyectos y observa cómo la calidad de tu base de código mejora significativamente. La combinación de MVVM y Combine es una receta ganadora para el desarrollo moderno en Swift y SwiftUI.

Tutoriales relacionados

Comentarios (0)

Aún no hay comentarios. ¡Sé el primero!