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.
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.
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
ViewModelse 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 diferentesViews 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
structUsercon propiedadesid,name,email. Una claseUserServiceque 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
structUserListViewque 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
UserListViewModelque expone una lista de usuarios formateada para laUserListViewy métodos para recargar la lista. - Características: Observa el
Modely expone propiedades observables para laView. Contiene lógica de presentación, pero no de UI directa. Es el "motor" de laView.
✨ 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 laViewmostrará.@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: UnSetpara 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 utilizauserServicepara obtener usuarios. Usareceive(on: DispatchQueue.main)para asegurarse de que las actualizaciones de UI ocurran en el hilo principal. Elsinkmaneja tanto el éxito (receiveValue) como el fallo (receiveCompletion).
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 elViewModely le indica a SwiftUI que lo posea y observe. Cuando elViewModelemita cambios (a través de sus propiedades@Published), laViewse invalidará y se redibujará.- La
ListutilizaviewModel.userspara mostrar la lista. También usaviewModel.isLoadingpara mostrar unProgressViewyviewModel.errorMessagepara mostrar errores. - El modificador
.refreshablede SwiftUI invocaviewModel.refreshUsers()cuando el usuario desliza hacia abajo para refrescar. - La
Viewno contiene ninguna lógica de negocio o cómo se obtienen los datos; simplemente muestra lo que elViewModelle proporciona y notifica alViewModelsobre 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.
Este diagrama ilustra el ciclo:
- View a ViewModel: El usuario interactúa con la
View(e.g., pulsa un botón, desliza para refrescar). LaViewllama a un método delViewModel(e.g.,viewModel.fetchUsers()). - ViewModel a Model: El
ViewModelrecibe la acción y, si es necesario, interactúa con elModelpara solicitar datos o ejecutar lógica de negocio (e.g.,userService.fetchUsers()). - Model a ViewModel: El
Modelrealiza su operación (e.g., obtiene datos de la red) y devuelve los resultados (o errores) alViewModela través de unPublisherde Combine. - ViewModel a View: El
ViewModelprocesa los datos delModel, los formatea para la presentación y actualiza sus propiedades@Published. Combine notifica a laViewque estas propiedades han cambiado. SwiftUI redibuja automáticamente las partes de laViewque dependen de esas propiedades.
✅ 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 deUserServiceProtocolque podemos controlar. Podemos simular tanto el éxito como el fallo en la carga de usuarios.setUpWithError()ytearDownWithError(): Métodos para configurar y limpiar el entorno de prueba para cada test.testFetchUsersSuccess():- Creamos un
XCTestExpectationpara esperar el resultado asíncrono. - Nos suscribimos a
$usersy$isLoadingdelViewModelpara verificar que sus valores cambien como esperamos. dropFirst()es importante para ignorar el estado inicial de la propiedad@Publishedantes de que la acción de prueba la modifique.- Llamamos a
viewModel.fetchUsers()y esperamos la expectativa. - Finalmente, asertamos que
errorMessageesnilyisLoadingesfalsedespués de un éxito.
- Creamos un
testFetchUsersFailure(): Similar al éxito, pero configuramosmockUserService.shouldReturnError = truepara simular un fallo y verificamos queerrorMessagese establezca correctamente.
⚖️ 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.
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!