158
116

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

某RxSwiftを使ったMVVMの記事の改善案

Last updated at Posted at 2018-11-18

まずはじめに

11月中旬にリリースされたとある記事が、初心者の方が見たら勘違いしやすい実装が複数紹介されている状態だったので、その実装に対する改善した実装を本投稿で紹介していければと思います。
ちなみに、以下の項目が改善の余地があるのではないかと感じた実装になります。

  • ViewControllerにObservableを加工する処理が入っている
    • ViewModelから流れてきたイベントをViewControllerで加工せず、そのままViewにバインドするだけの実装のほうが良い
  • Viewの操作で、observeOn(Main)またはBinderを利用していない
  • Variableをvarで定義する
    • Variable.valueを変更するが、Variableのインスタンス自体を変更することはほぼないはず
    • VariableはRxSwiftのDeprecated.swiftに実装されているので(Swiftのavailabilityでdeprecated宣言はまだされていない)、RxCocoaのBehaviorRelayを使うほうが良い
  • Variableを外部に公開してしまっている
    • itemsObservable: Observable<[Item]>や必要に応じてitems: [Item]を公開したほうが良い
  • APIリクエストのエラーが流れた処理をしていない
    • onErrorが流れると、そのObservableを繋いでいる部分がdisposeされてしまい、復帰の実装をしなければその後の動作が一切されなくなってしまう
  • インクリメンタルサーチでdebounceやthrottleが利用されていない
  • APIUtilがタイプセーフになっていない
  • Alamofireを使っている理由が不明瞭
    • Multi-Partなどを扱うので簡単にしたいなどの理由がない限り、URLSessionでも十分な実装ができる
    • またはAPIKitを選択する方がタイプセーフでモダンな実装にできる
  • struct Resultをカジュアルに定義しているが、Result<T> (またはResult<T, E>)を連想するので、あまり型の名前として独自オブジェクトに使わないほうが良い
  • Decodableではなく、Dictionaryでのパースが使われている
  • DataObjectからではなく、cellのlabelからURLを取得して表示している

※ この件に関して、 @yimajo さんもこちらの投稿で見解を示されているので、ご覧いただければと思います。

ViewControllerの実装

iOSのMVVMにおけるView(ViewController)の実装は

  • ViewModelから受け取った通知を反映する
  • 任意のユーザーインタラクションをViewModelに伝達する

のみが実装されているシンプルな状態が理想的だと考えています。
つまり、状態の変更などの実装は可能な限りViewModelへ移行し、View上ではバインドするだけの実装にするということです。
以下がViewControllerの実装になります。

WikipediaSearchViewController.swift
final class WikipediaSearchViewController: UIViewController {
    typealias Input = WikipediaSearchViewModelInput
    typealias Output = WikipediaSearchViewModelOutput

    @IBOutlet private(set) weak var searchBar: UISearchBar!
    @IBOutlet private(set) weak var tableView: UITableView!

    private let input: Input
    private let output: Output
    private let disposeBag = DisposeBag()

    init(viewModel: Input & Output = WikipediaSearchViewModel()) {
        self.input = viewModel
        self.output = viewModel
        super.init(nibName: nil, bundle: nil)
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        // UITableViewCellのsubtitleを扱えるようにするCellを指定
        tableView.register(WikipediaSearchCell.nib,
                           forCellReuseIdentifier: WikipediaSearchCell.identifiler)

        // 検索欄の入力値をViewModelにbind
        searchBar.rx.text
            .bind(to: input.searchText)
            .disposed(by: disposeBag)

        // セル選択時の処理をViewModelにbind
        tableView.rx.itemSelected
            .bind(to: input.itemSelected)
            .disposed(by: disposeBag)

        // 検索結果とテーブルのセルをbind
        // tableView.rx.itemsの内部でBinderを利用しているので、Main指定不要
        output.pages
            .bind(to: tableView.rx.items(cellIdentifier: WikipediaSearchCell.identifiler)) { _, page, cell in
                cell.textLabel?.text = page.title
                cell.detailTextLabel?.text = page.url.absoluteString
            }
            .disposed(by: disposeBag)

        // 検索文字列と検索結果の件数をタイトルに表示
        // navigationItem.rx.titleの内部でBinderを利用しているので、Main指定不要
        output.searchResultText
            .bind(to: navigationItem.rx.title)
            .disposed(by: disposeBag)

        // 選択されたURLを表示
        output.openURL
            .bind(to: Binder(self) { me, url in
                let safariViewController = SFSafariViewController(url: url)
                me.present(safariViewController, animated: true)
            })
            .disposed(by: disposeBag)
    }
}

まず、ViewModelから参照できるpropertyは、kickstarter/ios-ossの実装にもあるように、protocolでInputOutputに分かれています。
InputではViewからのユーザーインタラクションを受け取るためのObserverなどを定義し、OutputではViewModelの処理によって変更された状態などを外部に伝達するためのObservableなどを定義します。
Inputで定義されているものに対して、任意のUIコンポーネントのpropertyから状態をViewModelにバインドします。
Outputで定義されているものから、任意のUIコンポーネントのpropertyに対してVieModelの状態をバインドします。
このように実装することで、View上では任意のObservableをUIコンポーネントやViewModelのpropertyに対してバインドするだけで、状態変更のロジックなどが実装されていないシンプルな実装を実現できます。

ViewModelの実装

ViewModelで行っている処理としては

  • 外部から検索文字列が伝達された際に、3文字以上であればインクリメンタルサーチを実行する
  • 検索が完了したら、現在の検索文字列と検索結果の文字列を生成し、外部に出力する
  • 外部からIndexPathが伝達された際に、検索結果の中から該当の結果を取得し、そのURLを外部に出力する

本投稿のViewModelでは、外部から入力をAnyObserverとして受け取り、外部への出力はObservableとしています。
それらの入力と出力は、WikipediaSearchViewModelInputWikipediaSearchViewModelOutputで定義されており、WikipediaSearchViewModelがそれらに準拠している実装になっています。

以下がViewModelの実装になります。

WikipediaSearchViewModel.swift
protocol WikipediaSearchViewModelInput: AnyObject {
    var searchText: AnyObserver<String?> { get }
    var itemSelected: AnyObserver<IndexPath> { get }
}

protocol WikipediaSearchViewModelOutput: AnyObject {
    var pages: Observable<[WikipediaPage]> { get }
    var openURL: Observable<URL> { get }
    var searchResultText: Observable<String> { get }
}

final class WikipediaSearchViewModel: WikipediaSearchViewModelInput, WikipediaSearchViewModelOutput {
    typealias SendSearchRequest = (WikipediaSearchRequest) -> Observable<WikipediaSearchResponse>

    let searchText: AnyObserver<String?>
    let itemSelected: AnyObserver<IndexPath>

    let pages: Observable<[WikipediaPage]>
    let openURL: Observable<URL>
    let searchResultText: Observable<String>

    private let disposeBag   = DisposeBag()

    init(sendSearchRequest: @escaping SendSearchRequest = WikipediaSession.shared.send,
         debounceScheduler: SchedulerType = ConcurrentMainScheduler.instance) {
        // Inputのpropertyの初期化
        let _searchText = PublishRelay<String?>()
        self.searchText = AnyObserver<String?>() { event in
            guard let text = event.element else {
                return
            }
            _searchText.accept(text)
        }

        let _itemSelected = PublishRelay<IndexPath>()
        self.itemSelected = AnyObserver<IndexPath> { event in
            guard let indexPath = event.element else {
                return
            }
            _itemSelected.accept(indexPath)
        }

        // Ouputのpropertyの初期化
        let _pages = BehaviorRelay<[WikipediaPage]>(value: [])
        self.pages = _pages.asObservable()

        let _openURL = PublishRelay<URL>()
        self.openURL = _openURL.asObservable()

        let _searchResultText = BehaviorRelay<String>(value: "Wikipedia Search API")
        self.searchResultText = _searchResultText.asObservable()

        // 最後の入力から0.3秒が経過したら、3文字以上の場合に文字列を流す
        let searchWithText = _searchText
            .debounce(0.3, scheduler: debounceScheduler)
            .flatMap { text -> Observable<String> in
                guard let text = text, text.count > 2 else {
                    return .empty()
                }
                return .just(text)
            }
            .share()

        // APIへのリクエスト
        do {
            let searchResult = searchWithText
                .flatMap { text -> Observable<Event<[WikipediaPage]>> in
                    sendSearchRequest(WikipediaSearchRequest(searchText: text))
                        .map { $0.pages }
                        .materialize()
                }
                .share()

            searchResult
                .flatMap { $0.element.map(Observable.just) ?? .empty() }
                .bind(to: _pages)
                .disposed(by: disposeBag)

            searchResult
                .flatMap { $0.error.map(Observable.just) ?? .empty() }
                .subscribe(onNext: { error in
                    // エラー処理
                })
                .disposed(by: disposeBag)
        }

        // 初期値はスキップして、検索結果の文字列を生成
        _pages
            .skip(1)
            .withLatestFrom(searchWithText) { ($1, $0) }
            .map { "\($0) 検索結果:\($1.count)件" }
            .bind(to: _searchResultText)
            .disposed(by: disposeBag)

        // Itemが選択されたら、該当のindexのPageのURLを取り出す
        _itemSelected
            .withLatestFrom(_pages) { ($0.row, $1) }
            .flatMap { index, pages -> Observable<URL> in
                guard index < pages.count else {
                    return .empty()
                }
                return .just(pages[index].url)
            }
            .bind(to: _openURL)
            .disposed(by: disposeBag)
    }
}

initializerでは、sendSearchRequest: (WikipediaSearchRequest) -> Observable<WikipediaSearchResponse> = WikipediaSession.shared.senddebounceScheduler: SchedulerType = ConcurrentMainScheduler.instanceを引数としています。

通常では、抽象化されたAPIクライアント自体を引数として受け取り、テストの際はそれをMock化したものを利用して実装することになると思います。
今回の場合、ViewModel内で実行しているAPIリクエストが1つであるため、リクエスト実行のメソッドを直接closureとして受け取り、そのclosureを利用してAPIリクエストを実行しています。
後ほどテストについても記載しますが、このように実装することでclosure内で任意のPublishRelayなどを返す実装にすることもできるようになるため、柔軟なテスト実装が可能になります。

また引数でdebounceSchedulerを受け取っている理由としては、テスト時にRxTest.TesthSchedulerを利用して仮想時間でdebounceのテストが行えるようにするためです。

Wikipediaのページの検索をする処理は、外部から検索文字列を受け取り、その文字列をもとにAPIリクエストを送信する処理をViewModel内で完結しています。
リクエストを送信する前に、最後に文字列の変更があってから0.3秒が経過するまでは処理を実行させないという状態を、debounceを利用して実装します。この実装によって、インクリメンタルサーチで無駄にリクエストが実行されてしまう状態を回避します。
そして、そのリクエストの結果をViewModelで保持しつつ、外部に出力します。
またAPIリクエストのエラー処理に関しては、.materialize()を利用してObservalbe<Event<WikipediaSearchResponse>>に変換をし、disposeされることなくエラー処理を実装することができます。

検索結果の文字列に関しては、リクエストの結果が返ってきた際に、検索結果の数と検索文字列を組み合わせて文字列を生成し、外部に出力します。

Wikipediaのページの表示に関する処理は、外部から任意の検索結果のIndexPathを受け取り、そのIndexPathをもとに保持しているページの配列からWikipediaのページのURLを取得し、外部に出力します。

Modelの実装

タイプセーフなAPI周りの実装をしていきます。
まずは、APIリクエストに対するレスポンスを受け取るためのオブジェクトを実装します。
以下のように、レスポンスのJSONの構造に合わせたオブジェクトを実装し、Decodableに準拠します。

WikipediaPage.swift
struct WikipediaPage: Equatable {
    let id: Int
    let title: String
    let url: URL
}

extension WikipediaPage: Decodable {
    private enum CodingKeys: String, CodingKey {
        case id = "pageid"
        case title
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        self.id = try container.decode(Int.self, forKey: .id)
        self.title = try container.decode(String.self, forKey: .title)
        self.url = try URL(string: "https://ja.wikipedia.org/w/index.php?curid=\(id)") ??
            { throw DecodingError
                .dataCorrupted(.init(codingPath: [], debugDescription: "Failed to create URL"))
            }()
    }
}

WikipediaのURLに関する情報はJSONに含まれていないため、パース時に必要な情報を利用してURLを生成します。
パース時にURLを生成している理由としては、任意のWikipediaPageに対してURLは固定になるはずなので、computed propertyでアクセスする度に生成するコストをなくすためです。

またWikipediaPageの上階層となるWikipediaSearchResponseでは、今回利用しない階層部分を省いて実装しています。

WikipediaSearchResponse.swift
struct WikipediaSearchResponse {
    let pages: [WikipediaPage]
}

extension WikipediaSearchResponse: Decodable {

    private enum CodingKeys: String, CodingKey {
        case query
    }

    private enum InnerCodingKeys: String, CodingKey {
        case search
    }

    init(from decoder: Decoder) throws {
        let container = try decoder
            .container(keyedBy: CodingKeys.self)
            .nestedContainer(keyedBy: InnerCodingKeys.self, forKey: .query)
        self.pages = try container.decode([WikipediaPage].self, forKey: .search)
    }
}

次に、APIリクエストに関する処理をAPIKitを利用して実装していきます。
まずは、protocol WikipediaRequestを定義し、デフォルト実装でWikipediaに関するAPIリクエストを実装します。

WikipediaRequest.swift
protocol WikipediaRequest: Request where Response: Decodable {}

extension WikipediaRequest {
    var baseURL: URL {
        return URL(string: "https://ja.wikipedia.org") ??
            { fatalError("Faild to initialize URL") }()
    }

    var method: HTTPMethod {
        return .get
    }

    var dataParser: DataParser {
        return DecodableDataParser<Response>()
    }

    func response(from object: Any, urlResponse: HTTPURLResponse) throws -> Response {
        return try (object as? Response) ??
            { throw ResponseError.unexpectedObject(object) }()
    }
}

struct DecodableDataParser<T: Decodable>: DataParser {
    let contentType: String? = "application/json"

    func parse(data: Data) throws -> Any {
        return try JSONDecoder().decode(T.self, from: data)
    }
}

APIKitで用意されているDataParserにはDecodableに対応したものがないため、Decodableに対応したDataParserを実装します。
そして、それらに準拠したWikipediaSearchRequestを実装します。

WikipediaSearchRequest.swift
struct WikipediaSearchRequest: WikipediaRequest {
    typealias Response = WikipediaSearchResponse

    let path = "/w/api.php"
    var queryParameters: [String : Any]? {
        return [
            "format": "json",
            "action": "query",
            "list": "search",
            "srsearch": searchText
        ]
    }

    let searchText: String

    init(searchText: String) {
        self.searchText = searchText
    }
}

次にAPIクライアントを実装します。
APIKitをラップしたWikipediaSessionを実装し、func send(request:)でObservableを返せるようにします。

WikipediaSession.swift
final class WikipediaSession {
    static let shared = WikipediaSession()

    private let session = Session(adapter: URLSessionAdapter(configuration: .default))

    func send<T: WikipediaRequest>(request: T) -> Observable<T.Response> {
        return Observable.create { [session] observer in
            let task = session.send(request) { result in
                switch result {
                case let .success(value):
                    observer.onNext(value)
                    observer.onCompleted()
                case let .failure(error):
                    observer.onError(error)
                }
            }
            return Disposables.create {
                task?.cancel()
            }
        }
        .take(1)
    }
}

WikipediaSession.send(request:)の戻り値の型は、Generic Parameterによって確定します。
この実装によって、RequestとResponseの型を紐付けることができるため、タイプセーフな実装が可能になります。
その一方で、モック化をして擬似的なAPIのレスポンスを返す際に、その型に合わせるための型消去が必要になります。
本投稿では、APIリクエストのメソッドをViewModelのinitializerの引数で渡す形で実装しています。
そのため、WikipediaSessionをモック化する必要はなくなっています。

テストの実装

WikipediaSearchViewModelでは、Observerで外部からの入力を受け取り、Observableで状態の更新を外部に出力していました。
そしてinitializerでは、APIリクエストをclosureで受け取り注入が可能な状態にし、debounceのschedulerもテスト時には仮想時間が利用できるように注入可能な状態にしていました。
これらの実装は、よりテストがしやすい状態にするために行っています。

まずは、これらの依存を解決し、WikipediaSearchViewModelをテストできる状態にします。

※ViewはViewModelのObservalbeをバインドしているだけ、つまりViewに値を反映しているだけの状態なのでテストの対象に含めなくても良いと考えています。そのため、ViewModelのテストにのみ触れています。

WikipediaSearchViewModelTests.swift
final class WikipediaSearchViewModelTests: XCTestCase {

    private struct Dependency {
        let searchRequest: Observable<WikipediaSearchRequest>
        let searchResponse: PublishRelay<WikipediaSearchResponse>

        let scheduler = TestScheduler(initialClock: 0)
        let viewModel: WikipediaSearchViewModel

        init() {
            let _searchRequest = PublishRelay<WikipediaSearchRequest>()
            self.searchRequest = _searchRequest.asObservable()

            let _searchResponse = PublishRelay<WikipediaSearchResponse>()
            self.searchResponse = _searchResponse

            self.viewModel = WikipediaSearchViewModel(sendSearchRequest: { request in
                _searchRequest.accept(request)
                return _searchResponse.asObservable()
            }, debounceScheduler: scheduler)
        }
    }

    private var dependency: Dependency!
    private var disposeBag: DisposeBag!

    override func setUp() {
        dependency = Dependency()
        disposeBag = DisposeBag()
    }
}

WikipediaSearchViewModelでは、検索のAPIリクエストのメソッドをinitializerで渡しています。
sendSearchRequestの型は(WikipediaSearchRequest) -> Observable<WikipediaSearchResponse>なので、テスト用のclosureを渡すようにします。
closure内では、Requestの中身を確認できるようにするための_searchRequest: PublishRelay<WikipediaSearchRequest>と、APIリクエストの結果を返すための_searchResponse: PublishRelay<WikipediaSearchResponse>を利用しています。
そしてstruct Dependencyで実装をまとめ、func setUp()で簡単に利用できるようにします。

これらを利用し、テストをしていきます。
まずは、任意の検索文字列を入力した際に、適切なReqeustが生成されているかのテストを行います。

func testSearchRequestWhenTextCountIsGreaterThan3() {
    let request = BehaviorRelay<WikipediaSearchRequest?>(value: nil)
    dependency.searchRequest
        .bind(to: request)
        .disposed(by: disposeBag)

    let searchText = "Swift"
    dependency.viewModel.searchText.onNext(searchText)
    dependency.scheduler.advanceTo(dependency.scheduler.clock + 10)

    XCTAssertNotNil(request.value)
    XCTAssertEqual(request.value?.searchText, searchText)
}

先程定義したsearchRequestを利用し、変更があった場合にRequestを受け取れるようにします。
実際に検索文字列を流し、debounceを通過するために仮想時間を任意の時間だけ進め、Requestを生成させます。
最後に、Requestが生成され適切な値が入っていることを確認します。

同様に、条件を満たしていないため、Requestが生成されないテストを記載することもできます。

func testSearchRequestWhenTextCountIsLessThan3() {
    let request = BehaviorRelay<WikipediaSearchRequest?>(value: nil)
    dependency.searchRequest
        .bind(to: request)
        .disposed(by: disposeBag)

    let searchText = "Sw"
    dependency.viewModel.searchText.onNext(searchText)
    dependency.scheduler.advanceTo(dependency.scheduler.clock + 10)

    XCTAssertNil(request.value)
}

次にAPIのレスポンスのテストを行います。
先程定義したsearchResponseで任意の疑似レスポンスデータを渡すことでテストが可能になります。

func testSearchResponse() {
    let pages = BehaviorRelay<[WikipediaPage]>(value: [])
    dependency.viewModel.pages
        .bind(to: pages)
        .disposed(by: disposeBag)


    dependency.viewModel.searchText.onNext("Swift")
    dependency.scheduler.advanceTo(dependency.scheduler.clock + 10)

    let testData = (1...5).map {
        WikipediaPage(id: $0, title: "test-title", url: URL(string: "https://qiita.com")!)
    }
    let response = WikipediaSearchResponse(pages: testData)
    dependency.searchResponse.accept(response)

    XCTAssertFalse(pages.value.isEmpty)
    XCTAssertEqual(pages.value.count, testData.count)
    XCTAssertEqual(pages.value, testData)
}

まず、viewModel.pagesの変更を監視します。
APIリクエストが実行されるトリガーは検索文字列の入力となっているので、文字列を渡します。
そして、仮想時間を任意の時間進め、疑似レスポンスデータを渡します。
最後に、pagesの状態が想定しているものになっているかの確認を行います。

最後に

RxSwiftを利用したMVVMでは、ViewModelで公開されているObservable同士を比較的簡単に合成(combineLatestなど)できてしまいます。
そのため、View階層でもそれらを合成する処理を含めてしまいがちになり、ViewとViewModelのどちらにも処理が実装されてしまうという状態になってしまいます。
本来、ViewはViewModelの値を反映するだけで良いはずなので、そこに処理が実装されてしまうことでテストの難易度も上がってしまっていると思います。
実際に運用をしているプロジェクトでは、テストが実装されていることで時間を削減できるなどの恩恵を受けること多々あります。
MVVMに限らず、タイプセーフでテストが書きやすい実装を意識することは、重要になってくるのではないでしょうか。

158
116
5

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
158
116

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?