Posted at

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

More than 1 year has passed since last update.


【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


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


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