1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

[Swift]RxSwift×MVVM ホットペッパーAPIを利用したアプリを作ってみた Part 2

Last updated at Posted at 2024-11-07

ホットペッパーAPIの実装 Part2

今回は前回投稿したPart1の続きのホットペッパーAPIを利用したアプリのViewModel側の実装を紹介します。

ViewModelの実装コード

次回紹介しますが今回はsearchBartableViewをセットしたHomeViewと、ホットペッパーAPIで取得したデータを配置させるHomeCellでファイルを分けました。
このHomeViewとHomeCell各ファイルに対してViewModelを用意しました。View側ではUIパーツのバインドのみを行い、ロジックを持たせないことで、ViewとViewModelの責務が明確に分かれるようにしています。

HomeViewModel

HomeViewControllerにデータを加工して送る役割を担います。
ここではPart1で紹介したAPIデータを取得する処理を実装したgourmetSearchRequestを利用し、検索結果を表示させる仕組みを定義したファイルになります。

searchGourmetメソッド内の流れを下記に記載します。

  1. メソッドが走ると読み込み中である事を管理するisLoadingをtrueにします。今回は使用しませんでしたが、例えば画面に「読み込み中…」というインジケータを表示させたりするのに使えます
  2. 検索結果を取得します。searchGourmetメソッドの戻り値であるResult型の成功と失敗した各パターンの値を返却します
    • 成功したらGourmetResponseで定義した店舗情報のデータを格納するshopsをHomeCellViewModelに変換し返却
    • 失敗したらエラーをerrorに流し、isLoadingをfalseに設定します。これにより、画面でエラーを表示する準備が整います
  3. onFailureでネットワークの問題やサーバーエラーなどで失敗する可能性もあるため、エラーハンドリングの実装をしてます。.failure(let error)はあくまでも下記のいづれかような問題が起こった場合に走ります
    • 検索結果が空(データがない)場合
    • サーバーがリクエストを受け取ったが、クライアントに対してエラーレスポンス(例えば404や500など)を返した場合
    • 期待した形式のデータではないため、デコードに失敗した場合
HomeViewModel
import Foundation
import RxSwift
import RxCocoa

final class HomeViewModel {
    
    // MARK: - Properties
    
    let searchResults: BehaviorSubject<[HomeCellViewModel]> = BehaviorSubject(value: [])
    let isLoading: BehaviorSubject<Bool> = BehaviorSubject(value: false)
    let error = PublishSubject<String>()
    
    private let gourmetSearchRequest = GourmetSearchRequest()
    private let disposeBag = DisposeBag()
    
    // MARK: - Other Methods
    
    /// GourmetSearchRequestを叩く関数
    func searchGourmet(with keyword: String) {
        isLoading.onNext(true) // Start loading
        
        gourmetSearchRequest.searchGourmet(keyword: keyword)
            .subscribe(onSuccess: { [weak self] result in
                switch result {
                case .success(let results):
                    let cellViewModels = results.shops.map { HomeCellViewModel(with: $0) }
                    self?.searchResults.onNext(cellViewModels)
                case .failure(let error):
                    self?.error.onNext(error.localizedDescription)
                }
                self?.isLoading.onNext(false) // Loadingを終了
            }, onFailure: { [weak self] error in
                self?.error.onNext(error.localizedDescription)
                self?.isLoading.onNext(false) // Loadingを終了
            })
            .disposed(by: disposeBag)
    }
}

Subjectとは

通常のObservableはデータを一方的に流すことしかできません。しかしSubjectはデータを発信・受取りどちらでも可能にしたオブジェクトです。

例えるならグループチャットのイメージだと思います。
数名が登録してあるグループチャットがあると仮定します。Observableであれば、Aさんから一斉に数人に対してメッセージを送り連絡だけを送ることができます。但しそれは返信を受取ることはできません。
Subjectは発信者側、受取側どちらからも発信・返信をするグループチャットみたいに連絡を取れるようにした場所だと言えます。Observableは一方向の通知、Subjectは双方向のやり取りができるオブジェクトです。

Subjectには下記の2種類があります。

  • PublishSubject
  • BehaviorSubject

PublishSubject

初期値を持たず、過去のイベントは流れないようになっています。バッファを持たず、イベントをサブスクライブ(購読)された時にのみ流します。
Observableでありつつ、イベントを発生させるためのonNext/onError/onCompletedメソッドを提供しています。一番基本的なSubjectがPublishSubjectです。

※バッファとは一時的にデータを保存しておく場所や仕組みのこと

BehaviorSubject

過去、最後の値(イベント)をバッファとして保持することができます。この最後に保持した値を購読者に渡します。つまり最新の値(イベント)を受取ることが可能になります。
もしまだ何も値が流れていない場合は、最初に設定した初期値を受け取ります。
BehaviorSubjectをテレビで例えると。いつでも最新の時刻(データ)を保持していて、新しく来た人がそれをすぐ確認できる。

  • PublishSubjectは「今から放送される内容だけを見せる」テレビ。PublishSubjectをテレビで例えると「ライブ放送」です。今始まる新しい放送(データ)だけを流し、過去の放送内容は伝えません
  • BehaviorSubjectは「今の放送と直前の重要なシーンも見せる」テレビ。例えばサッカーの試合を途中から見始めても、直前のゴールシーンを最初に見せてくれるような機能です

どちらもリアルタイムで情報を取得できることは変わりませんが、このような違いがあります。

HomeCellViewModelファイル

ここのそれぞれの役割としてView(画面)から受け取るデータとViewに渡すデータを管理します。

Inputsとは

主にViewModelに対して「画面からの入力(操作やデータ)」を表すプロトコルです。ユーザーが画面上で行う操作や、画面から提供されるデータがInputsを通じてViewModelに渡されます。
ユーザーがボタンを押したり、テキストフィールドに入力を行ったりした場合に、その操作を ViewModel に伝えるプロパティやメソッドが Inputs に含まれます。

※今回このファイルではViewにデータを表示させる役割しか担わせないので、プロトコルのみを定義しました。

Outputsとは

ViewModelから「画面に出力するデータや状態」を表すプロトコルです。ViewModel内で処理された結果や、画面に表示するべきデータがOutputsに含まれます。Viewはこのプロトコルを通じてデータを取得しUIに表示します。
画面に表示するテキストや画像データ、ラベルに表示するための文字列など、UIに表示するデータが含まれます。

HomeCellViewModel
import Foundation
import RxSwift
import RxCocoa

protocol HomeCellViewModelInputs: AnyObject {
}

protocol HomeCellViewModelOutputs: AnyObject {
    var storeNameLabel: Driver<String> { get }
    var addressLabel: Driver<String> { get }
    var genreLabel: Driver<String> { get }
    var budgetLabel: Driver<String> { get }
    var capacityLabel: Driver<String> { get }
    var openTimeLabel: Driver<String> { get }
    var displayText: Driver<NSAttributedString> { get }
    var imageData: Driver<Data?> { get }
}

protocol HomeCellViewModelType: AnyObject {
    var inputs: HomeCellViewModelInputs { get }
    var outputs: HomeCellViewModelOutputs { get }
}

final class HomeCellViewModel: HomeCellViewModelInputs, HomeCellViewModelOutputs, HomeCellViewModelType {
    
    // MARK: - Input Sources
    // ある場合はここにInputを記入
    
    // MARK: - Output Sources

    var storeNameLabel: Driver<String>
    var addressLabel: Driver<String>
    var genreLabel: Driver<String>
    var budgetLabel: Driver<String>
    var capacityLabel: Driver<String>
    var openTimeLabel: Driver<String>
    var displayText: Driver<NSAttributedString>
    var imageData: Driver<Data?>
    
    // MARK: - Properties

    var inputs: HomeCellViewModelInputs { return self }
    var outputs: HomeCellViewModelOutputs { return self }

    private let disposeBag = DisposeBag()
    
    init(with data: Shop) {

        self.storeNameLabel = Observable.just(data.name ?? "")
            .asDriver(onErrorJustReturn: "")
        
        self.addressLabel = Observable.just(data.address ?? "")
            .map { address in "【住所】: \(address)" }
            .asDriver(onErrorJustReturn: "")
        
        self.genreLabel = Observable.just(data.genre?.name ?? "")
            .map { genre in "【ジャンル】: \(genre)" }
            .asDriver(onErrorJustReturn: "ジャンル不明")
        
        self.budgetLabel = Observable.just(data.budget?.average ?? "")
            .map { budget in "【予算】: \(budget)" }
            .asDriver(onErrorJustReturn: "予算不明")
        
        self.capacityLabel = Observable.just(data.capacity ?? 0)
            .map { capacity in "【収容人数】: \(capacity)人" }
            .asDriver(onErrorJustReturn: "収容人数不明")
        
        self.openTimeLabel = Observable.just(data.openTime ?? "")
            .map { open in "【営業時間】: \(open)" }
            .asDriver(onErrorJustReturn: "営業時間不明")
        
        self.displayText = Observable.just(data.urls?.pc)
            .compactMap{ $0 }
            .map { url in
                let displayText = "【URL】: \(url)"
                let attributedString = NSMutableAttributedString(string: displayText)
                if let linkRange = displayText.range(of: url) {
                    let nsRange = NSRange(linkRange, in: displayText)
                    attributedString.addAttribute(.link, value: url, range: nsRange)
                } else {
                    print("URL string not found in display text")
                }
                return attributedString
            }
            .asDriver(onErrorJustReturn: NSAttributedString(string: "URLなし"))
        
        self.imageData = Observable.just(data.photo?.mobile.l)
            .compactMap { $0 }
            .flatMap { imageUrl -> Observable<Data?> in
                guard let url = URL(string: imageUrl) else {
                    return Observable.just(nil)
                }
                return URLSession.shared.rx.data(request: URLRequest(url: url))
                    .map { data in return data }
                    .catchAndReturn(nil)
            }
            .asDriver(onErrorJustReturn: nil)
    }
}

店舗名や住所、ジャンル、予算などのデータを実際にViewに表示できるように加工をしています。

  • Observable.justで値をObservableを作ります。イメージはテレビ局がテレビ放送を開始し、「1回だけ放送する情報」を整えています
    Observable.just(data.name ?? "") は data.name が存在する場合その値を、ない場合は空文字列 "" を発行します
  • mapはデータを変換するための関数です
    例えば住所に「【住所】: 」を追加したり、予算やジャンルに適切な文言を加えたりして、人が見やすい形に整えています。
  • asDriverは、ObservableをDriverに変換する操作です
    mapまではまだObservableの状態なので、UIに表示できる安全な形式のDriverに変換を行なっております

URLと画像の取得処理ロジックはまた少し変わっています。URLの方は文字列に変換している作業ですが、.mapは説明済みなので画像取得処理の方だけ内容を纏めます。

  • .compactMapはObservableのデータやイベントを変換し、nilを除外しnilでない値だけを流す処理です
    ブロックで$0と囲んでいるので、そのままnilを除外したObservableを流しています。変換できるので例えばIntやStrigのように変換もできます。
let observable = Observable.of("123", "abc", "456", "def")

let integers = observable.compactMap { Int($0) }
  • flatMapで既存のObservableを加工し別のObservableに変換させる作業をしています
    戻り値をObservableとしているので、URLSessionで画像データを取得し.mapでdate型に加工してます。
  • .catchAndReturnで、もし仮に通信エラーなどが発生し取得ができなかったことを考慮し、デフォルトでnilを返すようにして、データストリームの流れを安定させるようにしています。

Observableとは

Observableとは、アプリケーションのイベントを監視して、その情報をリアルタイムに他の部分に知らせます。例えば、「データを取得して、それをUIに表示する」という流れがある場合、このデータの取得の部分をObservableで管理するとデータが取得された瞬間、UIに自動で表示できます。

  • ボタンが押された
  • データが取得された(お気に入りリストにアイテムが追加されたなど)
  • テキストフィールドに文字を入力した
  • サーバーからデータが取得された(天気情報やニュースフィードの取得)
  • 通信エラーが発生した
  • サーバーからのプッシュ通知が届いた
  • 指定した時間ごとに発生するイベント(ストップウォッチやカウントダウンタイマーの更新がされた)
  • 新しい画面が表示された

その他にも上記のようなイベントを管理して、そのイベントが発行されたら何かしらの処理を行うような仕組みを作っているというイメージです。

先ほど紹介しましたが今回のObservabl.justeとは「あるイベントを発生」的なような意味があり、「あるデータやイベント(データストリーム)を発生させる仕組みの装置を作る」 というイメージでいます。
店舗名のデータを渡すself.storeNameLabel = Observable.just(data.name ?? "")では、「一度だけ 'data.nameか空の文字'というデータを流す仕組み」を作っている感じです。

Driverとは

Driverとして宣言することでUI用の表示データに適しています。メインスレッドで動作するもので、お約束ごととして必ず初期値を持たせないといけません。
またエラーが発生しないようになっています。このエラーが発生しないとは、例えば通信エラーが発生した際にも単にエラーを返さず「エラーが発生しました」「通信再取得をする」などの文字を表示させるようにします。ObservableのままではエラーがUIに伝播してしまうが、Driverを使うとエラー発生時もUIを壊さないような仕組みになってます。

メインスレッドはUIなどの操作を行う場所にあたります。ここでDriverを動作させるようにすることで、全てのイベントに反応ができます。

Part2のまとめ

今回はRxSwift×MVVMで特に重要なロジック処理を担当するViewModel側の実装を行いました。
初見だと難しい処理になるので、各メソッドがどのような役割を担っているのかを紐解いて理解を進めて引き出しを増やしていけたらいいと思いました。

次回は今回のホットペッパーAPIを利用したView側の実装を最後にまとめて紹介します。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?