LoginSignup
128
110

More than 5 years have passed since last update.

RxSwiftでの実装練習の記録ノート(後編:DriverパターンとAPIへの通信を伴うMVVM構成のサンプル例)

Last updated at Posted at 2017-02-02

1. はじめに

こちらの記事は、前回の記事で紹介したRxSwiftでObserverパターンの例とUITableViewの例に関する実装解説の続編になります(あいだが1週間ほど空いてしまってすみませんでした。。。)今回は少し難易度を上げてAPI通信を伴うサンプルの実装(写経)を行なったものになります。

RxAlamofireを用いたものと、FoursquareのAPIクライアントのライブラリを使用してテキストフィールドから入力した際に、少し時間が経過したタイミングで入力した文字列に該当するデータを一覧で表示するタイプのサンプル2種類に関する実装について、前回同様にできる限り自分の言葉でのドキュメンテーションと実装時に参考にした資料等をまとめることができればと思い、まとめた次第です。

※前編の記事は下記になります。

2. 本サンプルに関して

今回のサンプルに関しては【Warming Up】〜【Chapter3】の現在全4パターンありますが、この記事内では外部のAPIでの通信を伴うもので、かつRxSwiftのDriverパターンとMVVM「View層 - ViewModel層 - Model層」の構成をするものに関しての解説を行なっていく形になります。

Githubのリポジトリ:

サンプルのキャプチャ画像:

sample_capture_2.jpg

今回解説を行うサンプル:

  • 【Chapter2】GithubのAPIを利用してuser名検索してリポジトリ一覧をUITableViewに表示をするプラクティス
  • 【Chapter3】FoursquareAPIを利用して検索した場所を表示するプラクティス

環境やバージョンについて:

  • Xcode8.2
  • Swift3.0.2
  • MacOS Sierra (Ver10.12.2)

使用ライブラリ:

本サンプルではRxSwiftとRxCocoaに加え【Chapter2】のサンプルではRxAlamofire及びObjectMapperを使用しています。

また、【Chapter3】のサンプルに関しては、下記の@koogawa様のライブラリ及びサンプルでの実装を元に作成したものになります。本当に素敵なサンプル及びライブラリをありがとうございますm(_ _)m

非常に便利なAPIクライアントでしたので、今後も積極的に活用していきたく思います!

今回紹介する2つのサンプルを作成するにあたり必要なライブラリとPodfileの書き方については下記の通りになります。


target 'RxSwiftPracticeNote' do
  use_frameworks!
  # RxSwift使用時に必要なライブラリ
  pod 'RxSwift'
  pod 'RxCocoa'
  ・・・(省略)・・・
  # 便利ライブラリ(Chapter2で使用)
  pod 'RxAlamofire/RxCocoa'
  pod 'ObjectMapper'
  # 便利ライブラリ(Chapter3で使用)
  pod 'SwiftyJSON'
  pod 'FoursquareAPIClient'
  pod 'SDWebImage'
end

※1. 実際にお手元で動かす場合には試して見たいサンプルのStoryBoard上のInitialViewControllerの矢印の位置を変更して各サンプルの動きを見て頂ければと思います。

chapter2_capture.png

※2. このサンプルに関しては適宜内容やサンプルケースの追加や修正を行う場合もあります。

3. Driverパターン及びMVVMでの構成に関する概要のまとめ

★3-1. Driverパターンを使用するメリットとデータ⇄UIの流れについて

UIの処理部はメインスレッドで行う必要があるので、非同期処理で取得したデータを取得した場合はこの処理が終わった後にUIに伝える必要があります。
もし非同期処理を行っている際に何がしかのエラーが発生してしまった場合に、UIに適用する前段階でエラーをさばいてクラッシュやUI側の処理が正しく行われるようにするための考慮も必要になります。

driver_pattern.jpg

また、任意のObservableシーケンスはDriver Unitへの変換が可能なので、ViewにObservableを適合するために必要な処理を裏側で行ってくれるDriverに変換する処理をUI関連処理をする前に行うことで、処理をしやすくする形にするのが今回の処理の勘所になるかと思います。

★3-2. 本サンプルでのMVVM構成における各々の役割について

今回の2つのサンプルはMVVM「Model層 - ViewModel層 - View層」の構成を取っています。それぞれの主な役割を図示すると下記のような形になるかと思います。

mvvm_flow.jpg

  1. Model層ではデータ定義とデータに関するマッピングやビジネスロジック等の表示のために必要なデータを用意する役割
  2. ViewModel層ではModel層とView層との仲介役としてUI側の変更を伝えたり、データを受け取って表示する値を整形する等の役割
  3. View層ではDriverを利用して変更させたいUIにバインドさせてデータの流れに応じた変化を受け取って表示を変更する役割

また、Driverパターンの概要やメリット、そして主な役割を知る際には下記の記事を参考にしました。Observableだけでも処理を実現することは十分に可能ですが、メインスレッドでObservable状態にすることやエラーを通知しない考慮がされているDriverへ変換することでより扱いやすくするといった形になります。

4. 【Chapter2】 GithubのAPIを利用してuser名検索してリポジトリ一覧をUITableViewに表示をするプラクティス

今回のサンプルはテキストフィールドの中にテキストを入れると0.5秒が経過したタイミングで、検索処理がバックグラウンドで実行されます。そして該当のリポジトリが見つかった場合には一覧のUITableView内に検索結果が表示され、結果が0件の場合には該当リポジトリが存在しない旨のポップアップ表示がされるような形の動きになります。

該当のセルをタップするとそのリポジトリのページへ遷移すると同時にテキストフィールドの開閉に関する考慮も行なっています。またView層部分の処理に関してはRxSwiftの実装が絡むUI部分setupRx()とRxSwiftが絡まない部分setupUi()を明示的に分割しています。

★4-1. サンプルの全体的な処理ポイントに関して

今回のサンプルに関するポイントをざっくりと図解にまとめると下記のようになります。このサンプルではデータとのマッピング部分にはObjectMapperを活用した形にしています。そしてViewModel側で「Observableな変数に対して、「.subscribeOn」→「.observeOn」→「.observeOn」...という形で数珠つなぎで処理を実行する」ような形でデータの取得処理を行なった後にDriverに変換する一連の流れがポイントになってくるかと思います。そして最終的にはDriverメソッドを用いて取得できたデータとUIをバインドする処理を行う形になります。

chapter2_introduction.jpg

今回紹介するものと後述するサンプルコードに関しては、全体的にボリュームも多かったため、サンプル全体のコードの要所となる部分にコメントを残すような形でポイントや実装の参考にしたリンクを掲載する形でまとめてみました。

★4-2. 各々のファイルの役割と処理のポイントに関して

1. Model層部分の実装に関して:

Repository.swift
/**
 * GithubのAPIより取得する項目を定義する(ObjectMapperを使用して表示したいものだけを抽出してマッピングする)
 * Model層に該当する部分
 */
import ObjectMapper

class Repository: Mappable {

    //表示する値を変数として定義
    var identifier: Int!
    var html_url: String!
    var name: String!

    //イニシャライザ
    required init?(map: Map) {}

    //ObjectMapperを利用したデータのマッピング
    func mapping(map: Map) {
        identifier <- map["id"]
        html_url <- map["html_url"]
        name <- map["name"]
    }
}

2. ViewModel層部分の実装に関して:

RepositoriesViewModel.swift
/**
 * リクエストで受け取った結果をDriverに変換するための部分
 * ViewModel層に該当する部分
 */
struct RepositoriesViewModel {

    /**
     * オブジェクトの初期化に合わせてプロパティの初期値を決定したいのでlazy varにする
     *
     * (参考)Swiftのlazyの使い所
     * http://blog.sclap.info/entry/swift-how-to-use-lazy
     */
    lazy var rx_repositories: Driver<[Repository]> = self.fetchRepositories()

    //監視対象のメンバ変数
    fileprivate var repositoryName: Observable<String>

    //監視対象の変数初期化処理(イニシャライザ)
    init(withNameObservable nameObservable: Observable<String>) {
        self.repositoryName = nameObservable
    }

    /**
     * GithubAPIへアクセスしてデータを取得してViewController側のUI処理とバインドするためにDriverに変換をする処理
     * (※データ取得にはRxAlamofireを使用)
     */
    fileprivate func fetchRepositories() -> Driver<[Repository]> {

        /**
         * Observableな変数に対して、「.subscribeOn」→「.observeOn」→「.observeOn」...という形で数珠つなぎで処理を実行
         * 処理の終端まで無事にたどり着いた場合には、ObservableをDriverに変換して返却する
         */
        return repositoryName

            //処理Phase1: 見た目に関する処理
            .subscribeOn(MainScheduler.instance) //メインスレッドで処理を実行する
            .do(onNext: { response in

                //ネットワークインジケータを表示状態にする
                UIApplication.shared.isNetworkActivityIndicatorVisible = true
            })

            //処理Phase2: 下記のAPI(GithubAPI)のエンドポイントへRxAlamofire経由でのアクセスをする
            .observeOn(ConcurrentDispatchQueueScheduler(qos: .background)) //バックグラウンドスレッドで処理を実行する
            .flatMapLatest { text in

                //APIからデータを取得する
                return RxAlamofire
                    .requestJSON(.get, "https://api.github.com/users/\(text)/repos")
                    .debug()
                    .catchError { error in

                        //エラー発生時の処理(この場合は値を持たせずにここで処理を止めてしまう)
                        return Observable.never()
                }
            }

            //処理Phase3: ModelクラスとObjectMapperで定義した形のデータを作成する
            .observeOn(ConcurrentDispatchQueueScheduler(qos: .background)) //バックグラウンドスレッドで処理を実行する
            .map { (response, json) -> [Repository] in

                //APIからレスポンスが取得できた場合にはModelクラスに定義した形のデータを返却する
                if let repos = Mapper<Repository>().mapArray(JSONObject: json) {
                    return repos
                } else {
                    return []
                }
            }

            //処理Phase4: データが受け取れた際の見た目に関する処理とDriver変換
            .observeOn(MainScheduler.instance) //メインスレッドで処理を実行する
            .do(onNext: { response in
                UIApplication.shared.isNetworkActivityIndicatorVisible = false //ネットワークインジケータを非表示状態にする
            })
            .asDriver(onErrorJustReturn: []) //Driverに変換する
    }
}

3. View層部分の実装に関して:

RepositoryListController.swift
import UIKit
import RxSwift
import RxCocoa
import ObjectMapper
import RxAlamofire

/*
 【Chapter2】GithubのAPIを利用してuser名を検索してリポジトリ一覧をUITableViewに一覧表示をするプラクティス

 このサンプルを作成する上での参考資料
 -----------
 ・通信+便利ライブラリを使用してにRxSwiftを使用した「View層 + Model層 + ViewModel層」のサンプル
 -----------
 【写したサンプルコード(一部だけカスタマイズ)】
 ・解説(英語)
 http://www.thedroidsonroids.com/blog/ios/rxswift-examples-4-multithreading/
 ・リポジトリ ※他にもサンプルたくさんある
 https://github.com/DroidsOnRoids/RxSwiftExamples/tree/master/Libraries%20Usage/RxAlamofireExample/RxAlamofireExample

 (参考)Webアプリケーション開発者から見た、MVCとMVP、そしてMVVMの違い
 http://qiita.com/shinkuFencer/items/f2651073fb71416b6cd7

 (参考)MVVM入門(objc.io #13 Architecture 日本語訳)
 http://qiita.com/FuruyamaTakeshi/items/6c4404f1fd61e3fa4eb7

 【GithubのAPIについて】
 https://developer.github.com/guides/getting-started/
 */

class RepositoryListController: UIViewController {

    //UIパーツの配置
    @IBOutlet weak var nameSearchBar: UISearchBar!
    @IBOutlet weak var repositoryListTableView: UITableView!

    @IBOutlet weak var tableViewBottomConstraint: NSLayoutConstraint!

    //disposeBagの定義
    let disposeBag = DisposeBag()

    //ViewModelのインスタンス格納用のメンバ変数
    var repositoriesViewModel: RepositoriesViewModel!

    //検索ボックスの値変化を監視対象にする(テキストが空っぽの場合はデータ取得を行わない)
    var rx_searchBarText: Observable<String> {
        return nameSearchBar.rx.text
            .filter { $0 != nil }
            .map { $0! }
            .filter { $0.characters.count > 0 }
            .debounce(0.5, scheduler: MainScheduler.instance) //0.5秒のバッファを持たせる
            .distinctUntilChanged()
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        //RxSwiftでの処理に関する部分をまとめたメソッドを実行
        setupRx()

        //RxSwiftを使用しない処理に関する部分をまとめたメソッド実行
        setupUI()
    }

    //ViewModelを経由してGithubの情報を取得してテーブルビューに検索結果を表示する
    func setupRx() {

        /**
         * メンバ変数の初期化(検索バーでの入力値の更新をトリガーにしてViewModel側に設置した処理を行う)
         *
         * (フロー1) → 検索バーでの入力値の更新が「データ取得のトリガー」になるので、ViewModel側に定義したfetchRepositories()メソッドが実行される
         * (フロー2) → fetchRepositories()メソッドが実行後は、ViewModel側に定義したメンバ変数rx_repositoriesに値が格納される
         *
         * 結果的に、githubのアカウント名でのインクリメンタルサーチのようになる
         */
        repositoriesViewModel = RepositoriesViewModel(withNameObservable: rx_searchBarText)

        /**
         *(UI表示に関する処理の流れの概要)
         *
         * リクエストをして結果が更新されるたびにDriverからはobserverに対して通知が行われ、
         * driveメソッドでバインドしている各UIの更新が働くようにしている。
         * 
         * (フロー1) → テーブルビューへの一覧表示
         * (フロー2) → 該当データが0件の場合のポップアップ表示
         */

        //リクエストした結果の更新を元に表示に関する処理を行う(テーブルビューへのデータ一覧の表示処理)
        repositoriesViewModel
            .rx_repositories
            .drive(repositoryListTableView.rx.items) { (tableView, i, repository) in
                let cell = tableView.dequeueReusableCell(withIdentifier: "RepositoryCell", for: IndexPath(row: i, section: 0))
                cell.textLabel?.text = repository.name
                cell.detailTextLabel?.text = repository.html_url

                return cell
            }
            .addDisposableTo(disposeBag)

        //リクエストした結果の更新を元に表示に関する処理を行う(取得したデータの件数に応じたエラーハンドリング処理)
        repositoriesViewModel
            .rx_repositories
            .drive(onNext: { repositories in

                //データ取得ができなかった場合だけ処理をする
                if repositories.count == 0 {

                    let alert = UIAlertController(title: ":(", message: "No repositories for this user.", preferredStyle: .alert)
                    alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil))

                    //ポップアップを閉じる
                    if self.navigationController?.visibleViewController is UIAlertController != true {
                        self.present(alert, animated: true, completion: nil)
                    }
                }
            })
            .addDisposableTo(disposeBag)
    }

    //キーボードのイベント監視の設定 & テーブルビューに付与したGestureRecognizerに関する処理
    //この部分はRxSwiftの処理ではないので切り離して書かれている形?
    func setupUI() {

        /**
         * 2017/01/14: 補足事項
         *
         * Notification周りやGesture周りもRxでの記載が可能
         *
         * (記載例)
         * ----------
         * Notification: 
         * ----------
         * NotificationCenter.default.rx.notification(.UIKeyboardWillChangeFram) ...
         * NotificationCenter.default.rx.notification(.UIKeyboardWillHide) ...
         *
         * ----------
         * Gesutre:
         * ----------
         * let tap = UITapGestureRecognizer(target: self, action: #selector(tableTapped(_:)))
         * let didTap = stap.rx.event ...
         * 
         * → NotificationやGestureに関しても、このような記述をすることでObservableとして利用可能!
         *
         * (さらに参考になった資料)【RxSwift入門】普段使ってるこんなんもRxSwiftで書けるんよ
         * http://qiita.com/ikemai/items/8d3efcc71ea9db340484
         *
         * RxKeyboard:
         * https://github.com/RxSwiftCommunity/RxKeyboard/blob/master/Sources/RxKeyboard.swift
         */

        //テーブルビューにGestureRecognizerを付与する
        let tap = UITapGestureRecognizer(target: self, action: #selector(tableTapped(_:)))
        repositoryListTableView.addGestureRecognizer(tap)

        //キーボードのイベントを監視対象にする
        //Case1. キーボードを開いた場合のイベント
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(keyboardWillShow(_:)),
            name: NSNotification.Name.UIKeyboardWillShow,
            object: nil)

        //Case2. キーボードを閉じた場合のイベント
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(keyboardWillHide(_:)),
            name: NSNotification.Name.UIKeyboardWillHide,
            object: nil)
    }

    //キーボード表示時に発動されるメソッド
    func keyboardWillShow(_ notification: Notification) {

        //キーボードのサイズを取得する(英語のキーボードが基準になるので日本語のキーボードだと少し見切れてしまう)
        guard let keyboardFrame = (notification.userInfo?[UIKeyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue else { return }

        //一覧表示用テーブルビューのAutoLayoutの制約を更新して高さをキーボード分だけ縮める
        tableViewBottomConstraint.constant = keyboardFrame.height
        UIView.animate(withDuration: 0.3, animations: {
            self.view.updateConstraints()
        })
    }

    //キーボード非表示表示時に発動されるメソッド
    func keyboardWillHide(_ notification: Notification) {

        //一覧表示用テーブルビューのAutoLayoutの制約を更新して高さを元に戻す
        tableViewBottomConstraint.constant = 0.0
        UIView.animate(withDuration: 0.3, animations: {
            self.view.updateConstraints()
        })
    }

    //メモリ解放時にキーボードのイベント監視対象から除外する
    deinit {
        NotificationCenter.default.removeObserver(self)
    }

    //テーブルビューのセルタップ時に発動されるメソッド
    func tableTapped(_ recognizer: UITapGestureRecognizer) {

        //どのセルがタップされたかを探知する
        let location = recognizer.location(in: repositoryListTableView)
        let path = repositoryListTableView.indexPathForRow(at: location)

        //キーボードが表示されているか否かで処理を分ける
        if nameSearchBar.isFirstResponder {

            //キーボードを閉じる
            nameSearchBar.resignFirstResponder()

        } else if let path = path {

            //タップされたセルを中央位置に持ってくる
            repositoryListTableView.selectRow(at: path, animated: true, scrollPosition: .middle)
        }
    }

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
    }

}

View層の実装に関しては今回はUIまわりの中でも、ViewModel層から取得したデータとUIとのバインドを行う部分に関してのみRxSwiftで実装を行なっています。もちろんキーボードの開閉に関する部分の処理やセルタップ時の処理についてもRxSwiftの記述に寄せて記述することも可能です。
(ソースコード内の補足コメントにこの部分に関するものを記しています)

このサンプルにおける処理の大きなポイントとしては、取得したGithubからのリポジトリのデータを取得する非同期処理の中におけるObservableのハンドリングとDriverへの変換処理をViewModel内で行った上で、View内のUI部品とのバインドするタイミングでdriveメソッドを用いてデータとバインドする形になっている点です。

このようにすることで、ViewにObservableを適合するために必要な処理を裏側で行なってくれるDriverを使うことによって、ハンドリングが面倒なUI部分の実装をしやすくしてくれます。

後述するサンプルでもView層の部分でも最終的には、UI部品とバインドさせるタイミングの前にDriverに変換をする形で実装する方針は同じですので、この点を踏まえて実装や処理の組み立てを行うようにすると、イメージがしやすくなるのではないかと思いました(この点については私もまだまだな部分なので引き続き精進します)

5. 【Chapter3】 FoursquareAPIを利用して検索した場所を表示するプラクティス

こちらで紹介しているサンプルも基本的な実装に関しては【Chapter2】でのサンプルと基本的な動きに関してはほとんど同じような形の実装になっています。
テキストフィールドから受け取った値を元にFoursquareのAPIでの検索を行い、検索結果が取得できたタイミングでUITableViewに表示する形になっています。元のサンプルから自分なりにアレンジを加えた点としては、

  1. アイコンの表示URLをSDWebImageを利用してキャッシュさせる処理を追加
  2. UITableViewCellクラスを継承した独自クラスとXibファイルを作成して自作のセルに取得データを表示する処理を追加

の2点になります。基本的には@koogawa様のリポジトリ内のサンプル及び下記の解説を参考にした上で「基本の実装+アレンジ」をしました。
※UITableViewDelegate及びUITableViewDataSourceに関しても、外部ファイルにして管理するような形になっています。

また今回使用したライブラリFoursquareAPIClientないしはFoursquareが提供している開発者向けAPIを活用したアプリを開発する場合には、下記の開発者向けページよりCLIENT_ID及びCLIENT_SECRETを取得する必要がありますので、下記のドキュメントを参考に取得して事前準備を行なって頂ければと思います。(念のためFoursquareのAPIのエンドポイントや取得データに関するドキュメントも併せて活用して頂ければと思います)

※このサンプルのConstants.swiftはfoursquareの情報を取得するためのAPIキーとシークレットキーを設定するenumを下記のように設定しています。

Constants.swift
enum APIKey: String {
    case foresquare_clientid = "自分のクライアントID"
    case foresquare_clientsecret = "自分のシークレット"
}

※上記のファイルに関しては、本サンプルからは除外していますのでご注意ください。

★5-1. サンプルの全体的な処理ポイントに関して

今回のサンプルに関するポイントをざっくりと図解にまとめると下記のようになります。このサンプルではFoursquareAPIClientSwiftyJSONを利用しています。処理のポイントとしては、FoursquareのAPIにアクセスしてデータを取得しそのデータをSwiftyJSONを用いてModelで定義した形式に合うようにデータのマッピングを行うロジックにしています。

データが取得できたタイミングでObservableに変換して値の変化を検知できるような形にしておき、View層での値の変化が起こったタイミングでさらにDriverに変換することでUI層と取得データのバインドを行うという形にしています。

chapter3_introduction.jpg

こちらのサンプルについても同様に、サンプル全体のコードの要所となる部分にコメントを残すような形でポイントや実装の参考にしたリンクを掲載する形でまとめています。

★5-2. 各々のファイルの役割と処理のポイントに関して

1. Model層部分の実装に関して(取得データの定義とマッピングに関するロジック部分):

Venue.swift
import UIKit
import SwiftyJSON

//アイコンのサイズに関する定数
//(参考)https://developer.foursquare.com/docs/responses/category
let kCategoryIconSize = 88

/**
 * ForesquareAPIから取得した情報に関する定義(Model層に該当)
 * 
 * (参考)【iOS Swift入門 #255】独自クラスでログ出力(description)を実装する
 * http://swift.swift-studying.com/entry/2015/09/14/090849
 */
struct Venue: CustomStringConvertible {

    let venueId: String
    let name: String
    let address: String?
    let latitude: Double?
    let longitude: Double?
    let state: String?
    let city: String?
    let categoryIconURL: URL?

    //取得データの詳細に関する変数
    var description: String {
        return "<venueId=\(venueId)"
            + ", name=\(name)"
            + ", address=\(address)"
            + ", latitude=\(latitude), longitude=\(longitude)"
            + ", state=\(state)"
            + ", city=\(city)"
            + ", categoryIconURL=\(categoryIconURL)>"
    }

    //イニシャライザ(取得したForesquareAPIからのレスポンスに対して必要なものを抽出する)
    init(json: JSON) {

        //ForesquareAPIからのレスポンスで主要情報を取得する(SWiftyJSONを使用)
        self.venueId = json["id"].string ?? ""
        self.name = json["name"].string ?? ""
        self.address = json["location"]["address"].string
        self.latitude = json["location"]["lat"].double
        self.longitude = json["location"]["lng"].double
        self.state = json["location"]["state"].string ?? ""
        self.city = json["location"]["city"].string ?? ""

        //ForesquareAPIからのレスポンスでカテゴリーを元にしてアイコンのURLを作成する(SWiftyJSONを使用)
        if let categories = json["categories"].array, categories.count > 0 {
            let iconPrefix = json["categories"][0]["icon"]["prefix"].string ?? ""
            let iconSuffix = json["categories"][0]["icon"]["suffix"].string ?? ""
            let iconUrlString = String(format: "%@%d%@", iconPrefix, kCategoryIconSize, iconSuffix)
            self.categoryIconURL = URL(string: iconUrlString)
        }
        else {
            self.categoryIconURL = nil
        }
    }
}

2. Model層部分の実装に関して(FoursquareAPIClientを経由したFoursquareのAPIとの通信処理):

VenueAPIClient.swift
import Foundation
import RxSwift
import SwiftyJSON
import FoursquareAPIClient

//Foresquareのベニュー情報を取得用のクライアント部分(実際のデータ通信部分)
class VenuesAPIClient {

    //クエリ文字列を元に検索を行う
    func search(query: String = "") -> Observable<[Venue]> {

        //Observable戻り値に対して
        return Observable.create{ observer in

            //APIクライアントへのアクセス用の設定
            let client = FoursquareAPIClient(
                clientId: APIKey.foresquare_clientid.rawValue,
                clientSecret: APIKey.foresquare_clientsecret.rawValue
            )

            //検索用のパラメータの設定(暫定的に東京メトロ新大塚駅にしています)
            let parameter: [String : String] = [
                "ll": "35.7260747,139.72983",
                "query": query
            ]

            //クライアントへのアクセス
            client.request(path: "venues/search", parameter: parameter) {
                [weak self] data, error in

                //データの取得と参照に関するチェックをする
                guard let strongSelf = self, let data = data else { return }

                //APIのJSONを解析する
                let json = JSON(data: data)
                let venues = strongSelf.parse(venuesJSON: json["response"]["venues"])

                //パースしてきたjsonの値を通知対象にする
                //(参考)RxSwiftの動作を深く理解する
                //http://qiita.com/k5n/items/643cc07e3973dd1fded4
                observer.on(.next(venues))
                observer.on(.completed)
            }

            //この取得処理を監視対象からはずすための処理(自信ない...)
            return Disposables.create {}
        }
    }

    //Venue.swift(Model層)で定義した形で取得した値を格納する
    fileprivate func parse(venuesJSON: JSON) -> [Venue] {
        var venues = [Venue]()
        for (key: _, venueJSON: JSON) in venuesJSON {
            venues.append(Venue(json: JSON))
        }
        return venues
    }
}

3. ViewModel層部分の実装に関して:

VenueViewModel.swift
import UIKit
import RxSwift
import SwiftyJSON
import FoursquareAPIClient

class VenueViewModel {

    fileprivate(set) var venues = Variable<[Venue]>([])

    //ForesquareのAPIクライアントのインスタンス
    let client = VenuesAPIClient()

    //disposeBagの定義
    let disposeBag = DisposeBag()

    //イニシャライザ
    init() {}

    //APIクライアント経由で情報を取得する
    public func fetch(query: String = "") {

        //APIクライアントのメソッドを実行する
        client.search(query: query)
            .subscribe { [weak self] result in

                //結果取得ができた際には、APIクライアントの変数:venuesに結果の値を入れる
                switch result {
                case .next(let value):
                    self?.venues.value = value
                case .error(let error):
                    print(error)
                case .completed:
                    ()
                }
            }
            .addDisposableTo(disposeBag)
    }
}

4. View層部分の実装に関して:

VenueSearchViewController.swift
import UIKit
import RxSwift
import RxCocoa
import SafariServices

/*
 【Chapter3】FoursquareAPIを利用して検索した場所を表示するプラクティス

 このサンプルを作成する上での参考資料
 -----------
 ・通信+便利ライブラリを使用してにRxSwiftを使用した「View層 + Model層 + ViewModel層」のサンプル
 -----------
 【参考にさせて頂いたサンプルコード(一部カスタマイズ)】

 ・解説(ありがとうございました!)
 http://blog.koogawa.com/entry/2016/10/23/195454

 ・リポジトリ
 https://github.com/koogawa/RxSwiftSample

 (参考)Webアプリケーション開発者から見た、MVCとMVP、そしてMVVMの違い
 http://qiita.com/shinkuFencer/items/f2651073fb71416b6cd7

 (参考)MVVM入門(objc.io #13 Architecture 日本語訳)
 http://qiita.com/FuruyamaTakeshi/items/6c4404f1fd61e3fa4eb7

 (参考)RxSwiftを使ってアプリを作ってみて、よく使った書き方
 http://qiita.com/Tueno@github/items/099d287217b38c314e1e

 【FoursquareのAPIについて】
 https://developer.foursquare.com/
 */
class VenueSearchViewController: UIViewController {

    //UIパーツの配置
    @IBOutlet weak var venueSearchTableView: UITableView!
    @IBOutlet weak var venueSearchBar: UISearchBar!
    @IBOutlet weak var bottomVenueTableConstraint: NSLayoutConstraint!

    //ViewModel・デリゲート・データソースのインスタンスを設定
    var venueViewModel = VenueViewModel()
    var venueDataSource = VenueDataSource()
    var venueDelegate = VenueDelegate()

    //disposeBagの定義
    let disposeBag = DisposeBag()

    override func viewDidLoad() {
        super.viewDidLoad()

        //RxSwiftでの処理に関する部分をまとめたメソッドを実行
        setupRx()

        //RxSwiftを使用しない処理に関する部分をまとめたメソッド実行
        setupUI()
    }

    //データ表示用のテーブルビュー内にForesquareから取得した情報を表示する
    fileprivate func setupRx() {

        //配置したテーブルビューにデリゲートの適用をする
        venueSearchTableView.delegate = self.venueDelegate

        //Xibのクラスを読み込む宣言を行う
        let nibTableView: UINib = UINib(nibName: "VenueCell", bundle: nil)
        venueSearchTableView.register(nibTableView, forCellReuseIdentifier: "VenueCell")

        //検索バーの変化から0.5秒後に.driveメソッド内の処理を実行する
        venueSearchBar.rx.text.asDriver()
            .throttle(0.5)
            .drive(onNext: { query in

                //ViewModelに定義したfetchメソッドを実行
                self.venueViewModel.fetch(query: query!)
            })
            .addDisposableTo(disposeBag)

        //データの取得ができたらテーブルビューのデータソースの定義に則って表示する値を設定する
        venueViewModel.venues
            .asDriver()
            .drive (
                self.venueSearchTableView.rx.items(dataSource: self.venueDataSource)
            )
            .addDisposableTo(disposeBag)

        //テーブルビューのセルを選択した際の処理
        venueSearchTableView.rx.itemSelected

            //テーブルビューのセルを選択した場合にはindexPathを元にセルの情報を取得する
            .bindNext { [weak self] indexPath in

                //この値を元に具体的な処理を記載する
                if let venue = self?.venueViewModel.venues.value[indexPath.row] {

                    //PLAN: 場所の表示だけではなく何かしらアレンジできればGood!

                    //キーボードが表示されていたらキーボードを閉じる
                    if (self?.venueSearchBar.isFirstResponder)! {
                        self?.venueSearchBar.resignFirstResponder()
                    }

                    //Foursquareのページを表示する
                    let urlString = "https://foursquare.com/v/" + venue.venueId
                    if let url = URL(string: urlString) {
                        let safariViewController = SFSafariViewController(url: url)
                        self?.present(safariViewController, animated: true, completion: nil)
                    }

                    //DEBUG: 取得データに関するチェック
                    print("-----------")
                    print(venue.venueId)
                    print(venue.name)
                    print(venue.city ?? "")
                    print(venue.state ?? "")
                    print(venue.address ?? "")
                    print(venue.latitude ?? "")
                    print(venue.longitude ?? "")
                    print(venue.categoryIconURL ?? "")
                    print("-----------")
                    print("")

                }
            }
            .addDisposableTo(disposeBag)
    }

    func setupUI() {

        //キーボードのイベントを監視対象にする
        //Case1. キーボードを開いた場合のイベント
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(keyboardWillShow(_:)),
            name: NSNotification.Name.UIKeyboardWillShow,
            object: nil)

        //Case2. キーボードを閉じた場合のイベント
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(keyboardWillHide(_:)),
            name: NSNotification.Name.UIKeyboardWillHide,
            object: nil)
    }

    //キーボード表示時に発動されるメソッド
    func keyboardWillShow(_ notification: Notification) {

        //キーボードのサイズを取得する(英語のキーボードが基準になるので日本語のキーボードだと少し見切れてしまう)
        guard let keyboardFrame = (notification.userInfo?[UIKeyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue else { return }

        //一覧表示用テーブルビューのAutoLayoutの制約を更新して高さをキーボード分だけ縮める
        bottomVenueTableConstraint.constant = keyboardFrame.height
        UIView.animate(withDuration: 0.3, animations: {
            self.view.updateConstraints()
        })
    }

    //キーボード非表示表示時に発動されるメソッド
    func keyboardWillHide(_ notification: Notification) {

        //一覧表示用テーブルビューのAutoLayoutの制約を更新して高さを元に戻す
        bottomVenueTableConstraint.constant = 0.0
        UIView.animate(withDuration: 0.3, animations: {
            self.view.updateConstraints()
        })
    }

    //メモリ解放時にキーボードのイベント監視対象から除外する
    deinit {
        NotificationCenter.default.removeObserver(self)
    }

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
    }

}

4. Component(UITableViewDelegate&UITableViewDataSource)部分の実装に関して:

今回は独自で作成したカスタムセルを使用する形にしているので、参考にさせて頂いたサンプルを元に少しアレンジを加えた実装にしています。

外部クラスに切り出したUITableViewDelegateクラス内ではセルの高さの定義を行なっています(記述方法については従来通りの記載とほとんど同じ形です)。

そしてUITableViewDataSource部分のポイントとなるのは下記のようにRxSwiftの実装に合わせてRxTableViewDataSourceTypeのメソッドを拡張してObserverに次のデータが渡ってきた際の処理を下記のように設定する形にしています(その他セルへの表示や表示数・セクション数に関しては従来通りの記載とほとんど同じ形です)。

VenueDataSource.swift
//RxTableViewDataSourceTypeのメソッドを拡張して設定する
public func tableView(_ tableView: UITableView, observedEvent: Event<Element>) {
    switch observedEvent {
    case .next(let value):
        self.venues = value
        tableView.reloadData()
    case .error(_):
        ()
    case .completed:
        ()
    }
}

また、View層の部分の実装についてはdriveメソッド内でデータを表示させる処理を行う形にします。

VenueSearchViewController.swift
self.venueSearchTableView.rx.items(dataSource: self.venueDataSource)

今回はFoursquareのAPIクライアントライブラリを活用した実装例になりますが、その他のAPIを活用するような実装で活用する場合も下記のように、基底となるようなAPIクライアントのクラスを作成しておき、このクラスを活用する形でうまくMVVMの構成に合わせて実装していく形になるかなと思います。

6. サンプル実装の際に参考にしたその他資料集

RxSwiftのDriverパターンを用いた実装に関しては、下記の記事と実装例が非常に参考になりました。サンプルはSwift3の例ではありませんが、実装を組み立てて行く際のポイントとなる部分がとても丁寧にまとめられており、Driverパターン使用例やMVVM構成のイメージを掴む際には非常に参考になりました。

下記は本家ReactiveX/RxSwiftのリポジトリを和訳している方のリポジトリになりますが、READMEの中盤あたりにDriverに関する和訳が掲載されていますので、こちらも英語のドキュメントを読む際と合わせて活用すると参考になるかと思います。

また、今回サンプルで紹介したDriverパターンだけではなくRxSwiftの実装や理解を深める際や、これからRxSwiftの基本的な実装に関する知識や概念を整理する上で下記のスライド資料も参考にしました。

なかなか普段から触れる機会がないと忘れがちになってしまうので、このあたりの基本的な概念や用使うであろうメソッドの機能や実装例に関しては定期的に私も復習しておかないといけないなと感じた次第です。

7. あとがき

これまで実装や解説を行なったトピックやサンプルに関してもUI実装の部分に関するものが多かったこともあり、RxSwiftの実装練習を行うにあたってもできるだけUI実装を伴うものに取り組む方が実装のイメージやポイントを持ち易いかと思ったので、前編に書いたものも含めて今回のサンプルはObserverパターンとDriverパターンを使用するものに思い切って絞った上で、参考にした資料や記事を元に自分なりの表現にしてまとめてみました。

今回収録したサンプルに関しても、前回の記事と同様に解釈や処理の書き方に関しては「こうするともっと良く書ける」ないしは「このコメントの表現はこのように修正した方が良い」という部分が多々あるかと思いますが、その際にはpull requestやissue等を頂けますと嬉しく思います。

今回の取り組みの中で、実際に簡単なサンプルをピックアップした上で実装(写経)をした上で自分の言葉でまとめてみると、少しずつ苦手意識が以前より薄れてきた + こんな場合に活用すると便利かもしれないという(自分なりの)予想ができたのは大きな収穫でした。

今後の展望としては機会があれば、自分が作成するUI実装のサンプル内の機能においてRxSwiftが活用できそうな余地がある場合には取り入れてみようかなと思っております。

追記1. Swift4.1 & XCode9.4に対応した際の変更点

遅ればせながら、本記事で掲載しているサンプルのバージョンを現在の最新版に更新しました。その中でサンプル内でバージョンアップ時に行ったコードの変更点に関して簡単にまとめておきました。

サンプルを検証&修正した際のバージョン:

  • Xcode9.4
  • Swift4.1
  • MacOS Hight Sierra (Ver10.13.4)

RxCocoa及びRxSwift4.1.2を使用しています。

その1: 【Chapter2】GithubのAPIを利用してuser名検索してリポジトリ一覧をUITableViewに表示をするプラクティスのコード内での変更点:

Swift4にバージョンアップした際には、前編での補足に追記した点 と同様のメソッド名の書き直しを行いました。
表示対象のUIパーツ内のプロパティとイベントの観測状態を紐づけるbindTo(●●)メソッドがbind(to: ●●)に変更になったことと、観測状態からの解放をするaddDisposableTo(●●)メソッドが.disposed(by: ●●)に変更になった部分の修正のみでいけるかと思います。

その2: 【Chapter3】FoursquareAPIを利用して検索した場所を表示するプラクティスのコード内での変更点:

Swift4にバージョンアップした際には、ViewController及びViewModelに関する部分についてはメソッド名の書き直しを行っています。
bindTo(●●).disposed(by: ●●)以外のものとしては、UITableViewCellをタップした際の処理に関する.bindNext { [weak self] indexPath in ... }.bind { [weak self] indexPath in ... }へ変更する点になります。

加えて直接RxSwiftのバージョンアップに起因する修正点ではありませんが、VenueAPIClient.swiftでFoursquareのAPIへリクエストをする処理の部分に関しては下記の様な形に変更しています。

  • Swift3:
client.request(path: "venues/search", parameter: parameter) {
    [weak self] data, error in

    //データの取得と参照に関するチェックをする
    guard let strongSelf = self, let data = data else { return }

    //APIのJSONを解析する
    let json = JSON(data: data)
    let venues = strongSelf.parse(venuesJSON: json["response"]["venues"])

    //パースしてきたjsonの値を通知対象にする
    //(参考)RxSwiftの動作を深く理解する
    //http://qiita.com/k5n/items/643cc07e3973dd1fded4
    observer.on(.next(venues))
    observer.on(.completed)
}
  • Swift4:
client.request(path: "venues/search", parameter: parameter) {
    result in

    switch result {
    case .success(let data):
        //APIのJSONを解析する
        let json = try! JSON(data: data)
        let venues = self.parse(venuesJSON: json["response"]["venues"])

        //パースしてきたjsonの値を通知対象にする
        //(参考)RxSwiftの動作を深く理解する
        //http://qiita.com/k5n/items/643cc07e3973dd1fded4
        observer.on(.next(venues))
        observer.on(.completed)

    case .failure(let error):
        observer.onError(error)
    }
}

※ この他にも変更点やご不明な点等がありましたら、ご指摘頂けますと嬉しく思いますm(_ _)m

追記2. DeprecatedになったVariableに関しての変更

紹介しているコード内ですでにDeprecatedとなったVariableを利用している箇所はBehaviorRelayを利用して下記のような形で変更します。※BehaviorRelayを利用する場合には利用するファイルの先頭へimport RxCocoaの記載を追加して下さい。

サンプルを検証&修正した際のバージョン:

  • Xcode10.1
  • Swift4.2
  • MacOS Mojave (Ver10.14)

RxCocoa及びRxSwiftは4.4.0を使用しています。

【Chapter3】における変更点:

  • Before: Variableを利用している箇所
VenueViewModel.swift
import UIKit
import RxSwift
import SwiftyJSON
import FoursquareAPIClient

class VenueViewModel {

    fileprivate(set) var venues = Variable<[Venue]>([])

    ・・・(途中省略)・・・

    //APIクライアント経由で情報を取得する
    public func fetch(query: String = "") {

        //APIクライアントのメソッドを実行する
        client.search(query: query)
            .subscribe { [weak self] result in

                //結果取得ができた際には、APIクライアントの変数:venuesに結果の値を入れる
                switch result {
                case .next(let value):
                    self?.venues.value = value
                case .error(let error):
                    print(error)
                case .completed:
                    ()
                }
            }
            .disposed(by: disposeBag)
    }
}
  • After: BehaviorRelayへの書き直し対応
VenueViewModel.swift
import UIKit
import RxSwift
import RxCocoa
import SwiftyJSON
import FoursquareAPIClient

class VenueViewModel {

    private(set) var venues = BehaviorRelay<[Venue]>(value: [])

    ・・・(途中省略)・・・

    //APIクライアント経由で情報を取得する
    func fetch(query: String = "") {

        //APIクライアントのメソッドを実行する
        client.search(query: query)
            .subscribe { [weak self] result in

                //結果取得ができた際には、APIクライアントの変数:venuesに結果の値を入れる
                switch result {
                case .next(let value):
                    self?.venues.accept(value)
                case .error(let error):
                    print(error)
                case .completed:
                    ()
                }
            }
            .disposed(by: disposeBag)
    }
}

※ この他にも変更点やご不明な点等がありましたら、ご指摘頂けますと嬉しく思いますm(_ _)m

128
110
1

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
128
110