はじめに
前回の続きとして、設計したYahoo!画像検索を利用した画像検索アプリの各クラスを作成します。
前回は各変数の定義の仕方まで説明しました。今回はそれらの変数を具体的にどう使うか、Actionクラスとどのようにデータバインドして処理と画面を結びつけるかなどを説明します。
サンプルの各クラスを作成する
作成するサンプルの各クラスは、MVVMモデルに準じて次のように作成します。
クラス/構造体名 | 概要 |
---|---|
Img | 検索結果を格納 |
ViewController | 入力欄、検索ボタン、コレクションビューを表示 |
ImageItemCell | コレクションビューのセル |
SearchViewModel | 入出力変数の管理/Actionによる処理の制御 |
サンプルでは、Actionライブラリを利用したView-ViewModel間の入出力の変数の管理を目的としています。MVVMモデル内のModelの役割については、簡略化するためにサンプルでは省いています。前回に引き続き、View-ViewModel間の変数のやり取りにはデータバインドを用い、View以外ではUIKitフレームワークをimportしないようにします。
検索結果の格納
Yahoo!画像検索では、APIは存在しないので、検索結果のHTMLを解析して検索結果を取得します。検索結果はimgタグに入っているので、imgタグに相当する構造体をモデルとして作成します。
// 検索結果を格納する構造体 struct Img { let src: String }
imgタグのsrcの値を格納できるようにしています。
ViewModelの作成
最初に、入力用のプロトコルを次のように定義します。
protocol SearchViewModelInputs { var searchWord: Variable<String?> { get } // 検索キーワード var searchTrigger: PublishSubject<Void> { get } // 検索トリガ }
画面から入力するものは、検索キーワードと、検索ボタンを押した時に発動される検索トリガの2つです。ボタンのようにUIからユーザーの操作を受けるものがあれば、それに相当するトリガを入力の要素として定義します。次に、出力のプロトコルを次のように定義します。
protocol SearchViewModelOutputs { var items: Observable<[Img]> { get } // 検索結果 var isSearchButtonEnabled: Observable<Bool> { get } // 検索ボタンの押下可否 var isLoading: Observable<Bool> { get } // 処理中フラグ var error: Observable<ActionError> { get } // エラー情報 }
画面へ出力するものは、検索結果と検索ボタンの押下可否のオブジェクトです。サンプルのように画面に入力制限がある場合は、入力の可否を判断できるオブジェクトをViewModelから出力するものとして考えます。そのほか、Actionの処理中とエラーに相当するオブジェクトを定義します。
入出力のプロトコルを定義した後に、2つのプロトコルにアクセスするプロトコルを定義します。
protocol SearchViewModelType { var inputs: SearchViewModelInputs { get } var outputs: SearchViewModelOutputs { get } }
ViewModelのクラスの実態であるSearchViewModelクラスでは、この3つのプロトコルを実装します。
class SearchViewModel: SearchViewModelType, SearchViewModelInputs, SearchViewModelOutputs { // MARK: - Properties var inputs: SearchViewModelInputs { return self } var outputs: SearchViewModelOutputs { return self } // Input Sources let searchWord = Variable<String?>(nil) let searchTrigger = PublishSubject<Void>() // Output Sources let items: Observable<[Img]> let isSearchButtonEnabled: Observable<Bool> let isLoading: Observable<Bool> let error: Observable<ActionError> # 略
3つのプロトコルを実装して、各プロパティも記述します。入出力のプロパティは初期化処理の後は変更しないので「let」で定義します。また入力のプロパティは、Viewとバインドした際に初期状態があるので、この時点で空のオブジェクトとしてインスタンスを生成しています。出力のプロパティは、initメソッド内で初期化するので、ここでは定義だけにしています。各プロパティをバインドする前に、Actionクラスでの処理も定義しておきます。
class SearchViewModel: SearchViewModelType, SearchViewModelInputs, SearchViewModelOutputs { # 略 private let action: Action<String, [Img]> // ------(1) private let disposeBag = DisposeBag() init() { self.action = Action { keyword in // ------(2) let urlStr = "https://search.yahoo.co.jp/image/search?n=60&p=\(keyword)" // ------(3) let url = URL(string:urlStr.addingPercentEncoding(withAllowedCharacters: NSCharacterSet.urlQueryAllowed)!)! var request = URLRequest(url: url) request.addValue("×××××@xxxx.com", forHTTPHeaderField: "User-Agent") return URLSession.shared.rx.response(request: request) .filter{ $0.response.statusCode == 200 }.map{ $0.data } // ------(4) .map{ try! HTML(html: $0 as Data, encoding: .utf8) } .map{ $0.css("img").compactMap { $0["src"] }.filter { $0.hasPrefix("https://msp.c.yimg.jp") } } // ------(5) .map{ $0.compactMap{ Img(src:$0) } }.asObservable() // ------(6) # 略
画像検索の処理は、検索キーワードを文字列で入力して、画像検索の結果をImgの配列で返します。その通りにActionクラスを利用することを宣言します(1)。
初期化処理の中でActionクラスの処理を定義します。クロージャに渡されるのは、Actionクラスを定義した際の第一引数のString型変数なので、keywordで受け取ります(2)。
受け取ったキーワードからYahoo!画像検索のURLオブジェクトを全角文字列が使えるように生成します。Yahoo!画像検索をプログラムから利用する場合は、ヘッダのUser-Agentに利用者のメールアドレスを追加するのがルールなので、URLRequestオブジェクトを生成する際にヘッダを付加しています(3)。サンプルを実行する際には、メールアドレスを自分のものに変更してください。
URLSessionクラスのrx拡張プロパティを利用してYahoo!画像検索へのリクエストを実行します。リクエスト結果のステータスコードが200の場合のみ、データストリームにリクエストで得られた値を渡します(4)。
リクエストで得られた値はData型です。ここでKannaライブラリを利用してHTMLを解析します。Kannaライブラリを利用する際の初期式は次の通りです。
let obj = try! HTML(html: HTML文書 または Dataオブジェクト, encoding: 文字エンコーディング) let tags = obj.css(セレクタ)
URLSessionクラスなどから得られたDataオブジェクトを、文字エンコーディングの指定によってすぐにHTMLをパースしたオブジェクトに変換できます。Kannaライブラリのメソッドにはthrowsが定義されていますので、tryが必要です。
パース結果は、cssメソッドでセレクタを利用して絞り込めます。ここではimgタグの名前をセレクタとするとともに、画像検索の結果のサムネイルが「https://msp.c.yimg.jp」のURL名で始まることからこれを条件としてimgタグのsrcのみの配列を作成しています(5)。
srcの配列をImg構造体に変換してObservableオブジェクトとして返却します(6)。ここで返却した値が、Actionクラスのelementsプロパティで得られます。
定義したActionクラスを使った処理の流れは次の通りです。
クラス内の他のプロパティは、initメソッド内で次のように処理を定義します。
// ボタンの押下可否 self.isSearchButtonEnabled = self.searchWord.asObservable() .filterNil().map { $0.count >= 3 } // ------(1) // 検索トリガ self.searchTrigger.withLatestFrom(self.searchWord.asObservable()) // ------(2) .filterNil().bind(to:self.action.inputs).disposed(by: self.disposeBag) // 検索結果 self.items = self.action.elements // 処理中か self.isLoading = self.action.executing.startWith(false) // ------(3) // エラー self.error = self.action.errors
データストリームが空になる可能性のあるものについては、RxOptionalライブラリの空の場合は処理を行わないfilterNil()メソッドを利用して処理を止めます。
ボタンの押下可否は検索文字列が3文字以上で有効になるようにします。検索トリガについては、空のPublishSubjectで定義していますので自分自身は値を持ちません。そこでwithLatestFromオペレーターを使って検索キーワードでデータストリームを作成し、これをActionクラスのinputsプロパティにバインドします(2)。こうすることで、ボタンを押した際にActionクラスの入力変数に検索キーワードの値が渡り、Actionクラスで定義した画像検索の処理が走ります。
Actionクラスを利用する際には、メソッドでの処理を行うのではなく、inputsプロパティの値の変化で処理が行われます。
Actionクラスの処理結果/処理中/エラーはそれぞれ各プロパティで値を参照できるようにします。エラーに関してのみ、何もない場合はfalseからデータストリームが始まるようにstartWithで初期値falseを指定しています(3)。