Índice de contenidos
- 1. Introducción
- 2. Entorno
- 3. Las dependencias
- 4. Ejemplo
- 5. Conclusiones
1. Introducción
La arquitectura VIPER es un patrón de diseño muy usado para la construcción de aplicaciones móviles, su diseño se basa en la separación por capas de los distintos componentes de nuestro código para una correcta aplicación del principio de responsabilidad única.
De tal forma, existen muchas variantes del patrón, pero en términos generales se trata de separar la capa de la interfaz (View), la capa de negocio (Interactor), la capa de presentación (Presenter), la capa de datos (Entity) y la capa de navegación (Routing).
Muchos de nosotros hacemos uso de la arquitectura VIPER para construir nuestras aplicaciones móviles, este patrón ya se usaba cuando Objective-C era el rey del mambo, y viéndolo en retrospectiva, era cuando más brillaba ya que tenía más sentido su aplicación, luego llegó swift, y hubo que adaptarlo, pasamos de una programación orientada a objetos a una orientada a protocolos, ya no era una elección hacer uso de VIPER, ya casi era una obligación usarlo por el propio contexto del lenguaje.
Con la llegada de SwiftUI se ha roto el paradigma de programación preestablecido en iOS, ahora esta forma de programar puede forzar a pensar que nuestra lógica de negocio o nuestra capa de datos ha de estar en la vista, pero siempre existen formas de esquivarlo y poder hacer una arquitectura limpia y con las responsabilidades separadas.
Vamos a ver cómo podemos hacer uso de este veterano patrón con la nueva forma de construcción de aplicaciones que nos ha regalado Apple con SwiftUI y Combine.
2. Entorno
El tutorial está escrito usando el siguiente entorno:
- Hardware: Portátil MacBook Pro 15′ (2,3 Ghz Intel Core i9 de 8 núcleos, 32GB DDR4).
- Sistema Operativo: Mac OS Catalina 10.15.4
- Entorno de desarrollo: XCode 11.4.1
3. Las dependencias
Veamos las dependencias que tenemos en VIPER con un proyecto construido SIN SwiftUI y CON SwiftUI.
3.1 Sin SwiftUI
Vista -> Presenter (PresenterInterface): La vista se comunica con un presenter que adopta el protocolo «PresenterInterface», este protocolo lo usaremos para informar de los eventos en la interfaz.
Presenter -> Vista (PresenterDelegate): El presenter se comunica con la vista a modo de delegado mediante el protocolo «PresenterDelegate», este protocolo se usa para informar a la interfaz de posibles cambios en el modelo.
Presenter -> Interactor (InteractorInterface): El presenter se comunica con un Interactor que adopta el protocolo «InteractorInterface», básicamente el presenter recibe los eventos de la vista, hace lo necesario en lo relativo a la capa de presentación en relación a ese evento y si éste requiere de algún tipo de lógica de negocio se le informará al interactor mediante esta interfaz.
Presenter -> Router (RouterInterface): El presenter se comunicará con el router a través de la interfaz «RouterInterface» que implementará nuestro Router, estas llamadas pueden ser activadas mediante eventos de la vista sin tener que pasar por el interactor, pero si la decisión de navegar a una vista tiene algún tipo de lógica de negocio este evento será activado pasando primero por el interactor, veamos como sería el flujo para entenderlo:
Evento sin lógica de negocio:
Evento UI -> PresenterInterface(Presenter) -> RouterInterface(Router)
Evento con lógica de negocio:
Evento UI -> PresenterInterface(Presenter) -> InteractorInterface(Interactor) -> InteractorDelegate(Presenter) -> RouterInterface(Router)
Interactor -> Presenter (InteractorDelegate): El interactor una vez hecho su trabajo con la lógica de negocio, si necesita actualizar la UI o navegar entre pantallas informará de ello al presenter mediante el delegado «InteractorDelegate».
3.2 Con SwiftUI
Y ahora veamos como quedarían las dependencias en un proyecto con SwiftUI, aunque suene raro, vamos a eliminar todos los protocolos, esto es debido a la magia del framework Combine, que nos ofrece un mecanismo de observación que nos va a suplir la implementación de estos protocolos, de tal forma que quedaría nuestro patrón de la siguiente manera:
La View contiene la propiedad @ObservedObject presenter: La vista contiene una referencia al presenter con la anotación @ObservedObject, esto quiere decir que es un objeto al que vamos a observar sus propiedades marcadas como @Published.
El Presenter contiene la propiedad @Published viewModels que va ser la que va a observar la vista para hacer los cambios en la interfaz cuando este modelo cambie, como veis, ahora el presenter ya no tiene referencia alguna a la vista, solamente expone un modelo observable. El presenter también contiene una referencia al interactor y al Router. El interactor va a contener también un modelo que vamos a observar desde el presenter pero este no es necesario que indiquemos la anotación @ObservedObject ya que no vamos a referenciarlo desde dentro de una View.
El Interactor contiene también la propiedad @Published viewModels que va a ser la que observe el presenter, en esta variable es donde vamos a poner los objetos de negocio, el interactor es el que se va a encargar de obtenerlos del backend/BBDD y transformarlos en viewModels para la capa de presentación aplicando la lógica oportuna (filtrado, ordenación…etc.)
En el ejemplo que vamos a ver he creado una clase DataLayer que contiene una propiedad @Published que observa el interactor en la que establecemos los modelos, cuando estos modelos cambien, el interactor actúa en consecuencia, transformándolos en viewModels y actualizando su propiedad viewModels para que en cascada se actualice el presenter y la vista.
Vamos a verlo mas claro en el ejemplo:
4. Ejemplo
Vamos a construir una aplicación simple que pueda crear y borrar unas notas, simplemente para poder ver cómo estructurar un caso de uso y ver cuales son las dependencias entre las clases.
Para empezar crearemos un proyecto en Xcode y nos aseguraremos de tener marcada la opción de SwiftUI y de Core data.
A continuación pulsando el comando command+n vamos a crear nuestros ficheros:
- NotesListView
- NotesDetailView
- NotesPresenter
- NotesInteractor
- NotesRouter
- DataLayer
- CoreDataStack
- NoteViewModel
Vamos a crear también nuestra entidad en Core data, para ello seleccionaremos el fichero ViperNotes.xcdatamodeld y pulsamos en «Add Entity» y llamamos a la entidad Note, también la vamos a añadir los atributos de forma que queden de la siguiente manera:
A continuación, con la entidad seleccionada vamos al inspector del modelo y lo dejamos de la siguiente manera:
Ahora ya podemos crear la subclase de NSManagedObject, para ello pulsamos en el menú editor y seleccionamos «Create NSManagedObject Subclass…» y seguimos los pasos para crearla.
Vamos a dejar implementar la clase CoreDataStack, que vamos a dejar de la siguiente manera:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 |
import Combine import CoreData protocol DataProvider { var notesPublisher: Published<[Note]>.Publisher { get } func addNewNote(title: String, body: String, date: Date) func delete(_ note: Note) } class CoreDataStack { private var managedObjectContext: NSManagedObjectContext private var cancellables = Set<AnyCancellable>() @Published var notes: [Note] = [] init(context: NSManagedObjectContext) { self.managedObjectContext = context publish() } private func allNotes() -> [Note] { do { let fetchRequest : NSFetchRequest<Note> = Note.fetchRequest() return try self.managedObjectContext.fetch(fetchRequest) } catch let error as NSError { print("\(error), \(error.userInfo)") return [] } } private func save() { do { try self.managedObjectContext.save() } catch let error as NSError { print("\(error), \(error.userInfo)") } publish() } private func removeAllNotes() { allNotes().forEach { object in managedObjectContext.delete(object) } save() } private func publish() { notes = allNotes() } } extension CoreDataStack: DataProvider { var notesPublisher: Published<[Note]>.Publisher { $notes } func addNewNote(title: String, body: String, date: Date) { let note = Note(context: managedObjectContext) note.id = UUID() note.title = title note.body = body note.date = date save() } func delete(_ note: Note) { self.managedObjectContext.delete(note) save() } } |
De cara agilizar el tutorial voy a explicar lo básico en esta clase para entender lo que queremos conseguir para cumplir con nuestro objetivo.
Tenemos una propiedad @Published con las notas que queremos observar, cuando se instancia la clase se llama a la función publish(), que asigna a esta variable todas las notas que hay en la base de datos.
Cada vez que se interactúa con la BBDD añadiendo o borrando un registro se llama a esta función para que la propiedad observable esté sincronizada.
También tenemos un protocolo llamado DataProvider que nos va a servir para exponer de forma anónima la utilidad de esta clase. En este se declara una variable de tipo Publisher<[Note]>.Publisher con un array de la entidad Note, así mismo también declaramos las funciones de alta y baja de la entidad.
Ahora vamos a implementar nuestra clase DataLayer, esta va ser la capa que interactúa con los interactors, no es conocedora de Core data ya que no es su responsabilidad saber de dónde salen los datos, solo debería ser responsable de dispensarlos, no de recuperarlos, y gracias al protocolo DataProvider vamos a enmascarar nuestro CoreDataStack en ella.
Vamos a dejarlo de la siguiente forma:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
import Foundation import Combine class DataLayer { private let dataProvider: DataProvider private var cancellables = Set<AnyCancellable>() @Published var notes: [Note] = [] init(provider: DataProvider) { self.dataProvider = provider setup() } // MARK: Private functions private func setup() { self.dataProvider.notesPublisher .assign(to: \.notes, on: self) .store(in: &cancellables) } // MARK: Public functions func addNewNote(title: String, body: String, date: Date) { dataProvider.addNewNote(title: title, body: body, date: date) } func delete(_ note: Note) { dataProvider.delete(note) } } |
Al instanciar esta clase inyectamos un objeto que adopta el protocolo DataProvider, en nuestro caso será el CoreDataStack. También vamos a tener un @Published con las notas, éste va a estar asignado al @Published del dataProvider, que va a ser el que observen los interactors.
Llegados a este punto vamos a crear nuestro ViewModel, en el fichero NoteViewModel vamos a escribir lo siguiente:
1 2 3 4 5 6 7 8 |
import Foundation struct NoteViewModel { let title: String let body: String let date: String let id: UUID } |
Únicamente necesitamos una estructura que represente los datos de forma visual y en caso de ser necesario un identificador.
Ahora vamos con nuestro Interactor, en esta clase en donde vamos a meter toda la lógica de negocio y vamos a preparar los datos para la capa de presentación, digamos que aquí es donde se hace lo el grueso del trabajo, pero ojo, esto no quiere decir que deba ser una clase monstruosa, debemos tener en cuenta que cada interactor es para un caso de uso, por tanto es posible necesitemos varios interactors para una sola pantalla de nuestra app.
En nuestro caso vamos a dejarlo de la siguiente manera:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 |
import Foundation import Combine class NotesInteractor { private let formatter = DateFormatter() private let model: DataLayer private var cancellables = Set<AnyCancellable>() @Published var noteViewModels: [NoteViewModel] = [] init (model: DataLayer) { formatter.dateStyle = .medium self.model = model setup() } // MARK: Private functions private func setup() { self.model.$notes .map({ notes -> [NoteViewModel] in return notes.map{ NoteViewModel(title: $0.title!, body: $0.body!, date: self.formatter.string(from: $0.date!), id: $0.id!) } }) .replaceError(with: []) .assign(to: \.noteViewModels, on: self) .store(in: &cancellables) } // MARK: Public functions func addNewNote() { model.addNewNote(title: "Note \(noteViewModels.count)", body: "Note body", date: Date() ) } func deleteNote(_ index: IndexSet) { var notesCopy = noteViewModels notesCopy.move(fromOffsets: index, toOffset: 0) if let noteToDelete = model.notes.filter({ notesCopy.first!.id == $0.id }).first { model.delete(noteToDelete) } } } |
Como podemos observar, en la instanciación de la clase asignamos la propiedad notes de nuestro dataLayer a la propiedad notesViewModel:
1 2 3 4 5 6 7 8 9 |
self.model.$notes .map({ notes -> [NoteViewModel] in return notes.map{ NoteViewModel(title: $0.title!, body: $0.body!, date: self.formatter.string(from: $0.date!), id: $0.id!) } }) .replaceError(with: []) .assign(to: \.noteViewModels, on: self) .store(in: &cancellables) |
Pero antes de la asignación necesitamos hacer un .map() para transformar nuestra entidad de negocio en un viewModel, que es lo que va a recibir la capa de presentación.
Antes del .map() podríamos hacer una ordenación o un filtrado de los modelos ya que combine te provee de funciones de todo tipo para manejar los publishers.
Como vemos esta es la única clase que es conocedora de las entidades de negocio y los viewModels.
Hemos creado unas funciones para recibir los eventos de la interfaz, en nuestro caso simplemente informamos a nuestro dataLayer de los eventos, pero en aplicaciones mas complejas es posible que, o bien tengamos distintos tipos de dataLayers o tengamos que hacer algún tipo de lógica, antes de guardar/enviar los datos.
En la función de borrado debido a la forma que tiene SwiftUI de jerarquizar sus vistas vamos a recibir un IndexSet, eso nos supone un pequeño «problema» de cara a identificar nuestro ViewModel correspondiente, es decir, no vamos a recibir directamente el indice que corresponde a nuestro modelo, las funciones de las colecciones que reciben un parámetro IndexSet son de mover o borrar, por lo que lo solventamos haciéndonos una copia, moviendo el elemento para tenerlo identificado y una vez identificado le informamos a nuestra DataLayer que elemento borrar.
Ahora vamos a ver nuestra capa de presentación, en el fichero NotesPresenter vamos a escribir lo siguiente:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
import SwiftUI import Combine class NotesPresenter: ObservableObject { private let interactor: NotesInteractor private var cancellables = Set<AnyCancellable>() @Published var noteViewModels: [NoteViewModel] = [] init(interactor: NotesInteractor) { self.interactor = interactor interactor.$noteViewModels .assign(to: \.noteViewModels, on: self) .store(in: &cancellables) } // MARK: Events func addNewNote() { interactor.addNewNote() } func delete(_ index: IndexSet) { interactor.deleteNote(index) } // MARK: Navigation func detailView(note: NoteViewModel) -> some View { Text("") } } |
Xcode se va a quejar de que no conoce la clase NotesRouter pero en seguida nos pondremos con ella.
Veamos lo que hace nuestro Presenter, en primer lugar asigna la propiedad notesViewModels de nuestro interactor a su propia notesViewModels, si fuera necesario modificar alguna cosa relacionada con la presentación de la información sería en este punto, haciendo un .map() antes del .assign(), tal como lo hicimos en el interactor para transformar los modelos en viewModels, por ejemplo nuestro viewModel podría contener textos atribuidos y aquí sería el punto donde darles el formato.
A continuación tenemos las funciones que se van a llamar desde nuestra vista con los eventos de la UI de añadir y borrar una nota. Éstos únicamente hacen de puente al interactor o, si no fuera necesario, a nuestro router.
Por último tenemos la función detailView() que devuelve una vista, ésta función va a llamar a nuestro router para pedirle la pantalla de detalle, si necesitásemos algún tipo de lógica extra podríamos necesitar llamar al interactor dentro de esta función para pedirle los datos/acciones oportunas y a continuación llamar al router, de momento ponemos que retorne un Text(«») y cuando construyamos el router volveremos para modificarlo.
Ahora vamos a crear nuestras vistas, empezando con NotesListView que la vamos a dejar de la siguiente manera:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
import SwiftUI struct NotesListView: View { @ObservedObject var presenter: NotesPresenter var body: some View { NavigationView { List { ForEach (presenter.noteViewModels, id: \.id) { item in NavigationLink(item.title, destination: self.presenter.detailView(note: item)) } .onDelete(perform: presenter.delete) } .navigationBarTitle("Notes", displayMode: .inline) .navigationBarItems(trailing: Button(action: presenter.addNewNote) { Image(systemName: "plus") } ) } } } |
Necesitamos un variable con el property wrapper @ObservedObject para el presenter ya que vamos iterar sobre sus viewModels en base a sus identificadores para construir una List de las notas.
En las acciones de borrado y pulsado del botón «+» llamamos a las funciones del presenter que hemos creado para estos eventos de interfaz.
Es probable que existan casos en los que la construcción de un subvista como un botón requiera de algún tipo de lógica condicional, en estos casos le pediremos al presenter dicha vista. Podría quedar de esta manera:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
struct NotesListView: View { @ObservedObject var presenter: NotesPresenter var body: some View { NavigationView { List { ForEach (presenter.noteViewModels, id: \.id) { item in NavigationLink(item.title, destination: self.presenter.detailView(note: item)) } .onDelete(perform: presenter.delete) } .navigationBarTitle("Notes", displayMode: .inline) .navigationBarItems(trailing: presenter.topButton()) } } } class NotesPresenter: ObservableObject { func topButton() -> some View { if interactor.showAddButton { return Button(action: interactor.addNewNote) { Image(systemName: "plus") } } else { return Button(action: interactor.deleteAllNotes) { Image(systemName: "trash") } } } } |
Ahora vamos a crear nuestra vista detalle, vamos al fichero NotesDetailView y escribimos lo siguiente:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
import SwiftUI struct NotesDetailView: View { var note: NoteViewModel var body: some View { VStack() { Text("Title: \(note.title)") Text("Body: \(note.body)") Spacer() } .navigationBarTitle(note.date) } } |
Aquí únicamente vamos a mostrar los detalles de nuestra nota, la he creado para que se vea un ejemplo de navegación a través del router.
Para implementar nuestro router, nos vamos al fichero NotesRouter y escribimos lo siguiente:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
import Foundation import CoreData import SwiftUI struct NotesRouter { func listView(context: NSManagedObjectContext) -> some View { let persistence = CoreDataStack(context: context) let dataLayer = DataLayer(provider: persistence) let contentView = NotesListView(presenter: NotesPresenter(interactor: NotesInteractor(model: dataLayer))) return contentView } func detailView(note: NoteViewModel) -> some View { NotesDetailView(note: note) } } |
Nuestro router nos va a servir para construir nuestros módulos, en casos complicados es posible que queramos delegar esta funcionalidad a una clase distinta (assembly) que inyecte las dependencias y devuelva solamente la vista.
Con nuestro router terminado volvemos a nuestro NotesPresenter y añadimos la propiedad router:
1 |
private let router = NotesRouter() |
Y en la función que devuelve la vista de detalle escribimos lo siguiente:
1 2 3 |
func detailView(note: NoteViewModel) -> some View { router.detailView(note: note) } |
Ya solo nos falta llamar a nuestro router en la inicialización de la aplicación, nos vamos al fichero SceneDelegate y en la función scene() vamos a borrar la constante contenView y la dejamos de la siguiente manera:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). // Get the managed object context from the shared persistent container. let context = (UIApplication.shared.delegate as! AppDelegate).persistentContainer.viewContext let router = NotesRouter() // Use a UIHostingController as window root view controller. if let windowScene = scene as? UIWindowScene { let window = UIWindow(windowScene: windowScene) window.rootViewController = UIHostingController(rootView: router.listView(context: context)) self.window = window window.makeKeyAndVisible() } } |
Y listo, si arrancamos ya tenemos la aplicación funcionando con nuestro patrón de arquitectura VIPER!
5. Conclusiones
VIPER es un patrón muy potente y a modo personal me gusta mas que MVVM, que es el patrón que más se está usando con esta nueva librería de Apple, pero como hemos podido ver, podemos adaptarlo haciendo uso de Combine para que nos encaje perfectamente.
Podéis descargar el proyecto aquí.