RxSwiftでインクリメンタルサーチ＆ページネーションのサンプル

# はじめに
この記事は、いわゆる、よくありがちな「Wikipediaのインクリメンタルサーチを作ってみた」の記事ですが、RxSwiftでページネーションまで実装しているのは、あまりなく、実装してみかったので、自分のメモがてら、書いてみました。

作成したサンプルソースは、以下のリポジトリから取得できます。

**Githubのリポジトリ**
[yoko-yan/RxSwiftIncrementalSearch](https://github.com/yoko-yan/RxSwiftIncrementalSearch)

作成したサンプルは、機能として、以下の実装をしています。

- WikipediaのAPIを使って記事をインクリメンタルサーチ
- paginationで追加読み込み
- Pull-to-Refreshでリストを引っ張って更新

# サンプルソースコードに関して
**キャプチャ**

|    　リスト画面    |    詳細画面    |
|:------------------:|:------------------:|
|![Simulator Screen Shot - iPhone 11 - 2020-06-08 at 03.05.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/35613/04ef9a77-5dfa-7799-9b22-bb8e9c63b770.png)|![Simulator Screen Shot - iPhone 11 - 2020-06-08 at 03.05.31.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/35613/c047ed55-88d5-0255-2a54-6a9b05bf9559.png)|

アーキテクチャは、MVVMを取り入れています。

**環境やバージョンについて**
Xcode 12.4
Swift 5.3.2
macOS Catalina 10.15.7

**使用ライブラリ**
CocoaPod
Carthage
APIKit
RxSwift 6.0.0
RxCocoa
RxSwiftExt
RxTest
RxBloking

## インクリメンタルサーチ
インクリメントサーチの実装には、日本語などの変換が必要な文字の場合

- 確定前（入力中）に検索させるか
- 確定後に検索させるか

の２通りあります。
今回は、変換する必要がない半角文字を対象にしており
日本語などの変換が必要な文字の場合、確定後にリクエストが走ります。

## ページネーション
機能としては、ごく普通のページネーションです。
２０件取得後、リストの最後までスクロールすると、APIを叩いて、２０件取得するような機能です。

## プルトゥリフレッシュ
機能としては、こちらもごく普通のプルトゥリフレッシュです。
リストを下にドラッグすると、現在のキーワードで、リロードします。

# WikipediaのAPI
このサンプルソースは、WikipediaのAPI（MediaWiki API）を使います。
MediaWiki APIについては、[MediaWiki API ヘルプ](https://ja.wikipedia.org/w/api.php)を参照ください。

## 一覧画面のデータ取得
```json:ja.wikipedia.org/w/api.php?continue=-||&srlimit=10&action=query&srsearch=Swift&sroffset=0&format=json&list=search

{
  "batchcomplete": "",
  "continue": {
    "sroffset": 10,
    "continue": "-||"
  },
  "query": {
    "searchinfo": {
      "totalhits": 1222,
      "suggestion": "sweet",
      "suggestionsnippet": "<em>sweet</em>"
    },
    "search": [
      {
        "ns": 0,
        "title": "スイフト(テスト)",
        "pageid": 499755,
        "size": 4190,
        "wordcount": 383,
        "snippet": "スイフト、スウィフト（英語 <span class=\"searchmatch\">swift</span>, <span class=\"searchmatch\">SWIFT</span>）の原義は、「迅速」。転じて英語圏の姓など。 グレアム・スウィフト - イギリスの作家。 ジョナサン・スウィフト - アイルランドの作家。『ガリヴァー旅行記』の作者として著名。 ジョン・トランブル・スウィフト - アメリカの教育者。明治時代の日本で活動。",
        "timestamp": "2020-01-18T06:47:30Z"
      },
      {
        "ns": 0,
        "title": "通貨",
        "pageid": 4126,
        "size": 33334,
        "wordcount": 3469,
        "snippet": "『デフレと円高の何が「悪」か』 光文社〈光文社新書〉、2010年、188頁。 ^ http://www.<span class=\"searchmatch\">swift</span>.com/about_<span class=\"searchmatch\">swift</span>/shownews?param_dcr=news.data/en/<span class=\"searchmatch\">swift</span>_com/2014/PR_RMB_Nov_Dec.xml RMB breaks into the",
        "timestamp": "2020-02-27T12:30:37Z"
      }
    ]
  }
}
```

ページングを行うには、取得したJsonに、ページングをするためのパラメータがあらかじめ付与されているので、それを参照し、値もそのままで、クエリにしてリクエストします。

例えば
> srlimit=10&sroffset=0

※srlimitは取得件数。sroffsetはオフセット
オフセット０で、取得件数が１０でリクエストしたので、
以下のように付与されます。

```json
"continue": {
  "sroffset": 10,
  "continue": "-||"
}
```

次のデータを取得するには、sroffsetが１０なので、上記のjsonの値をそのままクエリにして、リクエストします
> srlimit=10&sroffset=0&continue=-||

※continue=-||は、ページングのために連続したデータを取得するのに必須なパラメータになります。

## 詳細画面のデータ取得
一覧画面で取得したjsonの中の該当するデータのpageidでリクエストをします。

```json:ja.wikipedia.org/w/api.php?prop=extracts&pageids=3000778&action=query&exintro=explaintext&format=json
{
	"batchcomplete": "",
	"warnings": {
		"extracts": {
			"*": "HTML may be malformed and/or unbalanced and may omit inline images. Use at your own risk. Known problems are listed at https://www.mediawiki.org/wiki/Special:MyLanguage/Extension:TextExtracts#Caveats."
		}
	},
	"query": {
		"pages": {
			"3000778": {
				"pageid": 3000778,
				"ns": 0,
				"title": "Swift (プログラミング言語)",
				"extract": "<p><b>Swift</b>（スウィフト）は、アップルのiOSおよびmacOS、Linuxで利用出来るプログラミング言語である。Worldwide Developers Conference (WWDC) 2014で発表された。アップル製OS上で動作するアプリケーションの開発に従来から用いられていたObjective-CやObjective-C++、C言語と共存できるように、共通のObjective-Cランタイムライブラリが使用されている。 </p><p>Swiftは、<span title=\"リンク先の項目はまだありません。新規の執筆や他言語版からの翻訳が望まれます。\">動的ディスパッチ</span>や動的バインディング等のObjective-Cの特長を受け継いでいる一方で、Objective-Cより「安全」にバグを捕捉できることも意図している。また、タイプや構造体、クラスに適用可能なプロトコルによるシステムの拡張性の概念は「プロトコル指向プログラミング」と呼ばれる 。 </p><p>Swiftは、マルチパラダイムのコンパイラプログラミング言語であるが、XcodeのPlaygroundsの上やターミナルでインタラクティブにデバッグする事が可能である。 </p><p>LLVMコンパイラが使われており、ライブコーディングに対応していることが特徴。 </p>"
			}
		}
	}
}
```

# コードの解説
インクリメンタルリサーチのロジックは、ほぼ、ViewModelで書いているので、ViewModelのコードを解説します。

```swift:FirstViewModel.swift
// MARK: - Private
private extension FirstViewModel {
    struct RequestParameter {
        let word: String
        let offset: Int
    }
}

extension FirstViewModel: ViewModelType {
    struct Input {
        let viewDidReachBottom: Observable<Void>
        let pullToRefreshTrigger: Observable<Void>
        let incrementalSearchTrigger: Observable<String>
    }

    struct Output {
        let loading: Observable<Bool>
        let refreshing: Observable<Bool>
        let searchStream: Observable<[WikipediaSearch]>
        let error: Observable<Error>
    }

    func transform(input: Input) -> Output {
        let session = dependencies.session

        let requestParameterRelay = BehaviorRelay<RequestParameter>(value: RequestParameter(word: "", offset: 0))

        let continueRelay = BehaviorRelay<WikipediaSearchResponse.Continue?>(value: nil)

        let incrementalSearchTrigger = input.incrementalSearchTrigger
            .debounce(.milliseconds(300), scheduler: scheduler)
            .filter { $0.count >= 3 }
            .distinctUntilChanged()
            .map { RequestParameter(word: $0, offset: 0) }
            .share()

        let reloadRequestTrigger = input.pullToRefreshTrigger
            .withLatestFrom(requestParameterRelay.compactMap { $0 })
            .map { RequestParameter(word: $0.word, offset: 0) }
            .share()

        let paginationRequestTrigger = input.viewDidReachBottom
            .withLatestFrom(Observable.combineLatest(requestParameterRelay.compactMap { $0 }, continueRelay.compactMap { $0 }) )
            .map { RequestParameter(word: $0.word, offset: $1?.sroffset ?? 0) }
            .share()

        let load = PublishRelay.merge(
                incrementalSearchTrigger,
                reloadRequestTrigger,
                paginationRequestTrigger
            )
            .filter { !$0.word.isEmpty }
            .throttle(.milliseconds(300), latest: false, scheduler: scheduler)
            .share()

        load
            .bind(to: requestParameterRelay)
            .disposed(by: disposeBag)

        let sequence = load
            .flatMapLatest { arg -> Observable<Event<WikipediaSearchResponse>> in
                return session.rx.send(WikipediaSearchRequest(word: arg.word, offset: arg.offset))
                    .asObservable()
                    .materialize()
            }
            .share()

        let apiResponseStream = sequence.compactMap { $0.event.element }.share()
        apiResponseStream
            .map { $0.continue }
            .bind(to: continueRelay)
            .disposed(by: disposeBag)

        let searchStream = apiResponseStream
            .scan([]) { $1.continue?.sroffset == $1.query.search.count || $1.continue == nil ? $1.query.search : $0 + $1.query.search }
            .share(replay: 1)

        let loading = PublishRelay.merge(load.map { _ in true }, sequence.map { _ in false }).startWith(false)
        let refreshing = PublishRelay.merge(reloadRequestTrigger.map { _ in true }, sequence.map { _ in false }).startWith(false)
        let error = sequence.errors().share()

        return Output(
            loading: loading,
            refreshing: refreshing,
            searchStream: searchStream,
            error: error
        )
    }
}
```
全体的な流れで言うと、リクエストパラメータを各トリガー（インクリメンタルサーチ、リロード、ページネーション）のストリームに流し、APIリクエストを実行後、レスポンスに入っている次のデータを取得する為のオフセットを保持。
その後、ページネーションの場合だけ、APIから前回までに取得したレスポンスをストリームから取得して、今回取得したレスポンスとマージするという流れです。

## トリガーとリクエストパラメータ
各トリガーをどのようにうまく纏めるかを工夫してみましたので、その部分を掘り下げ。

```swift
        let requestParameterRelay = BehaviorRelay<RequestParameter>(value: RequestParameter(word: "", offset: 0))

        let continueRelay = BehaviorRelay<WikipediaSearchResponse.Continue?>(value: nil)

        let incrementalSearchTrigger = input.incrementalSearchTrigger
            .debounce(.milliseconds(300), scheduler: scheduler)
            .filter { $0.count >= 3 }
            .distinctUntilChanged()
            .map { RequestParameter(word: $0, offset: 0) }
            .share()

        let reloadRequestTrigger = input.pullToRefreshTrigger
            .withLatestFrom(requestParameterRelay.compactMap { $0 })
            .map { RequestParameter(word: $0.word, offset: 0) }
            .share()

        let paginationRequestTrigger = input.viewDidReachBottom
            .withLatestFrom(Observable.combineLatest(requestParameterRelay.compactMap { $0 }, continueRelay.compactMap { $0 }) )
            .map { RequestParameter(word: $0.word, offset: $1?.sroffset ?? 0) }
            .share()
```

ポイントは、以下
- インクリメンタルサーチ（incrementalSearchTrigger）にdebounceをかけているのは、文字の入力が早い時に、無駄な値がストリームに流れないように
- インクリメンタルサーチの場合、キーワードを入力された文字にして、オフセットを０にする
- リロード（reloadRequestTrigger）の場合、キーワードだけ引き継いで、オフセットを０にする
- ページネーション（paginationRequestTrigger）の場合、キーワードだけ引き継いで、前回取得したAPIのレスポンスにあるオフセットの値をセットする
- リクエストパラメータを、requestParameterRelayに保持
- APIレスポンスに含まれるContinueを、ページネーションのために、continueRelayで保持
- リロードとページネーションの場合、前回のリクエストパラメータをrequestParameterRelayから取得し、トリガーが実行された時に流れるように、withLatestFromでセット

## ページング
Wiki

```swift
        let searchStream = apiResponseStream
            .scan([]) { $1.continue?.sroffset == $1.query.search.count || $1.continue == nil ? $1.query.search : $0 + $1.query.search }
            .share(replay: 1)
```

- scanで、前回ストリームに流れたレスポンスデータと、新しく取得したレスポンスデータを合成する
- レスポンスにcontinueがない場合は、ページングはなし

## その他
RxTestやRxBlockingを使ったユニットテスト、Page Object Patternを使ったUIテストなどもサンプルソースコードでやってみたので、もし良ければ、そちらもご参照ください。