一から勉強させてください( ̄ω ̄;)

最下級エンジニアが日々の学びをアウトプットしていくだけのブログです。

RxSwiftを使ってGitHubのおすすめユーザーを表示するアプリをつくってみた

ひさしぶりの投稿です。。気づけばiOSの開発を始めて半年近く経ち、ようやく半人前になってきたかもしれません。

ドラクエで言うとちょうどスライムナイトが敵で出現しはじめるぐらいの段階でしょうか。どうでもいいですね。

さて最近は流行りのリアクティブプログラミングに興味津々なので、今回はその勉強がてらRxSwiftを使ってGitHubのおすすめユーザーを表示するアプリを作ってみました。その内容をざっくり書いてみたいと思います。

ちなみにこのアプリは有名なこちらの記事の中でRxJSで作られているのですが、今回作るのはそのiOS版になります。リアクティブプログラミングについても同記事をかなり参考にしております。

開発環境はXcode7(Swift2.0)でiOS8以上をターゲットにしております。

コードはこちら。(本記事に載せているコードはリファクタリングなどで修正されている可能性もあるので、差異がある場合はなるべくGitHub のコードを参考にしてください)

ffmpegで雑にmov→gif変換した紹介デモも乗せておきます。画質悪っ!!!しかも1件取得失敗してるやつおるやんけ…

WhoToFollowDemo

リアクティブプログラミングのざっくりしたイメージ

  1. Observable(ストリームと同義?)を作る。時間順に並んだ進行中のイベントの列のことらしい。よく見るマーブルダイアグラムのやつ。

  2. map, filterなどObservableに対して使用できる関数をかましてデータを加工する。

  3. Observerを作って、Observableをsubscribeして返ってくる値に対して処理する。

この流れに沿って良い感じにコードを書いていきたいです。

APIクライアント

GitHubAPIを叩いてUser情報を取得したいので、まずはAPIクライアントを作成しました。 ちなみにライブラリはCocoaPodsですべて導入済とします。 叩く想定のAPIhttps://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の作成そのもの?)の処理も指定のスレッドにできるみたいなイメージでいます。(参考)

というわけでここでの処理の流れは、

  1. ランダムなオフセットをつくって、URLを組み立ててAPIを叩く

  2. バックグラウンドスレッドでデータ加工(map)スタート

  3. Observable<AnyObject!>が返ってくる想定なのでそいつに対してParseJSON

  4. parseJSON内では引数に渡ってくるのデータはユーザー情報の一覧という想定なので、ほしい情報を抜き出してUserインスタンスの一覧を返す

  5. map内で無事にparseJSONできたら返ってくるのはObservable<[User]>

  6. メインスレッドに戻って、あとはこいつを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するというイベントを起点に考えてみました。

  1. pull to refreshされたというイベントをObservableにしたいのでrx_controlEventsメソッドを使用

  2. pull to refreshされたタイミングでUser.fetch()を呼び出す

  3. subscribeNext(AnonymousObserverという匿名のObserverをつくって該当のObservableを勝手にsubscribeしてくれるやつ)内で返ってきたユーザー一覧をTableViewController自身のusers(型は[User])にセット

  4. お作法的に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_controlEventssourceObservable<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
    }

}
  1. Controllerから渡ってくるUserモデルを初期値としてObservableを作成したかったので、Variable<User>を定義

  2. 1のVariableからnameavatarUrlのObservableをそれぞれ作成

  3. View側でViewModelの上記のObservableたちをsubscribeしてnameavatarUrlをセット

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も大歓迎でございます。

参考