Comparação das arquiteturas Viper e MVVM: como aplicar os dois



Atualmente, o VIPER e o MVVM são as soluções arquiteturais mais populares usadas no desenvolvimento de grandes aplicativos que requerem participação no desenvolvimento de grandes equipes bem testadas, com suporte a longo prazo e em constante evolução. Neste artigo, tentaremos aplicá-los em um pequeno projeto de teste, que é uma lista de contatos do usuário com a capacidade de adicionar um novo contato. Este artigo tem mais prática do que analítica e destina-se principalmente àqueles que já estão em teoria familiarizados com essas arquiteturas e agora gostariam de entender como isso funciona com exemplos específicos. No entanto, uma descrição básica das arquiteturas e sua comparação também está presente.


Este artigo é uma tradução do artigo de Rafael Sacchi “Comparando as arquiteturas MVVM e Viper: quando usar uma ou outra” . Infelizmente, em algum momento da criação do artigo, a "publicação" foi criada em vez de a "tradução", então você deve escrever aqui.

Uma arquitetura bem projetada é essencial para garantir suporte contínuo ao seu projeto. Neste artigo, veremos as arquiteturas MVVM e VIPER como uma alternativa ao MVC tradicional.

MVC é um conceito bem conhecido para todos aqueles que estão envolvidos no desenvolvimento de software há algum tempo. Esse padrão divide o projeto em três partes: Modelo representando entidades; View, que é uma interface para interação do usuário; e Controller, responsável por garantir a interação entre o View e o Model. Essa é a arquitetura que a Apple nos oferece para usar em nossos aplicativos.

No entanto, você provavelmente sabe que os projetos possuem muitas funcionalidades complexas: suporte para solicitações de rede, análise, acesso a modelos de dados, conversão de dados para saída, reação a eventos de interface etc. Como resultado, você obtém grandes controladores que resolvem as tarefas acima e um monte de código que não pode ser reutilizado. Em outras palavras, o MVC pode ser um pesadelo para um desenvolvedor com suporte a projetos de longo prazo. Mas como garantir alta modularidade e reutilização em projetos iOS?

Veremos duas alternativas muito famosas à arquitetura MVC: MVVM e VIPER. Ambos são bastante famosos na comunidade iOS e provaram que podem ser uma ótima alternativa ao MVC. Falaremos sobre sua estrutura, escreveremos um exemplo de aplicativo e consideraremos casos em que é melhor usar uma ou outra arquitetura.

Exemplo

Escreveremos um aplicativo com uma tabela de contatos do usuário. Você pode usar o código deste repositório . Nas pastas Starter, o esqueleto básico do projeto está contido e, nas pastas Final, um aplicativo totalmente concluído.

O aplicativo terá duas telas: na primeira, haverá uma lista de contatos exibidos em uma tabela; na célula, haverá o nome e o sobrenome do contato, além da imagem de base em vez da imagem do usuário.



A segunda tela é a tela para adicionar um novo contato, com os campos de entrada de nome e sobrenome e os botões Concluído e Cancelar.



MVVM

Como funciona:

MVVM significa Model-View-ViewModel . Essa abordagem difere do MVC na lógica da distribuição de responsabilidades entre os módulos.

  • Modelo : Este módulo não é diferente daquele no MVC. Ele é responsável pela criação de modelos de dados e pode conter lógica de negócios. Você também pode criar classes auxiliares, por exemplo, como uma classe gerenciadora para gerenciar objetos no Modelo e gerenciador de rede para processar solicitações e análise de rede.
  • Ver : E aqui tudo começa a mudar. O módulo View no MVVM abrange a interface (subclasses de arquivos UIView, .xib e .storyboard), lógica de exibição (animação, renderização) e manipulação de eventos do usuário (cliques no botão etc.) No MVC, View e Controller são responsáveis ​​por isso. Isso significa que as visualizações que você possui permanecerão inalteradas, enquanto o ViewController conterá uma pequena parte do que havia no MVC e, consequentemente, diminuirá bastante.
  • ViewModel : agora é o local onde a maior parte do código que você tinha anteriormente no ViewController estará localizada. A camada ViewModel solicita dados do Modelo (pode ser uma solicitação para um banco de dados local ou uma solicitação de rede) e os transfere de volta para a View, no formato em que serão usados ​​e exibidos lá. Mas este é um mecanismo bidirecional, ações ou dados inseridos pelo usuário passam pelo ViewModel e atualizam o modelo. Como o ViewModel controla tudo o que é exibido, é útil usar o mecanismo de vinculação entre as duas camadas.


Comparado ao MVC, você está migrando de uma arquitetura parecida com esta:



Para a próxima variável de arquitetura:



Na qual as classes e subclasses de UIView e UIViewController são usadas para implementar a View.

Bem, agora ao ponto. Vamos escrever um exemplo de nosso aplicativo usando a arquitetura MVVM.

Aplicativo MVVM Contacts

MODELO

A classe a seguir é um modelo de contato de contato:

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 } } } 


A classe de contato possui os campos firstName , lastName e a propriedade fullName calculada.

VER

VIEW inclui: Storyboard principal, com vistas já colocadas; ContactsViewController, que exibe uma lista de contatos em uma tabela; e AddContactViewController com um par de rótulos e campos de entrada para adicionar o nome e o sobrenome do novo contato. Vamos começar com o ContactsViewController . Seu código ficará assim:

 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 } } 


Mesmo com um olhar superficial, fica claro que essa classe é implementada na maioria das tarefas da interface. Ele também possui navegação no método prepareForSegue (: :) - e este é exatamente o momento que será alterado no VIPER ao adicionar uma camada de roteador.

Vamos dar uma olhada na extensão da classe que implementa o protocolo UITableViewDataSource. As funções não funcionam diretamente com o modelo de contato do usuário Contato na camada Modelo. Em vez disso, eles recebem dados (representados pela estrutura ContactViewModel) no formato em que serão exibidos, já formatados usando o ViewModelController.

O mesmo acontece em um circuito, que inicia imediatamente após a criação de um contato. Sua única tarefa é adicionar uma linha à tabela e atualizar a interface.

Agora você precisa estabelecer um relacionamento entre a subclasse de UITableViewCell e ViewModel. Isso seria semelhante à classe de célula da tabela ContactsTableViewCell :

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


E a classe AddContactViewController também é:

 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) } } 


E, novamente, o trabalho principalmente com a interface do usuário está acontecendo aqui. Observe que AddContactViewController delega a funcionalidade de criação de contato para o ViewModelController na função didClickOnDoneButton (:) .

VER MODELO

É hora de falar sobre a nova camada ViewModel para nós. Primeiro, crie uma classe de contato ContactViewModel que fornecerá a exibição que precisamos exibir e as funções <and> com parâmetros serão definidas para a classificação de contatos:

 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() } 


O código ContactViewModelController terá a seguinte aparência:

 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: O MVVM não fornece uma definição exata de como criar um ViewModel. Quando quero criar uma arquitetura mais em camadas, prefiro criar um ViewModelController que irá interagir com a camada Model e será responsável por criar os objetos ViewModel.

O principal é muito fácil de lembrar: a camada ViewModel não deve estar envolvida no trabalho com a interface do usuário. Para evitar isso, é melhor nunca importar o UIKit para um arquivo com o ViewModel.

A classe ContactViewModelController solicita contatos do armazenamento local e tenta não afetar a camada Modelo. Ele retorna os dados no formato que a visualização requer para ser exibida e notifica a visualização quando um novo contato é adicionado e os dados são alterados.

Na vida real, isso seria uma solicitação de rede, e não uma solicitação ao banco de dados local, mas em nenhum dos casos nenhum deveria fazer parte do ViewModel - o trabalho em rede e o trabalho com o banco de dados local devem ser fornecidos usando seus próprios gerentes ( gerentes).

Isso é tudo sobre o MVVM. Talvez essa abordagem pareça mais testável, suportada e distribuída que o MVC. Agora vamos falar sobre o VIPER e ver como ele difere do MVVM.

VIPER

Como funciona:

VIPER é uma implementação de Arquitetura Limpa para projetos iOS. Sua estrutura consiste em: Exibir, Interagir, Apresentador, Entidade e Roteador. Essa é realmente uma arquitetura modular e muito distribuída, que permite compartilhar responsabilidades, é muito bem coberta por testes de unidade e torna seu código reutilizável.

  • Exibição : uma camada de interface que geralmente implica arquivos UIKit (incluindo o UIViewController). É compreensível que em sistemas mais distribuídos, as subclasses do UIViewController estejam relacionadas ao View. No VIPER, as coisas são quase as mesmas que no MVVM: o View é responsável por exibir o que o Presenter fornece e por transmitir informações ou ações inseridas pelo usuário ao Presenter.
  • Interator : Contém a lógica de negócios necessária para o aplicativo funcionar. O Interactor é responsável por recuperar dados do Model (solicitações de rede ou locais) e sua implementação não está relacionada à interface do usuário. É importante lembrar que os gerentes locais e de rede não fazem parte do VIPER, mas são tratados como dependências separadas.
  • Apresentador : Responsável pela formatação dos dados a serem exibidos na Visualização. No MVVM em nosso exemplo, o ViewModelController foi responsável por isso. O Presenter recebe dados do Interactor, cria uma instância do ViewModel (uma classe formatada para exibição correta) e os passa para o View. Ele também responde à entrada de dados do usuário, solicita dados adicionais do banco de dados ou vice-versa, passa-os para ela.
  • Entidade : faz parte da responsabilidade da camada Modelo, que é usada em outras arquiteturas. Entidade é um objeto de dados simples, sem lógica de negócios, gerenciado por um trator online e vários gerenciadores de dados.
  • Roteador : Toda a lógica de navegação do aplicativo. Pode parecer que essa não é a camada mais importante, mas se você precisar, por exemplo, reutilizar a mesma exibição no iPhone e no aplicativo para iPad, a única coisa que pode mudar é como as exibições são exibidas na tela. Isso permite que você não toque em mais camadas, exceto o roteador, que será responsável por isso em cada caso.


Comparado ao MVVM, o VIPER possui várias diferenças importantes na distribuição de responsabilidades:

- ele tem um roteador, uma camada separada responsável pela navegação

- Entidades são objetos de dados simples, redistribuindo a responsabilidade de acessar dados do Model para o Interactor

- As responsabilidades do ViewModelController são compartilhadas entre o Interactor e o Presenter

E agora vamos repetir o mesmo aplicativo, mas já no VIPER. Mas, para facilitar a compreensão, faremos apenas um controlador com contatos. Você pode encontrar o código do controlador para adicionar um novo contato no projeto usando o link (pasta VIPER Contacts Starter neste repositório ).

Nota : Se você decidir criar seu projeto no VIPER, não tente criar todos os arquivos manualmente - você pode usar um dos geradores de código, por exemplo, como VIPER Gen ou Generamba (projeto Rambler) .

VIPER Contacts App

VER

VIEW é representado por elementos do Main.storyboard e da classe ContactListView. VIEW é muito passivo; suas únicas tarefas são transferir eventos da interface para o Presenter e atualizar seu estado, mediante notificação do Presenter. É assim que o código ContactListView se parece:

 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 } } 


O View envia os eventos viewDidLoad e didClickOnAddButton ao Presenter. No primeiro evento, o Presenter solicitará dados do Interactor e, no segundo, o Presenter solicitará que o Roteador mude para o controlador para adicionar um novo contato.

Os métodos do protocolo ContactListViewProtocol são chamados no Presenter quando uma lista de contatos é solicitada ou quando um novo contato é adicionado. Nos dois casos, os dados na Visualização contêm apenas as informações necessárias para exibição.

Também na exibição estão os métodos que implementam o protocolo UITableViewDataSource que preenchem a tabela com os dados recebidos.

INTERACTOR

Interator em nosso exemplo é bastante simples. Tudo o que ele faz é solicitar dados através do gerenciador de banco de dados local, e não importa para ele o que esse gerenciador, CoreData, Realm ou qualquer outra solução usa. O código em ContactListInteractor será o seguinte:

 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([]) } } } 


Após o Interactor receber os dados solicitados, ele notifica o Presenter. Além disso, como opção, o Interactor pode transmitir um erro ao Presenter, que precisará formatar o erro em uma exibição adequada para exibição em View.

Nota : Como você deve ter notado, cada camada no VIPER implementa um protocolo. Como resultado, as classes dependem de abstrações e não de uma implementação específica, atendendo ao princípio da inversão de dependência (um dos princípios do SOLID).

APRESENTADOR

O elemento mais importante da arquitetura. Toda a comunicação entre o View e o restante das camadas (Interactor e Roteador) passa pelo Presenter. Código da Empresa :

 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() {} } 


Depois que o View é carregado, ele notifica o Presenter, que, por sua vez, solicita dados através do Interactor. Quando o usuário clica no botão Adicionar novo contato, o View notifica o Presenter, que envia uma solicitação para abrir a tela Adicionar novo contato no roteador.

O Presenter também formata os dados e os retorna para a tela depois de consultar a lista de contatos. Ele também é responsável pela implementação do protocolo AddModuleDelegate. Isso significa que o Presenter receberá uma notificação quando um novo contato for adicionado, prepare os dados do contato para exibição e transfira para Exibir.

Como você deve ter notado, o Presenter tem todas as chances de se tornar bastante complicado. Se houver essa possibilidade, o Presenter pode ser dividido em duas partes: O Presenter, que recebe apenas dados, formata-os para exibição e os passa para View; e um manipulador de eventos que responderá às ações do usuário.

ENTITY

Essa camada é semelhante à camada Model no MVVM. Em nossa aplicação, ele é representado pela classe Contact e pelas funções de definição do operador <e>. O conteúdo do contato ficará assim:

 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 contém os campos que o Presenter preenche (formata) que a Exibição exibe. A classe Contact é uma subclasse de NSManagedObject que contém os mesmos campos que no modelo CoreData.

ROUTER

E, finalmente, a última camada, mas certamente não em importância. Toda responsabilidade pela navegação é do Presenter e do WireFrame. O Presenter recebe um evento do usuário e sabe quando fazer a transição, e o WireFrame sabe como e onde fazer essa transição. Para que você não fique confuso, neste exemplo, a camada Roteador é representada pela classe ContactListWireFrame e é referida como WireFrame no texto. Código do quadro:

 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 o WireFrame é responsável pela criação do módulo, será conveniente configurar todas as dependências aqui. Quando você deseja abrir outro controlador, a função que abre o novo controlador recebe como argumento o objeto que o abrirá e cria um novo controlador usando seu WireFrame. Além disso, ao criar um novo controlador, os dados necessários são transferidos para ele; nesse caso, apenas o delegado (Apresentador do controlador com contatos) para receber o contato criado.

A camada Roteador oferece uma boa oportunidade para evitar o uso de segues (transições) nos storyboards e organizar toda a navegação de código. Como os storyboards não fornecem uma solução compacta para a transferência de dados entre controladores, nossa implementação de navegação não adicionará código extra. Tudo o que temos é apenas a melhor reutilização.


Resumo :

Você pode encontrar os dois projetos neste repositório .

Como você pode ver, o MVVM e o VIPER, embora diferentes, não são exclusivos. O MVVM nos diz que além de View e Model, também deve haver uma camada ViewModel. Mas nada é dito sobre como essa camada deve ser criada, nem sobre como os dados são solicitados - a responsabilidade por essa camada não está claramente definida. Existem várias maneiras de implementá-lo e você pode usá-las.

VIPER, por outro lado, é uma arquitetura bastante única. Ele consiste em várias camadas, cada uma com uma área de responsabilidade bem definida e menor que o MVVM é influenciada pelo desenvolvedor.

Quando se trata de escolher uma arquitetura, geralmente não existe a única solução certa, mas ainda tentarei dar algumas dicas. Se você tem um projeto grande e demorado, com requisitos claros e deseja ter ampla oportunidade de reutilizar componentes, o VIPER será a melhor solução. Uma definição mais clara da responsabilidade torna possível organizar melhor os testes e melhorar a reutilização.

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


All Articles