Dentro da área de desenvolvimento iOS, é possível trabalhar com diferentes arquiteturas de projeto, mesmo algumas sendo mais utilizadas que outras, entender minimamente as diferenças pode te ajudar a entender melhor a arquitetura que você trabalha hoje.
Por que VIP, mesmo sendo mais verbosa que MVVM, por exemplo?
- Separação clara de responsabilidades
- Código mais testável
- Ideal para lógicas de negócio complexas e projetos robustos
- Mesmo que você não a use no dia a dia, entender VIP melhora seu MVVM
Vamos implementar um app que exibe uma tela de Artigos (fazendo uma chamada à API)
Nesse projeto, usarei view code, caso você seja novo nisso, aqui está um link de como remover o storyboard do projeto
Talvez pela versão do Xcode dele, mas o campo do passo 4 não apareceu pra mim, ao invés disso, tive que remover um outro campo no Info.plist que estava nomeando o storyboard, pode ser que aconteça com você também.
- Modelando a resposta da API
Considerando o JSON retornado:
Nosso model em Swift será assim:
struct Article: Codable {
let id: Int
let title: String
let description: String
let readablePublishDate: String
let url: String
let coverImage: String?
let tags: String
let user: User
enum CodingKeys: String, CodingKey {
case id, title, description, url, tags, user
case readablePublishDate = "readable_publish_date"
case coverImage = "cover_image"
}
}
struct User: Codable {
let name: String
let username: String
let profileImage: String
enum CodingKeys: String, CodingKey {
case name, username
case profileImage = "profile_image"
}
}
- Criando o Worker (Service)
É responsável por:
- Fazer requests
- Decodificar respostas
- Comunicação com o Interactor
protocol ArticlesWorkerProtocol {
func fetchArticles(completion: @escaping (Result<[Article], Error>) -> Void)
}
class ArticlesWorker: ArticlesWorkerProtocol {
func fetchArticles(completion: @escaping (Result<[Article], Error>) -> Void) {
guard let url = URL(string: "https://dev.to/api/articles") else {
completion(.failure(NetworkError.invalidURL))
return
}
URLSession.shared.dataTask(with: url) { data, response, error in
if let error = error {
completion(.failure(error))
return
}
guard let data = data else {
completion(.failure(NetworkError.noData))
return
}
do {
let articles = try JSONDecoder().decode([Article].self, from: data)
completion(.success(articles))
} catch {
completion(.failure(error))
}
}.resume()
}
}
enum NetworkError: Error {
case invalidURL
case noData
case decodingError
}
- Implementando o Interactor
Usado para gerenciar as regras de negócio e também pode ter um ou mais worker's.
Criaremos métodos que serão expostos à View Controller, aqui um será para carregar os dados dos artigos e o outro será fazer a navegação quando um artigo for selecionado.
protocol ArticlesBusinessLogic {
func fetchArticles(request: Articles.FetchArticles.Request)
func didSelectArticle(request: Articles.DidSelectArticle.Request)
}
Além do Worker, o Presenter também é uma dependência do Interactor, ele recebe os dados vindo do Worker e passa para o Presenter fazer a formatação, costumo injetar essas dependências via inicializador.
class ArticlesInteractor: ArticlesBusinessLogic {
let presenter: ArticlesPresentationLogic
let worker: ArticlesWorkerProtocol
let router: ArticlesRoutingLogic
init(presenter: ArticlesPresentationLogic, worker: ArticlesWorkerProtocol, router: ArticlesRoutingLogic) {
self.presenter = presenter
self.worker = worker
self.router = router
}
Ao implementar o método, chamamos o worker para fazer a request, e quando feita, é uma boa prática ter pelo menos dois métodos na presenter, um para tratar tanto o caso de sucesso e o outro para o caso de erro. Além disso, também implementei um caso de loading para uma experiência mais orgânica.
func fetchArticles(request: Articles.FetchArticles.Request) {
presenter.presentLoading(response: .init(isLoading: true))
worker.fetchArticles { [weak self] result in
switch result {
case .success(let articles):
self?.presenter.presentArticles(response: .init(articles: articles))
case .failure(let error):
self?.presenter.presentError(response: .init(errorMessage: error.localizedDescription))
}
self?.presenter.presentLoading(response: .init(isLoading: false))
}
}
Para a função de selecionar o artigo, chamamos o Router que será responsável por controlar a navegação desse fluxo. Não é incomum ver uma implementação de Router sendo feita na View Controller, mas na minha experiência, pensando que esses comportamentos também podem ser vistos como regra de negócio, faz sentido que o Interactor também seja responsável por isso.
func didSelectArticle(request: Articles.DidSelectArticle.Request) {
router.routeToArticleDetail(id: request.id)
}
}
- Recebendo e formatando dados com Presenter
Para auxiliar na formatação, criaremos o arquivo Article.swift e então criamos um enum Articles, dentro dele teremos um FetchArticles que será responsável por manter os valores de Request, Response e ViewModel dentro. A ViewModel a Presenter usará para criar um objeto de acordo com os dados recebidos do Interactor.
Na primeira versão desse código, não havia tanto encapsulamento, os parâmetros estavam soltos, e então entendi que no VIP isso é importante para relembrar a separação de responsabilidade, ja que reforça os pontos:
- Request: Dados brutos que vêm da View → Interactor.
- Response: Dados processados pelo Interactor → Presenter.
- ViewModel: Dados formatados pelo Presenter → View. Além disso, deixa uma organização mais clara e facilita na hora de escrever testes unitários.
enum Articles {
struct FetchArticles {
struct Request {}
struct Response {
let articles: [Article]
}
struct ViewModel {
let displayedArticles: [DisplayedArticle]
}
}
struct PresentError {
struct Request {}
struct Response {
let errorMessage: String
}
}
struct PresentLoading {
struct Request {}
struct Response {
let isLoading: Bool
}
}
struct DidSelectArticle {
struct Request {
let id: Int
}
struct Response {
let articles: [Article]
}
}
struct ArticleDetail {
struct Request {
let articleId: Int
}
}
}
struct DisplayedArticle {
let id: Int
let title: String
let description: String
let publishDate: String
let imageUrl: String?
let authorName: String
let tags: String
}
Em ArticlesPresenter.swift, teremos o protocolo que foi utilizado pelo Interactor
protocol ArticlesPresentationLogic {
func presentArticles(response: Articles.FetchArticles.Response)
func presentError(response: Articles.PresentError.Response)
func presentLoading(response: Articles.PresentLoading.Response)
}
Implementando a classe ArticlesPresenter, temos um atributo da View Controller, onde passaremos os dados ja formatados e prontos para serem exibidos
As referências do Presenter para a View são sempre fracas, para evitar ciclos de retenção.
Por ser unidirecional, a arquitetura VIP exige que a View (ViewController) mantenha uma referência forte ao Interactor, o Interactor mantenha uma referência forte ao Presenter, e se o Presenter mantiver uma referência forte de volta à View, cria-se um ciclo de retenção de memória que impede o ARC de desalocar os objetos corretamente.
ViewController (forte) → Interactor (forte) → Presenter (fraca) → ViewController
class ArticlesPresenter: ArticlesPresentationLogic {
weak var viewController: ArticlesDisplayLogic?
func presentLoading(response: Articles.PresentLoading.Response) {
viewController?.displayLoading(viewModel: .init(isLoading: response.isLoading))
}
func presentArticles(response: Articles.FetchArticles.Response) {
let displayedArticles = response.articles.map { article in
DisplayedArticle(
id: article.id,
title: article.title,
description: article.description,
publishDate: article.readablePublishDate,
imageUrl: article.coverImage,
authorName: article.user.name,
tags: article.tags
)
}
let viewModel = Articles.FetchArticles.ViewModel(displayedArticles: displayedArticles)
viewController?.displayArticles(viewModel: viewModel)
}
func presentError(response: Articles.PresentError.Response) {
viewController?.displayError(viewModel: .init(errorMessage: response.errorMessage))
}
}
- View Controller
Na View Controller, teremos o interactor como dependência, e chamaremos na viewDidLoad o método que faz a request e retorna os dados para serem exibidos nas células.
import UIKit
protocol ArticlesDisplayLogic: AnyObject {
func displayArticles(viewModel: Articles.FetchArticles.ViewModel)
func displayError(viewModel: Articles.PresentError.Response)
func displayLoading(viewModel: Articles.PresentLoading.Response)
func displayArticleDetail(_ articleDetail: ArticleDetail)
}
class ArticlesViewController: UIViewController, ArticlesDisplayLogic {
var interactor: ArticlesBusinessLogic?
private lazy var collectionView: UICollectionView = {
let layout = UICollectionViewFlowLayout()
layout.scrollDirection = .horizontal
layout.minimumLineSpacing = 0
let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.backgroundColor = .clear
collectionView.isPagingEnabled = true
collectionView.showsHorizontalScrollIndicator = false
collectionView.translatesAutoresizingMaskIntoConstraints = false
collectionView.delegate = self
collectionView.dataSource = self
collectionView.register(ArticleCell.self, forCellWithReuseIdentifier: ArticleCell.identifier)
collectionView.register(EmptyArticlesCell.self, forCellWithReuseIdentifier: EmptyArticlesCell.identifier)
return collectionView
}()
private lazy var activityIndicator: UIActivityIndicatorView = {
let indicator = UIActivityIndicatorView(style: .large)
indicator.center = view.center
indicator.hidesWhenStopped = true
indicator.translatesAutoresizingMaskIntoConstraints = false
return indicator
}()
private lazy var pageControl: UIPageControl = {
let pageControl = UIPageControl()
pageControl.currentPageIndicatorTintColor = .persianBlue
pageControl.pageIndicatorTintColor = .lightGray
pageControl.translatesAutoresizingMaskIntoConstraints = false
return pageControl
}()
private var displayedArticles: [DisplayedArticle] = []
init(interactor: ArticlesBusinessLogic) {
self.interactor = interactor
super.init(nibName: nil, bundle: nil)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
setupNavigationBar()
setupViews()
setupConstraints()
loadArticles()
}
private func setupViews() {
view.backgroundColor = .systemBackground
view.addSubview(collectionView)
view.addSubview(pageControl)
view.addSubview(activityIndicator)
}
private func setupConstraints() {
NSLayoutConstraint.activate([
collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
collectionView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16),
collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -240),
pageControl.bottomAnchor.constraint(equalTo: collectionView.bottomAnchor, constant: 16),
pageControl.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
pageControl.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
activityIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor),
activityIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor)
])
}
private func setupNavigationBar() {
title = HomeStrings.articlesTitle
navigationController?.navigationBar.prefersLargeTitles = false
let appearance = UINavigationBarAppearance()
appearance.configureWithOpaqueBackground()
appearance.backgroundColor = .systemBackground
appearance.titleTextAttributes = [.foregroundColor: UIColor.persianBlue]
let backButton = UIBarButtonItem(image: UIImage(systemName: "xmark"),
style: .plain,
target: self,
action: #selector(backButtonTapped))
backButton.tintColor = .persianBlue
navigationItem.leftBarButtonItem = backButton
navigationItem.backBarButtonItem = UIBarButtonItem(title: "", style: .plain, target: nil, action: nil)
navigationController?.navigationBar.standardAppearance = appearance
navigationController?.navigationBar.scrollEdgeAppearance = appearance
navigationController?.navigationBar.compactAppearance = appearance
}
private func loadArticles() {
let request = Articles.FetchArticles.Request()
interactor?.fetchArticles(request: request)
}
func displayLoading(_ isLoading: Bool) {
DispatchQueue.main.async { [weak self] in
if isLoading {
self?.activityIndicator.startAnimating()
self?.collectionView.isHidden = true
self?.pageControl.isHidden = true
self?.collectionView.isHidden = true
} else {
self?.activityIndicator.stopAnimating()
self?.collectionView.isHidden = false
self?.pageControl.isHidden = false
}
}
}
func displayArticles(viewModel: Articles.FetchArticles.ViewModel) {
DispatchQueue.main.async { [weak self] in
self?.activityIndicator.stopAnimating()
self?.displayedArticles = viewModel.displayedArticles
self?.collectionView.reloadData()
if viewModel.displayedArticles.isEmpty {
self?.pageControl.isHidden = true
} else {
self?.pageControl.numberOfPages = viewModel.displayedArticles.count
self?.pageControl.isHidden = false
self?.collectionView.isHidden = false
}
}
}
func displayError(viewModel: Articles.PresentError.Response) {
DispatchQueue.main.async { [weak self] in
self?.activityIndicator.stopAnimating()
let alert = UIAlertController(title: HomeStrings.errorMessage,
message: viewModel.errorMessage,
preferredStyle: .alert)
alert.addAction(UIAlertAction(title: HomeStrings.errorButton, style: .default))
self?.present(alert, animated: true)
}
}
func displayLoading(viewModel: Articles.PresentLoading.Response) {
DispatchQueue.main.async { [weak self] in
if viewModel.isLoading {
self?.activityIndicator.startAnimating()
self?.collectionView.isHidden = true
self?.pageControl.isHidden = true
} else {
self?.activityIndicator.stopAnimating()
self?.collectionView.isHidden = false
self?.pageControl.isHidden = false
}
}
}
func displayArticleDetail(_ articleDetail: ArticleDetail) {
guard let id = articleDetail.id else { return }
interactor?.didSelectArticle(request: .init(id: id))
}
@objc private func backButtonTapped() {
navigationController?.popViewController(animated: true)
}
}
extension ArticlesViewController: UICollectionViewDataSource {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return displayedArticles.isEmpty ? 1 : displayedArticles.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
if displayedArticles.isEmpty {
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: EmptyArticlesCell.identifier, for: indexPath) as? EmptyArticlesCell else {
fatalError("Unable to dequeue EmptyArticlesCell")
}
cell.configureEmptyView()
return cell
}
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: ArticleCell.identifier, for: indexPath) as? ArticleCell else {
fatalError("Unable to dequeue cell")
}
cell.configure(with: displayedArticles[indexPath.item])
return cell
}
}
extension ArticlesViewController: UICollectionViewDelegateFlowLayout {
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
return CGSize(width: collectionView.frame.width, height: collectionView.frame.height)
}
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
let pageNumber = round(scrollView.contentOffset.x / scrollView.frame.size.width)
pageControl.currentPage = Int(pageNumber)
}
}
extension ArticlesViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard !displayedArticles.isEmpty else { return }
let request = Articles.DidSelectArticle.Request(id: displayedArticles[indexPath.item].id)
interactor?.didSelectArticle(request: request)
}
}
- Retornando a view controller com Factory
A Factory é responsável por centralizar a criação de objetos complexos, como ViewControllers no VIP. Neste caso, ela orquestra a construção de todas as camadas da arquitetura (View, Interactor, Presenter e Router), injetando as dependências necessárias e garantindo que todas as referências sejam corretamente configuradas.
enum ArticlesFactory {
static func build() -> ArticlesViewController {
let worker = ArticlesWorker()
let presenter = ArticlesPresenter()
let router = ArticlesRouter()
let interactor = ArticlesInteractor(presenter: presenter, worker: worker, router: router)
let viewController = ArticlesViewController(interactor: interactor)
router.viewController = viewController
presenter.viewController = viewController
return viewController
}
}
- Criando uma navegação com Router
O Router é o componente responsável por toda a lógica de navegação e coordenação de fluxos entre cenas (ViewControllers). Na arquitetura VIP, ele:
Centraliza a navegação: Remove a responsabilidade de transição de telas da ViewController e do Interactor
Gerencia dependências: Pode instanciar e configurar novas cenas com suas próprias factories
Implementa desacoplamento: Permite alterar fluxos de navegação sem impactar outras camadas
Interactor → Router → Nova ViewController
(regra de negócio) (ação de navegação)
import Foundation
protocol ArticlesRoutingLogic {
func routeToArticleDetail(id: Int)
}
protocol ArticlesDataStore {}
class ArticlesRouter: NSObject, ArticlesRoutingLogic, ArticlesDataStore {
weak var viewController: ArticlesViewController?
func routeToArticleDetail(id: Int) {
let articleDetailViewController = ArticleDetailFactory.build(id: id)
viewController?.navigationController?.pushViewController(articleDetailViewController, animated: true)
}
}
O código da celula da Collection View, da tela de detalhe e de login pode ser encontrado no meu github