[iOS]RxSwift + APIKit + Decodableでインクリメンタルサーチ

こんにちは。
モバイルアプリサービスの太田です。

今さながらRxSwiftを初めて触っています。

RxSwiftの使い方も少しずつわかってきましたので、通信ライブラリAPIKitを使って、
wikiのapiをコールして、記事をインクリメンタルサーチしてみたいと思います。

環境

Xcode 10
Swift 4.2
RxSwift 4.4.1
APIKit 3.2.1

APIKit.DataParser

APIKitは標準だとレスポンスがData->JSON(DictionaryArray)にパースされるJSONDataParserが利用されます。
そのままではDecodableによるデコードができません。

そこで、APIKit.DataParserを継承したクラスを作り、Decodableによるデコードを行います。

final class EntityDataParser<T: Decodable>: APIKit.DataParser {
    var contentType: String? {
        return "application/json"
    }

    func parse(data: Data) throws -> Any {
        // デコードする
        return try JSONDecoder().decode(T.self, from: data)
    }
}

EntityDataParserを使うことで、デコードされたEntityが返ってくるようになります。

Entity

wikiの検索結果を格納するCodableに準拠したEntityを定義します。

APIから返ってくるJSONは以下のような感じです。

{
  "continue" : {
    "sroffset" : 30,
    "continue" : "-||"
  },
  "query" : {
    "searchinfo" : {
      "totalhits" : 427418
    },
    "search" : [
      {
        "pageid" : 13618,
        "ns" : 0,
        "snippet" : "(<span class=\"searchmatch\">A<\/span>+, <span class=\"searchmatch\">A<\/span>-)。 劇場や競技場、業績等ではS(特別席)に次ぐよいランクを表す)に次ぐよいランクを表す\343。 スポーツなどの競技での、リーグ等のグループの段階を表す。Sが「特<span class=\"searchmatch\">A<\/span>」として<span class=\"searchmatch\">A<\/span>(即ち「平<span class=\"searchmatch\">A<\/span>」)より上で最上級の場合と、<span class=\"searchmatch\">A<\/span>が「平<span class=\"searchmatch\">A<\/span>」でも最上級の場合がある。 会社の経営状態に関するランク付け等で<span class=\"searchmatch\">A<\/span>",
        "title" : "A",
        "size" : 13654,
        "wordcount" : 1453,
        "timestamp" : "2019-01-15T14:19:36Z"
      },
      {
        "pageid" : 1892553,
        "ns" : 0,
        "snippet" : "o または <span class=\"searchmatch\">a<\/span> を標識としてつけて、序数を表す。o と <span class=\"searchmatch\">a<\/span> の2種類があるのは、ロマンスロマンス\350\252語の形容詞に男性形と女性ロマンス\350\252語の形容詞に男性形と女性\345\275形が存在するためである。標識のロマンス\350\252語の形容詞に男性形と女性\345\275形が存在するためである。標識の\344下に線が引かれロマンス\350\252語の形容詞に男性形と女性\345\275形が存在するためである。標識の\344下に線が引かれ\343\202る場合もある。ポルトガル語では数字のあとにピリオドをつけてから o \/ <span class=\"searchmatch\">a<\/span> をつける。 例:1 (uno) の場合",
        "title" : "序数標識",
        "size" : 6304,
        "wordcount" : 727,
        "timestamp" : "2018-10-26T09:44:07Z"
      },
      ...
    ]
  },
  "batchcomplete" : ""
}

欲しい情報は、searchの中のtitilepageidなのでそれだけを保持するようにします。

struct WikiEntity: Decodable {
    let query: Query?
}

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

struct Search: Decodable {
    let title: String
    let pageid: Int
}

APIKit.Request

APIKit.Requestに準拠したWikiRequestを作成します。

struct WikiRequest: APIKit.Request {

    typealias Response = WikiEntity

    let query: String

    var baseURL: URL {
        let urlStr = "https://ja.wikipedia.org/w/api.php?format=json&action=query&list=search&srlimit=30&srsearch=\(self.query)"
        return URL(string: urlStr.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)!)!
    }

    let method: HTTPMethod = .post

    let path: String = ""
}

baseURLのパラメータについてはここを参考にしました。

APIKit.Request.ResponseDecodableに準拠している場合に、EntityDataParserを使うように変更します。

extension APIKit.Request where Self.Response: Decodable {

    var dataParser: APIKit.DataParser {
        // 作成したEntityDataParserを使用する
        return EntityDataParser<Response>()
    }

    func response(from object: Any, urlResponse: HTTPURLResponse) throws -> Response {
        // objectの型をチェック
        // Responseと型が一致していなければエラーを投げる
        guard let entity = object as? Response else {
            throw ResponseError.unexpectedObject(object)
        }
        return entity
    }
}

さらに、レスポンスをObservableでラップするメソッドを用意。

extension APIKit.Request {

    func rx_send() -> Observable<Self.Response> {
        return Observable.create { observer in
            let task = Session.shared.send(self) { result in
                switch result {
                case .success(let res):
                    observer.on(.next(res))
                    observer.on(.completed)

                case .failure(let err):
                    observer.onError(err)
                }
            }
            return Disposables.create {
                task?.cancel()
            }
        }
    }
}

UI側とRxSwiftで接続

Storyboard上で以下のようなUIを準備

WikiViewController

検索文字を入力するUISearchBarと、検索結果表示用のUITableViewを設置。
UITableViewにはUITableViewCellを設置して、Identifierに適当な名前を設定してます。(ここでは、"Cell"と設定してます。)

class WikiViewController: UIViewController {

    @IBOutlet weak var searchBar: UISearchBar!
    @IBOutlet weak var tableView: UITableView!

    // Observableの登録解除用
    private let disposeBag = DisposeBag()

    override func viewDidLoad() {
        super.viewDidLoad()

        // UISearchBarの入力を検知してWikiのAPIをコール
        self.searchBar.rx.text.orEmpty
            .flatMapLatest { WikiRequest(query: $0).rx_send() }
            .map { $0.query?.search ?? [] }
            // APIのレスポンスとUITableViewをバインド
            // Identifierで設定した"Cell"でUITableViewCellを使いまわし
            .bind(to: self.tableView.rx.items(cellIdentifier: "Cell")) {
                // 検索結果のタイトルとURLを表示に反映
                $2.textLabel?.text = $1.title
                $2.detailTextLabel?.text = "https://ja.wikipedia.org/w/index.php?curid=\($1.pageid)"
            }
            .disposed(by: self.disposeBag)
    }
}

完成

以上で、インクリメンタルサーチの完成です。

完成品

最後に

RxSwiftに触り出したばかりなので、どういったときにどれを使うのが良いのかがわからず、色々調べならが試行錯誤しております。
様々な便利な機能がありますので、活用していければと思います。