post Image
【Swift3】RxSwift + APIKit + Himotokiで作るAPIクライアント

【Swift3】 RxSwift + APIKit + Himotokiで作るAPIクライアント

(おそらくn番煎じですが…)

前置き

春休みに企業のインターンに参加させてもらったときに、APIKitとHimotokiを教えていただきました。
今まではAlamofireとObjectMapperを使っていたのですが、APIKitが書きやすくて可読性も良いのですごく気に入りました。
別の企業のインターンに参加したときは、MVVM+RxSwiftを使用しているプロジェクトに参加させていただいたのですが、なかなか難しかったので頑張って勉強しています。

今回、RxSwiftの勉強をしながらGitHubのリポジトリビューアーを作ったので、記事にしたいと思います。

あと、RxSwiftとAPIKitを使用した記事で、Swift3に対応したものが少なかった(気がする)ので、参考になればと思います。

作ったもの


https://github.com/natmark/GitHubRepositoryViewer

GitHubのユーザIDを入力すると、ユーザの公開リポジトリ一覧を表示できるようにしました。

環境

Mac OS X El Capitan 10.12.3
Xcode 8.2.1
Swift 3.0.2

実装

ライブラリのインポート

Cartfile
github "ishkawa/APIKit" ~> 3.0
github "ikesyo/Himotoki" ~> 3.0
github "ReactiveX/RxSwift" ~> 3.0
github "pinterest/PINRemoteImage"

Carthageの導入についてはこちらを参考にしてください。
http://qiita.com/kobad/items/dddab651c91b3dee9fbf

以下のライブラリをインポートしました。

  • ishkawa/APIKit
    タイプセーフな軽量HTTPクライアントライブラリ

  • ikesyo/Himotoki
    タイプセーフなJSONデコーダライブラリ

  • ReactiveX/RxSwift
    Rx(Reactive Extensions)のSwift実装ライブラリ

    observableのシークエンスを使って非同期でイベントベースのプログラムを実現するためのライブラリ。
    http://qiita.com/jollyjoester/items/c4013c60acd453ea7248

  • pinterest/PINRemoteImage
    スレッドセーフな画像フェッチライブラリ

Model

今回GitHub APIの /users/[username]/repos を使用しました。
使用したい項目は、以下になります。

  • full_name
    ユーザID / リポジトリ名
  • owner.avatar_url
    ユーザのアバター画像のURL
  • language
    リポジトリの主要言語(optional)
  • url
    リポジトリのURL

上記を取得できるようなモデルをHimotokiを用いて以下のように作成しました。

Repository.swift
import Himotoki

struct Repository: Decodable {
    let fullName: String
    let ownerAvatarUrl: String
    let language: String?
    let url: String

    static func decode(_ e: Extractor) throws -> Repository {
        return try Repository(
            fullName: e <| "full_name",
            ownerAvatarUrl: e <| ["owner", "avatar_url"], //nested
            language: e <|? "language", //optional
            url: e <| "url"
        )
    }
}

APIKitとRxSwiftの連携

前述の APIKit の Session.sendRequest(request) には Observable を返すインターフェースではないので、別で用意する必要があります。 (http://qiita.com/pm11/items/57b2dff4b1ac19bd89ba)

こちらの記事を参考にしました。
http://h3poteto.hatenablog.com/entry/2016/05/16/000351

SessionRx.swift
import APIKit
import RxSwift

extension Session {
    func rx_sendRequest<T: Request>(request: T) -> Observable<T.Response> {
        return Observable.create { observer in
            let task = self.send(request) { result in
                switch result {
                case .success(let res):
                    observer.on(.next(res))
                    observer.on(.completed)
                case .failure(let err):
                    observer.onError(err)
                }
            }
            return Disposables.create {
                task?.cancel()
            }
        }
    }

    class func rx_sendRequest<T: Request>(request: T) -> Observable<T.Response> {
        return shared.rx_sendRequest(request: request)
    }
}

参考にした記事はSwift2のものだったので、Swift3で使用できるように一部書き換えました。

AnonymousDisposableですが、Disposables.create() で代替できるとのことでした。
http://stackoverflow.com/questions/40936295/what-is-the-rxswift-3-0-equivalent-to-anonymousdisposable-from-rxswift-2-x

API Client

APIKitを使用してAPIクライアントを作成しました。
開発者のIshkawaさんもおっしゃているように、まるでドキュメントを写したかのように定義することができます。

API.swift
import APIKit
import Himotoki

protocol GitHubRequest: Request {

}

extension GitHubRequest {
    var baseURL: URL {
        return URL(string: "https://api.github.com")!
    }

    func intercept(object: Any, urlResponse: HTTPURLResponse) throws -> Any {
        guard (200..<300).contains(urlResponse.statusCode) else {
            throw ResponseError.unacceptableStatusCode(urlResponse.statusCode)
        }
        return object
    }
}

extension GitHubRequest where Response: Decodable {
    func response(from object: Any, urlResponse: HTTPURLResponse) throws -> Self.Response {
        return try decodeValue(object)
    }
}

struct FetchRepositoryRequest: GitHubRequest {
    var userName: String
    var path: String {
        return "/users/\(self.userName)/repos"
    }
    typealias Response = [Repository]

    var method: HTTPMethod {
        return .get
    }

    init(userName: String) {
        self.userName = userName
    }
    func response(from object: Any, urlResponse: HTTPURLResponse) throws -> FetchRepositoryRequest.Response {
        return try decodeArray(object)
    }
}

今回ですが、/users/[username]/repos はルートがArrayのJSONを返却するAPIなので、
decodeArray(_ JSON: Any)でレスポンスをデコードするようにしました。

(decodeValue(_ JSON: Any)でデコードしている記事が多く、ルートがArrayのものをデコードするためにモデルをいろいろいじってハマりました。これなかなか気づかなかったです。)

ViewModel

ListViewModel.swift
import UIKit
import RxSwift
import APIKit

class ListViewModel: NSObject, UITableViewDataSource {

    private var cellIdentifier = "ListCell"

    private(set) var repos = Variable<[Repository]>([])

    private(set) var error = Variable<Error?>(nil)

    let disposeBag = DisposeBag()

    override init() {
        super.init()
    }

    func reloadData(userName: String) {
        let request = FetchRepositoryRequest(userName: userName)
        Session.rx_sendRequest(request: request)
            .subscribe {
                [weak self] event in
                switch event {
                case .next(let repos):
                   self?.repos.value = repos
                case .error(let error): break
                    self?.error.value = error
                default: break
                }
            }
            .addDisposableTo(disposeBag)
    }

    // MARK: - TableView
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return repos.value.count
    }
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: self.cellIdentifier, for: indexPath) as! ListCell
        cell.configureCell(repo: repos.value[indexPath.row])
        return cell
    }
}

reloadData(userName: String)内で、リクエストを発行し
reposに値を格納するようにしました。

TableViewCell

スクリーンショット 2017-04-07 17.45.38.png

ListCell.swift
import UIKit
import PINRemoteImage
class ListCell: UITableViewCell {

    @IBOutlet weak var fullNameLabel: UILabel!
    @IBOutlet weak var avatarImageView: UIImageView!
    @IBOutlet weak var languageLabel: UILabel!
    @IBOutlet weak var urlLabel: UILabel!

    override func awakeFromNib() {
        super.awakeFromNib()
    }

    func configureCell(repo: Repository) {
        fullNameLabel.text = repo.fullName
        avatarImageView.pin_setImage(from: URL(string: repo.ownerAvaterUrl), completion: nil)
        languageLabel.text = repo.language ?? ""
        urlLabel.text = repo.url
    }

    override func setSelected(_ selected: Bool, animated: Bool) {
        super.setSelected(selected, animated: animated)
        // Configure the view for the selected state
    }
}
avatarImageView.pin_setImage(from: URL(string: repo.ownerAvatarUrl), completion: nil)

avatarImageViewに表示する画像をPINRemoteImageを使用して非同期でフェッチしています。

ViewController

スクリーンショット 2017-04-07 17.55.36.png

ViewController
import UIKit
import APIKit
import RxSwift
import RxCocoa

class ViewController: UIViewController, UITableViewDelegate, UISearchBarDelegate {

    @IBOutlet weak var tableView: UITableView!
    @IBOutlet weak var searchBar: UISearchBar!

    private let viewModel = ListViewModel()

    let disposeBag = DisposeBag()

    override func viewDidLoad() {
        super.viewDidLoad()
        searchBar.delegate = self
        tableView.delegate = self

        tableView.dataSource = viewModel

        bind()
    }

    func bind() {
        // Connection
        viewModel.repos.asObservable()
            .filter { x in
                return !x.isEmpty
            }
            .subscribe(onNext: { [unowned self] x in
                self.tableView.reloadData()
            }, onError: { error in
            }, onCompleted: { () in
            }, onDisposed: { () in
            })
            .addDisposableTo(disposeBag)

        //search
        searchBar.rx.text
            .subscribe(onNext: { [unowned self] q in
                self.navigationItem.title = q!
                self.viewModel.reloadData(userName: q!)
            }, onError: { error in
            }, onCompleted: { () in
            }, onDisposed: { () in
            })
            .addDisposableTo(disposeBag)
    }
    // MARK: - TableView
    func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
        return 130
    }
    // MARK: - SearchBar
    func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
        searchBar.resignFirstResponder()
    }
    func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
        searchBar.resignFirstResponder()
    }

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
        // Dispose of any resources that can be recreated.
    }
}
viewModel.repos.asObservable()
    .filter { x in
        return !x.isEmpty
    }
    .subscribe(onNext: { [unowned self] x in
        self.tableView.reloadData()
    }, onError: { error in
    }, onCompleted: { () in
    }, onDisposed: { () in
    })
    .addDisposableTo(disposeBag)

viewModel.reposをsubscribeして、変化があった場合にtableView.reloadData()を呼び出すようにしています。

searchBar.rx.text
    .subscribe(onNext: { [unowned self] q in
        self.navigationItem.title = q!
        self.viewModel.reloadData(userName: q!)
    }, onError: { error in
    }, onCompleted: { () in
    }, onDisposed: { () in
    })
    .addDisposableTo(disposeBag)

searchBarの文字を監視して、変化があった場合に
navigationBarのタイトルを変更して、viewModel.reloadData(userName: String)を呼び出すようにしました。

まとめ

普段MVCばかりなので、MVVMになかなか慣れません。
ただ、MVVMを使うことでModel、View、ViewModelを区別できるので、頑張って勉強したいです。(MVCが悪いわけではないですが、MVCを使っていてFat View Controllerになることも多く、課題も感じているので…)

参考

http://qiita.com/pm11/items/57b2dff4b1ac19bd89ba
http://h3poteto.hatenablog.com/entry/2016/05/16/000351


『 Swift 』Article List
Category List

Eye Catch Image
Read More

Androidに関する現役のエンジニアのノウハウ・トレンドのトピックなど技術的な情報を提供しています。コード・プログラムの丁寧な解説をはじめ、初心者にもわかりやすいように写真や動画を多く使用しています。

Eye Catch Image
Read More

AWSに関する現役のエンジニアのノウハウ・トレンドのトピックなど技術的な情報を提供しています。コード・プログラムの丁寧な解説をはじめ、初心者にもわかりやすいように写真や動画を多く使用しています。

Eye Catch Image
Read More

Bitcoinに関する現役のエンジニアのノウハウ・トレンドのトピックなど技術的な情報を提供しています。コード・プログラムの丁寧な解説をはじめ、初心者にもわかりやすいように写真や動画を多く使用しています。

Eye Catch Image
Read More

CentOSに関する現役のエンジニアのノウハウ・トレンドのトピックなど技術的な情報を提供しています。コード・プログラムの丁寧な解説をはじめ、初心者にもわかりやすいように写真や動画を多く使用しています。

Eye Catch Image
Read More

dockerに関する現役のエンジニアのノウハウ・トレンドのトピックなど技術的な情報を提供しています。コード・プログラムの丁寧な解説をはじめ、初心者にもわかりやすいように写真や動画を多く使用しています。

Eye Catch Image
Read More

GitHubに関する現役のエンジニアのノウハウ・トレンドのトピックなど技術的な情報を提供しています。コード・プログラムの丁寧な解説をはじめ、初心者にもわかりやすいように写真や動画を多く使用しています。

Eye Catch Image
Read More

Goに関する現役のエンジニアのノウハウ・トレンドのトピックなど技術的な情報を提供しています。コード・プログラムの丁寧な解説をはじめ、初心者にもわかりやすいように写真や動画を多く使用しています。

Eye Catch Image
Read More

Javaに関する現役のエンジニアのノウハウ・トレンドのトピックなど技術的な情報を提供しています。コード・プログラムの丁寧な解説をはじめ、初心者にもわかりやすいように写真や動画を多く使用しています。

Eye Catch Image
Read More

JavaScriptに関する現役のエンジニアのノウハウ・トレンドのトピックなど技術的な情報を提供しています。コード・プログラムの丁寧な解説をはじめ、初心者にもわかりやすいように写真や動画を多く使用しています。

Eye Catch Image
Read More

Laravelに関する現役のエンジニアのノウハウ・トレンドのトピックなど技術的な情報を提供しています。コード・プログラムの丁寧な解説をはじめ、初心者にもわかりやすいように写真や動画を多く使用しています。

Eye Catch Image
Read More

Pythonに関する現役のエンジニアのノウハウ・トレンドのトピックなど技術的な情報を提供しています。コード・プログラムの丁寧な解説をはじめ、初心者にもわかりやすいように写真や動画を多く使用しています。

Eye Catch Image
Read More

Rubyに関する現役のエンジニアのノウハウ・トレンドのトピックなど技術的な情報を提供しています。コード・プログラムの丁寧な解説をはじめ、初心者にもわかりやすいように写真や動画を多く使用しています。

Eye Catch Image
Read More

Scalaに関する現役のエンジニアのノウハウ・トレンドのトピックなど技術的な情報を提供しています。コード・プログラムの丁寧な解説をはじめ、初心者にもわかりやすいように写真や動画を多く使用しています。

Eye Catch Image
Read More

Swiftに関する現役のエンジニアのノウハウ・トレンドのトピックなど技術的な情報を提供しています。コード・プログラムの丁寧な解説をはじめ、初心者にもわかりやすいように写真や動画を多く使用しています。

Eye Catch Image
Read More

Unityに関する現役のエンジニアのノウハウ・トレンドのトピックなど技術的な情報を提供しています。コード・プログラムの丁寧な解説をはじめ、初心者にもわかりやすいように写真や動画を多く使用しています。

Eye Catch Image
Read More

Vue.jsに関する現役のエンジニアのノウハウ・トレンドのトピックなど技術的な情報を提供しています。コード・プログラムの丁寧な解説をはじめ、初心者にもわかりやすいように写真や動画を多く使用しています。

Eye Catch Image
Read More

Wordpressに関する現役のエンジニアのノウハウ・トレンドのトピックなど技術的な情報を提供しています。コード・プログラムの丁寧な解説をはじめ、初心者にもわかりやすいように写真や動画を多く使用しています。

Eye Catch Image
Read More

機械学習に関する現役のエンジニアのノウハウ・トレンドのトピックなど技術的な情報を提供しています。コード・プログラムの丁寧な解説をはじめ、初心者にもわかりやすいように写真や動画を多く使用しています。