Aprende a usar la Arquitectura VIPER en Swift
Aprende a usar la Arquitectura VIPER en Swift

Aprende a usar la Arquitectura VIPER en Swift - Parte 2

VIPER es una arquitectura muy potente para crear nuestras aplicaciones en Swift. En este post continuamos con la segunda parte, en este caso vamos a crear todos los componentes de VIPER y vamos a conectar 2 módulos diferentes para poder crear la navegación

SwiftBeta

Tabla de contenido


👇 SÍGUEME PARA APRENDER SWIFTUI, SWIFT, XCODE, etc 👇
Aprende a usar la Arquitectura VIPER en Swift - PARTE 2
Aprende a usar la Arquitectura VIPER en Swift - PARTE 2

Hoy en SwiftBeta vamos a continuar con la arquitectura VIPER, vamos a ver la segunda parte. En el video anterior empezamos a crear una arquitectura VIPER y explicábamos muchos conceptos nuevos. Te sugiero que si no lo has visto le eches un vistazo, te lo dejo por aquí arriba. La app que creamos desde cero muestra un listado de películas, esta información la obtenemos de la API de https://www.themoviedb.org, y hoy vamos a continuar y a acabar nuestra app. La funcionalidad que vamos a añadir es que al pulsar una de las películas, en una de las celdas de nuestro UITableView, lo que haremos será navegar a una nueva pantalla para ver el detalle de la película.

En el video de hoy vamos a crear un módulo nuevo de VIPER, este módulo va a contener todas las clases necesarias para crear la funcionalidad de ver el detalle de la película, para hacerlo, vamos a crear una carpeta y vamos a crear todas las clases de todos los componentes de VIPER. Nos vamos a nuestro listado de ficheros y creamos una nueva carpeta llamada DetailMovie

Dentro de esta nueva carpeta, vamos a crear la siguiente estructura:

  • DetailView
  • DetailPresenter
  • DetailInteractor
  • DetailRouter
  • Carpeta Entities
Organización del nuevo módulo de VIPER en Xcode
Organización del nuevo módulo de VIPER en Xcode

Seguiremos el mismo orden que vimos en el anterior video, vamos a crear la lógica de nuestro Interactor y luego crearemos el Presenter, la View, etc. Es decir, empezamos por la capa más baja e iremos subiendo poco a poco.

Pero antes, si quieres apoyar el contenido que subo cada semana, suscríbete. De esta manera seguiré publicando contenido completamente grautito en Youtube. También comentarte que tengo un libro de Swift con varios capítulos y ejercicios prácticos totalmente en castellano. Al adquirir el libro tendrás todo el código de cada capítulo en Playgrounds y también la solución de cada ejercicio, te dejo el enlace en la descripción del video.

Información de la API

Empezamos por el interactor, vamos a empezar por la capa inferior e iremos subiendo poco a poco, vamos a seguir el mismo proceso que el video anterior. Nos vamos al fichero DetailInteractor, y aquí vamos a crear nuestra petición HTTP para extraer la información de la película. Pero ¿cómo sabemos de qué película tenemos quer buscar la información? a nuestro Interactor le llegará un parámetro que será el ID, el identificador único de la película, con esta información podemos hacer una petición HTTP a la API y obtener la información de esa película. Vamos a crear la firma de nuestra función pasándole este identificador que te acabo de mencionar:

func getDetailMovie(withId id: String) async -> ???

Antes de continuar tenemos que ir a la documentación de la API para sacar 2 datos que necesitamos.

  • El primero es el endpoint al que vamos a llamar
  • El segundo es el modelo que vamos a crear para transformar, los datos del JSON de nuestra petición HTTP a un modelo de nuestro dominio.

Para hacerlo, visitamos la documentación de la API, y vamos al siguiente enlace:

API Docs
Hosted API documentation for every OAS (Swagger) and RAML spec out there. Powered by Stoplight.io. Document, mock, test, and more, with the StopLight API Designer.

Entity

Aquí podemos ver la URL y la respuesta que vamos a obtener. Una vez tenemos esta información, vamos a crear nuestra primer Entity de nuestro nuevo módulo de VIPER, va a contener las siguiente propiedades:

struct DetailMovieEntity: Decodable {
    let title: String
    let overview: String
    let backdropPath: String
    let status: String
    let releaseDate: String
    let voteAverage: Double
    let voteCount: Int
}

Interactor

Ahora que ya tenemos el modelo, vamos otra vez al Interactor y aquí creamos la petición HTTP al endpoint correcto:

class DetailInteractor {
    func getDetailMovie(withId id: String) async -> DetailMovieEntity {
        let url = URL(string: "https://api.themoviedb.org/3/movie/\(id)?api_key=02face8651c1fbb596638eaa99e07790")!
        let (data, _) = try! await URLSession.shared.data(from: url)
        let jsonDecoder = JSONDecoder()
        jsonDecoder.keyDecodingStrategy = .convertFromSnakeCase
        return try! jsonDecoder.decode(DetailMovieEntity.self, from: data)
    }
}

Como ya mencionamos y vimos en el anterior video, vamos a trabajar con protocolos en lugar de implementaciones concretas de nuestros tipos, de esta manera podemos desacoplarnos de otros componentes. A continuación creamos el siguiente protocolo y lo conformamos en DetailInteractor:

protocol DetailInteractable: AnyObject {
    func getDetailMovie(withId id: String) async -> DetailMovieEntity
}

class DetailInteractor: DetailInteractable {
...
}

Ahora ya podemos continuar y crear nuestro Presenter.

Presenter

Una vez hemos creado el Interactor, creamos una nueva class llamada DetailPresenter. Dentro de esta clase vamos a añadir una referencia de nuestro nuevo Interactor:

class DetailPresenter {
    private let interactor: DetailInteractable
    
    init(interactor: DetailInteractable) {
        self.interactor = interactor
    }
}

En este caso ya hemos usado el protocolo DetailInteractable en lugar de DetailInteractor, y durante este video trabajaremos con los protocolos que iremos creando de cada componente. Ahora, vamos a crear un método en nuestro Presenter que llamará al método que acabamos de crear de nuestro Interactor. Este método lo vamos a llamar onViewAppear y tiene el siguiente código:

func onViewAppear() {
    Task {
        let model = await interactor.getDetailMovie(withId: ???)
        print(model)
    }
}

Para poder extraer la información del detalle de la película debemos pasarle el identificador al método del interactor, pero aún no tenemos este valor en nuestro Presenter. Vamos a añadir esta dependencia en el init de DetailPresenter:

class DetailPresenter {
    private let movieId: String
    private let interactor: DetailInteractable
    
    init(movieId: String,
         interactor: DetailInteractable) {
        self.interactor = interactor
        self.movieId = movieId
    }
    ...
}

Ahora podemos pasar la propiedad movieId al método del Interactor.

func onViewAppear() {
    Task {
        let model = await interactor.getDetailMovie(withId: movieId)
        print(model)
    }
}

No te preocupes por este identificador que algún componente de nuestra arquitectura VIPER se lo acabará inyectando al Presenter. Una vez obtenemos los datos, hay que pasarselos a la View para que los muestre. Pero si te acuerdas del último video, no le vamos a pasar la Entity, vamos a crear un ViewModel.

ViewModel

Creamos una carpeta nueva y vamos a crear el ViewModel, el modelo que vamos a pasar a la View.

struct DetailMovieViewModel {
    let title: String
    let overview: String
    let backdropPath: URL?
}

Mapper

A continuación vamos a crear el mapper llamado MapperDetailMovieViewModel, este mapper nos va a transformar el Entity al ViewModel:

struct MapperDetailMovieViewModel {
    func map(entity: DetailMovieEntity) -> DetailMovieViewModel {
        .init(title: entity.title,
              overview: entity.overview,
              backdropPath: URL(string: "https://image.tmdb.org/t/p/w200" + entity.backdropPath))
    }
}

Una vez tenemos el ViewModel y Mapper creado, nos vamos al Presenter y aquí vamos a inyectar el mapper. El resultado final de nuestro código sería el siguiente:

class DetailPresenter {
    private let movieId: String
    private let interactor: DetailInteractable
    private let mapper: MapperDetailMovieViewModel
    
    init(movieId: String,
         interactor: DetailInteractable,
         mapper: MapperDetailMovieViewModel) {
        self.interactor = interactor
        self.movieId = movieId
        self.mapper = mapper
    }
    
    func onViewAppear() {
        Task {
            let model = await interactor.getDetailMovie(withId: movieId)
            let viewModel = mapper.map(entity:model)
            print(viewModel)
        }
    }
}

Una vez tenemos los datos listos para enviar a la vista, se los tenemos que pasar de alguna manera. Vamos a crear un protocolo para usar el Delegation Pattern y así pasarle esta información a la View. Creamos el siguiente Protocolo:

protocol DetailPresenterUI: AnyObject {
    func updateUI(viewModel: DetailMovieViewModel)
}

A continuación creamos una propiedad WEAK, que sea opcional del mismo tipo de protocolo que acabamos de crear:

class DetailPresenter {
    weak var ui: DetailPresenterUI?
    ...
}

Y una vez hemos mapeado los datos de Entity a ViewModel dentro del método onAppear, llamamos a la ui para pasarle los datos.

func onViewAppear() {
    Task {
        let model = await interactor.getDetailMovie(withId: movieId)
        let viewModel = mapper.map(entity:model)
        DispatchQueue.main.async {
            self.ui?.updateUI(viewModel: viewModel)
            print(viewModel)
        }
    }
}

Ahora que ya tenemos listo nuestro Presenter, vamos a crear un protocolo que es la abstracción que usaremos desde la View.

protocol DetailPresentable: AnyObject {
    var ui: DetailPresenterUI? { get }
    var movieId: String { get }
    func onViewAppear()
}

class DetailPresenter: DetailPresentable {
    weak var ui: DetailPresenterUI?
    var movieId: String
    ...
}

Una vez creado el Protocolo del Presenter, ya podemos crear la View.

View

Vamos a crear una vista muy sencilla, lo primero de todo es crear la class DetailView y que herede de UIViewController.

class DetailView: UIViewController {
    private let presenter: DetailPresentable
    
    init(presenter: DetailPresentable) {
        self.presenter = presenter
        super.init(nibName: nil, bundle: nil)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

Fíjate que estamos usando el Protocolo que acabamos de crear en el Presenter. De esta manera conseguimos desacoplarnos de una implementación concreta. Antes de llamar al método de nuestro Presenter, lo que vamos hacer es añadir 3 subvistas a nuestra View. Estas subvistas son un UIImageView y 2 UILabels.

Ahora vamos a llamar al método onViewAppear de nuestro Presenter dentro del método viewDidLoad de nuestro UIViewController. Nuestra View debe tener el siguiente código:

class DetailView: UIViewController {
    private let presenter: DetailPresentable
    
    let movieImageView: UIImageView = {
        let imageView = UIImageView()
        imageView.contentMode = .scaleAspectFit
        imageView.translatesAutoresizingMaskIntoConstraints = false
        return imageView
    }()
    
    let movieName: UILabel = {
        let label = UILabel()
        label.numberOfLines = 2
        label.font = .systemFont(ofSize: 32, weight: .bold, width: .condensed)
        label.translatesAutoresizingMaskIntoConstraints = false
        return label
    }()
    
    let movieDescription: UILabel = {
        let label = UILabel()
        label.numberOfLines = 0
        label.font = .systemFont(ofSize: 12, weight: .regular, width: .standard)
        label.textColor = .gray
        label.translatesAutoresizingMaskIntoConstraints = false
        return label
    }()
    
    init(presenter: DetailPresentable) {
        self.presenter = presenter
        super.init(nibName: nil, bundle: nil)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        view.backgroundColor = .white
        setupView()
        presenter.onViewAppear()
    }
    
   	private func setupView() {
        view.addSubview(movieImageView)
        view.addSubview(movieName)
        view.addSubview(movieDescription)
        
        NSLayoutConstraint.activate([
            movieImageView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            movieImageView.topAnchor.constraint(equalTo: view.topAnchor, constant: 12),
            movieImageView.heightAnchor.constraint(equalToConstant: 200),
            movieImageView.widthAnchor.constraint(equalToConstant: 300),
            
            movieName.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
            movieName.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
            movieName.topAnchor.constraint(equalTo: movieImageView.bottomAnchor, constant: 20),
            
            movieDescription.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
            movieDescription.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
            movieDescription.topAnchor.constraint(equalTo: movieName.bottomAnchor, constant: 20),
        ])
    }
}

Ahora lo único que nos falta es rellenar la información una vez el Presenter envía el ViewModel a la View. Para hacerlo vamos a crear una extension de DetailView que conforme el protocolo DetailPresenterUI:

extension DetailView: DetailPresenterUI {
    func updateUI(viewModel: DetailMovieViewModel) {
        movieImageView.kf.setImage(with: viewModel.backdropPath)
        movieName.text = viewModel.title
        movieDescription.text = viewModel.overview
    }
}

Perfecto, ya hemos creado Entities, Interactor, Presenter y View. Ahora solo nos falta crear el Router de esta pantalla. Para hacerlo, nos vamos al fichero DetailRouter.

Pero antes, si quieres apoyar el contenido que subo cada semana, suscríbete. De esta manera seguiré publicando contenido completamente grautito en Youtube. También comentarte que tengo un libro de Swift con varios capítulos y ejercicios prácticos totalmente en castellano. Al adquirir el libro tendrás todo el código de cada capítulo en Playgrounds y también la solución de cada ejercicio, te dejo el enlace en la descripción del video.

Router

Aquí dentro vamos a crear un método que se va a encargar de unir todos los componentes de este módulo de VIPER:

class DetailRouter {
    func showDetail(withMovieId movieId: String) {
        let interactor = DetailInteractor()
        let presenter = DetailPresenter(movieId: movieId,
                                        interactor: DetailInteractor(),
                                        mapper: MapperDetailMovieViewModel())
        let view = DetailView(presenter: presenter)
        
        presenter.ui = view
    }
}

En realidad, como te comenté en el anterior video, lo suyo sería mover esta responsabilidad a otra clase para que se encargue de hacer el ensamblado, pero en este caso los vamos a dejar en el Router. Una vez creado este código, el Router debe ser capaz de navegar, y es justo la lógica que vamos a añadir. Para poder navegar necesitamos tener una referencia, saber desde que ViewController A vamos a presentar un ViewController B, en este caso el ViewController A es el listado de películas y el ViewController B es el detalle de la película. En nuestro caso necesitamos una referencia del ViewController A, y se la vamos a pasar como parámetro a nuestro DetailRouter, y dentro del scope del a función, una vez hemos incializado y conectado todos los componentes de VIPER, hacemos la navegación:

class DetailRouter {
    func showDetail(fromViewController: UIViewController, withMovieId movieId: String) {
        let interactor = DetailInteractor()
        let presenter = DetailPresenter(movieId: movieId,
                                        interactor: DetailInteractor(),
                                        mapper: MapperDetailMovieViewModel())
        let view = DetailView(presenter: presenter)
        
        presenter.ui = view
        
        fromViewController.present(view, animated: true)
    }
}

Para finalizar, vamos a crear el protocolo DetailRouting y lo vamos a conformar en DetailRouter:

protocol DetailRouting: AnyObject {
    func showDetail(fromViewController: UIViewController, withMovieId movieId: String)
}

class DetailRouter: DetailRouting {
...
}

Perfecto! Ya tenemos el módulo de DetailView creado, ahora tan solo debemos conectarlo con el módulo de listar películas, el módulo que vimos en el primer video de esta serie de VIPER.

Conectar 2 módulos en VIPER

Para hacerlo nos vamos al ListOfMoviesRouter, aquí dentro vamos a añadir una propiedad llamada detailRouter y va a ser de tipo DetailRouting opcional.

var detailRouter: DetailRouting?

Una vez creada, vamos a implementar el método que llamará a esta propiedad y podremos mostrar el detalle de la película:

func showDetailMovie(withMovieId movieId: String) {
    detailRouter?.showDetail(fromViewController: <#T##UIViewController#>, withMovieId: movieId)
}

Al hacerlo nos damos cuenta que nos falta la referencia del View Controller desde donde queremos presentar. En este caso es el ListOfMoviesView. Para crear esta referencia podemos crear una propiedad en nuestro Router y la vamos a enviar como parámetro en el método showDetail:

class ListOfMoviesRouter {
    var detailRouter: DetailRouting?
    var listOfMoviesView: ListOfMoviesView?
    
    func showListOfMovies(window: UIWindow?) {
        let interactor = ListOfMoviesInteractor()
        let presenter = ListOfMoviesPresenter(listOfMoviesInteractor: interactor)
        listOfMoviesView = ListOfMoviesView(presenter: presenter)
        
        presenter.ui = listOfMoviesView
        
        window?.rootViewController = listOfMoviesView
        window?.makeKeyAndVisible()
    }
    
    func showDetailMovie(withMovieId movieId: String) {
        guard let vc = listOfMoviesView else { return }
        detailRouter?.showDetail(fromViewController: vc, withMovieId: movieId)
    }
}

En este caso podríamos lanzar un error si nuestro listOfMoviesView es opcional, ya que no podremos mostrar la pantalla de detalle de la película. Para finalizar, vamos a crear un protocolo llamado ListOfMoviesRouting y lo vamos a conformar en ListOfMoviesRouter.

protocol ListOfMoviesRouting {
    var detailRouter: DetailRouting? { get }
    var listOfMoviesView: ListOfMoviesView? { get }
    
    func showListOfMovies(window: UIWindow?)
    func showDetailMovie(withMovieId movieId: String)
}

class ListOfMoviesRouter: ListOfMoviesRouting {
...
}

Vamos a continuar! Ahora que hemos creado este método y la conexión entre los dos módulos de nuestra arquitectura VIPER, lo que debemos hacer es el paso final. Nos vamos a nuestro ListOfMoviesPresenter, y aquí vamos a crear una propiedad nueva llamada router de tipo ListOfMoviewsRouting, esta propiedad la vamos a usar para navegar a la vista detail de la película:

class ListOfMoviesPresenter: ListOfMoviesPresentable {
...
    private let router: ListOfMoviesRouting
    
    init(listOfMoviesInteractor: ListOfMoviesInteractable = ListOfMoviesInteractor(),
         mapper: Mapper = Mapper(),
         router: ListOfMoviesRouting) {
        self.listOfMoviesInteractor = listOfMoviesInteractor
        self.mapper = mapper
        self.router = router
    }
...
}

Pero, una vez tenemos esta referencia en nuestro Presenter, cómo sabemos que tenemos que abrir la pantalla de detalle de una película? Es decir, ¿cómo triggereamos esta acción dentro de nuestra app? Esta acción la recibiremos de la View cuando se pulse una celda de la UITableView, cuando esto ocurra llamaremos a un método que vamos a implementar en nuestro Presenter en los próximos minutos, pero primero vamos a detectar qué celda se está pulsando. Nos vamos al ListOfMoviesView y vamos a implementar el delegado nuestra TableView.

UITableViewDelegate

Para hacerlo añadimos esta línea en nuestro método setupTableView:

moviesTableView.delegate = self

Y ahora vamos a conformar y a implementar el método que se encarga de recibir la acción cuando una celda es pulsada, vamos a implementar el siguiente método:

extension ListOfMoviesView: UITableViewDelegate {
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        <#code#>
    }
}

El código que vamos a añadir en este método es muy simple, vamos a pasarle al presenter el index de la celda que se ha pulsado:

extension ListOfMoviesView: UITableViewDelegate {
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        presenter.onTapCell(atIndex: indexPath.row)
    }
}

Como es normal, tenemos un error del compilador, tenemos que crear este método en el protocolo y en la implementación de nuestro Presenter, vamos a añadirlo primero al protocolo:

protocol ListOfMoviesPresentable: AnyObject {
    var ui: ListOfMoviesUI? { get }
    var viewModels: [ViewModel] { get }
    func onViewAppear()
    func onTapCell(atIndex: Int)
}

Y ahora vamos a crear la implementación, podemos crearla justo después del método onViewAppear:

func onTapCell(atIndex: Int) {
    // TODO
}

Dentro de la lógica de este método necesitamos obtener el id de la película. Pero de  ¿dónde la obtenemos? Cuando hicimos el cambio de modelo de Entity a ViewModel suprimimos todas aquellas propiedades que no usábamos en la View. El único modelo que tiene el id de la movie es el Entity que recibimos al pedir la información al Interactor. Una posible solución sería guardar esta información en el presenter para poderla acceder cuando un user pulse una celda de nuestro listado. Para hacerlo, vamos a crear una propiedad que almacene un Array de PopularMovieEntity:

private var models: [PopularMovieEntity] = []

Y una vez creado vamos a guardar esta información cuando obtenemos la respuesta de nuestro Interactor:

func onViewAppear() {
    Task {
        models = await listOfMoviesInteractor.getListOfMovies().results
        viewModels = models.map(mapper.map(entity:))
        DispatchQueue.main.async {
            self.ui?.updateUI(viewModel: viewModel)
            print(viewModel)
        }
    }
}

Ahora ya podemos acceder a esta información desde nuestro método onTapCell:

func onTapCell(atIndex: Int) {
    let movieId = models[atIndex].id
    print(movieId)
}

Si ahora intentamos compilar para ver si se printa correctamente el id de la película, vemos que tenemos un error, esto es porque hemos añadido una dependencia nueva a nuestro presenter, y debemos pasarle una instancia del router:

func showListOfMovies(window: UIWindow?) {
    let interactor = ListOfMoviesInteractor()
    let presenter = ListOfMoviesPresenter(listOfMoviesInteractor: interactor,
                                          router: self)
    listOfMoviesView = ListOfMoviesView(presenter: presenter)
    
    presenter.ui = listOfMoviesView
    
    window?.rootViewController = listOfMoviesView
    window?.makeKeyAndVisible()
}

Si ahora compilamos y pulsamos en una de las celdas vemos como se printa por consola el identificador de la película. Este identificador es el que necesitamos enviar al otro módulo, el que se va a encargar de mostrar la vista detalle. Vamos a pasar el id como parámetro en el método de nuestro Router:

func onTapCell(atIndex: Int) {
    let movieId = models[atIndex].id
    router.showDetailMovie(withMovieId: movieId.description)
}

Si ahora compilamos, vamos a ver qué ocurre al pulsar una celda. No ocurre nada, y esto es normal, y te lo quería mostrar. Cuando trabajamos con VIPER es muy importante que conectemos bien todas nuestras piezas, en este caso nos hemos dejado un paso muy importante que tenemos en ListOfMoviesRouter. Aquí en ningún momento hemos asignado un valor a la propiedad detailRouter, para darle un valor, nos vamos al método showListOfMovies y añadimos en la primera línea el siguiente código:

func showListOfMovies(window: UIWindow?) {
    self.detailRouter = DetailRouter()
    ...
}
            

Si ahora compilamos podemos navegar a los detalles de las películas que aparece en nuestro UITableView.

Con este videos hemos aprendido las bases de VIPER, hay más temas super interesantes que podríamos mencionar y aplicar en esta arquitectura, pero como introducción es bastante completo todo el contenido que hemos visto. Podríamos crear más protocolos para abstraer aún más nuestros componentes, también podríamos crear más módulos en nuestra app para añadir diferentes funcionalidades, también podemos usar Clean Architecture con sus respositorios, dataSources, APIClient, etc e incluso podríamos crear diferentes módulos con Swift Package Manager para nuestra app. Otro tema que no hemos abordado es el testing, ya que ese contenido lo explicaré más adelante en el canal.

Conclusión

Y hasta aquí el video de hoy, espero que hayas disfrutado tanto como yo creando esta aplicación con VIPER. Ahora puedes continuar y crear más funcionalidad a la app que hemos creado desde cero!