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
Top comments (0)