Desenvolvimento

4 jan, 2017

Como criar um app iOS da Marvel – Parte 01

Publicidade

Vamos começar hoje uma série de artigos para mostrar como criar um app iOS do início, usando muitas pods e ferramentas para deixar sua vida mais fácil. A ideia é cobrir vários tópicos importantes ao longo desse e dos próximos artigos.

Provavelmente, você pode achar todas essas informações espalhadas pela internet, cada uma em um tutorial diferente. Minha intenção aqui é passar tudo em um projeto só. Se você quiser ver o código, pode acessar aqui. Vou usar tags diferentes para diferentes pontos no tempo, cobrindo a primeira parte, a segunda e assim por diante…

lioy-1024x680

O plano é esse:

  • Criar uma camada de rede usando Moya + RxSwift;
  • Como criar modelos a partir de um Json usando um framework de mapeamento;
  • Como evitar view controllers massivas, usando datasources e delegates externos. Isso vai manter o código “boilerplate” da CollectionView e TableView onde deveria;
  • Como lidar com storyboards e view controllers usando Enums com SwiftGen, deixando tudo mais seguro e organizado;
  • Um jeito melhor de lidar com células de tableView e CollectionView;
  • Como lidar com downloads de imagens usando o Kingfisher;
  • Lidar com necessidades de segurança nas especificações da API da Marvel usando CryptoSwift;
  • Usar cocoapods-keys pra manter dados e chaves sensíveis fora do seu repositório.

Nos próximos artigos vamos falar sobre:

  • Como testar as camadas do seu app (redes, modelos, viewControllers, datasources e etc.);
  • Como gerar um relatório de cobertura;
  • Automatizar seu pipeline de desenvolvimento com o Fastlane;
  • Como configurar integração contínua para projetos do GitHub com o Travis;
  • Criar regras de Pull Request para seu repositório usando o Danger;
  • Skecth para desenvolvedores! Como podemos criar designs “bootstrapeds”.

O app

Para esse projeto, eu decidi criar um app Marvel usando a API deles. Esta primeira parte está toda na tag v0.1. Você só precisa clonar o repositório e trocar para a tag v0.1.

Aviso: Vamos melhorar bastante o layout em um próximo artigo, quando formos falar sobre sketch e design para desenvolvedores. Por enquanto, ainda está bastante simples, como você pode ver abaixo:

appios1

Começando pelo início: o Podfile

Podemos notar abaixo diversos pods que vamos usar nessa primeira parte:

# Uncomment the next line to define a global platform for your project
platform :ios, '9.0'
 
target 'Marvel' do
  # Comment the next line if you're not using Swift and don't want to use dynamic frameworks
  use_frameworks!
 
  plugin 'cocoapods-keys', {
    :project => "Marvel",
    :keys => [
      "MarvelApiKey",
      "MarvelPrivateKey"
    ]}
 
  # Pods for Marvel
   pod 'SwiftGen'
   pod 'RxSwift', '~> 3.0.0-beta.2'
   pod 'Moya/RxSwift','~> 8.0.0-beta.1'
   pod 'Moya-ObjectMapper/RxSwift', :git => 'https://github.com/ivanbruel/Moya-ObjectMapper'
   pod 'CryptoSwift'
   pod 'Dollar'
   pod 'Kingfisher'
   pod "Reusable"
 
  post_install do |installer|
    installer.pods_project.targets.each do |target|
      target.build_configurations.each do |config|
        config.build_settings['SWIFT_VERSION'] = '3.0'
      end
    end
  end
end

Tem uma coisa importante sobre esse Podfile: estamos usando, como foi mencionado, o plugin cocoapods-keys. Ele vai nos permitir definir no nosso keychain os valores das chaves na primeira vez que instalarmos o pod. Depois desse passo de configuração, nós podemos injetar no nosso código as chaves sem precisar adicioná-las ao nosso repositório. E não é só isso: dessa forma cada um de vocês poderá usar suas próprias chaves da API da Marvel, que  podem ser geradas aqui. Legal, né? Obrigado orta!

plugin 'cocoapods-keys', {
    :project => "Marvel",
    :keys => [
      "MarvelApiKey",
      "MarvelPrivateKey"
    ]}

Depois de criar as chaves da API e configurar o projeto, vamos para a próxima parte.

Camada de rede com Moya

Moya é um ótimo pod para gerenciar todo o boilerplate e a complexidade de ter que criar sua própria camada de abstração de rede de projeto para projeto. Embora não seja obrigatório usá-lo com RxSwift, para esse artigo vou combinar os dois.

import Foundation
import Moya
import CryptoSwift
import Dollar
import Keys
 
fileprivate struct MarvelAPIConfig {
    fileprivate static let keys = MarvelKeys()
    static let privatekey = keys.marvelPrivateKey()!
    static let apikey = keys.marvelApiKey()!
    static let ts = Date().timeIntervalSince1970.description
    static let hash = "\(ts)\(privatekey)\(apikey)".md5()
}
 
enum MarvelAPI {
    case characters(String?)
    case character(String)
}
 
extension MarvelAPI: TargetType {
    var baseURL: URL { return URL(string: "https://gateway.marvel.com:443")! }
    
    
    var path: String {
        switch self {
        case .characters:
            return "/v1/public/characters"
        case .character(let characterId):
            return "/v1/public/characters/\(characterId)"
        }
    }
    
    var method: Moya.Method {
        switch self {
        case .characters, .character:
            return .get
        }
    }
    
    func authParameters() -> [String: String] {
        return ["apikey": MarvelAPIConfig.apikey,
                "ts": MarvelAPIConfig.ts,
                "hash": MarvelAPIConfig.hash]
    }
    
    var parameters: [String: Any]? {
        
        switch self {
        
        case .characters(let query):
            if let query = query {
                return $.merge(authParameters(),
                               ["nameStartsWith": query])
            }
            return authParameters()
            
        case .character(let characterId):
            return $.merge(authParameters(),
                           ["characterId": characterId])
        }
    }
    
    var task: Task {
        return .request
    }
    
    var sampleData: Data {
        switch self {
        default:
            return Data()
        }
    }
}

E é isso! Muito fácil definir as configurações da API, os endpoints, parâmetros, métodos e todo o resto. Vou usar a propriedade sampleData mais tarde, em um artigo sobre testes. Também vale mencionar que a estrutura MarvelAPIConfig e o método authParameters estão aqui por uma necessidade específica da API da Marvel. O authParameters é mergeado com os parâmetros do usuário e podemos facilmente fazer isso usando um pod legal chamado Dollar, que tem vários métodos úteis.

Depois de criar as configurações da API, nós ainda precisamos criar um provider do Moya para usar a API. Isso pode ser feito em qualquer lugar, mas recomendo fortemente criar outra abstração para lidar com esse tipo de lógica.

import Foundation
import Moya
import RxSwift
import ObjectMapper
import Moya_ObjectMapper
 
extension Response {
    func removeAPIWrappers() -> Response {
        guard let json = try? self.mapJSON() as? Dictionary<String, AnyObject>,
            let results = json?["data"]?["results"] ?? [],
            let newData = try? JSONSerialization.data(withJSONObject: results, options: .prettyPrinted) else {
                return self
        }
        
        let newResponse = Response(statusCode: self.statusCode,
                                   data: newData,
                                   response: self.response)
        return newResponse
    }
}
 
struct MarvelAPIManager {
    
    let provider: RxMoyaProvider<MarvelAPI>
    let disposeBag = DisposeBag()
    
    
    init() {
        provider = RxMoyaProvider<MarvelAPI>()
    }
    
}
 
extension MarvelAPIManager {
    typealias AdditionalStepsAction = (() -> ())
    
    fileprivate func requestObject<T: Mappable>(_ token: MarvelAPI, type: T.Type,
                                   completion: @escaping (T?) -> Void,
                                   additionalSteps: AdditionalStepsAction? = nil) {
        provider.request(token)
            .debug()
            .mapObject(T.self)
            .subscribe { event -> Void in
                switch event {
                case .next(let parsedObject):
                    completion(parsedObject)
                    additionalSteps?()
                case .error(let error):
                    print(error)
                    completion(nil)
                default:
                    break
                }
            }.addDisposableTo(disposeBag)
    }
    
    fileprivate func requestArray<T: Mappable>(_ token: MarvelAPI, type: T.Type,
                                  completion: @escaping ([T]?) -> Void,
                                  additionalSteps:  AdditionalStepsAction? = nil) {
        provider.request(token)
            .debug()
            .map { response -> Response in
                return response.removeAPIWrappers()
            }
            .mapArray(T.self)
            .subscribe { event -> Void in
                switch event {
                case .next(let parsedArray):
                    completion(parsedArray)
                    additionalSteps?()
                case .error(let error):
                    print(error)
                    completion(nil)
                default:
                    break
                }
            }.addDisposableTo(disposeBag)
    }
}
 
 
 
 
extension MarvelAPIManager {
    
    func characters(query: String? = nil, completion: @escaping ([Character]?) -> Void) {
        requestArray(.characters(query),
                     type: Character.self,
                     completion: completion)
    }
    
    
}

Tem muita coisa acontecendo aqui. Eu criei dois métodos genéricos (um para array, outro para objeto) para abstrair como interagir com a API e lidar com as resposta. Depois, esses métodos podem ser usados para fazer chamadas mais específicas a API, como:

func characters(query: String? = nil, completion: @escaping ([Character]?) -> Void) {        
    requestArray(.characters(query),
                 type: Character.self,                            completion: completion)    
}

Aviso: eu sei que não estou usando nem 1% do RxSwift. Uso só para fazer o “unwrap” da resposta da API (a API da Marvel tem duas camadas de objetos, que não importa para esse artigo), “parsear” em modelos e lidar com o fluxo de sucesso ou erro. Muitos podem argumentar, e eles estão certos por isso, que eu deveria retornar uma lista de Observables que podem ser, mais tarde, combinados com outras chamadas da API, filtradas etc, ao invés de lidar com isso dentro da minha abstração da API e prover um bloco de “completion”. A questão é que não há certo ou errado aqui. Tudo é um trade off, e este projeto não tem tais necessidades. Sendo assim, não pede por tal implementação. Portanto, vamos manter as coisas simples.

Agora nós podemos fazer chamadas à API sem nos preocupar com nenhum detalhe de implementação. Simples assim:

let apiManager = MarvelAPIManager()
apiManager.characters(query: query) { characters in                   }

Essa explicação não pretende ser um Guia completo, é muito mais como um guideline, um exemplo. É importante que você clone o repositório e vá brincando com ele.

Datasources externos + Reusable

Agora vamos dar uma olhada nos datasources e em como podemos extrair um monte de código das nossas view controllers. Vamos criar um protocolo pra definir um contrato, e assim podemos repetir esse padrão ao longo de todo o projeto.

import UIKit
 
protocol ItemsTableViewDatasource: UITableViewDataSource {
    associatedtype T
    var items:[T] {get}
    weak var tableView: UITableView? {get}
    weak var delegate: UITableViewDelegate? {get}
    
    init(items: [T], tableView: UITableView, delegate: UITableViewDelegate)
    
    func setupTableView()
}
 
extension ItemsTableViewDatasource {
    func setupTableView() {
        self.tableView?.dataSource = self
        self.tableView?.delegate = self.delegate
        self.tableView?.reloadData()
    }
}

Agora nós podemos implementar facilmente qualquer datasource de uma forma padrão. Tudo o que precisamos é estar de acordo com o protocolo.

final class CharactersDatasource: NSObject, ItemsTableViewDatasource {
    
    var items:[Character] = []
    weak var tableView: UITableView?
    weak var delegate: UITableViewDelegate?
    
    required init(items: [Character], tableView: UITableView, delegate: UITableViewDelegate) {
        self.items = items
        self.tableView = tableView
        self.delegate = delegate
        super.init()
        tableView.register(cellType: CharacterTableCell.self)
        self.setupTableView()
    }
    
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return self.items.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(for: indexPath, cellType: CharacterTableCell.self)
        let character = self.items[indexPath.row]
        cell.setup(item: character)
        return cell
    }
}

Aqui, você pode ver métodos como register(cellType: ) e dequeReusableCell. Eles pertencem ao pod Reusable, que se adotado no projeto pode realmente melhorar a forma como lidamos com células de TableView e CollectionView no nosso código. Na verdade, é bastante fácil implementar um comportamento similar usando protocolos. Tudo o que precisamos fazer é:

protocol ReusableTableViewCell {
    
}
 
extension ReusableTableViewCell where Self: UITableViewCell {
    
    static func cellIdentifier() -> String {
        return String(describing: Self.self)
    }
    
    static func registerForTableView(_ tableView: UITableView) {
        let nib = UINib(nibName: cellIdentifier(), bundle: nil)
        tableView.register(nib, forCellReuseIdentifier: cellIdentifier())
    }
    
    static func dequeueCell(from tableView: UITableView, at indexPath: IndexPath) -> Self {
        if let cell = tableView
            .dequeueReusableCell(withIdentifier: cellIdentifier(), for: indexPath) as? Self {
            return cell
        } else {
            return Self()
        }
    }
}

É claro que o pod Reusable faz algumas outras coisas a mais também, mas você entendeu. Só para manter a sanidade, acredito de fato que devemos usar o pod ao invés de implementar por nós mesmos. Não reinvente a roda!

Fazendo o download de imagens

Mais frequentemente do que gostaríamos, nos pegamos recriando o mesmo código várias vezes. Um desses padrões repetitivos está relacionado ao download de imagens e como carregá-las dentro da imageView. No exemplo abaixo, você pode ver uma interface simples chamada download(image:), que leva um parâmetro de string com a URL da imagem.

final class CharacterTableCell: UITableViewCell, NibReusable {
    @IBOutlet weak var name: UILabel!
    @IBOutlet weak var thumb: UIImageView!
    
    static func height() -> CGFloat {
        return 80
    }
    
    func setup(item: Character) {
        name.text = item.name
        thumb.download(image: item.thumImage?.fullPath() ?? "")
    }
}

Essa interface é definida em uma extensão da classe UIImageView, uma solução simples que pode ajudar a centralizar o comportamento. Claro que existem diversas outras maneiras, melhores que essa de fato, para resolver essa situação, estou apenas mostrando um jeito criativo de usar as extensões do Swift.

import UIKit
import Kingfisher
 
extension UIImageView {
    func download(image url: String) {
        guard let imageURL = URL(string:url) else {
            return
        }
        self.kf.setImage(with: ImageResource(downloadURL: imageURL))
    }
}

SwiftGen e Storyboards enum

Se você é como eu, e “meio” que não gosta de segues (para dizer o mínimo), você pode acabar repetindo a mesma receita de código toda vez que você precisar navegar entre uma view controller e outra. Instanciar o storyboard, recuperar a view controler usando uma string “hardcoded” e assim por diante… Esses passos, além de chatos, são propensos a erros e tendem a produzir comportamentos inesperados.

Entretanto, não precisa ser assim. Ferramentas como swiftGen podem nos ajudar a criar um Storyboard Enum e nos proporcionar o mesmo comportamento, com muito menos ou nenhuma dor. Tudo o que precisamos fazer é adicionar uma “run script phase” em nossa build, com esse código:

lioy3

$PODS_ROOT/SwiftGen/bin/swiftgen storyboards -t swift3 $SOURCE_ROOT --output $SOURCE_ROOT/Marvel/Resources/Storyboard.swift --sceneEnumName Storyboard

Depois disso, só precisamos adicionar o arquivo Storyboard.swift ao nosso projeto e resolver o problema, like a boss.

func navigateExample() {
 guard let nextController = Storyboard.Main.characterViewControllerScene
            .viewController() as? CharacterViewController else {
            return
        }
        
        let character = characters[index.row]
        nextController.character = character
        self.navigationController?.pushViewController(nextController, animated: true)
}

Finalizando…

Chegamos ao fim da primeira parte dessa série de artigos. Ainda estou testando esse formato, ou seja, é possível que esse conteúdo possa mudar um pouco, talvez eu siga na linha de screencasts. Por isso, é muito importante que você  clone o projeto e brinque com ele. Não seria prático nem benéfico colocar todas as informações aqui, senão seria um artigo gigante e impossível de ler, por isso novamente enfatizo a importância de clonar o repositório e meter a “mão na massa”.

Como sempre, qualquer dúvida, comentário ou feedback são mais que bem-vindos! Aproveite os campos abaixo.

***

Artigo publicado originalmente em: http://www.concretesolutions.com.br/2016/12/13/app-ios-do-inicio-1/