iOS
Swift
RxSwift

CodeZineにあるRxSwiftの記事(第4回)に対して自分ならこうするという話

この文章は、
「RxSwiftの仕組みを利用して、MVVMモデルを導入しよう - RxSwiftを使った一歩進んだiOSアプリ開発 第4回」に書かれている記事にある内容を、自分がやるならこうやるなーというものを解説します。

元記事ではWikipediaのWeb APIに対して文字列を送信し、その結果をtableViewに表示するというものです。

書き換えにあたってコンセプトとしては

  • 無駄にSubject(というかVariable)を使わない
    • Subjectは自由にイベントを発火できるためコードが圧倒的に読みづらくなるため
  • 無意味なprotocolは作らない
    • protocolを作るならそれを利用するコードを書く
      • 自己満足でボトムアップなインターフェースを揃えても意味がない
  • 「テストが容易」というのならテストコードを書いて示す
    • 実際にテストコードを書かないとそれが正しいかどうかを証明できない
  • RxSwiftの入門的な記事であることは念頭に置く
    • Alamofireなんてわざわざ必要ないものは使わない

記事元のコードを読んだり動かしてみての感想ですが、記事元の人はおそらく次のように考えたんではないかと思います

  • 入力からrx.asObservable()せずにわざわざPublishSubjectを作っている件
    • filterを知らなかったわけではなくUIからObservableシーケンスを繋げるとエラー時にUIが反応しなくなった
      • 異常終了の概念を理解していないのではないか

それでは実際の私のやり方を示していきます

ViewController

まずViewController

class WikipediaSearchAPIViewController: UIViewController {

    @IBOutlet private weak var searchBar: UISearchBar!
    @IBOutlet private weak var tableView: UITableView!
    private let disposeBag = DisposeBag()

    override func viewDidLoad() {
        super.viewDidLoad()

        // 1.
        let viewModel = WikipediaSearchViewModel(
            searchWord: searchBar.rx.text.orEmpty.asObservable(),
            API: WikipediaDefaultAPI(URLSession: .shared)
        )

        // 2.
        viewModel.wikipediaPages
            .bind(to: tableView.rx.items(cellIdentifier: "Cell")) { _, result, cell in
                cell.textLabel?.text = result.title
                cell.detailTextLabel?.text = result.url.absoluteString
            }
            .disposed(by: disposeBag)


        // 3. サンプルでも処理は書いてあるが肝心のnavigationBarをそもそも表示していないのでほぼ無視
        viewModel.wikipediaPages
            .subscribe(onNext: { [weak self] in
                guard let self = self else { return }
                // そもそもnavigationBarがないので何も起こらないので何もしないでおく
            })
            .disposed(by: disposeBag)

        // 4. 元記事にはないがサンプルコードダウンロードするとコードには書かれていた
        tableView.rx.modelSelected(WikipediaPage.self)
            .asDriver()
            .drive(onNext: { [weak self] searchResult in
                guard let self = self else { return }

                let safariViewController = SFSafariViewController(url: searchResult.url)
                self.present(safariViewController, animated: true)
            })
            .disposed(by: disposeBag)
    }
}
  1. ViewModelはviewDidLoadで初期化するだけで十分です。保持する必要もありません(この仕様では)
    • init時にtextのObservableとAPIを渡します
      • 元記事ではtextのObservableに対してsubscribeして文字数上限をしていますがそうすべきではないです
        • 入力一つに対してPublishSubjectを1つ作るとなると入力が増えるごとに増えていってしまいます
      • APIも外部から渡さなければテストの難易度が上がりすぎます
        • 実際に通信をしなくてもViewModelのテストができるようにします
  2. tableViewのbindはほぼ変えていません
    • ただ私ならURLはたいてい再利用するのでViewでpageidから組み立てないようにしています
  3. 元記事ではナビゲーションバーに情報出すようなコードがありますが、実際はナビゲーションバーなんて表示されてない
    • もともと不完全なので修正するの面倒
    • viewModelのresultsが変わったら変わるようにしておきますが実動作の確認はしていません
  4. 元記事では書かれていないがサンプルコードにタップイベントがあった
    • itemSelectをしてindexPathからcellの情報を取得していた
    • 本来はViewにセットした情報をそのまま信じて画面を開いたりすべきではないためModelの値を取得する

ViewModel

次にViewModel

class WikipediaSearchViewModel {
    // 1.
    let wikipediaPages: Observable<[WikipediaPage]>
    let error: Observable<Error>

    init(searchWord: Observable<String>, API: WikipediaAPI) {
        // 2.
        let results = searchWord
            .filter{ 3 <= $0.count }
            .flatMapLatest {
                return API.search(from: $0)
                    .materialize()
            }
            .share(replay: 1)
        // 3.
        wikipediaPages = results
            .flatMap { $0.element.map(Observable.just) ?? .empty() }

        error = results
            .flatMap { $0.error.map(Observable.just) ?? .empty() }
    }
}
  1. outputとしてObservable<[WikipediaPage]>を用意します
    • 元記事ではsubscribeしてvariableのオブジェクトに代入し書き換えをしているわけですが、よっぽどのことがない限りVariableを使うべきではないです
    • ViewModel内でVariableの配列が自由に書き換えられるのは自由かもしれませんがカオスです(オブジェクト指向的にRxの通知を使っているんだなということは分かる)
      • 本来、副作用をカジュアルに使わせないために関数型風の良さがあるわけですが、それをガン無視している感は読むのを困難にします
  2. filterして通信を呼び出す
    • さらにdebounce/throttleやdistinctUntilChangedで無駄に通信しないようにすると良いでしょう
      • つまりそのためにもrx.tapからsubscribeしてPublishSubjectを作るというのは間違いっす
      • 記事元はfilterを知らなかったからと思いこんでいましたが、Observableシーケンスを分断していることで異常終了時にもUIが反応しなくなるのを防ごうと思ったんでしょうか
  3. materializeする
    • 元記事ではDriver使ってエラーハンドリングしていないので余計なお世話かもしれませんがエラーハンドリングできるようにします
    • 通信エラーやWikipediaがダウンしている場合の異常系に備えます
    • 参考: RxSwiftでエラーを分岐するTips https://speakerdeck.com/yimajo/rxswiftdeerawofen-qi-surutips
      • このコードではfilter, mapを一行で書いてますが、extensionを作って取り出す方が楽です

Model

Modelとして分類されるものを示します

Response

元記事では検索結果のためにオリジナルなstruct Result { let title:String ... }というのを使っているようでした。
しかし、wikipedia検索の結果をResultという名前にしてしまうのは役割に沿って命名できているとは思えません。

そもそもSwift(やおそらく他言語)でのプログラミングでも、結果をResultという型を作ってまとめて、表現すること自体は重要なパターンでもあります。安易に特定のレスポンスの型として使うべきではでありません。

さらにそもそもSwift標準のResultというのも議論されているわけです

ということでResultという型名を避け、WikipediaPage構造体を作りましょう。しかし自前でJSONデコードなんてこのアプリの仕様上、やる必要もないわけですから、Decodableとします。

// 1. 
struct WikipediaSearchResponse: Decodable {
    let query: Query

    struct Query: Decodable {
        let search: [WikipediaPage]
    }
}

struct WikipediaPage: Decodable {
    let title: String
    let pageid: Int

    // 2.
    var url: URL {
        return URL(string: "https://ja.wikipedia.org/w/index.php?curid=\(pageid)")!
    }
}
  1. JSONのレスポンス構造に合わせています
    • レスポンス仕様に沿って設計しておくほうが後々楽ですし手間も少ないです
  2. urlというプロパティはJSONにはないんですけど用意しておきます
    • ありませんが、我々がほしいのはpageidではなくURLでしょう多分
    • wikipediaのAPIのレスポンスにあっても良さそうなのでプロパティとして生やしたりします
    • URLへの都度変換がオーバーヘッドに感じたら別の方法を検討します

このコードが正しいかっつうのを作りながら確認するために、WebAPIの結果をjsonファイルにしてXcodeからファイル追加し、ユニットテストを動作させます。だいたい一発では動きませんが失敗に気づきやすくなって良いのです。

    func test文字列Swiftとして検索したJSONファイルをDecodableでマッピングする() {

        let url = Bundle(for: type(of: self)).url(forResource: "search_swift", withExtension: "json")!

        let data = try! Data(contentsOf: url)
        let response = try! JSONDecoder().decode(WikipediaSearchResponse.self, from: data)

        print(response.query.search)
        XCTAssertNotNil(response.query.search)
        XCTAssertEqual(response.query.search.first!.title, "スイフト")
    }

protocol wikipediaAPI

元記事ではfunc getRequest(_ parameters: [String : String]) -> DataRequestというプロトコルを決めてテストがしやすいようなことを日本語で書いていますが、意味が汲み取れません。DataRequestというインスタンスが取得できてもそれでテスト容易性が上がるとは想像がつきづらいです。

仕事などで複数人でコードを書く場合、「こうするとテストをかきやすい」などのように言葉だけで伝えてこられることあると思います。それが正しいかどうかをレビューする側は指摘するのが難しいです。

なので言葉で書くのではなく、どのようにしてテストがしやすいかコードで示せばいいと思います。

私ならWikipediaAPIをプロトコルとして決め、これを通信する実装と、モック用の実装に分けます。

protocol WikipediaAPI {
    // 1.
    func search(from word: String) -> Observable<[WikipediaPage]>
}
  1. 検索したい文字列を条件として渡しObservableシーケンスを戻します
    • モックは通信せず結果を返せるようにこのプロトコルに準拠させます

MockWikipediaAPI

モック用の実装です。protocol WikipediaAPIに準拠したものを作りテスト時に差し替えられるようにします。

class MockWikipediaAPI: WikipediaAPI {

    private let results: Observable<[WikipediaPage]>

    init(results: Observable<[WikipediaPage]>) {
        // 1. 
        self.results = results
    }
    // 2.
    func search(from word: String) -> Observable<[WikipediaPage]> {
        return results
    }
}

  1. 単にテスト用のObservableを保持するだけです
    • テストコードでこのObservableを変えるわけです
  2. protocolに準拠したメソッドの実装では、内部のObservableを返すだけです

"モックだよ"という結果を返すだけのAPIができましたので使い方を示すと次のようになります。

let page = WikipediaPage(title: "モックだよ", pageid: 1)
let mock = MockWikipediaAPI(results: Observable.of([page]))

WikipediaDefaultAPI

実際に通信する実装は次のような感じです。

class WikipediaDefaultAPI : WikipediaAPI {

    private let host = URL(string: "https://ja.wikipedia.org")!
    // 1.
    private let URLSession: Foundation.URLSession

    init(URLSession: Foundation.URLSession) {
        // URLSessionも外からカスタマイズできるようにしておく
        self.URLSession = URLSession
    }

    func search(from word: String) -> Observable<[WikipediaPage]> {
        // 2.
        var components = URLComponents(url: host, resolvingAgainstBaseURL: false)!
        components.path = "/w/api.php"

        let items = [
            URLQueryItem(name: "format", value: "json"),
            URLQueryItem(name: "action", value: "query"),
            URLQueryItem(name: "list", value: "search"),
            URLQueryItem(name: "srsearch", value: word)
        ]

        components.queryItems = items
        let request = URLRequest(url: components.url!)
        // 3.
        return URLSession.rx.response(request: request)
            .map { pair in
                do {
                    let response = try JSONDecoder().decode(WikipediaSearchResponse.self,
                                                            from: pair.data)

                    return response.query.search
                } catch {
                    // 4.
                    throw error
                }
            }
    }
}
  1. URLSessionも細かなカスタマイズをしたいこともあるでしょうから外部から渡すようにします
    • 例えばタイムアウト時間とかをコントロールしたいとかそういう要求絶対あるわけです
  2. これもRxSwiftと関係ないですがAPIの仕様と合っているかどうかを明確にするコードを書きます
    • 仕様が感じられるコードを書いて間違い探ししやすくすることでミスがわかりやすくなるわけです
  3. 元記事ではAlamofire使っているのですが必要ないっす。URLSessionで必要十分です
    • RxSwiftの記事なのにAlamofireという必要もない外部OSS使うのはどうかと思います
    • 標準のAPIであるURLSessionをRxがカスタマイズしたURLSession.rxを使います
    • 仕事だとAPIKitを使いますが、これはRxSwiftの紹介記事ということでぐっと我慢してURLSessionでまかないます
  4. 悩ましいところですが、とりあえずエラーを投げるようにしておきます
    • アプリの仕様上エラーハンドリングしたいわけではないようなので、ここでエラーを握りつぶしても良いんですが投げます
    • レイヤーの深いところで握りつぶすより浅いところで握りつぶすほうがハンドリングしたくなったとき楽でしょうし

ViewModelのテストコード

最後にViewModelをテストします。RxTestで文字数を徐々に増やして3文字以上が出力に関係していることだけを確認します。

    func testViewModelの正常系出力をモックで確認() {

        let searchWord = scheduler.createHotObservable([
            Recorded.next(1, "S"), // 1文字なので出力に影響しないことを確認したい
            Recorded.next(2, "Sw"),// 2文字なので出力に影響しないことを確認したい
            Recorded.next(3, "Swi")
        ])

        let json = "{\"pageid\": 499755, \"title\": \"スイフト\"}"
        let wikipediaPage = try! JSONDecoder().decode(
            WikipediaPage.self,
            from: json.data(using: .utf8)!
        )

        let viewModel = WikipediaSearchViewModel(
            searchWord: searchWord.asObservable(),
            wikipediaAPI: MockWikipediaAPI(results: Observable.of([wikipediaPage]))
        )

        let observer = scheduler.createObserver([WikipediaPage].self)

        viewModel.wikipediaPages
            .bind(to: observer)
            .disposed(by: disposeBag)

        let expectedEvents = [
            Recorded.next(3, [wikipediaPage])
        ]

        scheduler.start()
        XCTAssertEqual(observer.events, expectedEvents)
    }

テスト用のclassを用意しておきます。

class MockWikipediaAPI {

    private let results: Observable<[WikipediaPage]>

    init(results: Observable<[WikipediaPage]>) {
        self.results = results
    }
}

extension MockWikipediaAPI: WikipediaAPI {
    func search(from word: String) -> Observable<[WikipediaPage]> {
        return results
    }
}

まとめとその他のツッコミどころ

RxSwift的なことに関して

  • 元記事ではfilterオペレータの存在を知らないからsubscribeしてPublishSubjectを使っている
    • こんなことをしていると入力に対してPublishSubjectが単調増加する
    • リアクティブプログラミングはfilterやdebounceなどを組み合わせるというメリットがあることを知らないのだろう
  • 元記事ではViewModelに対してObservableで入力を与えて出力を見るということをしていない
    • 今回の書き換えでかなりシンプルにテストを書けるようになったことでメリットが分かるかもしれない...
  • 元記事Variableが非推奨相当であっても気にせず使っている
    • 基本的にアプリ作ろうとするとWikipedia検索するようなシンプルさでは収まらない
      • Variableを使われた普通のアプリのコードでも相当読み取りづらい
        • そもそもVariableをカジュアルに使う段階の開発者のコードは熟練度が低いからVariableが辛いという逆説的なこともあるかもしれない
  • 元記事、onErrorしたあとにreturnしていない
    • ただのミスだろうけどチェックが弱すぎる
  • 元記事、「RxSwiftのObserveパターン」という記述がある
    • 私は知らないだけでそういう用語があるのかもしれないが、無いと思う

Swift的なことに関して

  • 元記事のResultという名前の手抜き感
    • 世の中のOSSに頻繁に出てくるResultを知らないということは元記事の作者は仕事でiOSアプリを作っていない
      • まあそれでもいいのかもしれないが
    • 普通にiOSアプリ作ってる人向けの解説としてはオリジナルなResultなんて使われたら違和感強すぎと思う
  • 元記事ではselfを省略していない
    • selfを省略するメリットは経験でしか得られない
      • おそらくSwiftを読み書きし慣れていない
  • 元記事のプロトコルfunc getRequest(_ parameters: [String : String]) -> DataRequest
    • 検索のためのキーsrsearchを利用側が文字列で指定しないといけない
      • 文字列を間違えてもコンパイルエラーにならない
      • 文字列はWikipediaの仕様なのにViewModelが知っている時点で設計がおかしい
    • DataRequestというインスタンスが取得できるprotocolを作ってもそれでテストしやすいというのは意図が伝わってこない

おわりに

RxSwiftとMVVMは難しいです。なぜかというと元記事の最後にある文章を見るとわかります

MVVMモデルを導入することで、ソースコードを整理でき、特にView側のソースコードを短く読みやすくできることが実感できたと思います。MVVMモデルと言っても、堅苦しいものではなく、比較的開発者の自由にプログラムが組めます。

比較的自由にプログラムが組めるというのは間違いではないかもしれませんが、フレームワークの特性を調べること、そして実際にテストコードを書いてそれを確認することが重要だと感じます。

そして最近、技術同人誌「RxSwift研究読本1 入門編」というのを作りましたのでリンク貼っておきます。

RxSwift研究読本1 入門編
https://booth.pm/ja/items/1076262