LoginSignup
62
53

More than 5 years have passed since last update.

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

Posted at

【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

62
53
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
62
53