Desenvolvimento

1 mar, 2017

Como migrar um app da Marvel para View Code

Publicidade

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/