【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
実装
ライブラリのインポート
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を用いて以下のように作成しました。
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
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さんもおっしゃているように、まるでドキュメントを写したかのように定義することができます。
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
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
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
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