DEV Community

Cover image for Implementando Arquitetura VIP em um App iOS com Swift
Lys
Lys

Posted on • Edited on

4 2 2 2 2

Implementando Arquitetura VIP em um App iOS com Swift

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"
    }
}
Enter fullscreen mode Exit fullscreen mode
  • 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
}
Enter fullscreen mode Exit fullscreen mode
  • 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)
}
Enter fullscreen mode Exit fullscreen mode

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
    }
Enter fullscreen mode Exit fullscreen mode

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))
        }
    }
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode
  • 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
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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))
    }
}
Enter fullscreen mode Exit fullscreen mode
  • 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)
    }
}

Enter fullscreen mode Exit fullscreen mode
  • 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
    }
}
Enter fullscreen mode Exit fullscreen mode
  • 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)
    }
}
Enter fullscreen mode Exit fullscreen mode

O código da celula da Collection View, da tela de detalhe e de login pode ser encontrado no meu github

Image of Datadog

Optimize UX with Real User Monitoring

Learn how Real User Monitoring (RUM) and Synthetic Testing provide full visibility into web and mobile performance. See best practices in action and discover why Datadog was named a Leader in the 2024 Gartner MQ for Digital Experience Monitoring.

Tap into UX Best Practices

Top comments (0)

Image of Datadog

Keep your GPUs in check

This cheatsheet shows how to use Datadog’s NVIDIA DCGM and Triton integrations to track GPU health, resource usage, and model performance—helping you optimize AI workloads and avoid hardware bottlenecks.

Get the Cheatsheet

👋 Kindness is contagious

Dive into this informative piece, backed by our vibrant DEV Community

Whether you’re a novice or a pro, your perspective enriches our collective insight.

A simple “thank you” can lift someone’s spirits—share your gratitude in the comments!

On DEV, the power of shared knowledge paves a smoother path and tightens our community ties. Found value here? A quick thanks to the author makes a big impact.

Okay