はじめに
この記事は、いわゆる、よくありがちな「Wikipediaのインクリメンタルサーチを作ってみた」の記事ですが、RxSwiftでページネーションまで実装しているのは、あまりなく、実装してみかったので、自分のメモがてら、書いてみました。
作成したサンプルソースは、以下のリポジトリから取得できます。
Githubのリポジトリ
yoko-yan/RxSwiftIncrementalSearch
作成したサンプルは、機能として、以下の実装をしています。
- WikipediaのAPIを使って記事をインクリメンタルサーチ
- paginationで追加読み込み
- Pull-to-Refreshでリストを引っ張って更新
サンプルソースコードに関して
キャプチャ
リスト画面 | 詳細画面 |
---|---|
アーキテクチャは、MVVMを取り入れています。
環境やバージョンについて
Xcode 12.4
Swift 5.3.2
macOS Catalina 10.15.7
使用ライブラリ
CocoaPod
Carthage
APIKit
RxSwift 6.0.0
RxCocoa
RxSwiftExt
RxTest
RxBloking
インクリメンタルサーチ
インクリメントサーチの実装には、日本語などの変換が必要な文字の場合
- 確定前(入力中)に検索させるか
- 確定後に検索させるか
の2通りあります。
今回は、変換する必要がない半角文字を対象にしており
日本語などの変換が必要な文字の場合、確定後にリクエストが走ります。
ページネーション
機能としては、ごく普通のページネーションです。
20件取得後、リストの最後までスクロールすると、APIを叩いて、20件取得するような機能です。
プルトゥリフレッシュ
機能としては、こちらもごく普通のプルトゥリフレッシュです。
リストを下にドラッグすると、現在のキーワードで、リロードします。
WikipediaのAPI
このサンプルソースは、WikipediaのAPI(MediaWiki API)を使います。
MediaWiki APIについては、MediaWiki API ヘルプを参照ください。
一覧画面のデータ取得
{
"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はオフセット
オフセット0で、取得件数が10でリクエストしたので、
以下のように付与されます。
"continue": {
"sroffset": 10,
"continue": "-||"
}
次のデータを取得するには、sroffsetが10なので、上記のjsonの値をそのままクエリにして、リクエストします
srlimit=10&sroffset=0&continue=-||
※continue=-||は、ページングのために連続したデータを取得するのに必須なパラメータになります。
詳細画面のデータ取得
一覧画面で取得したjsonの中の該当するデータのpageidでリクエストをします。
{
"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のコードを解説します。
// 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から前回までに取得したレスポンスをストリームから取得して、今回取得したレスポンスとマージするという流れです。
トリガーとリクエストパラメータ
各トリガーをどのようにうまく纏めるかを工夫してみましたので、その部分を掘り下げ。
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をかけているのは、文字の入力が早い時に、無駄な値がストリームに流れないように
- インクリメンタルサーチの場合、キーワードを入力された文字にして、オフセットを0にする
- リロード(reloadRequestTrigger)の場合、キーワードだけ引き継いで、オフセットを0にする
- ページネーション(paginationRequestTrigger)の場合、キーワードだけ引き継いで、前回取得したAPIのレスポンスにあるオフセットの値をセットする
- リクエストパラメータを、requestParameterRelayに保持
- APIレスポンスに含まれるContinueを、ページネーションのために、continueRelayで保持
- リロードとページネーションの場合、前回のリクエストパラメータをrequestParameterRelayから取得し、トリガーが実行された時に流れるように、withLatestFromでセット
ページング
Wiki
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テストなどもサンプルソースコードでやってみたので、もし良ければ、そちらもご参照ください。