2019/12/16追記
記事作成は2019年4月頃に行いましたがそこから半年以上が経ち、Unioも機能が増えたりしたので記事を更新しました。
また、Unioについての登壇なども行っておりますので、こちらの資料も参考になりましたら幸いです。
はじめに
RxSwiftを用いたMVVMでアプリケーションを開発する際、ViewModelが以下のような実装になりやすくはないでしょうか。
- ①公開用の入力メソッドと、内部でsubscribeするためのRelayの2つを定義している
- ②内部状態を公開するためのObservableと、値自体を公開するためのpropertyの2つを定義している
- ③内部状態なのか、入力のRelayなのかが一見わかりにくい
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を用いて解決方法はちょうど良い利用事例になるのではないかなと思います。
I just published my slides for my @tryswiftconf talk about #keypaths 🗝🎉 in #swift.
— Benedikt Terhechte (@terhechte) March 21, 2019
You can find them here.https://t.co/UJXxsETAMv
For any questions, feel free to contact me here. #tryswiftconf
③に関しては、定義する場所を明示することで解決しようと思います。
KeyPathを用いて実装するViewModel
UnioというOSSを利用することで、上記の問題を解決することができます。UnioとはUnidirectional Input Outputを示しており、InputからOutputまでの流れを単一方向にすることを目的としたframeworkです。まずは上記の
SearchViewModel
を、Unioを利用してInputとOutputの流れを単一方向にしたSearchViewStream
として実装を置き換えます。
※Unioの内部実装については後ほど解説します。
Unioを利用した実装例
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で利用すると以下のようになります。
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は、以下のようにinput
とoutput
のみになります。そして、それらはそれぞれInputWrapper
型とOutputWrapper
型でラップされています。

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

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

Input
とOutput
のpropertyがinternalになっているが、単一方向は保証されているのか?
SearchViewStream
で定義されている、Input
とOutput
を再度見てみましょう。
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
の定義を見てみましょう。
public typealias UnioStream<Logic: LogicType> = PrimitiveStream<Logic> & LogicType & UnioStreamType
UnioStreamはクラスではなく、PrimitiveStream
とLogicType
とUnioStreamType
のtypealiasとなっています。
このような定義をすることでabstract classのように使えるので、任意のクラスを継承させつつ実装を強制することができます。
次に、PrimitiveStream
の定義を見てみましょう。
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を利用して、Input
やOutput
で定義されているpropertyの任意のメソッドを1階層飛び越えて呼び出すために、それらのpropertyはinternalで定義されています。
内部状態なのか、入力のRelayなのかがわかりやすくなったのか?
SearchViewStream
で、Input
(入力)、State
(内部状態)とExtra
(その他依存)が定義されています。
定義自体が分かれているため、何を示しているものなのかはわかりやすくなりました。
それではOutput
を生成する際に、それらがわかりやすい状態になっているのでしょうか?
Output
を生成しているLogic
のstatic 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で利用します。
public protocol InputType {}
public protocol OutputType {}
PublishRelayとBehaviorRelayを表現するためのprotocolを定義します。
この2つは、KeyPathでpropertyにアクセスする際のGeneric Where Clauseで利用します。
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によって型が制限されています。
public final class InputWrapper<T: InputType> {
internal let dependency: T
public init(_ dependency: T) {
self.dependency = dependency
}
}
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になっている必要があります。
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を取得できる実装をします。
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内ではInput
とOutput
をもとに、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は、Input
、Output
、State
、Extra
とLogic
が定義された状態になっています。加えて、ViewStreamTypeのprotocolも定義されるため、テストターゲットでMockも簡単に作成することが可能になっています。
Stream内で別のStreamを利用する
ExtraにpropertyとしてStreamを定義することで、Stream内で別なStreamを利用することができます。
例として、SearchViewStreamで利用していた、SearchAPIActionをStream化します。
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:
の部分がもともとの実装との差分となります。
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を定義します。
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
は、Logic
のstatic 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
は、Logic
のstatic 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を利用することで改善し、①、②をまとめることができたことでInput
、Output
、Extra
を明示することができので③も改善できました。
それらはUnioを利用することで簡単に実装することができるので、是非試してみてください!