Como migrar um app da Marvel para View Code

Atualmente, View Code é a nova “hype” da comunidade iOS. Não é nada novo, mas podemos dizer que essa abordagem é, de diversas formas, uma volta às origens do iOS, nas quais as views eram criadas em código. Se você voltar alguns anos no tempo nas primeiras versões do XCode, nosso famoso Interface Builder estava presente. O Storyboard, por exemplo, nasceu muito tempo depois.

Depois de falar com vários desenvolvedores que estão adotando View Code nas suas aplicações e afirmando vários benefícios, eu comecei a me interessar bastante pelo assunto.

Benefícios: quais são eles?

  • Melhora o trabalho em equipe, evita conflitos do tipo storyboards/xibs;
  • Código tende a se tornar modular, reusável e com um propósito claro;
  • É mais fácil testar;
  • É mais fácil manter e evoluir o code base.

Hoje eu vou migrar um app da Marvel que eu criei em uma série de artigos (cujo início você pode ver aqui), para View Code. A ideia é compartilhar com vocês minha experiência, opiniões e lições que aprendi no processo. Você pode clonar o repositório com View Code aqui.

A abordagem

Migrar para View Code não é algo que precisa ser feito de uma vez, você pode começar migrando algumas partes pequenas do seu código até eventualmente ter migrado o projeto por inteiro.  Comecei esse processo migrando as células. Elas já estavam fora do Storyboard, em uma xib separada, então “let’s make the diff”.

Usando xib

//  //  CharacterTableCell.swift  //  Marvel  //  //  Created by Thiago Lioy on 15/11/16.  //  Copyright © 2016 Thiago Lioy. All rights reserved.  //  import UIKit  import Reusable     final class CharacterTableCell: UITableViewCell, NibReusable {      @IBOutlet weak var name: UILabel!      @IBOutlet weak var characterDescription: UILabel!      @IBOutlet weak var thumb: UIImageView!            static func height() -> CGFloat {          return 80      }            func setup(item: Character) {          name.text = item.name          characterDescription.text = item.bio.isEmpty ? "No description" : item.bio          thumb.download(image: item.thumImage?.fullPath() ?? "")      }  }

Usando View Code

import UIKit  import Reusable  import UIKit     final class CharacterTableCell: UITableViewCell {      var characterRow = CharacterRowView()            static func height() -> CGFloat {          return 80      }            override init(style: UITableViewCellStyle, reuseIdentifier: String?) {          super.init(style: style, reuseIdentifier: reuseIdentifier)          buildViewHierarchy()          setupConstraints()          configureViews()      }            required init?(coder aDecoder: NSCoder) {          fatalError("init(coder:) has not been implemented")      }                  func setup(item: Character) {          characterRow.name.text = item.name          characterRow.bio.text = item.bio.isEmpty ? "No description" : item.bio          characterRow.imageThumb.download(image: item.thumImage?.fullPath() ?? "")      }  }     extension CharacterTableCell: Reusable {  }     extension CharacterTableCell: ViewConfiguration {      func setupConstraints() {          characterRow.snp.makeConstraints { make in              make.top.equalTo(self)              make.left.equalTo(self)              make.right.equalTo(self)              make.bottom.equalTo(self)          }      }            func buildViewHierarchy() {          self.contentView.addSubview(characterRow)      }            func configureViews() {          self.contentView.backgroundColor = ColorPalette.black          self.selectionStyle = .none      }  }

Tem um monte de coisa acontecendo aqui. A segunda versão, usando view code, é muito maior. Vamos analisar a diferença antes de prejulgar, ok?

  • Primeiro: removemos os IBOutlets, portanto esqueça os force unwraps!
  • A célula implementa agora o protocolo Reusable ao invés do NibReusable, ambos do excelente pod  Reusable.
  • A célula deve implementar agora alguns initializers esperados, que não precisávamos antes quando isso era criado na xib ou Storyboard. Isso deve parecer algo ruim a princípio, mas agora nós temos controle do processo de inicialização, o que é ótimo! Isso deixa o teste bem simples.
  • A célula também implementa um protocolo ViewConfiguration, que eu defini para criar um padrão.
import Foundation  protocol ViewConfiguration: class {      func setupConstraints()      func buildViewHierarchy()      func configureViews()  }
  • Estes métodos serão responsáveis por construir a hierarquia da view, configurar as constraints do autolayout e permitir que configurações extras sejam facilmente adicionadas;
  • Você pode notar que estou usando o SnapKit, uma biblioteca que tem uma DSL que permite trabalhar com autolayout no código de uma forma realmente fácil. Existem diversas outras bibliotecas similares como Cartography PureLayout, dentre outras, que fazem a mesma coisa. Aqui, o gosto pessoal é seu aliado, escolha a melhor pra você.

Não precisamos mudar mais nada nesse projeto, e tudo está funcionando como deveria. A grande diferença é que, agora, se você quiser mudar a célula, adicionar outro label ou botão, por exemplo, está muito mais fácil. Você só precisa mudar em um lugar e esquecer os problemas de merge. Além de rápida, essa abordagem permite acabar com o “dragging and dropping” programming, para aqueles já estão cansados disso.

Repetindo o padrão

De agora em diante, seu trabalho é migrar o restante do projeto: as outras células, view controllers etc. É quase a mesma coisa, então, vou só mencionar as partes importantes, ok? Você pode ver o código no repositório sempre que precisar.

LoadView, the new kid on the block

Agora que não estamos carregando view Controllers dos storyboards, devemos nos preocupar com outro método do life cycle, chamado de loadView, que é chamado antes do viewDidLoad. Até agora, isso era feito automaticamente pelo storyboard; não mais. Neste método, nós precisamos setar a self.view da view controller com alguma coisa que faça sentido.

import UIKit     final class CharacterViewController: UIViewController {      var characterView = CharacterView()      var character: Character?            override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {          super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)      }            required init?(coder aDecoder: NSCoder) {         fatalError("init(coder:) has not been implemented")      }  }     extension CharacterViewController {      override func loadView() {          self.view = characterView      }            override func viewDidLoad() {          super.viewDidLoad()          setupView()      }            override func viewWillAppear(_ animated: Bool) {          super.viewWillAppear(animated)          self.navigationItem.title = character?.name ?? ""      }  }        extension CharacterViewController {      func setupView() {          let bio = character?.bio ?? ""          characterView.bio.text = bio.isEmpty ? "No description" : bio          characterView.image.download(image: character?.thumImage?.fullPath() ?? "")      }  }

A última parte do quebra-cabeças, depois de abandonar o Storyboard, é dizer para seu app que não precisamos dele. Graças a Deus! (brincadeira). Isso pode ser feito em dois passos: primeiro nós dizemos ao app que não precisamos de uma main Interface, depois configuramos o AppDelegate.

Passo 01:

Passo 02:

import UIKit     @UIApplicationMain  class AppDelegate: UIResponder, UIApplicationDelegate {         var window: UIWindow?            func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {          // Override point for customization after application launch.          ApperanceProxyHelper.customizeNavigationBar()          ApperanceProxyHelper.customizeSearchBar()                    self.window = UIWindow(frame: UIScreen.main.bounds)          self.window?.backgroundColor = UIColor.white                    let navController = UINavigationController(rootViewController: CharactersViewController())          self.window?.rootViewController = navController                    self.window?.makeKeyAndVisible()                    return true      }        }

Depois deste último passo, tudo deve funcionar como esperado. Vamos analisar mais uma controller para ver o antes e depois.

Sem usar o View Code

import UIKit     @UIApplicationMain  class AppDelegate: UIResponder, UIApplicationDelegate {         var window: UIWindow?            func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {          // Override point for customization after application launch.          ApperanceProxyHelper.customizeNavigationBar()          ApperanceProxyHelper.customizeSearchBar()                    self.window = UIWindow(frame: UIScreen.main.bounds)          self.window?.backgroundColor = UIColor.white                    let navController = UINavigationController(rootViewController: CharactersViewController())          self.window?.rootViewController = navController                    self.window?.makeKeyAndVisible()                    return true      }        }

Usando View Code

import UIKit     protocol CharactersDelegate {      func didSelectCharacter(at index: IndexPath)  }     fileprivate enum PresentationState {      case table, collection  }     final class CharactersViewController: UIViewController {      var apiManager: MarvelAPICalls = MarvelAPIManager()            var characters: [Character] = []            fileprivate var currentPresentationState = PresentationState.table            let containerView = CharactersContainerView()                  override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {          super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)      }            required init?(coder aDecoder: NSCoder) {          fatalError("init(coder:) has not been implemented")      }  }     extension CharactersViewController {      override func viewDidLoad() {          super.viewDidLoad()          setupNavigationItem()          setupSearchBar()          fetchCharacters()      }            override func loadView() {          self.view = containerView      }  }     extension CharactersViewController {      func setupNavigationItem() {          self.navigationItem.title = "Characters"          self.navigationItem.rightBarButtonItems = [             NavigationItems.grid(self, #selector(showAsGrid(_:))).button(),             NavigationItems.list(self, #selector(showAsTable(_:))).button()          ]      }            func fetchCharacters(for query: String? = nil) {          containerView.charactersTable.isHidden = true          containerView.charactersCollection.isHidden = true                    apiManager.characters(query: query) { characters in              self.characters = characters ?? []              switch self.currentPresentationState {              case .table:                  self.setupTableView(with: self.characters)              case .collection:                  self.setupCollectionView(with: self.characters)              }          }      }            func setupSearchBar() {          self.containerView.searchBar.doSearch = { query in              self.fetchCharacters(for: query)          }      }            fileprivate func setPresentationState(to state: PresentationState) {          currentPresentationState = state          switch state {          case .collection:              containerView.charactersTable.isHidden = true              containerView.charactersCollection.isHidden = false          case .table:              containerView.charactersTable.isHidden = false              containerView.charactersCollection.isHidden = true          }      }            func setupTableView(with characters: [Character]) {          setPresentationState(to: .table)          containerView.charactersTable.updateItems(characters)          containerView.charactersTable.didSelectCharacter = { [weak self] char in              self?.navigateToNextController(with: char)          }      }            func setupCollectionView(with characters: [Character]) {          setPresentationState(to: .collection)          containerView.charactersCollection.updateItems(characters)          containerView.charactersCollection.didSelectCharacter = { [weak self] char in              self?.navigateToNextController(with: char)          }      }            func navigateToNextController(with character: Character) {          self.containerView.searchBar.resignFirstResponder()          let nextController = CharacterViewController()          nextController.character = character          self.navigationController?.pushViewController(nextController, animated: true)      }  }     extension CharactersViewController {      @IBAction func showAsGrid(_ sender: UIButton) {          setupCollectionView(with: characters)      }            @IBAction func showAsTable(_ sender: UIButton) {          setupTableView(with: characters)      }  }

Vocês podem notar que o tamanho da controller é quase o mesmo, mas a segunda versão tem muito menos responsabilidade que a primeira. Muita coisa foi movida para lugares mais adequados, sem contar que não temos mais IBoutlet ou IBActions, force unwrap etc. Também é muito mais fácil testar essa nova ViewController, já que agora controlamos o processo de inicialização e não precisamos trazê-la do storyboard.

Então…

Eu tenho gostado muito dessa experiência. View Code é algo que definitivamente vou explorar e usar nos meus novos projetos. Em breve, pretendo escrever outro artigo sobre testes e view code, e como nós podemos refatorar os testes no nosso antigo app da Marvel para trabalhar no nosso novo design. Dito isso, é bom lembrar que esse foi só o meu primeiro contato com o View Code. Ainda tem muita coisa pra fazer.

Se você tiver alguma experiência sobre o assunto, alguma dúvida, outros padrões etc, deixe seu comentário abaixo.

Até a próxima!

***

Este artigo foi originalmente publicado no Cocoa Academy (em inglês). Veja aqui.

***

Artigo publicado originalmente em: http://www.concretesolutions.com.br/2017/02/17/migrar-app-marvel-view-code/

You may also like...