Aprende a usar la Arquitectura Model-View-Presenter en Swift
Aprende a usar la Arquitectura Model-View-Presenter en Swift

Arquitectura Model-View-Presenter en Swift (MVP)

La Arquitectura Model-View-Presenter es una de las arquitecturas más usadas al crear una aplicación en Swift. Dentro de esta arquitectura hay 3 capas bien diferencias, la View,Model y Presenter. Sigue leyendo que vamos a crear una app muy práctica usando esta Arquitectura

SwiftBeta

Tabla de contenido


👇 SÍGUEME PARA APRENDER SWIFTUI, SWIFT, XCODE, etc 👇
Aprende a usar la Arquitectura Model-View-Presenter en Swift
Aprende a usar la Arquitectura Model-View-Presenter en Swift

Hoy en SwiftBeta vamos a aprender a usar la Arquitectura Model-View-Presenter en Swift. Esta arquitectura está compuesta por 3 capas, 3 componentes bien diferenciados, el Model, la View y el Presenter. Y antes de entrar en detalle, te voy a mostrar la aplicación que vamos a crear.

La aplicación que vamos a crear es una lista de tareas, una TODO app. En esta aplicación podremos ir añadiendo las tareas que queremos realizar, al añadir una nueva tarea aparece un nueva celda con el Button de favorito y el Button de borrar. También podremos borrar todas las tareas y dejar nuestra lista completamente vacía.

Vamos a ver en detalle la arquitectura Model-View-Presenter, y para hacerlo voy a explicar las 3 claras separaciones que tenemos, voy a empezar por el Model:

  • Model, El modelo representa los datos y la lógica de negocio. El modelo tiene la responsabilidad de guardar y manipular los datos de la aplicación. Por ejemplo, esta capa se encarga de realizar peticiones HTTP, acceder y persistir información en la base de datos, etc.
  • View, es la vista que acabamos viendo por pantalla, esta vista mostrará la información necesaria a un user. En el ejemplo de hoy nuestra View será el UICollectionView que va mostrando celdas que heredan de UICollectionViewCell, dentrode cada celda mostraremos la información de la tarea. Dentro de la celda aparecerán otras subvistas como el Button de borrar la tarea y el Button de marcar una tarea como favorita.
    La View tiene una doble función, por un lado se encarga de recibir acciones que un user realiza en la View (estas acciones se envían al presenter). Y por otro lado, se encarga de recibir los datos del Presenter. En este caso recibiremos las tareas para poderlas mostrar dentro del UICollectionView.
  • Presenter, el presenter es el intermediario entre el modelo y la vista. En el ejemplo de hoy vamos a ver cómo el Presenter recibe acciones de la Vista y se las pasa al Modelo, y también vamos a ver cómo el Presenter pasa información a la Vista para que la muestre por pantalla. El presenter es un mediador, y para poder comunicar la información que le llega desde el Modelo a la Vista, utilizamos los protocolos.

La Arquitectura MVP nos permite mantener nuestra aplicación desacoplada y esto nos permite crear tests fácilmente, por ejemplo el Presenter está desacoplado de la UI y esto nos permite crear tests de una manera muy limpia. Una vez hemos visto una introducción de cada capa dentro de la Arquitectura Model-View-Presenter, vamos a crear nuestra aplicación

Creamos el Proyecto en Xcode

Lo primero de todo que vamos hacer es crear nuestro proyecto en Xcode. Al crearlo le damos un nombre y especificamos que el tipo de Interface que vamos a usar es Storyboard (ya que estamos usando el framework UIKit).

Una vez creado el proyecto, lo siguiente que vamos hacer es crear la estructura de nuestra arquitectura. Vamos a crear 3 carpetas para diferencias bien el código de cada componente. Creamos la carpeta Model, View, y Presenter.

Renombramos ViewController a ListOfTasksView

Dentro de la carpeta View vamos a mover el ViewController, ya que este Controller será nuestra Vista principal de la aplicación. Y para que tenga un nombre más acorde con la aplicación que vamos a construir, vamos a renombrar nuestro ViewController a ListOfTasksView:

  • Renombrar class
  • Renombrar fichero
  • Renombrar ViewController en el Storyboard main (dentro del inspector de identidad)

Compilamos y nos aseguramos que funcione correctamente.

Creamos la capa Model

Una vez hecho este paso vamos a continuar y vamos a empezar por la capa del modelo, vamos a empezar por la capa más baja de todas. Dentro de la carpeta Model creamos la struct con la que trabajaremos en toda nuestra app, creamos la struct Task:

struct Task {
    let id: UUID = UUID()
    let text: String
    var isFavorite: Bool
}

Y a continuación ya podemos crear toda la lógica de negocio que tendrá nuestra aplicación, dentro de la carpeta Model creamos una clase llamada TaskDatabase, dentro de esta nueva clase creamos una propiedad llamada tasks que sea un Array de tareas:

final class TaskDatabase {
    var tasks: [Task]
    
    init(tasks: [Task] = []) {
        self.tasks = tasks
    }
}

Dentro de esta clase, vamos a crear método para crear tareas, modificar una tarea para que sea favorita o deje de serlo, borrar una tarea y borrar todas las tareas. Cada nuevo cambio que hagamos se almacenará en la propiedad tasks. Vamos a empezar por el método de crear una tarea:

Crear una tarea

Este método es muy sencillo, como parámetro de entrada le llega una tarea y la añadimos a nuestra propiedad tasks.

func create(task: Task) -> [Task] {
    tasks.append(task)
    return tasks
}

Actualizar como favorita una tarea

A continuación, creamos el método para actualizar un tarea y marcarla o desmarcarla como favorita

func updateFavorite(taskId: UUID) -> [Task] {
    if let index = tasks.firstIndex(where: { $0.id == taskId }) {
        tasks[index].isFavorite = !tasks[index].isFavorite
    }
    return tasks
}

Borrar una tarea

El siguiente método que vamos a crear es para borrar una tarea a partir de un identificador de tipo UUID:

func remove(taskId: UUID) -> [Task] {
    if let index = tasks.firstIndex(where: { $0.id == taskId }) {
        tasks.remove(at: index)
    }
    return tasks
}

Borrar todas las tareas

Y el último método es para borrar todas las tareas, es decir, dejar nuestro Array de la propiedad tasks completamente vacío:

func removeAll() -> [Task] {
    tasks.removeAll()
    return tasks
}

Perfecto, acabamos de crear la parte del modelo de nuestra aplicación de tareas. Tenemos nuestra lógica de dominio dentro de la class TaskDatabase, ahora ya podemos continuar y vamos a una capa superior que sería el presenter.

Presenter

Como hemos dicho al inicio del video, nuestro Presenter es el mediador entra la View y el Model. Lo que vamos hacer es crear nuestro Presenter, que vamos a llamar TaskPresenter, y vamos a crear una referencia a la clase TaskDatabase que acabamos de crear:

final class TaskPresenter {
    var tasks: [Task] = []
    
    private var taskDatabase = TaskDatabase()
}

Y una vez tenemos la referencia de TaskDatabase dentro del Presenter, podemos utilizarla para acceder a sus métodos. Vamos a crear un método para poder crear una task nueva desde el Presenter

func create(task: String) {
    guard !task.isEmpty else {
        return
    }
    
    let newTask: Task = .init(text: task,
                              isFavorite: false)
    tasks = taskDatabase.create(task: newTask)
}

Fíjate que el parámetro de entrada es de tipo String, este valor será el que nos enviará la View y nosotros desde el Presenter crearemos el tipo Task para pasarselo al modelo (el valor task, lo recogeremos de un TextView que crearemos en los próximos minutos en la View).
Para que este código compile debemos crear la propiedad tasks dentro de nuestro Presenter. Esta propiedad tasks va a ser un Array que irá almacenando todas las tareas que vayamos creando, modificando y eliminando.

var tasks: [Task] = []

Ahora que ya tenemos este método, vamos a crear el método para poder actualizar si una tarea es favorita o no.

func updateFavorite(taskId: UUID) {
    tasks = taskDatabase.updateFavorite(taskId: taskId)
}

Ahora vamos a crear el método para borrar una única Task:

func removeTask(taskId: UUID) {
    tasks = taskDatabase.remove(taskId: taskId)
}

Y finalmente creamos el método que se va a encargar de borrar todas las tareas:

func removeAllTasks() {
    tasks = taskDatabase.removeAll()
}

Con estas líneas de código podemos continuar y dar por acabado el Presenter. En realidad volveremos aquí en los próximos minutos para crear un pequeña implementación, pero por ahora podemos movernos a la capa superior, podemos ir a la capa de la View.

View

Dentro de la View, vamos a crear un UICollectionView con una celda customizada. Esto significa que acabaremos teniendo dos ficheros dentro de esta carpeta, vamos a empezar por el primero. Vamos a la class ListOfTasksView, la que hemos renombrado al inicio del video, y ahí vamos a crear las subvistas de nuestra aplicación, vamos a crear un UITextView para poder escribir la tarea que queremos crear, vamos a crear un UIButton para lanzar la acción de que queremos crear una acción, y vamos a crear un UICollectionView para mostrar todas las tareas que vamos creando:

import UIKit

class ListOfTasksView: UIViewController {
    var presenter = TaskPresenter()
    
    private let taskTextView: UITextView = {
        let textView = UITextView()
        textView.font = UIFont.systemFont(ofSize: 18, weight: .regular)
        textView.backgroundColor = .systemGray6
        textView.textColor = .label
        textView.layer.cornerRadius = 12
        textView.layer.borderColor = UIColor.systemGray3.cgColor
        textView.layer.borderWidth = 1
        textView.textContainerInset = UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8)
        textView.translatesAutoresizingMaskIntoConstraints = false
        return textView
    }()
    
    private lazy var createTaskButton: UIButton = {
        let button = UIButton(type: .system)
        button.setTitle("Crear", for: .normal)
        button.titleLabel?.font = UIFont.systemFont(ofSize: 18, weight: .semibold)
        button.setTitleColor(.white, for: .normal)
        button.backgroundColor = .systemBlue
        button.layer.cornerRadius = 12
        button.clipsToBounds = true
        button.addTarget(self, action: #selector(didTapOnCreateTask), for: .touchUpInside)
        button.translatesAutoresizingMaskIntoConstraints = false        
        return button
    }()
    
    private lazy var tasksCollectionView: UICollectionView = {
        let layout = UICollectionViewFlowLayout()
        layout.itemSize = .init(width: 340, height: 80)
        
        let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
        collectionView.translatesAutoresizingMaskIntoConstraints = false
        collectionView.backgroundColor = .systemGroupedBackground
        return collectionView
    }()

   ...

}

Al crear estas 3 subvistas, lo que vamos hacer a continuación, es añadirlas a la jerarquía de vistas y aplicar Auto Layout para crear las constraints, todo esto lo vamos hacer dentro del método viewDidLoad:

override func viewDidLoad() {
    super.viewDidLoad()
    
    [taskTextView,
     createTaskButton,
     tasksCollectionView]
        .forEach(view.addSubview)
    
    NSLayoutConstraint.activate([
        taskTextView.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor, constant: 20),
        taskTextView.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor),
        taskTextView.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor),
        taskTextView.heightAnchor.constraint(equalToConstant: 60),
        
        createTaskButton.topAnchor.constraint(equalTo: taskTextView.bottomAnchor, constant: 6),
        createTaskButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
        createTaskButton.heightAnchor.constraint(equalToConstant: 40),
        createTaskButton.widthAnchor.constraint(equalToConstant: 80),
        
        tasksCollectionView.topAnchor.constraint(equalTo: createTaskButton.bottomAnchor, constant: 12),
        tasksCollectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
        tasksCollectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
        tasksCollectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
    ])
}

Antes de compilar, vamos a resolver el error que nos está dando el compilador. Vamos a crear el método que se lanzará cada vez que pulsemos el Button de crear una tarea. Nos vamos debajo del viewDidLoad y escribimos lo siguiente:

@objc
func didTapOnCreateTask() {
    print("Create Task")
    presenter.create(task: taskTextView.text)
}

Ahora podemos pulsar COMMAND+R y vamos a ver qué resultado nos muestra el simulador. Al hacerlo podemos ver un UITextView con un UIButton, y debajo podemos intuir el UICollectionView que hemos creado.

Una vez tenemos creada esta parte de la UI, de la View, lo que vamos hacer es añadir el Button que se encargará de borrar todas las tareas de golpe. Para hacerlo, lo vamos a añadir dentro del navigationController, pero, nosotros todavía no hemos creado nuestro NavigationController. No te preocupes que es muy sencillo, nos vamos al Storyboard llamado Main, y ahí seleccionamos el único ViewController que aparece. Una vez seleccionado, nos vamos al menú superior y seleccionamos la opción Editor -> Embed In -> Navigation Controller, al hacerlo debe aparter una vista nueva en el Storyboard, esta nueva Vista es el Navigation Controller. Ahora volvemos a nuestro código y vamos a añadir el Button para borrar todas las tareas, para hacerlo, vamos a nuestro método viewDidLoad y añadimos la siguiente línea:

self.navigationItem.rightBarButtonItem = UIBarButtonItem(title: "Borrar Todos",
                                                        style: .done,
                                                        target: presenter,
                                                         action: #selector(presenter.removeAllTasks))

Aquí tenemos un error, para arreglarlo nos vamos al presenter, y justo encima del método removeAllTasks, añadimos la keyword @objc. Si ahora compilamos, podemos ver el button en nuestro NavigationController. Perfecto, ya tenemos nuestra View casi completa.

UICollectionViewCell

Ahora vamos a crear la celda que representará nuestra Tarea, es una celda muy sencilla con un UILabel y 2 UIButton. El UILabel lo usamos para mostrar el texto de la Tarea, y los buttons son para marcar o desmarcar como favorito una tarea y el  otro button para borrar la tarea. Así que, dentro de nuestra carpeta View, vamos a crear una celda llamada TaskCollectionViewCell:

import Foundation
import UIKit

class TaskCollectionViewCell: UICollectionViewCell {
...
}

Ahora, dentro de esta celda voy a crear las subvistas que te comentaba, y en este caso también voy a crear 2 StackView. Voy a poner el modo rápido:

private let titleLabel: UILabel = {
        let label = UILabel()
        label.numberOfLines = 0
        label.translatesAutoresizingMaskIntoConstraints = false
        label.text = "Task Title"
        return label
    }()
    
    private lazy var starButton: UIButton = {
        let button = UIButton(type: .system)
        button.translatesAutoresizingMaskIntoConstraints = false
        let config = UIImage.SymbolConfiguration(pointSize: 24, weight: .medium)
        let starImage = UIImage(systemName: "star", withConfiguration: config)
        button.setImage(starImage, for: .normal)
        button.addTarget(self, action: #selector(didTapOnFavorite), for: .touchDown)
        button.widthAnchor.constraint(equalToConstant: 30).isActive = true
        button.heightAnchor.constraint(equalToConstant: 30).isActive = true
        
        return button
    }()
    
    private lazy var trashButton: UIButton = {
        let button = UIButton(type: .system)
        button.translatesAutoresizingMaskIntoConstraints = false
        let config = UIImage.SymbolConfiguration(pointSize: 24, weight: .medium)
        let trashImage = UIImage(systemName: "trash", withConfiguration: config)
        button.setImage(trashImage, for: .normal)
        button.addTarget(self, action: #selector(didTapOnRemove), for: .touchDown)
        button.widthAnchor.constraint(equalToConstant: 30).isActive = true
        button.heightAnchor.constraint(equalToConstant: 30).isActive = true
        return button
    }()
    
    private lazy var buttonsStackView: UIStackView = {
        let stackView = UIStackView(arrangedSubviews: [starButton, trashButton])
        stackView.translatesAutoresizingMaskIntoConstraints = false
        stackView.axis = .vertical
        stackView.alignment = .trailing
        stackView.distribution = .fillEqually
        stackView.spacing = 8
        return stackView
    }()
    
    private lazy var mainStackView: UIStackView = {
        let stackView = UIStackView(arrangedSubviews: [titleLabel, buttonsStackView])
        stackView.translatesAutoresizingMaskIntoConstraints = false
        stackView.axis = .horizontal
        stackView.alignment = .center
        stackView.distribution = .fill
        stackView.spacing = 8
        return stackView
    }()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        setupViews()
        setupConstraints()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    private func setupViews() {
        backgroundColor = .white
        contentView.addSubview(mainStackView)
    }
    
    private func setupConstraints() {
        NSLayoutConstraint.activate([
            mainStackView.topAnchor.constraint(equalTo: contentView.topAnchor),
            mainStackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 8),
            mainStackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -8),
            mainStackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
        ])
    }
}

A continuación, vamos a arreglar los 2 errores de compilación que tenemos. En este caso debemos crear 2 métodos, uno que se lanzará cuando se pulse el Button de favorito, y otro método que se ejecutará cuando pulsemos el Button de borrar una tarea:

@objc
func didTapOnFavorite() {
    // TODO
}

@objc
func didTapOnRemove() {
    // TODO
}

Si ahora compilamos no deberíamos tener ningún error. En un rato volveremos y crearemos la implementación de estos 2 métodos, ahora vamos a crear un método que nos permitirá configurar nuestra celda, es decir, podremos asignar un texto a nuestro titleLabel, y también podremos asignar la imagen correcta del Button de favorito para saber si una tarea en concreto está marcada como favorita o no:

func configure(id: UUID, text: String, isFavorite: Bool) {
    self.titleLabel.text = text
    
    let starImage = UIImage(systemName: isFavorite ? "star.fill" : "star")
    starButton.setImage(starImage, for: .normal)
}

Perfecto, ya tenemos nuestra celda casi implementada, más adelante volveremos para añadir un par de líneas. Ahora ya estamos listos para registrar nuestra celda en nuestro UICollectionView. Para hacerlo volvemos a la class ListOfTasksView, y dentro de la propiedad tasksCollectionView escribimos la siguiente línea de código:

collectionView.register(TaskCollectionViewCell.self, forCellWithReuseIdentifier: "TaskCollectionViewCell")

Una vez hemos hecho este paso, vamos a nutrir nuestro UICollectionView con el array de tareas, de esta manera se podrán mostrar dentro del CollectionView. Para crear esta conexión, tenemos que conformar el protocolo UICollectionViewDataSource. Dentro del método viewDidLoad añadimos la siguiente línea de código:

tasksCollectionView.dataSource = self

Al hacerlo, obtendremos un error de compilación. Aquí lo que hemos indicado es que ListOftasksView va a conformar el protocolo, pero aún no hemos implementado los métodos obligatorios de este protocolo. Nos vamos abajo del todo del fichero y creamos la siguiente extensión:

extension ListOfTasksView: UICollectionViewDataSource {
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
	// TODO
	}
	
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
    // TODO
	}  
}

Al hacerlo, podemos usar la ayuda de Xcode para que aparezcan los método que debemos implementar. Estos 2 métodos ya los hemos visto varias veces en el canal.  El primero sirve para saber cuántas celdas queremos mostrar en el CollectionView, y serán tantas celdas como tareas tengamos en el Array. Y el segundo método lo necesitamos para poder representar visualmente cada tarea. Vamos a implementarlos para verlo más claro:

func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
    return presenter.tasks.count
}

El primer método va a tener tantas celdas como tareas tengamos en nuestro Presenter

func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
    let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "TaskCollectionViewCell", for: indexPath) as! TaskCollectionViewCell
    
    let task = presenter.tasks[indexPath.row]

    cell.configure(id: task.id, text: task.text, isFavorite: task.isFavorite)
    
    return cell
}

Parece que ya lo tenemos todo conectado, vamos a compilar y vamos a ver qué ocurre. En este caso al escribir una tarea y pulsar el Button de Create no ocurre nada, y esto es normal. Ya que la View está pasando la información al Presenter, y el Presenter al Modelo. Una vez el Modelo crea la nueva tarea, lo comunica al Presenter, pero el Presenter no está notificando a la View para que refresque el contenido de su CollectionView y así mostrar la nueva información. Nos falta hacer este último paso. ¿Cómo lo solucionamos? Necesitamos usar el Delegation Pattern. Vamos a nuestro Presenter y allí creamos un protocolo llamado UI:

protocol UI: AnyObject {
    func update()
}

Este protocolo nos va a servir para notificar a la View que se refresque con la nueva información. Creamos una propiedad en nuestro presenter:

weak var delegate: UI?

Y ahora, en todos los métodos que queramos notificar a la View de que hemos hecho un cambio en nuestra propiedad Tasks, llamamos al delegate, por ejemplo, al final de cada método añadimos la línea delegate?.update(), un ejemeplo:

func create(task: String) {
    guard !task.isEmpty else {
        return
    }
    
    let newTask: Task = .init(text: task,
                              isFavorite: false)
    tasks = taskDatabase.create(task: newTask)
    delegate?.update()
}

Ahora ya hemos acabado con nuestro Presenter, vamos a la View e indicamos que somos el delegado del Presenter, dentro del viewDidLoad añadimos la siguiente línea:

presenter.delegate = self

Y conformamos el protocolo UI que hemos creado en el Presenter, para hacerlo creamos otra extensión de ListOfTasksView:

extension ListOfTasksView: UI {
    func update() {
        taskTextView.text = ""
        tasksCollectionView.reloadData()
    }
}

Si ahora compilamos, vamos a empezar a añadir tareas! funciona perfectamente. pero, ¿qué ocurre si pulsamos el Button de favorito y borrar? No ocurre nada, vamos a crear esta pequeña implementación con muy pocas líneas de código. Y para hacerlo volvemos a nuestro TaskCollectionViewCell. Aquí vamos a crear 3 propiedades nuevas.

var tapOnFavorite: (UUID) -> Void = { _ in }
var tapOnRemove: (UUID) -> Void = { _ in }
    
var taskId: UUID? = nil

Las 2 primeras son 2 closures que nos permitirán avisar a la vista Padre que hemos pulsado el Button de favorito, y el Button de borrar una tarea. Y la 3 propiedad nos servirá para tener el identificador único de la tarea, de esta manera podremos saber qué tarea debe ser actualizada para marcar como favorita o para borrar.

Y ahora más abajo, rellenamos los 2 métodos donde no hemos creado la implementación:

@objc
func didTapOnFavorite() {
    guard let taskId = taskId else { return }
    tapOnFavorite(taskId)
}

@objc
func didTapOnRemove() {
    guard let taskId = taskId else { return }
    tapOnRemove(taskId)
}

Y también seteamos la propiedad taskId dentro del método configure:

self.taskId = id

Una vez tenemos esta parte finalizada, volvemos a nuestra View ListOfTasksView, y dentro del método cellForItemAt, añadimos la siguiente lógica:

cell.tapOnFavorite = { [weak self] taskId in
    self?.presenter.updateFavorite(taskId: taskId)
}

cell.tapOnRemove = { [weak self] taskId in
    self?.presenter.removeTask(taskId: taskId)
}

Vamos a compilar y vamos a ver qué tal nuestra aplicación de tareas. Si añadimos tareas podemos marcarlas como favoritas y eliminarlas. También podemos usar el Button de nuestro NavigationController para eliminar todas de golpe.

Conclusión

Hoy en SwiftBeta hemos aprendido a cómo usar la arquitectura MVP en Swift. Esta arquitectura nos permite tener 3 capas bien diferencias, el Model, la View y el Presenter. Es una arquitectura muy sencilla, que puedes usar para implementar tus aplicaciones en Swift.

Y hasta aquí el video de hoy!