Edited at

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


はじめに

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: Relay<SearchViewStream.Input> { get }
var output: Relay<SearchViewStream.Output> { get }
}

final class SearchViewStream: UnioStream<SearchViewStream.Logic>: 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()
}

struct Logic: LogicType {
let disposeBag = DisposeBag()

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

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

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

dependency.inputObservable(for: \.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(), logic: Logic())
}
}


上記の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.accept("search-text", for: \.search)

viewStream.output.observable(for: \.repositories)
.subscribe()
.disposed(by: disposeBag)

print(viewStream.output.value(for: \.repositories))
}
}


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

スクリーンショット 2019-03-23 20.56.56.png

inputは、KeyPathで指定しているオブジェクトがPublishRelayだった場合に、acceptを実行しています。(PublishSubjectだった場合は、onEventを実行します)

input.png

そしてoutputでは、KeyPathで指定しているオブジェクトがObservableConvertibleTypeだった場合に、Observable<[Repository]>を返しています。

加えて、KeyPathで指定しているオブジェクトがBehaviorRelayだった場合は、valueとして[Repository]を返しています。(BehaviorSubjectだった場合は、throwableなメソッドでvalueを返します)

output.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

open class UnioStream<Logic> where Logic : LogicType {

public let input: Unio.Relay<Logic.Input>

public let output: Unio.Relay<Logic.Output>

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


UnioStreamではinput: Relay<Input>output: Relay<Output>が公開されています。InputやOutputが直接公開されているのではなく、Relay<T>にラップされています。

そのため、Relay<T>とKeyPathを介してInputやOutputに定義されているPropertyの任意のメソッドにアクセスすることはできますが、InputやOutputだったりそれらが保持しているPropertyに直接アクセスすることはできません

つまり、ラップしたオブジェクトからKeyPathを利用して、InputOutputで定義されているPropertyの任意のメソッドを1階層飛び越えて呼び出すために、それらのPropertyはinternalで定義されています。


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

SearchViewStreamで、Input(入力)、State(内部状態)とExtra(その他依存)が定義されています。

定義自体が分かれているため、何を示しているものなのかはわかりやすくなりました。

それではOutputを生成する際に、それらがわかりやすい状態になっているのでしょうか?

Outputを生成しているLogicfunc bind(from dependency: Dependency<Input, State, Extra>) -> Outputを見てみます。

(※func bind(from:) -> OutputはUnioStreamが初期化される際に一度だけ呼び出されます。)

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

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

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

dependency.inputObservable(for: \.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:)からKeyPathを介してInputのPropertyからObservableを取得します。

このようにOutput生成時でも、内部状態や入力が明示的になっています。


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

こちらでも触れていますが、InputとOutputはRelay<T>にラップされ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をラップしているRelay型です。

Generic ArgumentはTとなっていますが、initializerがprivateとなっているため、初期化が制限されます。

public final class Relay<T> {

private let dependency: T

private init(dependency: T) {
self.dependency = dependency
}
}

RelayのGeneric Where ClauseがInputの場合、Inputが引数となるinitializerを公開します。

そして、KeyPath<Root, Value>のRootがInputでValueがPublishRelayである場合、dependency: TからKeyPathを介してacceptを実行できる実装をします。

dependencyの定義がprivateになっていても、RelayのメソッドのKeyPathのRootをGeneric ArgumentのTにすることで、階層が違っても内部では任意のpropertyの任意のメソッドなどにアクセスすることができるようになります。

ただ、それらにアクセスするためにはInputで定義しているproperty自体はinternalになっている必要があります。


TがInputTypeの場合

extension Relay where T: InputType {

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

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


一方で、RelayのGeneric Where ClauseがOutputの場合、Outputが引数となるinitializerを公開します。

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

加えて、ValueがBehaviorRelayである場合、dependency: TからKeyPathを介してvalueを取得できる実装をします。


TがOutputTypeの場合

extension Relay where T: OutputType {

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

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を取得できる実装にします。

なぜRelayを使わ回さないかというと、RelayでObservableが利用できる場合はGeneric ArgumentがOutputであるはずです。

つまり、性質が逆になってしまうので単一方向を保てなくなってしまうので、別な型で表現しています。

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()
}
}

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

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

public let input: Relay<Input>
public let output: Relay<Output>

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

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

UnioStreamが公開しているRelay<Input>Relay<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 SearchAPIStream: AnyObject {

var input: Relay<SearchStream.Input> { get }
var output: Relay<SearchStream.Output> { get }
}

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

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

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

struct Logic: LogicType {

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

let response = dependency.inputObservable(for: \.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(), logic: Logic())
}
}


Inputにsearch、Outputにrepositoriesとerrorが定義されています。

SearchViewStreamでは、それらにアクセスして処理を実行するようなります。

SearchViewStreamでSearchAPIStreamを利用した場合、// before:の部分がもともとの実装との差分となります。


SearchViewStream

final class SearchViewStream: UnioStream<SearchViewStream.Logic>: 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()

}

struct Logic: LogicType {
let disposeBag = DisposeBag()

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

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

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

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

return Output(repositories: state.repositories, error: apiStream.output.observable(for: \.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, logic: Logic())
// before: init() {
// before: super.init(input: Input(), state: State(), extra: Extra(), logic: Logic())
}
}


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: Relay<SearchAPIStream.Input>
let output: Relay<SearchAPIStream.Output>

let _input = SearchAPIStream.Input()

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

init() {
self.input = Relay(_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 = Relay(_output)
}
}


input: Relay<SearchAPIStream.Input>output: Relay<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は、Logicfunc bind(from:) -> 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.accept(expected, for: \.search)

XCTAssertEqual(expected, searchTextStack.value)

disposable.dispose()
}

次に、出力のテストを見てみます。

SearchViewStreamのoutputの出力のrepositoriesは、Logicfunc bind(from:) -> Output内でSearchAPIStreamのrepositoriesから接続されています。

つまり、SearchAPIStreamのrepositoriesから変更を通知することで確認することができます。

下記のように実装することで、Outputのテストが可能となります。

func testOutput_recieving_repositories() {

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

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

mock._outputRepositories.accept(expected)

XCTAssertEqual(expected, repositoriesStack.value.first)

disposable.dispose()
}


最後に

はじめにであげた①、②の冗長になる実装をKeyPathを利用することで改善し、①、②をまとめることができたことでInputOutputExtraを明示することができので③も改善できました。

それらはUnioを利用することで簡単に実装することができるので、是非試してみてください!