LoginSignup
54
38

More than 3 years have passed since last update.

[iOS]KeyPathを利用してRxSwiftベースのViewModelを刷新する

Last updated at Posted at 2019-03-24

2019/12/16追記

記事作成は2019年4月頃に行いましたがそこから半年以上が経ち、Unioも機能が増えたりしたので記事を更新しました。

また、Unioについての登壇なども行っておりますので、こちらの資料も参考になりましたら幸いです。

はじめに

RxSwiftを用いたMVVMでアプリケーションを開発する際、ViewModelが以下のような実装になりやすくはないでしょうか。

  • ①公開用の入力メソッドと、内部でsubscribeするためのRelayの2つを定義している
  • ②内部状態を公開するためのObservableと、値自体を公開するためのpropertyの2つを定義している
  • ③内部状態なのか、入力のRelayなのかが一見わかりにくい
上記の問題があるViewModel
final class SearchViewModel {
    let repositories: Observable<[Repository]>
    let error: Observable<Error>

    var repositoriesValue: [Repository] { return _repositories.value }

    private let _repositories = BehaviorRelay<[Repository]>(value: [])
    private let _search = PublishRelay<String>()
    private let disposeBag = DisposeBag()

    init() {
        let apiAciton = SearchAPIAction()

        self.repositories = _repositories.asObservable()
        self.error = apiAction.error

        apiAction.response
            .bind(to: _repositories)
            .disposed(by: disposeBag)

        _search
            .subscribe(onNext: { apiAction.execute($0) })
            .disposed(by: disposeBag)
    }

    func search(query: String) {
        _search.accept(query)
    }
}

①、②については、Swift4から利用できるようになったKeyPathを用いて解決します。
try! Swift Tokyo 2019にてKeypath入門のトークもあったので、KeyPathを用いて解決方法はちょうど良い利用事例になるのではないかなと思います。

③に関しては、定義する場所を明示することで解決しようと思います。

KeyPathを用いて実装するViewModel

UnioというOSSを利用することで、上記の問題を解決することができます。UnioとはUnidirectional Input Outputを示しており、InputからOutputまでの流れを単一方向にすることを目的としたframeworkです。まずは上記のSearchViewModelを、Unioを利用してInputとOutputの流れを単一方向にしたSearchViewStreamとして実装を置き換えます。

※Unioの内部実装については後ほど解説します。

Unioを利用した実装例

SearchViewStream.swift
protocol SearchViewStreamType: AnyObject {
    var input: InputWrapper<SearchViewStream.Input> { get }
    var output: OutputWrapper<SearchViewStream.Output> { get }
}

final class SearchViewStream: UnioStream<SearchViewStream>, SearchViewStreamType {

    struct Input: InputType {
        let search = PublishRelay<String>()
    }

    struct Output: OutputType {
        let repositories: BehaviorRelay<[Repository]>
        let error: Observable<Error>
    }

    struct State: StateType {
        let repositories = BehaviorRelay<[Repository]>(value: [])
    }

    struct Extra: ExtraType {
        let apiAction = SearchAPIAction()
    }

    static func bind(from dependency: Dependency<Input, State, Extra>, disposeBag: DisposeBag) -> Output {

        let apiAction = dependency.extra.apiAction
        let state = dependency.state

        apiAction.response
            .bind(to: state.repositories)
            .disposed(by: disposeBag)

        dependency.inputObservables.search
            .subscribe(onNext: { apiAction.execute($0) })
            .disposed(by: disposeBag)

        return Output(repositories: state.repositories, error: apiAction.error)
    }

    init() {
        super.init(input: Input(), state: State(), extra: Extra())
    }
}

上記のSearchViewStreamの実装が

  • Inputに外部からの入力を定義
  • Outputに外部への出力を定義
  • Stateに内部状態を定義
  • Extraに上記以外の依存を定義
  • LogicがInput・State・ExtraをもとにOutputを生成

という区切り方で、明示的になっていることがわかるかと思います。

SearchViewStreamを実際にViewControllerで利用すると以下のようになります。

SearchViewController.swift
final class SearchViewController: UIViewController {

    let viewStream: SearchViewStreamType = SearchViewStream()
    let disposeBag = DisposeBag()

    override func viewDidLoad() {
        super.viewDidLoad()

        viewStream.input.search("search-text")

        viewStream.output.repositories
            .subscribe()
            .disposed(by: disposeBag)

        print(viewStream.output.repositories.value)
    }
}

まず、viewStreamで公開されているpropertyは、以下のようにinputoutputのみになります。そして、それらはそれぞれInputWrapper型とOutputWrapper型でラップされています。

スクリーンショット 2019-12-16 19.32.28.png

inputは、KeyPathで指定しているオブジェクトがPublishRelayだった場合に、acceptを実行しています。(PublishSubjectだった場合は、onEventを実行します)
また、Swift5.1のKeyPath dynamicMemberLookupを利用することで、input.searchのように直接closureとして呼び出すこともできます。

スクリーンショット 2019-12-16 19.37.24.png

そしてoutputでは、KeyPathで指定しているオブジェクトがObservableConvertibleTypeだった場合に、Observable<[Repository]>を返しています。
加えて、KeyPathで指定しているオブジェクトがBehaviorRelayだった場合は、valueとして[Repository]を返しています。(BehaviorSubjectだった場合は、throwableなメソッドでvalueを返します)
Outputでも、Swift5.1のKeyPath dynamicMemberLookupを利用することで、output.repositoriesのように呼び出して、Property<[Repository]>Observable<[Repository]>[Repository]のいずれかの型で取り出すことができます。

スクリーンショット 2019-12-16 19.43.58.png

InputOutputのpropertyがinternalになっているが、単一方向は保証されているのか?

SearchViewStreamで定義されている、InputOutputを再度見てみましょう。

extension SearchViewStream {

    struct Input: InputType {
        let search = PublishRelay<String>()
    }

    struct Output: OutputType {
        let repositories: BehaviorRelay<[Repository]>
        let error: Observable<Error>
    }

    ...
}

それぞれで保持しているpropertyはinternalになっています。一見、PublishRelayやBehaviorRelayに直接アクセスできそうで、単一方向が保証されていないように見えます。
続いて、SearchViewStreamのsuper classであるUnioStreamの定義を見てみましょう。

Unio.UnioStream.swift
public typealias UnioStream<Logic: LogicType> = PrimitiveStream<Logic> & LogicType & UnioStreamType

UnioStreamはクラスではなく、PrimitiveStreamLogicTypeUnioStreamTypeのtypealiasとなっています。
このような定義をすることでabstract classのように使えるので、任意のクラスを継承させつつ実装を強制することができます。
次に、PrimitiveStreamの定義を見てみましょう。

Unio.PrimitiveStream.swift
open class PrimitiveStream<Logic: LogicType> {

    public let input: InputWrapper<Logic.Input>
    public let output: OutputWrapper<Logic.Output>

    public init(input: Logic.Input, state: Logic.State, extra: Logic.Extra)
}

PrimitiveStreamではinput: InputWrapper<Input>output: OutputWrapper<Output>が公開されています。InputやOutputが直接公開されているのではなく、それぞれラップされているということです。
そのため、InputWrapper<T>またはOutputWrapper<T>とKeyPathを介してInputやOutputに定義されているpropertyの任意のメソッドにアクセスすることはできます。
しかし、InputやOutputだったりそれらが保持しているpropertyに直接アクセスすることはできません
つまり、ラップしたオブジェクトからKeyPathを利用して、InputOutputで定義されているpropertyの任意のメソッドを1階層飛び越えて呼び出すために、それらのpropertyはinternalで定義されています。

内部状態なのか、入力のRelayなのかがわかりやすくなったのか?

SearchViewStreamで、Input(入力)、State(内部状態)とExtra(その他依存)が定義されています。
定義自体が分かれているため、何を示しているものなのかはわかりやすくなりました。
それではOutputを生成する際に、それらがわかりやすい状態になっているのでしょうか?
Outputを生成しているLogicstatic func bind(from dependency: Dependency<Input, State, Extra>, disposeBag: DisposeBag) -> Outputを見てみます。
(※static func bind(from:disposeBag:) -> OutputはPrimitiveStreamが初期化される際に一度だけ呼び出されます。)

static func bind(from dependency: Dependency<Input, State, Extra>, disposeBag: DisposeBag) -> Output {

    let apiAction = dependency.extra.apiAction
    let state = dependency.state

    apiAction.response
        .bind(to: state.repositories)
        .disposed(by: disposeBag)

    dependency.inputObservables.search
        .subscribe(onNext: { apiAction.execute($0) })
        .disposed(by: disposeBag)

    return Output(repositories: state.repositories, error: apiAction.error)
}

Outputを生成する際に必要なものは、Dependency<Input, State, Extra>として引数で渡されます。

dependency.stateでアクセスができ、dependency.inputObservable(for:)またはdependency.inputObservablesからKeyPathを介して、InputのpropertyからObservableを取得します。
このようにOutput生成時でも、内部状態や入力が明示的になっています。

公開用のpropertyなどを2つ定義していた部分は不要になったのか?

こちらでも触れていますが、InputとOutputはラップしている型とKeyPathを介すことでアクセスできるものが変わるようになっています。
そのため、公開用と内部用であったり、Observable用とValue用で定義を2つする必要はなくなっていて、PublishRelayやBehaviorRelayを1つずつ定義するだけで済むようになっています。

Unio内部でどのようにKeyPathを用いているのか

Unio内部の実装で、必要な部分だけを抜き出した類似コードを下記に記載しています。
そちらをもとに解説します。

まず、InputとOutputを表現するprotocolを定義します。
この2つは、様々なオブジェクトのGeneric Where Clauseで利用します。

InputとOutput
public protocol InputType {}
public protocol OutputType {}

PublishRelayとBehaviorRelayを表現するためのprotocolを定義します。
この2つは、KeyPathでpropertyにアクセスする際のGeneric Where Clauseで利用します。

PublishRelayとBehaviorRelay
public protocol PublishRelayType {
    associatedtype E
    func accept(_ element: E)
    func asObservable() -> Observable<E>
}

public protocol BehaviorRelayType: PublishRelayType {
    var value: E { get }
}

extension PublishRelay: PublishRelayType {}
extension BehaviorRelay: BehaviorRelayType {}

そして、InputとOutputをラップしているInputWrapper型とOutputWrapper型です。
それぞれのGeneric Argumentは、protocolによって型が制限されています。

InputWrapper
public final class InputWrapper<T: InputType> {

    internal let dependency: T

    public init(_ dependency: T) {
        self.dependency = dependency
    }
}
OutputWrapper
public final class swift:OutputWrapper<T: OutputType> {

    internal let dependency: T

    public init(_ dependency: T) {
        self.dependency = dependency
    }
}

KeyPath<Root, Value>のRootがInputでValueがPublishRelayである場合、dependency: TからKeyPathを介してacceptを実行できる実装をします。
dependencyの定義がinternalになっていても、InputWrapperのメソッドのKeyPathのRootをGeneric ArgumentのTにすることで、階層が違っても内部では任意のpropertyの任意のメソッドなどにアクセスすることができるようになります。
ただ、それらにアクセスするためにはInputで定義しているproperty自体はinternalになっている必要があります。

TがInputTypeの場合
extension InputWrapper {

    public func accept<U: PublishRelayType>(_ element: U.E, for keyPath: KeyPath<T, U>) {
        return dependency[keyPath: keyPath].accept(element)
    }
}

一方で、、KeyPath<Root, Value>のRootがOutputでValueがPublishRelayである場合、dependency: TからKeyPathを介してObservableを取得できる実装をします。
加えて、ValueがBehaviorRelayである場合、dependency: TからKeyPathを介してvalueを取得できる実装をします。

TがOutputTypeの場合
extension OutputWrapper {

    public func observable<U: PublishRelayType>(for keyPath: KeyPath<T, U>) -> Observable<U.E> {
        return dependency[keyPath: keyPath].asObservable()
    }

    public func value<U: BehaviorRelayType>(for keyPath: KeyPath<T, U>) -> U.E {
        return dependency[keyPath: keyPath].value
    }
}

また、Outputを生成する際に必要となるDependencyはInputをラップし、KeyPathを利用してObservableを取得できる実装にします。
なぜInputWrapperを使わ回さないかというと、性質が逆になってしまうので単一方向を保てなくなってしまうので、別な型で表現しています。

public final class Dependency<T: InputType> {

    private let input: T

    internal init(_ input: T) {
        self.input = input
    }

    public func inputObservable<U: PublishRelayType>(for keyPath: KeyPath<T, U>) -> Observable<U.E> {
        return input[keyPath: keyPath].asObservable()
    }
}

そして、PrimitiveStream内ではInputOutputをもとに、InputWrapper<Input>OutputWrapper<Output>を生成しています。

open class UnioStream<Input: InputType, Output: OutputType> {

    public let input: InputWrapper<Input>
    public let output: OutputWrapper<Output>

    public init(input: Input, output: (Dependency<Input>) -> Output) {
        self.input = InputWrapper(input)

        let dependency = Dependency(input)
        self.output = OutputWrapper(output(dependency))
    }
}

UnioStreamが公開しているInputWrapper<Input>OutputWrapper<Output>をもとに、ViewControllerではviewStream.inputに入力だけを行い、viewStream.outputから出力の受取だけを行えるようになっています。
これらが、KeyPathを利用してInput / Outputの流れをKeyPathを利用して保証している実装となります。

※Playground上で動作確認ができるサンプルコードをGistで公開しています。

その他

ここまでに、Unioが従来のViewModelとは何が違い、どういった動作をしているのかを説明してきました。
ここでは、開発でUnioを導入するための補足情報を紹介していきます。

Xcode Templateを用いた自動生成

./Tools/install-xcode-template.shを実行することで、UnioのXcode Templateがインストールされます。
インストールが完了すると、ファイルの追加時に以下のテンプレートが選べるようになります。

Unio Componentsを選択すると、ViewControllerとViewStreamが生成されます。
生成されたViewControllerはViewStreamがpropertyとして定義された状態になっています。
そしてViewStreamは、InputOutputStateExtraLogicが定義された状態になっています。加えて、ViewStreamTypeのprotocolも定義されるため、テストターゲットでMockも簡単に作成することが可能になっています。

ezgif.com-optimize.gif

Stream内で別のStreamを利用する

ExtraにpropertyとしてStreamを定義することで、Stream内で別なStreamを利用することができます。
例として、SearchViewStreamで利用していた、SearchAPIActionをStream化します。

SearchAPIStream
protocol SearchAPIStreamType: AnyObject {
    var input: InputWrapper<SearchStream.Input> { get }
    var output: OutputWrapper<SearchStream.Output> { get }
}

final class SearchAPIStream: UnioStream<SearchStream>: SearchAPIStreamType {
    typealias State = NoState
    typealias Extra = NoExtra

    struct Input: InputType {
        let search = PublishRelay<String>()
    }

    struct Output: OutputType {
        let repositories: Observable<[Repository]>
        let error: Observable<Error>
    }

    static func bind(from dependency: Dependency<Input, State, Extra>, disposeBag: DisposeBag) -> Output {

        let response = dependency.inputObservables.search
            .flatMapLatest { search -> Observable<Event<[Repository]>>
                // APIアクセスの実装
            }
            .share()

        return Output(repositories: response.flatMap { $0.element.map(Observable.just) ?? .empty() }
                      error: response.flatMap { $0.error.map(Observable.just) ?? .empty() })
    }

    init() {
        super.init(input: Input(), state: State(), extra: Extra())
    }
}

Inputにsearch、Outputにrepositoriesとerrorが定義されています。
SearchViewStreamでは、それらにアクセスして処理を実行するようなります。
SearchViewStreamでSearchAPIStreamを利用した場合、// before:の部分がもともとの実装との差分となります。

SearchViewStream
final class SearchViewStream: UnioStream<SearchViewStream>: SearchViewStreamType {

    struct Input: InputType {
        let search = PublishRelay<String>()
    }

    struct Output: OutputType {
        let repositories: BehaviorRelay<[Repository]>
        let error: Observable<Error>
    }

    struct State: StateType {
        let repositories = BehaviorRelay<[Repository]>(value: [])
    }

    struct Extra: ExtraType {
        let apiStream: SearchAPIStreamType
// before: let apiAction = SearchAPIAction()

    }

    static func bind(from dependency: Dependency<Input, State, Extra>, disposeBag: DisposeBag) -> Output {

        let apiStream = dependency.extra.apiStream
// before:  let apiAction = dependency.extra.apiAction
        let state = dependency.state

        apiStream.output.observables.repositories
// before:  apiAction.response
            .bind(to: state.repositories)
            .disposed(by: disposeBag)

        dependency.inputObservables.search
            .bind(to: apiStream.input.search)
// before:  .subscribe(onNext: { apiAction.execute($0) })
            .disposed(by: disposeBag)

        return Output(repositories: state.repositories, error: apiStream.output.error)
// before:  return Output(repositories: state.repositories, error: apiAction.error)
        }
    }

    init(searchAPIStream: SearchAPIStreamType = SearchAPIStream()) {
        let extra = Extra(apiStream: searchAPIStream)
        super.init(input: Input(), state: State(), extra: extra)
// before: init() {
// before:    super.init(input: Input(), state: State(), extra: Extra())
    }
}

Stream内で別のStreamを利用する場合でも、input経由で入力、output経由でObservableの取得を行っていることがわかると思います。
また、SearchViewStreamのinitializerでprotocol SearchAPIStreamTypeを受け取ることで、Streamをモック化してテストを容易に行うことができるようになります。

UnioStreamのテスト方法

Streamをテストする場合、InputとOutputに注目してテストをすることになります。
SearchViewStreamを例に、テストを実装していきます。
まずSearchViewStreamは、SearchAPIStreamTypeに依存しているので、Mock化したSearchAPIStreamを定義します。

MockSearchAPIStream
final class MockSearchAPIStream: SearchAPIStreamType {
    let input: InputWrapper<SearchAPIStream.Input>
    let output: OutputWrapper<SearchAPIStream.Output>

    let _input = SearchAPIStream.Input()

    let _outputRepositories = BehaviorRelay<[Repository]?>(value: nil)
    let _outputError = BehaviorRelay<Error?>(value: nil)

    init() {
        self.input = InputWrapper(_input)

        let _repositories = _outputRepositories.flatMap { $0.map(Observable.just) ?? .empty() }
        let _error = _outputError.flatMap { $0.map(Observable.just) ?? .empty() }
        let _output = SearchAPIStream.Output(repositories: _repositories, error: _error)
        self.output = OutputWrapper(_output)
    }
}

input: InputWrapper<SearchAPIStream.Input>output: OutputWrapper<SearchAPIStream.Output>はそれぞれ公開されているメソッドが限定されているため、依存しているものをpropertyで定義し外部からそれらに変更を加えられるようにします。
それでは、実際のテストケースの実装を見ていきます。
func setUp()では、テストターゲットとなるSearchViewStreamを、依存してるMockSearchAPIStream (SearchAPIStreamType)とともに初期化しています。

final class SearchViewtreamTests: XCTestCase {

    private var stream: SearchViewStream!
    private var mock: MockSearchAPIStream!

    override func setUp() {
        self.mock = MockSearchAPIStream()
        self.stream = SearchViewStream(searchAPIStream: mock)
    }
}

まず、入力のテストを見てみます。
SearchViewStreamの入力のsearchは、Logicstatic func bind(from:disposeBag:) -> Output内でSearchAPIStreamのsearchに接続されています。
つまり、SearchAPIStreamのsearchから結果を確認することができます。
下記のように実装することで、Inputのテストが可能となります。

func testInput_search_is_called() {

    let expected = "test-search-text"
    let searchTextStack = BehaviorRelay<String?>(value: nil)

    let disposable = dependency.mock._input.search
        .bind(to: searchTextStack)

    stream.input.search(expected)

    XCTAssertEqual(expected, searchTextStack.value)

    disposable.dispose()
}

次に、出力のテストを見てみます。
SearchViewStreamのoutputの出力のrepositoriesは、Logicstatic func bind(from:disposeBag:) -> Output内でSearchAPIStreamのrepositoriesから接続されています。
つまり、SearchAPIStreamのrepositoriesから変更を通知することで確認することができます。
下記のように実装することで、Outputのテストが可能となります。

func testOutput_recieving_repositories() {

    let expected = [GitHub.Repository(...)]
    let repositoriesStack = BehaviorRelay<[Repository]?>(value: nil)

    let disposable = stream.output.repositories
        .bind(to: repositoriesStack)

    mock._outputRepositories.accept(expected)

    XCTAssertEqual(expected, repositoriesStack.value.first)

    disposable.dispose()
}

最後に

はじめにであげた①、②の冗長になる実装をKeyPathを利用することで改善し、①、②をまとめることができたことでInputOutputExtraを明示することができので③も改善できました。
それらはUnioを利用することで簡単に実装することができるので、是非試してみてください!

54
38
1

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
54
38