Patrón MVVM en SwiftUI

0
7167
    1. Introducción
    2. Entorno
    3. El patrón MVVM
    4. Construyendo una aplicación con MVVM
    5. El resultado
    6. Conclusiones
    7. Referencias

Introducción

Model-View-ViewModel (MVVM) es el patrón arquitectónico bastante popular para diseñar aplicaciones de iOS. Es una buena razón para estudiarlo. El patrón de arquitectura MVVM consiste en separar nuestra aplicación en tres capas: la lógica de negocio, la interfaz gráfica y la lógica de presentación. El ViewModel representa el estado de la vista y maneja los componentes de la vista y sus estados a través de “binding” (atadura). Antes para utilizar este patrón teníamos que usar un cierre (closure), el mecanismo Key-Value Observing (KVO) o las librerías de terceros, así como RxSwift. La introducción de SwiftUI ha cambiado las cosas. Ahora sí que tenemos los instrumentos como property wrappers @State y @Binding para crear binding que nos permitan utilizar este patrón sin usar las librerías de terceros. En este tutorial vamos a ver cómo se puede crear las aplicaciones iOS con SwiftUI y Combine utilizando este patrón.

Entorno

Este tutorial está escrito usando el siguiente entorno:

      • Hardware: MacBook Pro 15’ (2,5 GHz Intel Core i7, 16GB DDR3, mediados de 2015)
      • Sistema operativo: macOS Catalina 10.15
      • Versiones del software:
        • Xcode: 11
        • iOS SDK: 13.4

Es importante saber que para probar SwiftUI se necesita instalar Mac OS Catalina 10.15 y Xcode 11.

El patrón MVVM

El objetivo de MVVM es separar la lógica del negocio y de presentación de la interfaz de usuario. De este modo mejora la capacidad de prueba y la capacidad de mantenimiento. Para lograr su objetivo, MVVM minimiza la toma de decisiones en las vistas y traslada el estado y el comportamiento de la vista al modelo de vista. De esta manera, la vista se vuelve pasiva: tiene su estado administrado por el ViewModel. Tal diseño nos permite testear la lógica de presentación aislada de la la interfaz gráfica.

En una arquitectura MVVM, los componentes de View tienen un ViewModel, que proporciona un estado (State) específico a la View. Se puede implementar diferentes versiones de MVVM. Mientras que en una arquitectura a la «Redux» normalmente se expone completamente el estado único de toda la app, también podemos hacer que cada ViewModel pueda tener su propio estado. En nuestro ejemplo, la vista solo conoce las propiedades que le proporciona ViewModel, ya que luego ViewModel podría tener la información completamente diferente al Model inicial. En lugar de especificar un estado global de la aplicación, especificamos un estado específico de la vista.

Nuestra versión de MVVM

Nuestra versión de MVVM se basa en un estado específico de cada módulo y acciones activadas por entradas especificadas. También queremos poder cambiar la lógica de presentación del ViewModel (es decir, las acciones de un ViewModel) sin ningún cambio en los componentes de View, por eso definimos un protocolo ViewModel y un contenedor de objeto ViewModel AnyViewModel.

El protocolo ViewModel tiene dos tipos asociados. El tipo de estado se refiere al tipo de estado de una vista especifica, mientras que Input se puede usar para especificar una entrada que se puede activar utilizando el método trigger(). Este método de activación se puede implementar para mandar los eventos a nuestro ViewModel.

protocol ViewModel: ObservableObject where ObjectWillChangePublisher.Output == Void {
    associatedtype State
    associatedtype Input

    var state: State { get }
    func trigger(_ input: Input)
}

El tipo AnyViewModel es un contenedor que se ajusta al protocolo ViewModel y usa los tipos genéricos State y Input.

final class AnyViewModel<State, Input>: ObservableObject {

    private let wrappedObjectWillChange: () -> AnyPublisher<Void, Never>
    private let wrappedState: () -> State
    private let wrappedTrigger: (Input) -> Void

    var objectWillChange: some Publisher {
        wrappedObjectWillChange()
    }

    var state: State {
        wrappedState()
    }

    func trigger(_ input: Input) {
        wrappedTrigger(input)
    }

    init<V: ViewModel>(_ viewModel: V) where V.State == State, V.Input == Input {
        self.wrappedObjectWillChange = { viewModel.objectWillChange.eraseToAnyPublisher() }
        self.wrappedState = { viewModel.state }
        self.wrappedTrigger = viewModel.trigger
    }

}

Construyendo una aplicación con MVVM

Vamos a construir la aplicación con los personajes de Marvel. La app será muy simple. La utilizamos solo para demostrar el patrón MVVM. La aplicación tendrá dos pantallas:

  • La vista con el listado de los personajes de Marvel que se puede filtrar y que soporta la paginación.
  • La vista de detalles de un personaje que muestra el avatar de personaje.

Para nuestra principal vista con el listado de los personajes definimos un tipo de estado y un tipo de entrada. El estado de CharacterListView contiene el listado de los personajes, el estado del datos del modelo (inactivo, cargando, cargado, error), el numero de la pagina del listado (nuestro listado soporta la paginación) y el publisher que emite los términos de la búsqueda (nuestro listado se puede filtrar).

struct CharactersListState {
    var characters: [Character] = []
    var dataState: ModelDataState = .idle
    var page: Int = 0
    var searchTerm: CurrentValueSubject<String, Never> = CurrentValueSubject<String, Never>("")
    
    mutating func changeViewModelState(newViewModelState: ModelDataState) {
        dataState = newViewModelState
    }
    
    mutating func changePage(newPageNumber: Int){
        page = newPageNumber
    }
}

También definimos el estado de los datos del modelo que puede ser inactivo (al principio), cargando (si la petición está en marcha), cargado (si ya tenemos los resultados de la petición) y error (si la petición nos devuelve el error).

enum ModelDataState: Equatable {
    static func == (lhs: ModelDataState, rhs: ModelDataState) -> Bool {
        switch (lhs, rhs) {
        case (.idle, .idle):
            return true
        case (.loading, .loading):
            return true
        default:
            return false
        }
    }
    case idle
    case loading
    case loaded
    case error(Error)
}

CharacterListView

Inyectamos el ViewModel a nuestra vista a través del property wrapper EnvironmentObject. Como veis nuestra vista solo sabe de los datos que le proporciona el ViewModel. Reflejamos en la interfaz gráfica el estado de los datos mostrando un spinner o un texto en el caso de error.

struct CharacterListView: View {
    @EnvironmentObject
    var viewModel: AnyViewModel<CharactersListState, CharacterListInput>
    
    var body: some View {
        VStack() {
            SearchBar(input: viewModel.searchTerm, placeholder: "Search characters")
            content
        }
    }
    
    private var content: some View {
        switch viewModel.state.dataState {
        case .idle:
            return Color.clear.eraseToAnyView()
        case .loading:
            return LoadingView().frame(maxHeight: .infinity).eraseToAnyView()
        case .error(let error):
            return Text(error.localizedDescription).eraseToAnyView()
        case .loaded:
            return self.characterList(characters: self.viewModel.state.characters).eraseToAnyView()
        }
    }

Si los datos ya han sido cargados en nuestro modelo mostramos una lista con los personajes.

private func characterList(characters: [Character]) -> some View {
        List {
            ForEach(characters, id: \.id) { character in
                NavigationLink(
                    destination: DetailCharacterView(result: character)
                ) {
                        VStack(spacing: 10) {
                            Text("\(character.name ?? "")").frame(maxWidth: .infinity, alignment: Alignment.leading)
                            if self.viewModel.state.dataState == .loaded && self.viewModel.state.characters.isLastItem(character) {
                                Divider()
                                LoadingView()
                            }
                        }.onAppear {
                            self.listItemAppears(character)
                        }
                }
            }
        }
    }

Para crear una lista con la paginación tenemos que realizar algunos cambios a la lista normal. Implementamos el método isThresholdItem dentro de la extension List para saber como está de cerca el elemento actual de la lista.

public func isThresholdItem<Item: Identifiable>(offset: Int,
                                                    item: Item) -> Bool {
        guard !isEmpty else {
            return false
        }
        
        guard let itemIndex = firstIndex(where: { AnyHashable($0.id) == AnyHashable(item.id) }) else {
            return false
        }
        
        let distance = self.distance(from: itemIndex, to: endIndex)
        let offset = offset < count ? offset : count - 1
        return offset == (distance - 1)
    }

Si estamos cerca del final de lista añadimos otra «pagina» de datos a nuestra lista. El método trigger() es una forma de pasar los eventos a nuestro ViewModel.

extension CharacterListView {
    
    private func listItemAppears<Item: Identifiable>(_ item: Item) {
        if self.viewModel.state.characters.isThresholdItem(offset: 5, item: item)  {
            self.viewModel.trigger(.nextPage)
            self.viewModel.trigger(.reloadPage)
        }
    }
}

En el constructor de CharactersListViewModel lanzamos la petición de obtener los personajes de Marvel. Además ponemos un limite de rebote en 0,5 segundo al publisher de los términos de la búsqueda, porque el usuario puede introducir el texto al campo de la búsqueda bastante rápido. Y de este modo restringimos la cantidad de peticiones.

Ten en cuenta que CharactersListViewModel implementa el ObservableObject protocolo. Esto nos permite vincular una vista al modelo de vista. SwiftUI actualizará automáticamente la vista siempre que el modelo de vista actualice su estado.

class CharactersListViewModel: ViewModel {
    
    private var cancellableSet: Set<AnyCancellable> = []
    
    @Published
    var state: CharactersListState

    init(state: CharactersListState) {
        self.state = state
        loadCharacters(searchTermInput: state.searchTerm)
        //set the waiting time limit at 0.5 sec when the value changes
        self.state.searchTerm.debounce(for: 0.5, scheduler: RunLoop.main)
        .removeDuplicates()
        .sink(receiveCompletion: {_ in}) { (searchTerm) in
            self.trigger(.newSearch)
        }.store(in: &cancellableSet)
    }
}

Lanzamos la petición de obtener los personajes a través de nuestro servicio MarvelAPI. Depende de la respuesta establecemos el estado de nuestro ViewModel llamando a changeViewModelState: el método mutante de nuestro estado ViewModelState.

func loadCharacters(searchTermInput: CurrentValueSubject<String, Never>) {
        do {
            MarvelAPI.characters(page: self.state.page, searchTerm: searchTermInput.value).sink(receiveCompletion: { completion in
                switch completion {
                case .failure(let error):
                    self.state.changeViewModelState(newViewModelState: .error(error))
                    switch error {
                    case .serverError(code: let code, message: let reason):
                        print("Server error: \(code), reason: \(reason)")
                    case .decodingError:
                        print("Decoding error \(error)")
                    case .internalError:
                        print("Internal error \(error)")
                    }
                default: break
                }
            }) { (charactersResponse) in
                            if let results = charactersResponse.data?.results {
                                self.state.changeViewModelState(newViewModelState: .loaded)
                                print("Characters: \(results)")
                                if self.state.page > 0 && !self.state.characters.elementsEqual(results, by: { (character, result) -> Bool in
                                    character.id==result.id
                                }) {
                                    var addedCharacters = Array(self.state.characters)
                                    addedCharacters.append(contentsOf: results)
                                    self.state = CharactersListState(characters: addedCharacters, dataState: .loaded, page: self.state.page, searchTerm: searchTermInput)
                                } else {
                                    self.state = CharactersListState(characters: results, dataState: .loaded, page: self.state.page, searchTerm: searchTermInput)
                                }
                            }
                        }
            .store(in: &cancellableSet)
        }
    }

El cliente de red habla con la API de Marvel para obtener los personajes. Estoy omitiendo algunos detalles de implementación para mantener el enfoque en el tema principal:

enum MarvelAPI {
    static func characters(page: Int, characterId: String? = nil, searchTerm: String? = nil) throws -> AnyPublisher<CharacterResponse, APIError> {
        guard let url = URL.characters(limit: 20, offset: (page * 20), nameStartsWith: searchTerm) else {
            throw MarvelApiError.urlIncorrect
        }
        if page >= 0 {
            return send(url, method: .GET)
        }
        else {
            throw MarvelApiError.pageOutOfRange
        }
    }

    static func comics(comicId: String? = nil) -> AnyPublisher<ComicResponse, APIError> {
        return send(URL.comics(comicId), method: .GET)
    }
}

CharacterDetailView

Para nuestro CharacterDetailView, no vamos a utilizar ViewModel, porque es muy sencillo.

struct DetailCharacterView: View {
    var character: Character?
    @Environment(\.imageCache) var cache: ImageCache
    
    init(result: Character? = nil) {
        UINavigationBar.appearance().largeTitleTextAttributes = [.font : UIFont.boldSystemFont(ofSize: 30)]
        self.character = result
    }
    
    var body: some View {
        GeometryReader{ viewGeometry in
            VStack {
                    ScrollView(.horizontal, showsIndicators: false){
                        GeometryReader{ imageGeometry in
                            AsyncImage(
                                url: self.character?.thumbnailUrl(),
                                cache: self.cache,
                                placeholder: LoadingView()
                            )
                            .frame(width: 300, height: 300)
                            .clipShape(Circle())
                            .shadow(radius: 10)
                            .padding(15)
                        }
                    }.frame(height:340)
                
                    Text(self.character?.description ?? "").padding(5)
                    
                    if (self.character?.comics?.items?.count ?? 0 > 0) {
                        List {
                            Section(header: Text("Comics")) {
                                ForEach(self.character?.comics?.items ?? [], id: \.id)  {
                                    comic in
                                    Text(comic.name ?? "")
                                }
                            }
                        }
                    }
                
            }.navigationBarTitle(Text(self.character?.name ?? ""))
        }
        
    }
}

El resultado

Cambiamos la interfaz gráfica dependiendo del estado de nuestro modelo de vista (ViewModel):

struct CharactersListView: View {
    ...
    
    private var content: some View {
        switch viewModel.state {
        case .idle:
            return Color.clear.eraseToAnyView()
        case .loading:
            return Spinner(isAnimating: true, style: .large).eraseToAnyView()
        case .error(let error):
            return Text(error.localizedDescription).eraseToAnyView()
        case .loaded(let characters):
            return list(of: characters).eraseToAnyView()
        }
    }
    
    private func list(of characters: [CharactersListViewModel.ListItem]) -> some View {
        ...
    }
}

Aquí list(of:) se implementa el método:

private func list(of characters: [CharactersListViewModel.ListItem]) -> some View {
    return List(characters) { character in
        NavigationLink(
            destination: CharacterDetailView(viewModel: CharacterDetailViewModel(characterID: character.id)),
            label: { CharacterListItemView(character: character) }
        )
    }
}

CharacterDetailView representa detalles de un personaje de Marvel. Si un usuario toca una fila de la lista, se empujará a la pila de navegación. CharacterDetailView se inicializa con un modelo de vista que, a su vez, acepta un identificador del personaje.

Conclusiones

Teniendo en cuenta una arquitectura MVVM con el estado separado en cada ViewModel, identificamos los siguientes posibles ventajas y desventajas.

Desventajas:

  1. No hay una única fuente de verdad: los diferentes ViewModels son objetos separados y no hay una única fuente de verdad por defecto.
  2. Sobrecarga de código: la mayor modularización de una aplicación aumenta la sobrecarga de código, ya que en lugar de usar un almacenamiento único para múltiples vistas, definimos una clase ViewModel para cada vista individualmente.
  3. A medida que crezca la complejidad del modulo existe el riesgo de crecimiento imparable de un ViewModel. Hay que tener muchísimo cuidado con el ViewModel y trasladar la lógica adicional (por ejemplo la navegación) a otros ficheros.

Ventajas:

  1. Reutilización: una vista no depende del estado de toda la aplicación o de un ViewModel. Podemos reutilizar esta vista en varios lugares o incluso en diferentes aplicaciones sin cambiar el código de una vista.
  2. Sin dependencias entre vistas: solo tenemos estados específicos de cada vista, no son posibles dependencias imprevistas entre vistas.
  3. Mantenimiento: el estado de una vista única es visible de inmediato, los desarrolladores que no estén familiarizados con un determinado proyecto pueden identificar fácilmente los errores sin comprender cómo funcionan en profundidad todos los componentes.

Hemos visto lo fácil que es implementar el patrón MVVM con SwiftUI. Lo que es aún más interesante es que SwiftUI facilita cualquier patrón gracias a sus novedades como binding o propery wrappers, las vistas como estructuras etc.
La combinación de SwiftUI y MVVM puede ayudar a impedir la creación de archivos masivos (como en UIKit con UIViewController enorme) que son difíciles de mantener.
En resumen, siempre y cuando no implementas ninguna lógica pesada en su SwiftUI View,  cualquier patrón es apropiado.
SwiftUI se convierte en un candidato perfecto para muchos otros patrones. Mientras el código siga siendo fácil de probar y respete otras prácticas recomendadas, puedes experimentar y probar diferentes patrones y arquitecturas.

Puedes descargar el proyecto completo en el repo aquí .

Referencias

  1. CombineFeedback
  2. MVVM in SwiftUI
  3. SwiftUI Architectures
  4. Modern MVVM architecture

 

DEJA UNA RESPUESTA

Por favor ingrese su comentario!

He leído y acepto la política de privacidad

Por favor ingrese su nombre aquí

Información básica acerca de la protección de datos

  • Responsable:
  • Finalidad:
  • Legitimación:
  • Destinatarios:
  • Derechos:
  • Más información: Puedes ampliar información acerca de la protección de datos en el siguiente enlace:política de privacidad