RxSwiftを使ってGitHubのおすすめユーザーを表示するアプリをつくってみた
ひさしぶりの投稿です。気づけば iOS の開発を始めて半年近く経ち、ようやく半人前になってきたかもしれません。
最近は流行りのリアクティブプログラミングに興味津々なので、今回はその勉強がてらRxSwiftを使って GitHub のおすすめユーザーを表示するアプリを作ってみました。その内容をざっくり書いてみたいと思います。
ちなみにこのアプリはこちらの記事の中で RxJS で作られているものを参考にしており、今回作るのはその iOS 版になります。リアクティブプログラミングについても同記事をかなり参考にしております。
開発環境は Xcode7(Swift2.0)で iOS8 以上をターゲットにしております。
コードはこちら。(本記事に載せているコードはリファクタリングなどで修正されている可能性もあるので、差異がある場合はなるべく GitHub のコードを参考にしてください)
ffmpegで雑に mov→gif 変換した紹介デモも乗せておきます。画質悪っ!しかも 1 件取得失敗してるやつおるやんけ…

リアクティブプログラミングのざっくりしたイメージ
Observable(ストリームと同義?)を作る。時間順に並んだ進行中のイベントの列のことらしい。よく見るマーブルダイアグラムのやつ。
map,filterなど Observable に対して使用できる関数をかましてデータを加工する。Observer を作って、Observable を subscribe して返ってくる値に対して処理する。
この流れに沿って良い感じにコードを書いていきたいです。
API クライアント
GitHub の API を叩いて User 情報を取得したいので、まずは API クライアントを作成しました。
ちなみにライブラリは CocoaPods ですべて導入済とします。
叩く想定の API はhttps://api.github.com/users?since=◯◯です。
class GithubAPIClient: {
// MARK: - Properties
let scheme = "https"
let host: String = "api.github.com"
private let manager = Alamofire.Manager.sharedInstance
static let sharedInstance: GithubAPIClient = GithubAPIClient()
// MARK: - Initializers
private init() {}
// MARK: - Instance methods
func request(method: Alamofire.Method = .GET, path: String, params: [String : String] = [:]) -> Observable<AnyObject!> {
let request = self.manager.request(method, self.buildPath(path), parameters: params).request
if let request = request {
return self.manager.session.rx_JSON(request)
} else {
fatalError("Invalid request")
}
}
// MARK: - Private methods
private func buildPath(path: String) -> NSURL {
let trimmedPath = path.hasPrefix("/") ? path.substringFromIndex(path.startIndex.successor()) : path
return NSURL(scheme: self.scheme, host: self.host, path: "/" + trimmedPath)!
}
}
通信は Alamofire を使用して GitHub のユーザー一覧を取得する API を叩きます。そしてここで Observable を返したいので、requestメソッドの返り値はObservable<AnyObject!>です。
RxCocoa のNSURLSession+Rx.swiftの中で、通信で返ってきた JSON データを Observable にして返すrx_JSONというメソッドがあるので、それをありがたく使用しています。
User モデル
API から返ってくるlogin, avatar_url, html_urlを使用して、名前、アバター画像の表示、プロフィールページへの遷移を実現できるようにします。
あとは先ほどの API クライアントを使ってユーザー一覧の Fetch、JSON の Parse 機能を実装します。
class User {
// MARK: - Properties
let name: String
let url: String
let avatarUrl: String
static let apiClient = GithubAPIClient.sharedInstance
// MARK: - Initializers
init(name: String, url: String, avatarUrl: String) {
self.name = name
self.url = url
self.avatarUrl = avatarUrl
}
// MARK: - Static methods
static func fetch() -> Observable<[User]> {
let randomOffset = String(arc4random_uniform(500))
return self.apiClient.request(path: "users", params: ["since": randomOffset])
.observeOn(Dependencies.sharedInstance.backgroundScheduler)
.map { json in
guard let json = json as? [AnyObject] else { fatalError("Cast failed") }
return self.parseJSON(json)
}.observeOn(Dependencies.sharedInstance.mainScheduler)
}
static func parseJSON(json: [AnyObject]) -> [User] {
return json.map { result in
guard let name = result["login"] as? String else { fatalError("Parse error") }
guard let url = result["html_url"] as? String else { fatalError("Parse error") }
guard let avatarUrl = result["avatar_url"] as? String else { fatalError("Parse error") }
return User(name: name, url: url, avatarUrl: avatarUrl)
}
}
}
ObserveOnというメソッドにスケジューラーなるものを渡すことでメインスレッド、バックグラウンドスレッドなど処理を実行するスレッドを切り替えられます。重い処理は裏で勝手にやっとけよと。RxSwift の Examples でも同じようにしていたのでおそらく使い方は間違っていないかと。。
ちなみにSubscribeOnというメソッドもあるみたいです。使ったこと無いので違いをあまり理解していないですが、ObserveOnはそれを呼び出した後の処理を行うスレッドを変更するのに対し、SubscribeOnはそれを呼び出す前(Observable の作成そのもの?)の処理も指定のスレッドにできるみたいなイメージでいます。(参考)
というわけでここでの処理の流れは、
ランダムなオフセットをつくって、URL を組み立てて API を叩く
バックグラウンドスレッドでデータ加工(
map)スタートObservable<AnyObject!>が返ってくる想定なのでそいつに対してParseJSONparseJSON内では引数に渡ってくるのデータはユーザー情報の一覧という想定なので、ほしい情報を抜き出してUserインスタンスの一覧を返すmap内で無事にparseJSONできたら返ってくるのはObservable<[User]>メインスレッドに戻って、あとはこいつを Observer が subscribe すれば OK
という感じです。
ユーザー一覧の表示
TableViewController をつくって、ユーザーの一覧をいい感じに表示できるようにします。
コードが長いので、関係ありそうなとこだけ抜粋します。
self.refreshControl!.rx_controlEvents(.ValueChanged).startWith({ print("Start loading...") }())
.flatMap {
return User.fetch()
}.subscribeNext { [unowned self] result in
self.users = result
self.refreshControl!.endRefreshing()
}.addDisposableTo(self.disposeBag)
いよいよ Observable を Subscribe していきます。まずは初期表示時と pull to refresh した時にデータを良い感じに表示できるようにしたいです。
とりあえず pull to refresh するというイベントを起点に考えてみました。
pull to refresh されたというイベントを Observable にしたいので
rx_controlEventsメソッドを使用pull to refresh されたタイミングで
User.fetch()を呼び出すsubscribeNext(AnonymousObserverという匿名の Observer をつくって該当の Observable を勝手に subscribe してくれるやつ)内で返ってきたユーザー一覧を TableViewController 自身のusers(型は[User])にセットお作法的に
disposeBagをつくっておいてaddDisposableToを呼んでおけば不要になったタイミングで勝手に subscribe やめてくれる(はず。。)
ただこれだと pull to refresh しないとデータを Fetch できないので初回何も表示されず、かなりツラくなります。
なので、startWithで初回の pull to refresh を無理矢理発火させて初回も Fetch できるようにしました。
rx_controlEventsの返り値はControlEvent<Void>となっており、ControlEventは
public struct ControlEvent<PropertyType> : ControlEventType {
public typealias E = PropertyType
let source: Observable<PropertyType>
init(source: Observable<PropertyType>) {
self.source = source
}
/**
Subscribes an observer to control events.
- parameter observer: Observer to subscribe to events.
- returns: Disposable object that can be used to unsubscribe the observer from receiving control events.
*/
public func subscribe<O : ObserverType where O.E == E>(observer: O) -> Disposable {
return self.source.subscribe(observer)
}
/**
- returns: `Observable` interface.
*/
public func asObservable() -> Observable<E> {
return self.source
}
/**
- returns: `ControlEvent` interface.
*/
public func asControlEvent() -> ControlEvent<E> {
return self
}
}
またstartWithについては
extension ObservableType {
/**
Prepends a sequence of values to an observable sequence.
- parameter elements: Elements to prepend to the specified sequence.
- returns: The source sequence prepended with the specified values.
*/
public func startWith(elements: E ...)
-> Observable<E> {
return StartWith(source: self.asObservable(), elements: elements)
}
}
となっています。
これらのコードからrx_controlEventsのsourceはObservable<Void>となりそうなので、startWith({ print("Start loading...") }())としてクロージャを強引に実行したらうまく Fetch してくれました。
あとmapではなくflatMapを使っていますが、mapだとsubscribeNextで返ってくるのがRxSwift.ObserveOnSerialDispatchQueue<Swift.Array<User>>のような意味不明なものになります。
今回は pull to refresh のイベントを起点にしており、そこでObservable<Void>が返ってきているので、入れ子の Observable を良い感じにマージしてくれるflatMapを使わないと欲しい値が手に入らないのではないかと思われます。(参考)
RxSwift.ObserveOnSerialDispatchQueue<Swift.Array<User>>に関してはまだちょっとコードを追えていないです。。
取得したユーザー一覧をもとにセルを作成・表示
正直 ViewModel 必要ない感満載なのですが、一応 ViewModel を介してUserモデルからセルを表示してみました。画像表示には SDWebImage を使用しています。
class UserTableViewCellModel {
// MARK: - Properties
let model: Variable<User>
let name: Observable<String>
let avatarUrl: Observable<NSURL>
let disposeBag = DisposeBag()
// MARK: - Initializers
init(model: User) {
self.model = Variable(model)
self.name = self.model.map { return $0.name }
self.avatarUrl = self.model.map { return NSURL(string: $0.avatarUrl)! }
}
}
class UserTableViewCell: UITableViewCell {
// MARK: - Properties
let disposeBag = DisposeBag()
var viewModel: UserTableViewCellModel? {
didSet {
guard let vModel = self.viewModel else { return }
vModel.name.bindTo(self.nameLabel.rx_text).addDisposableTo(self.disposeBag)
vModel.avatarUrl.subscribeNext {
self.avatarImageView.sd_setImageWithURL($0, placeholderImage: UIImage(named: "DefaultImage.png"))
}.addDisposableTo(self.disposeBag)
}
}
static let rowHeight: CGFloat = 80
// MARK: - IBOutlets
@IBOutlet weak var avatarView: UIView!
@IBOutlet weak var avatarImageView: UIImageView!
@IBOutlet weak var nameLabel: UILabel!
// MARK: - Lifecycles
override func awakeFromNib() {
super.awakeFromNib()
// Initialization code
}
override func layoutSubviews() {
super.layoutSubviews()
self.avatarImageView.frame = self.avatarView.bounds
}
}
Controller から渡ってくる
Userモデルを初期値として Observable を作成したかったので、Variable<User>を定義1 の Variable から
nameとavatarUrlの Observable をそれぞれ作成View 側で ViewModel の上記の Observable たちを subscribe して
name、avatarUrlをセット
Variable については
Variables represent some observable state. Variable without containing value can't exist because initializer requires initial value.
です(だんだん雑になってきた。。)
vModel.name.bindTo(self.nameLabel.rx_text)の部分はvModel.name.subscribeNext { self.nameLabel.text = $0 }と同じようなことを簡易的に書いている感じでしょうか。ラベルのテキストなど、ただ値をセットするだけのときによく使いそうです。
ちなみにこれは Rx ではなく SDWebImage の話ですが、デフォルト画像をセットしてない場合、スクロールしないと画像表示してくれないんですね。。地味にちょっとハマりました。(参考)
あと更に余談ですが、ImageView の Constraint をちゃんと設定しているはずなのに ImageView の位置が右に若干ずれる事件が発生しました。 これも同じようなことで困っている人がいて解決しましたが、これまた地味にハマりました。(参考)
ユーザーの削除(別ユーザーの表示)
iOS でよくあるスワイプで削除ボタンが出るやつを使ってユーザーを削除したら別のユーザーを表示してあげるようにします。
self.tableView.rx_itemDeleted.subscribeNext { [unowned self] indexPath in
var data = self.users
let nextIndex = Int(arc4random_uniform(18) + 11) // Select from remaining users
let prevData = data[indexPath.row]
data[indexPath.item] = data[nextIndex]
data[nextIndex] = prevData
self.users = data
}.addDisposableTo(self.disposeBag)
ユーザーが削除された時のイベントの Observable はrx_itemDeletedで取れるので、そいつを subscribe してやります。
実は API を叩いた時にユーザーは 30 人返ってきているのですが、アプリ上でははじめの 10 人しか出していない(元の参考にしたアプリは 3 人だったのですが、TableView で 3 人は寂しかったので 10 件)ので、余っている人の中から適当に選んで入れ替えて表示しています。この辺はかなり雑なのでバグとかあるかもしれません。。
ユーザーのプロフィールページへ遷移
最後にセルを選択したら該当のユーザーの GitHub プロフィールページに遷移できるようにします。iOS9 からSFSafariViewControllerが使えるので iOS9 の場合はそれを使うようにしました。
self.tableView.rx_itemSelected.subscribeNext { [unowned self] indexPath in
guard let user = self.userForIndexPath(indexPath) else { return }
guard let url = NSURL(string: user.url) else { return }
if #available(iOS 9.0, *) {
let safari = SFSafariViewController(URL: url)
safari.delegate = self
self.presentViewController(safari, animated: true, completion: nil)
} else {
UIApplication.sharedApplication().openURL(url)
}
self.tableView.deselectRowAtIndexPath(indexPath, animated: false)
}.addDisposableTo(self.disposeBag)
削除時と同様にrx_itemSelectedから Observable を作れるので、それを subscribe してユーザーのプロフィールページを SafariView または Safari に遷移して表示するようにしています。
あとセルの選択状態は残らないようにdeselectRowAtIndexPathを呼んでいます。
一般的にはセル選択時の挙動ってtableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath)で書くと思うんですが、それをrx_itemSelectedで書くのって本当に良いんですかね?削除時の挙動も。。アップルに逆らいまくっている気がして妙な罪悪感を感じます。
まとめ
以上、RxSwift を使ってリアクティブっぽくアプリをつくってみました。Rx 関係のライブラリは機能が豊富すぎて難しくてかなりツラいですが、うまく動くと最高に気持ちいいです。実装も楽しくて妙な中毒性があります。
今回のアプリの元となった記事でも
現代のアプリは、高度にインタラクティブな体験をユーザーに与えるために、多数のリアルタイムイベントを扱っている。我々はこれを適切に取り扱うツールを探しており、リアクティブプログラミングがその答えなのだ。
と書いてありますし、ぜひ使いこなしたいもんです。
まだまだ理解しきれていない部分が多いのでおかしな点などがありましたら、僕が傷つかない程度に優しくご指摘ください。GitHubへの Issue や PR も大歓迎でございます。