Implementando Arquitetura VIP em um App iOS com Swift
Lys

Lys @lys

About: Formada em análise e desenvolvimento de sistemas e desenvolvedora iOS!

Joined:
Sep 29, 2020

Implementando Arquitetura VIP em um App iOS com Swift

Publish Date: Apr 16
7 0

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

Comments 0 total

    Add comment