Comparación de las arquitecturas Viper y MVVM: cómo aplicar ambas



Actualmente, VIPER y MVVM son las soluciones arquitectónicas más populares utilizadas en el desarrollo de grandes aplicaciones que requieren la participación en el desarrollo de grandes equipos que están bien probados, respaldados a largo plazo y en constante evolución. En este artículo intentaremos aplicarlos en un pequeño proyecto de prueba, que es una lista de contactos de usuarios con la capacidad de agregar un nuevo contacto. Este artículo tiene más práctica que análisis, y está dirigido principalmente a aquellos que ya están en teoría familiarizados con estas arquitecturas y ahora quisieran entender cómo funciona esto con ejemplos específicos. Sin embargo, una descripción básica de las arquitecturas y su comparación también está presente.


Este artículo es una traducción del artículo de Rafael Sacchi "Comparación de las arquitecturas MVVM y Viper: cuándo usar una u otra" . Desafortunadamente, en algún momento de la creación del artículo, se creó "publicación" en lugar de "traducción", por lo que debe escribir aquí.

Una arquitectura bien diseñada es esencial para garantizar un soporte continuo para su proyecto. En este artículo, veremos las arquitecturas MVVM y VIPER como una alternativa al MVC tradicional.

MVC es un concepto bien conocido para todos aquellos que han estado involucrados en el desarrollo de software durante bastante tiempo. Este patrón divide el proyecto en tres partes: modelo que representa entidades; Ver, que es una interfaz para la interacción del usuario; y Controlador, responsable de garantizar la interacción entre Vista y Modelo. Esta es la arquitectura que Apple nos ofrece para usar en nuestras aplicaciones.

Sin embargo, probablemente sepa que los proyectos vienen con una funcionalidad bastante compleja: soporte para solicitudes de red, análisis, acceso a modelos de datos, conversión de datos para salida, reacción a eventos de interfaz, etc. Como resultado, obtienes enormes controladores que resuelven los problemas anteriores y un montón de código que no se puede reutilizar. En otras palabras, MVC puede ser una pesadilla para un desarrollador con soporte de proyectos a largo plazo. Pero, ¿cómo garantizar una alta modularidad y reutilización en proyectos de iOS?

Veremos dos alternativas muy famosas a la arquitectura MVC: MVVM y VIPER. Ambos son bastante famosos en la comunidad iOS y han demostrado que pueden ser una gran alternativa a MVC. Hablaremos sobre su estructura, escribiremos una aplicación de ejemplo y consideraremos casos en los que es mejor usar una u otra arquitectura.

Ejemplo

Escribiremos una aplicación con una tabla de contactos de usuarios. Puede usar el código de este repositorio . En las carpetas de inicio, el esqueleto básico del proyecto está contenido, y en las carpetas finales hay una aplicación completamente terminada.

La aplicación tendrá dos pantallas: en la primera se mostrará una lista de contactos en forma de tabla, la celda tendrá el nombre y el apellido del contacto, así como la imagen base en lugar de la imagen del usuario.



La segunda pantalla es la pantalla para agregar un nuevo contacto, con los campos de entrada de nombre y apellido y los botones Listo y Cancelar.



MVVM

Cómo funciona

MVVM significa Model-View-ViewModel . Este enfoque difiere de MVC en la lógica de distribución de responsabilidad entre módulos.

  • Modelo : este módulo no es diferente al de MVC. Es responsable de crear modelos de datos y puede contener lógica de negocios. También puede crear clases auxiliares, por ejemplo, como una clase de administrador para administrar objetos en Model y el administrador de red para procesar solicitudes de red y análisis.
  • Ver : Y aquí todo comienza a cambiar. El módulo View en MVVM cubre la interfaz (subclases de archivos UIView, .xib y .storyboard), la lógica de visualización (animación, renderizado) y el manejo de eventos del usuario (clics de botones, etc.) En MVC, View y Controller son responsables de esto. Esto significa que las vistas que tiene permanecerán sin cambios, mientras que ViewController contendrá una pequeña parte de lo que había en MVC y, en consecuencia, disminuirá considerablemente.
  • ViewModel : ahora es el lugar donde se ubicará la mayor parte del código que tenía anteriormente en ViewController. La capa ViewModel solicita datos del Modelo (puede ser una solicitud a una base de datos local o una solicitud de red) y los transfiere nuevamente a la Vista, en el formato en el que se utilizará y se mostrará allí. Pero este es un mecanismo bidireccional, las acciones o los datos ingresados ​​por el usuario pasan a través del ViewModel y actualizan el Modelo. Dado que ViewModel realiza un seguimiento de todo lo que se muestra, es útil utilizar el mecanismo de enlace entre las dos capas.


En comparación con MVC, se está moviendo de una arquitectura que se ve así:



Para el próximo varant de arquitectura:



En el que las clases y subclases de UIView y UIViewController se utilizan para implementar la Vista.

Bueno, ahora al grano. Escribamos un ejemplo de nuestra aplicación usando la arquitectura MVVM.

Aplicación de contactos MVVM

MODELO

La siguiente clase es un modelo de contacto de contacto:

import CoreData open class Contact: NSManagedObject { @NSManaged var firstName: String? @NSManaged var lastName: String? var fullName: String { get { var name = "" if let firstName = firstName { name += firstName } if let lastName = lastName { name += " \(lastName)" } return name } } } 


La clase de contacto tiene los campos firstName , lastName , así como la propiedad calculada fullName .

VER

VIEW incluye: Storyboard principal, con vistas ya colocadas en él; ContactsViewController, que muestra una lista de contactos en una tabla; y AddContactViewController con un par de etiquetas y campos de entrada para agregar el nombre y el apellido del nuevo contacto. Comencemos con el ContactsViewController . Su código se verá así:

 import UIKit class ContactsViewController: UIViewController { @IBOutlet var tableView: UITableView! let contactViewModelController = ContactViewModelController() override func viewDidLoad() { super.viewDidLoad() tableView.tableFooterView = UIView() contactViewModelController.retrieveContacts({ [unowned self] in self.tableView.reloadData() }, failure: nil) } override func prepare(for segue: UIStoryboardSegue, sender: Any?) { let addContactNavigationController = segue.destination as? UINavigationController let addContactVC = addContactNavigationController?.viewControllers[0] as? AddContactViewController addContactVC?.contactsViewModelController = contactViewModelController addContactVC?.didAddContact = { [unowned self] (contactViewModel, index) in let indexPath = IndexPath(row: index, section: 0) self.tableView.beginUpdates() self.tableView.insertRows(at: [indexPath], with: .left) self.tableView.endUpdates() } } } extension ContactsViewController: UITableViewDataSource { func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "ContactCell") as? ContactsTableViewCell guard let contactsCell = cell else { return UITableViewCell() } contactsCell.cellModel = contactViewModelController.viewModel(at: (indexPath as NSIndexPath).row) return contactsCell } func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return contactViewModelController.contactsCount } } 


Incluso con una mirada superficial, está claro que esta clase implementa en su mayor parte tareas de interfaz. También tiene navegación en el método prepareForSegue (: :) , y este es exactamente el momento que cambiará en VIPER al agregar una capa de enrutador.

Echemos un vistazo más de cerca a la extensión de clase que implementa el protocolo UITableViewDataSource. Las funciones no funcionan directamente con el modelo de contacto del usuario de contacto en la capa Modelo; en su lugar, reciben datos (representados por la estructura ContactViewModel) en la forma en que se mostrarán, ya formateados con ViewModelController.

Lo mismo sucede en un circuito, que comienza inmediatamente después de crear un contacto. Su única tarea es agregar una fila a la tabla y actualizar la interfaz.

Ahora necesita establecer una relación entre la subclase de UITableViewCell y ViewModel. Esto se vería como la clase de celda de la tabla ContactsTableViewCell :

 import UIKit class ContactsTableViewCell: UITableViewCell { var cellModel: ContactViewModel? { didSet { bindViewModel() } } func bindViewModel() { textLabel?.text = cellModel?.fullName } } 


Y también lo es la clase AddContactViewController :

 import UIKit class AddContactViewController: UIViewController { @IBOutlet var firstNameTextField: UITextField! @IBOutlet var lastNameTextField: UITextField! var contactsViewModelController: ContactViewModelController? var didAddContact: ((ContactViewModel, Int) -> Void)? override func viewDidLoad() { super.viewDidLoad() firstNameTextField.becomeFirstResponder() } @IBAction func didClickOnDoneButton(_ sender: UIBarButtonItem) { guard let firstName = firstNameTextField.text, let lastName = lastNameTextField.text else { return } if firstName.isEmpty || lastName.isEmpty { showEmptyNameAlert() return } dismiss(animated: true) { [unowned self] in self.contactsViewModelController?.createContact(firstName: firstName, lastName: lastName, success: self.didAddContact, failure: nil) } } @IBAction func didClickOnCancelButton(_ sender: UIBarButtonItem) { dismiss(animated: true, completion: nil) } fileprivate func showEmptyNameAlert() { showMessage(title: "Error", message: "A contact must have first and last names") } fileprivate func showMessage(title: String, message: String) { let alertView = UIAlertController(title: title, message: message, preferredStyle: .alert) alertView.addAction(UIAlertAction(title: "Ok", style: .destructive, handler: nil)) present(alertView, animated: true, completion: nil) } } 


Y de nuevo, principalmente el trabajo con la interfaz de usuario está sucediendo aquí. Tenga en cuenta que AddContactViewController delega la funcionalidad de creación de contactos al ViewModelController en la función didClickOnDoneButton (:) .

VER MODELO

Es hora de hablar sobre la capa ViewModel completamente nueva para nosotros. Primero, cree una clase de contacto ContactViewModel que proporcionará la vista que necesitamos mostrar, y se definirán las funciones <and> con parámetros para ordenar los contactos:

 public struct ContactViewModel { var fullName: String } public func <(lhs: ContactViewModel, rhs: ContactViewModel) -> Bool { return lhs.fullName.lowercased() < rhs.fullName.lowercased() } public func >(lhs: ContactViewModel, rhs: ContactViewModel) -> Bool { return lhs.fullName.lowercased() > rhs.fullName.lowercased() } 


El código de ContactViewModelController se verá así:

 class ContactViewModelController { fileprivate var contactViewModelList: [ContactViewModel] = [] fileprivate var dataManager = ContactLocalDataManager() var contactsCount: Int { return contactViewModelList.count } func retrieveContacts(_ success: (() -> Void)?, failure: (() -> Void)?) { do { let contacts = try dataManager.retrieveContactList() contactViewModelList = contacts.map() { ContactViewModel(fullName: $0.fullName) } success?() } catch { failure?() } } func viewModel(at index: Int) -> ContactViewModel { return contactViewModelList[index] } func createContact(firstName: String, lastName: String, success: ((ContactViewModel, Int) -> Void)?, failure: (() -> Void)?) { do { let contact = try dataManager.createContact(firstName: firstName, lastName: lastName) let contactViewModel = ContactViewModel(fullName: contact.fullName) let insertionIndex = contactViewModelList.insertionIndex(of: contactViewModel) { $0 < $1 } contactViewModelList.insert(contactViewModel, at: insertionIndex) success?(contactViewModel, insertionIndex) } catch { failure?() } } } 


Nota: MVVM no proporciona una definición exacta de cómo crear un ViewModel. Cuando quiero crear una arquitectura más estratificada, prefiero crear un ViewModelController que interactuará con la capa Modelo y será responsable de crear los objetos ViewModel.

Lo principal que es muy fácil de recordar: la capa ViewModel no debe participar en el trabajo con la interfaz de usuario. Para evitar esto, es mejor no importar nunca UIKit a un archivo con ViewModel.

La clase ContactViewModelController solicita contactos del almacenamiento local e intenta no afectar la capa Modelo. Devuelve los datos en el formato que la vista requiere que se muestre y notifica la vista cuando se agrega un nuevo contacto y los datos cambian.

En la vida real, esto sería una solicitud de red, y no una solicitud a la base de datos local, pero en ninguno de los casos debería ser parte de ViewModel; tanto el trabajo en red como el trabajo con la base de datos local deben proporcionarse utilizando sus propios administradores ( gerentes).

Eso es todo sobre MVVM. Quizás este enfoque le parezca más comprobable, compatible y distribuido que MVC. Ahora hablemos de VIPER y veamos cómo se diferencia de MVVM.

VIPER

Cómo funciona

VIPER es una implementación de arquitectura limpia para proyectos de iOS. Su estructura consiste en: Vista, Interactor, Presentador, Entidad y Enrutador. Esta es realmente una arquitectura muy distribuida y modular que le permite compartir la responsabilidad, está muy bien cubierta por las pruebas unitarias y hace que su código sea reutilizable.

  • Ver : Una capa de interfaz que generalmente implica archivos UIKit (incluido el UIViewController). Es comprensible que en sistemas más distribuidos, las subclases de UIViewController se relacionen con View. En VIPER, las cosas son casi lo mismo que en MVVM: View es responsable de mostrar lo que Presenter proporciona y de transmitir información o acciones ingresadas por el usuario a Presenter.
  • Interactor : contiene la lógica de negocios necesaria para que la aplicación funcione. Interactor es responsable de recuperar datos del Modelo (solicitudes locales o de red) y su implementación no está relacionada de ninguna manera con la interfaz de usuario. Es importante recordar que los administradores locales y de red no son parte de VIPER, sino que se tratan como dependencias separadas.
  • Presentador : responsable de formatear los datos para mostrar en la vista. En MVVM en nuestro ejemplo, ViewModelController fue responsable de esto. Presenter recibe datos de Interactor, crea una instancia de ViewModel (una clase formateada para una visualización correcta) y la pasa a View. También responde a la entrada de datos del usuario, solicita datos adicionales de la base de datos o viceversa, se los pasa.
  • Entidad : Forma parte de la responsabilidad de la capa Modelo, que se usa en otras arquitecturas. La entidad es un objeto de datos simple, sin lógica empresarial, administrado por un tractor en línea y varios administradores de datos.
  • Enrutador : toda la lógica de navegación de la aplicación. Puede parecer que esta no es la capa más importante, pero si necesita, por ejemplo, reutilizar la misma vista tanto en el iPhone como en la aplicación para iPad, lo único que puede cambiar es cómo aparecen sus vistas en la pantalla. Esto le permite no tocar más capas, excepto el enrutador, que será responsable de esto en cada caso.


En comparación con MVVM, VIPER tiene varias diferencias clave en la distribución de responsabilidad:

- él tiene un enrutador, una capa separada responsable de la navegación

- Las entidades son simples objetos de datos, que redistribuyen la responsabilidad de acceder a los datos del Modelo al Interactor.

- Las responsabilidades de ViewModelController se comparten entre Interactor y Presenter

Y ahora repitamos la misma aplicación, pero ya en VIPER. Pero para facilitar la comprensión, solo haremos un controlador con contactos. Puede encontrar el código del controlador para agregar un nuevo contacto en el proyecto utilizando el enlace (carpeta de inicio de contactos VIPER en este repositorio ).

Nota : Si decide realizar su proyecto en VIPER, entonces no debe intentar crear todos los archivos manualmente; puede usar uno de los generadores de código, por ejemplo, como VIPER Gen o Generamba (proyecto Rambler) .

Aplicación de contactos VIPER

VER

VIEW está representado por elementos de Main.storyboard y la clase ContactListView. VIEW es muy pasivo; sus únicas tareas son transferir eventos de interfaz al presentador y actualizar su estado, previa notificación del presentador. Así es como se ve el código ContactListView :

 import UIKit class ContactListView: UIViewController { @IBOutlet var tableView: UITableView! var presenter: ContactListPresenterProtocol? var contactList: [ContactViewModel] = [] override func viewDidLoad() { super.viewDidLoad() presenter?.viewDidLoad() tableView.tableFooterView = UIView() } @IBAction func didClickOnAddButton(_ sender: UIBarButtonItem) { presenter?.addNewContact(from: self) } } extension ContactListView: ContactListViewProtocol { func reloadInterface(with contacts: [ContactViewModel]) { contactList = contacts tableView.reloadData() } func didInsertContact(_ contact: ContactViewModel) { let insertionIndex = contactList.insertionIndex(of: contact) { $0 < $1 } contactList.insert(contact, at: insertionIndex) let indexPath = IndexPath(row: insertionIndex, section: 0) tableView.beginUpdates() tableView.insertRows(at: [indexPath], with: .right) tableView.endUpdates() } } extension ContactListView: UITableViewDataSource { func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { guard let cell = tableView.dequeueReusableCell(withIdentifier: "ContactCell") else { return UITableViewCell() } cell.textLabel?.text = contactList[(indexPath as NSIndexPath).row].fullName return cell } func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return contactList.count } } 


Ver envía los eventos viewDidLoad y didClickOnAddButton al presentador. En el primer evento, el presentador solicitará datos de Interactor, y en el segundo, el presentador le pedirá al enrutador que cambie al controlador para agregar un nuevo contacto.

Los métodos de protocolo ContactListViewProtocol se llaman desde Presenter cuando se solicita una lista de contactos o cuando se agrega un nuevo contacto. En cualquier caso, los datos en la Vista contienen solo la información necesaria para la visualización.

También en la Vista hay métodos que implementan el protocolo UITableViewDataSource que puebla la tabla con los datos recibidos.

INTERACTOR

Interactor en nuestro ejemplo es bastante simple. Todo lo que hace es solicitar datos a través del administrador de la base de datos local, y no le importa lo que use este administrador, CoreData, Realm o cualquier otra solución. El código en ContactListInteractor será el siguiente:

 class ContactListInteractor: ContactListInteractorInputProtocol { weak var presenter: ContactListInteractorOutputProtocol? var localDatamanager: ContactListLocalDataManagerInputProtocol? func retrieveContacts() { do { if let contactList = try localDatamanager?.retrieveContactList() { presenter?.didRetrieveContacts(contactList) } else { presenter?.didRetrieveContacts([]) } } catch { presenter?.didRetrieveContacts([]) } } } 


Después de que Interactor recibe los datos solicitados, notifica al presentador. Además, como opción, Interactor puede transmitir un error al presentador, que luego tendrá que formatear el error en una vista adecuada para mostrar en la vista.

Nota : Como habrás notado, cada capa en VIPER implementa un protocolo. Como resultado, las clases dependen de abstracciones, y no de una implementación particular, cumpliendo así el principio de inversión de dependencia (uno de los principios de SOLID).

Presentador

El elemento más importante de la arquitectura. Toda la comunicación entre la Vista y el resto de las capas (Interactor y Enrutador) pasa por el Presentador. ContactListPresenter Code:

 class ContactListPresenter: ContactListPresenterProtocol { weak var view: ContactListViewProtocol? var interactor: ContactListInteractorInputProtocol? var wireFrame: ContactListWireFrameProtocol? func viewDidLoad() { interactor?.retrieveContacts() } func addNewContact(from view: ContactListViewProtocol) { wireFrame?.presentAddContactScreen(from: view) } } extension ContactListPresenter: ContactListInteractorOutputProtocol { func didRetrieveContacts(_ contacts: [Contact]) { view?.reloadInterface(with: contacts.map() { return ContactViewModel(fullName: $0.fullName) }) } } extension ContactListPresenter: AddModuleDelegate { func didAddContact(_ contact: Contact) { let contactViewModel = ContactViewModel(fullName: contact.fullName) view?.didInsertContact(contactViewModel) } func didCancelAddContact() {} } 


Después de cargar View, notifica a Presenter, que a su vez solicita datos a través de Interactor. Cuando el usuario hace clic en el botón Agregar nuevo contacto, Ver notifica al presentador, que envía una solicitud para abrir la pantalla Agregar nuevo contacto en el enrutador.

Presenter también formatea los datos y los devuelve a la Vista después de consultar la lista de contactos. También es responsable de implementar el protocolo AddModuleDelegate. Esto significa que Presenter recibirá una notificación cuando se agregue un nuevo contacto, prepare los datos del contacto para mostrar y transfiera a Ver.

Como habrás notado, Presenter tiene muchas posibilidades de volverse bastante engorroso. Si existe tal posibilidad, el Presentador se puede dividir en dos partes: el Presentador, que solo recibe datos, los formatea para mostrarlos y los pasa a Ver; y un controlador de eventos que responderá a las acciones del usuario.

ENTIDAD

Esta capa es similar a la capa Modelo en MVVM. En nuestra aplicación, está representado por la clase de contacto y las funciones de definición de operador <y>. El contenido del contacto se verá así:

 import CoreData open class Contact: NSManagedObject { var fullName: String { get { var name = "" if let firstName = firstName { name += firstName } if let lastName = lastName { name += " " + lastName } return name } } } public struct ContactViewModel { var fullName = "" } public func <(lhs: ContactViewModel, rhs: ContactViewModel) -> Bool { return lhs.fullName.lowercased() < rhs.fullName.lowercased() } public func >(lhs: ContactViewModel, rhs: ContactViewModel) -> Bool { return lhs.fullName.lowercased() > rhs.fullName.lowercased() } 


ContactViewModel contiene los campos que presenta el presentador (formatos) que muestra la vista. La clase Contact es una subclase de NSManagedObject que contiene los mismos campos que en el modelo CoreData.

ROUTER

Y finalmente, la última, pero ciertamente no importante, capa. Toda la responsabilidad de la navegación recae en Presenter y WireFrame. El presentador recibe un evento del usuario y sabe cuándo hacer la transición, y WireFrame sabe cómo y dónde hacer esta transición. Para que no se confunda, en este ejemplo, la capa Router está representada por la clase ContactListWireFrame y se denomina WireFrame en el texto. ContactListWireFrame Code:

 import UIKit class ContactListWireFrame: ContactListWireFrameProtocol { class func createContactListModule() -> UIViewController { let navController = mainStoryboard.instantiateViewController(withIdentifier: "ContactsNavigationController") if let view = navController.childViewControllers.first as? ContactListView { let presenter: ContactListPresenterProtocol & ContactListInteractorOutputProtocol = ContactListPresenter() let interactor: ContactListInteractorInputProtocol = ContactListInteractor() let localDataManager: ContactListLocalDataManagerInputProtocol = ContactListLocalDataManager() let wireFrame: ContactListWireFrameProtocol = ContactListWireFrame() view.presenter = presenter presenter.view = view presenter.wireFrame = wireFrame presenter.interactor = interactor interactor.presenter = presenter interactor.localDatamanager = localDataManager return navController } return UIViewController() } static var mainStoryboard: UIStoryboard { return UIStoryboard(name: "Main", bundle: Bundle.main) } func presentAddContactScreen(from view: ContactListViewProtocol) { guard let delegate = view.presenter as? AddModuleDelegate else { return } let addContactsView = AddContactWireFrame.createAddContactModule(with: delegate) if let sourceView = view as? UIViewController { sourceView.present(addContactsView, animated: true, completion: nil) } } } 


Como WireFrame es responsable de crear el módulo, será conveniente configurar todas las dependencias aquí. Cuando desee abrir otro controlador, la función que abre el nuevo controlador recibe como argumento el objeto que lo abrirá, y crea un nuevo controlador utilizando su WireFrame. Además, al crear un nuevo controlador, los datos necesarios se transfieren a él, en este caso solo el delegado (Presentador del controlador con contactos) para recibir el contacto creado.

La capa de enrutador ofrece una buena oportunidad para evitar el uso de segues (transiciones) en guiones gráficos y organizar todo el código de navegación. Dado que los guiones gráficos no proporcionan una solución compacta para transferir datos entre controladores, nuestra implementación de navegación no agregará código adicional. Todo lo que obtenemos es solo la mejor reutilización.


Resumen :

Puede encontrar ambos proyectos en este repositorio .

Como puede ver, MVVM y VIPER, aunque diferentes, no son únicos. MVVM nos dice que además de Ver y Modelo, también debería haber una capa ViewModel. Pero no se dice nada sobre cómo se debe crear esta capa, ni sobre cómo se solicitan los datos: la responsabilidad de esta capa no está claramente definida. Hay muchas formas de implementarlo y puede usar cualquiera de ellas.

VIPER, por otro lado, es una arquitectura bastante única. Consiste en muchas capas, cada una de las cuales tiene un área de responsabilidad bien definida y menos de MVVM está influenciada por el desarrollador.

Cuando se trata de elegir una arquitectura, generalmente no existe la única solución correcta, pero aún así intentaré dar algunos consejos. Si tiene un proyecto grande y largo, con requisitos claros y desea tener muchas oportunidades para reutilizar componentes, entonces VIPER será la mejor solución. Una delimitación más clara de la responsabilidad hace posible organizar mejor las pruebas y mejorar la reutilización.

Source: https://habr.com/ru/post/440904/


All Articles