はじめに
iOSDC2017にてMVC→MVP→MVVM→Fluxの実装の違いを比較してみるという内容で、Githubのユーザー検索のデモアプリをベースにした発表資料で登壇させていただきました。
登壇枠が15分だったため、ViewControllerを跨いだ(FavoriteViewController <-> RepositoryViewController)4つのデザインパターンの実装の違いにフォーカスした内容となっているので、画面遷移やテストの書き方などについても補足説明を書いていきます。
※MVP、MVVM、Fluxでの補足説明という形で書いていきます。
登壇資料は下記になります。
本日のプレゼン資料、アップしました。https://t.co/TIecyULVp4#iosdc
— marty-suzuki (@marty_suzuki) September 17, 2017
また登壇時に利用したサンプルソースは、こちらのリポジトリで公開されています。
https://github.com/marty-suzuki/iOSDesignPatternSamples
デモアプリのアプリの動きはこのようになります。
- SearchViewController... 文字列検索でGithub上のユーザーのリストを取得し、表示します。
- UserRepositoryViewController... SearchViewControllerで選択されたユーザーのリポジトリ一覧を取得し、表示します。
- RepositoryViewController... UserRepositoryViewControllerまたはFavoriteViewControllerで選択されたリポジトリをSafariViewで表示します。また、右上のお気に入りボタンから、レポジトリをFavoriteViewControllerに追加・削除ができます。
- FavoriteViewController... お気に入りされたリポジトリの一覧が表示されます。
MVP
それでは、まずMVPによる実装について書いていきます。
ViewControllerに実装されるプレゼンテーション層に関するロジックをPresenterに実装することで、責務を明確にすることができます。
流れとしては、Viewはユーザーからのアクションを受け取ってPresenterに渡します。
そして、そのアクションによってPresenterはModelの取得や更新を行います。
Presenterは更新された情報をもとにViewの反映メソッドを呼び、Viewに更新結果を反映させます。
画面遷移
UITableViewDataSourceやUITableViewDelegateの実装を、FavoriteViewControllerの場合はFavoriteViewDataSourceに移しています。
FavoriteViewDataSource内のtableView(_:didSelectRowAt:)では、FavoritePresenterのshowFavoriteRepository(at:)が呼び出されています。
final class FavoriteViewDataSource: NSObject, UITableViewDelegate, ... {
private let presenter: FavoritePresenter
init(presenter: FavoritePresenter) {
self.presenter = presenter
}
...
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
presenter.showFavoriteRepository(at: indexPath.row)
}
...
}
FavoritePresenterはprotocolとなっていて、実装がされてるのはFavoriteViewPresenterになります。
showFavoriteRepository(at:)が呼び出されると、presenter内で保持しているGithubKit.Repositoryの配列から該当のGithubKit.Repositoryを取得し、FavoriteViewのshowRepository(with:)を呼び出しています。
protocol FavoritePresenter: class {
init(view: FavoriteView)
...
func showFavoriteRepository(at index: Int)
...
}
final class FavoriteViewPresenter: FavoritePresenter {
private weak var view: FavoriteView?
private var favorites: [GithubKit.Repository] = [] {
didSet {
view?.reloadData()
}
}
...
init(view: FavoriteView) {
self.view = view
}
...
func showFavoriteRepository(at index: Int) {
let repository = favorites[index]
view?.showRepository(with: repository)
}
}
FavoriteViewはprotocolとなっていて、実装がされてるのはFavoriteViewControllerになります。
showRepository(with:)が呼び出されるとRepositoryViewControllerを初期化し、navigationControllerでpushすることで遷移させます。
protocol FavoriteView: class {
...
func showRepository(with repository: GithubKit.Repository)
}
final class FavoriteViewController: UIViewController, FavoriteView {
@IBOutlet weak var tableView: UITableView!
private(set) lazy var presenter: FavoritePresenter = FavoriteViewPresenter(view: self)
private lazy var dataSource: FavoriteViewDataSource = .init(presenter: self.presenter)
...
func showRepository(with repository: GithubKit.Repository) {
let vc = RepositoryViewController(repository: repository, favoritePresenter: presenter)
navigationController?.pushViewController(vc, animated: true)
}
...
}
上記のTableViewのCellが選択されてから、次のViewControllerに遷移するまでの流れは下図のようになります。
お気に入りに追加
favoriteButtonItemがタップされるとfavoriteButtonTap(_:)が呼び出されるので、RepositoryPresenterのfavoriteButtonTap()を呼び出します。
また、presenterのviewがinitializerの引数に含まれていない理由としては、repositoryとfavoritePresenterをViewControllerで保持せずに済むようにするためです。
protocol RepositoryView: class {
...
}
final class RepositoryViewController: SFSafariViewController, ... {
private(set) lazy var favoriteButtonItem: UIBarButtonItem = {...}()
private let presenter: RepositoryPresenter
init(repository: GithubKit.Repository,
favoritePresenter: FavoritePresenter) {
self.presenter = RepositoryViewPresenter(repository: repository,
favoritePresenter: favoritePresenter)
super.init(url: repository.url, entersReaderIfAvailable: true)
self.presenter.view = self
}
...
@objc private func favoriteButtonTap(_ sender: UIBarButtonItem) {
presenter.favoriteButtonTap()
}
...
}
RepositoryPresenterの初期化時にViewも引数として含めたい場合は、下記のようにLazy Initializationを利用することで実現可能です。
しかしこの場合に、RepositoryPresenter内でrepositoryとfavoritePresenterを保持するにも関わらず、ViewControllerでも保持してしまうという問題もあるかと思います。
final class RepositoryViewController: SFSafariViewController, ... {
...
private(set) lazy var presenter: RepositoryPresenter = {
return RepositoryViewPresenter(repository: self.repository,
favoritePresenter: self.favoritePresenter,
view: self)
}()
private let repository: Repository
private let favoritePresenter: FavoritePresenter
init(repository: Repository,
favoritePresenter: FavoritePresenter) {
self.repository = repository,
self.favoritePresenter = favoritePresenter
super.init(url: repository.url, entersReaderIfAvailable: true)
}
...
}
RepositoryPresenterはprotocolとなっていて、実装がされてるのはRepositoryViewPresenterになります。
favoriteButtonTap()が呼ばれると、RepositoryViewPresenterで保持しているRepositoryがFavoritePresenter内に含まれているかを確認し、その結果によってお気に入りの一覧に追加または削除をしています。
protocol RepositoryPresenter: class {
init(repository: GithubKit.Repository, favoritePresenter: FavoritePresenter)
weak var view: RepositoryView? { get set }
...
func favoriteButtonTap()
}
final class RepositoryViewPresenter: RepositoryPresenter {
weak var view: RepositoryView?
private let favoritePresenter: FavoritePresenter
private let repository: GithubKit.Repository
...
init(repository: GithubKit.Repository, favoritePresenter: FavoritePresenter) {
self.repository = repository
self.favoritePresenter = favoritePresenter
}
func favoriteButtonTap() {
if favoritePresenter.contains(repository) {
favoritePresenter.removeFavorite(repository)
...
} else {
favoritePresenter.addFavorite(repository)
...
}
}
}
FavoritePresenterはprotocolとなっていて、実装がされてるのはFavoriteViewPresenterになります。
お気に入りの一覧を保持しているfavorites: [GithubKit.Repository]が更新されると、FavoriteViewのreloadData()を呼び出します。
protocol FavoritePresenter: class {
...
func addFavorite(_ repository: GithubKit.Repository)
func removeFavorite(_ repository: GithubKit.Repository)
func contains(_ repository: Repository) -> Bool
...
}
final class FavoriteViewPresenter: FavoritePresenter {
private weak var view: FavoriteView?
private var favorites: [GithubKit.Repository] = [] {
didSet {
view?.reloadData()
}
}
...
func addFavorite(_ repository: GithubKit.Repository) {
if favorites.lazy.index(where: { $0.url == repository.url }) != nil {
return
}
favorites.append(repository)
}
func removeFavorite(_ repository: GithubKit.Repository) {
guard let index = favorites.lazy.index(where: { $0.url == repository.url }) else {
return
}
favorites.remove(at: index)
}
func contains(_ repository: GithubKit.Repository) -> Bool {
return favorites.lazy.index { $0.url == repository.url } != nil
}
...
}
reloadData()の中ではtableView?.reloadData()が実行され、お気に入り一覧の画面が更新されます。
protocol FavoriteView: class {
func reloadData()
...
}
final class FavoriteViewController: UIViewController, FavoriteView {
@IBOutlet weak var tableView: UITableView!
private(set) lazy var presenter: FavoritePresenter = FavoriteViewPresenter(view: self)
...
func reloadData() {
tableView?.reloadData()
}
}
お気に入りに追加/削除される流れは下記のようになります。
FavoritePresenterを所有しているのはFavoriteViewControllerですが、ViewControllerが遷移する際にFavoritePresenterの参照が渡されていきます。
RepositoryViewControllerでお気に入りにボタンがタップされた際に、FavoritePresenter内で保持しているGithubKit.Repositoryの配列の状態によってRepositoryViewControllerのRepositoryが追加または削除されます。
Repositoryの配列に変更があるとview?.reloadData()が実行されるので、FavoriteViewController上でリストが更新されます。
テスト
FavoriteViewはprotocolなので、モック化にすることでFavoritePresenterのテストを行うことができます。
FavoriteViewMockは下記のようになります。
該当するメソッドが呼び出されたとき用のclosureをpropertyで定義し、それぞれのメソッドが呼び出された際に実行します。
class FavoriteViewMock: FavoriteView {
var presenter: FavoritePresenter?
var didCallReloadData: (() -> Void)?
var didCallShowRepository: ((GithubKit.Repository) -> Void)?
func reloadData() {
didCallReloadData?()
}
func showRepository(with repository: GithubKit.Repository) {
didCallShowRepository?(repository)
}
}
XCTestなどでテストを実行する際の実装は、下記のようになります。
FavoriteViewPresenterのinitializerの引数であるviewはFavoriteViewとなっているので、FavoriteViewを採用しているFavoriteViewMockを引数として渡すことが可能です。
テストメソッド内では該当するclosureに対して期待する結果を記述し、その後に紐づくpresenterのメソッドを呼び出しています。
class FavoritePresenterTests: XCTestCase {
var favoriteView: FavoriteViewMock!
var favoritePresenter: FavoritePresenter!
override func setUp() {
super.setUp()
self.favoriteView = FavoriteViewMock()
self.favoritePresenter = FavoriteViewPresenter(view: self.favoriteView)
self.favoriteView.presenter = favoritePresenter
}
func testReloadData() {
let expectation = self.expectation(description: "testReloadData expectation")
favoriteView.didCallReloadData = {
expectation.fulfill()
}
let repository = GithubKit.Repository.mock()
favoritePresenter.addFavorite(repository)
waitForExpectations(timeout: 1, handler: nil)
}
func testShowRepository() {
let expectation = self.expectation(description: "testShowRepository expectation")
let repository = GithubKit.Repository.mock()
favoritePresenter.addFavorite(repository)
favoriteView.didCallShowRepository = { repo in
XCTAssertEqual(repository.url, repo.url)
expectation.fulfill()
}
favoritePresenter.showFavoriteRepository(at: 0)
waitForExpectations(timeout: 1, handler: nil)
}
}
考察
ViewとPresenterはお互いの参照も持ち合うので、依存し合う形になります。
しかし、PresenterとViewを抽象化しておくことでMock化が容易になります。
よって、PresenterにViewのMockを渡すことプレゼンテーション層に関するロジックのテストが可能になります。
MVVM
続いてMVVMによる実装について書いていきます。
ViewControllerに実装されるプレゼンテーション層に関するロジックをViewModelに実装することで、責務を明確にすることができます。
流れとしては、Viewはユーザーからのアクションを受け取って、そのイベントをViewModelにbindします。
そして、そのbindされたイベントによってViewModelはModelの取得や更新を行います。
ViewModelは更新された情報をイベントとしてViewにbindし、Viewはbindされたイベントをもとに結果を反映させます。
※Data bindingを実現するために、RxSwiftを利用しています。
画面遷移
UITableViewDataSourceやUITableViewDelegateの実装を、FavoriteViewControllerの場合はFavoriteViewDataSourceに移しています。
FavoriteViewDataSource内のtableView(_:didSelectRowAt:)では、_selectedIndexPath: PublishSubject<IndexPath>が呼び出されています。
そして、selectedIndexPath: Observable<IndexPath>によって外部に公開されています。
final class FavoriteViewDataSource: NSObject, UITableViewDelegate, ... {
let selectedIndexPath: Observable<IndexPath>
private let _selectedIndexPath = PublishSubject<IndexPath>()
private let viewModel: FavoriteViewModel
init(viewModel: FavoriteViewModel) {
self.viewModel = viewModel
self.selectedIndexPath = _selectedIndexPath
}
...
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
_selectedIndexPath.onNext(indexPath)
}
...
}
selectedIndexPath: Observable<IndexPath>をFavoriteViewModel内のinputとして、initializerの引数として受け取ります。
そのselectedIndexPathからイベントが渡ってくると、ViewModel内で保持している_favorites: Variable<[GithubKit.Repository]>の最新の状態をwithLatestFromで取得し、配列内で該当するrowのRepositoryを返します。
そのイベントをselectedRepository: Observable<GithubKit.Repository>として外部に公開します。
またfavorites: Observable<[GithubKit.Repository]>は_favoritesのイベントを外部に公開するために利用しています。
final class FavoriteViewModel {
let favorites: Observable<[GithubKit.Repository]>
let selectedRepository: Observable<GithubKit.Repository>
...
private let _favorites = Variable<[GithubKit.Repository]>([])
...
init(..., selectedIndexPath: Observable<IndexPath>) {
self.favorites = _favorites.asObservable()
self.selectedRepository = selectedIndexPath
.withLatestFrom(_favorites.asObservable()) { $1[$0.row] }
...
}
}
FavoriteViewDataSourceでもFavoriteViewModelを利用しているので、FavoriteViewModelのinitializerの引数でdataSource.selectedIndexPathを渡すことができません。
そのため、FavoriteViewControllerでもselectedIndexPath: PublishSubject<IndexPath>定義し、そのselectedIndexPathをFavoriteViewModelのinitializerに渡します。
dataSource.selectedIndexPathをself.selectedIndexPathにbindすることで、dataSourceからのイベントをviewModelに流すことができます。
また、FavoriteViewModelで公開されているselectedRepositoryをshowRepository: AnyObserver<GithubKit.Repository>にbindすることで、RepositoryViewControllerに遷移させることができます。
また、self.favoritesをfavoritesInput: AnyObserver<[GithubKit.Repository]>として外部に公開し、viewModel.favoritesをfavoritesOutput: Observable<[GithubKit.Repository]>として外部に公開します。
これらが遷移する際に、それぞれのViewControllerのinitializerの引数として渡されていきます。
final class FavoriteViewController: UIViewController {
@IBOutlet weak var tableView: UITableView!
var favoritesInput: AnyObserver<[GithubKit.Repository]> { return favorites.asObserver() }
var favoritesOutput: Observable<[GithubKit.Repository]> { return viewModel.favorites }
private lazy var dataSource: FavoriteViewDataSource = .init(viewModel: self.viewModel)
private private(set) lazy var viewModel: FavoriteViewModel = {
.init(..., selectedIndexPath: self.selectedIndexPath)
}()
...
private let selectedIndexPath = PublishSubject<IndexPath>()
private let disposeBag = DisposeBag()
override func viewDidLoad() {
super.viewDidLoad()
...
// observe dataSource
dataSource.selectedIndexPath
.bind(to: selectedIndexPath)
.disposed(by: disposeBag)
// observe viewModel
viewModel.selectedRepository
.bind(to: showRepository)
.disposed(by: disposeBag)
...
}
private var showRepository: AnyObserver<GithubKit.Repository> {
return UIBindingObserver(UIElement: self) { me, repository in
let vc = RepositoryViewController(repository: repository,
favoritesOutput: me.favoritesOutput,
favoritesInput: me.favoritesInput)
me.navigationController?.pushViewController(vc, animated: true)
}.asObserver()
}
...
}
上記のTableViewのCellが選択されてから、次のViewControllerに遷移するまでの流れは下図のようになります。
お気に入りに追加
favoriteButtonItemのタップイベントをfavoriteButtonItem.rx.tapとして、RepositoryViewModelのinitializerに渡します。
そうすることによって、テスト時にPublishSubjectを利用することでタップを再現することができるようになります。
final class RepositoryViewController: SFSafariViewController {
private let favoriteButtonItem: UIBarButtonItem
private let disposeBag = DisposeBag()
private let viewModel: RepositoryViewModel
init(repository: Repository,
favoritesOutput: Observable<[GithubKit.Repository]>,
favoritesInput: AnyObserver<[GithubKit.Repository]>) {
let favoriteButtonItem = UIBarButtonItem(title: nil, style: .plain, target: nil, action: nil)
self.favoriteButtonItem = favoriteButtonItem
self.viewModel = RepositoryViewModel(repository: repository,
favoritesOutput: favoritesOutput,
favoritesInput: favoritesInput,
favoriteButtonTap: favoriteButtonItem.rx.tap)
super.init(url: repository.url, entersReaderIfAvailable: true)
}
...
}
RepositoryViewModelでは、initializerの引数であるfavoritesOutput: Observable<[GithubKit.Repository]>からお気に入り一覧の配列の更新イベントを受け取ります。
その際に、ViewModelで保持しているGithubKit.Repositoryが渡ってきた配列に含まれている場合は配列と該当するindexを返し、含まれていなかった場合は配列とnilを返します。
favoriteButtonTap: ControlEvent<Void>のイベントを受け取った際に上記の最新状態をwithLatestFromで受け取り、indexが存在した場合は該当のindexを削除した状態の配列返し、indexが存在しない場合はViewModelが保持しているGithubKit.Repositoryを追加した配列を返します。
そして、favoritesInput: AnyObserver<[GithubKit.Repository]>にその配列を流します。
final class RepositoryViewModel {
...
private let disposeBag = DisposeBag()
init(repository: Repository,
favoritesOutput: Observable<[GithubKit.Repository]>,
favoritesInput: AnyObserver<[GithubKit.Repository]>,
favoriteButtonTap: ControlEvent<Void>) {
let favoritesAndIndex = favoritesOutput
.map { [repository] favorites in
(favorites, favorites.index(where: { $0.url == repository.url }))
}
...
favoriteButtonTap
.withLatestFrom(favoritesAndIndex)
.map { [repository] favorites, index in
var favorites = favorites
if let index = index {
favorites.remove(at: index)
return favorites
}
favorites.append(repository)
return favorites
}
.subscribe(onNext: { favoritesInput.onNext($0) })
.disposed(by: disposeBag)
}
}
FavoriteViewModelでは、initializerの引数として受け取ったfavoritesObservable: Observable<[GithubKit.Repository]>を利用して外部からのイベントを受け取ります。
favoritesObservableを_favoritesにbindして、保持している配列を更新します。
_favoritesが更新されたイベントをObservable<Void>に変換して、relaodData: Observable<Void>として外部に公開します。
final class FavoriteViewModel {
...
let relaodData: Observable<Void>
...
private let _favorites = Variable<[GithubKit.Repository]>([])
private let disposeBag = DisposeBag()
init(favoritesObservable: Observable<[GithubKit.Repository]>, ...) {
...
self.relaodData = _favorites.asObservable().map { _ in }
...
favoritesObservable
.bind(to: _favorites)
.disposed(by: disposeBag)
}
}
FavoriteViewModelで公開されているreloadDataをFavoriteViewControllerのreloadData: AnyObserver<Void>にbindするとtableView.reloadData()が実行されるので、FavoriteViewController上でリストが更新されます。
final class FavoriteViewController: UIViewController {
@IBOutlet weak var tableView: UITableView!
...
private private(set) lazy var viewModel: FavoriteViewModel = {
.init(favoritesObservable: self.favorites, ...)
}()
private let favorites = PublishSubject<[GithubKit.Repository]>()
...
private let disposeBag = DisposeBag()
override func viewDidLoad() {
super.viewDidLoad()
...
// observe viewModel
viewModel.relaodData
.bind(to: reloadData)
.disposed(by: disposeBag)
...
}
...
private var reloadData: AnyObserver<Void> {
return UIBindingObserver(UIElement: self) { me, _ in
me.tableView.reloadData()
}.asObserver()
}
}
お気に入りに追加/削除される流れは下記のようになります。
FavoriteViewControllerのfavoriteInput: Obsservable<[GithubKit.Repository]>とfavoriteOutput: Observable<[GithubKit.Repository]>は画面遷移時に、他のViewControllerのinitializerの引数として渡されていきます。
RepositoryViewControllerではfavoriteOutputから受け取った配列をもとに、保持しているGithubKit.Repositoryが含まれているかを確認し、追加または削除を行った配列をfavoriteOutputに対して流します。
favoriteInputはFavoriteViewModel内で保持されている_favorites: Variable<[GithubKit.Repository]>にbindし、その更新イベントをfavoriteOutputによって外部に公開されます。
また、_favoritesの更新イベントをObservable<Void>に変換しreloadDataとして公開することで、FavoriteViewController側でreloadData: AnyObserverにbindされ、tableView.reloadData()が実行されるのでFavoriteViewController`上でリストが更新されます。
テスト
FavoriteViewModelはinitializerの引数として、favoritesObservable: Observable<[GithubKit.Repository]>とselectedIndexPath: Observable<IndexPath>を受け取っています。
XCTestなどでテストを行う際に、それらを外部から注入してイベントを送ることで、ViewModelのプレゼンテーション層に関するロジックのテストが可能になります。
テストメソッド内では該当するsubscribeのclosureに対して期待する結果を記述し、その後に紐づくPublishSubjectからイベントを送っています。
class FavoriteViewModelTests: XCTestCase {
var viewModel: FavoriteViewModel!
var favorites: PublishSubject<[GithubKit.Repository]>!
var selectedIndexPath: PublishSubject<IndexPath>!
override func setUp() {
super.setUp()
self.viewModel = FavoriteViewModel(favoritesObservable: self.favorites,
selectedIndexPath: self.selectedIndexPath)
}
func testRelaodData() {
let expectation = self.expectation(description: "testReloadData expectation")
let disposable = viewModel.relaodData
.subscribe(onNext: {
expectation.fulfill()
})
let repository = GithubKit.Repository.mock()
favorites.onNext([repository])
waitForExpectations(timeout: 1, handler: nil)
disposable.dispose()
}
func testSelectedRepository() {
let expectation = self.expectation(description: "testSelectedRepository expectation")
let repository = Repository.mock()
let disposable = viewModel.selectedRepository
.subscribe(onNext: { repo in
XCTAssertEqual(repository.url, repo.url)
expectation.fulfill()
})
favorites.onNext([repository])
waitForExpectations(timeout: 1, handler: nil)
disposable.dispose()
}
}
考察
ViewとViewModelはData bindingによってやり取りが行われるため、ViewはViewModelの参照を持ちますがViewModelは直接的にViewに依存はしません。
また、ViewModelのinputに対して擬似的にユーザーのアクションをイベントとして送り、ViewModelのoutputのイベントを確認することでプレゼンテーション層に関するロジックのテストが可能です。
そういった利点がある一方で、標準フレームワークだけでData bindingを実現することは容易ではありません。
NotificationCenterやKVOを利用することで近しいものは実装できますが、取得できる値の型がAnyになってしまうという問題があります。
また、propertyのdidSetでdelegateのメソッドを呼ぶことで近しい実装もできますが、delegateのオブジェクトは基本的に1つなので複数にしたい場合はforwardInvocationを利用する必要があったりしてしまいます。
Flux
最後に、Fluxによる実装について書いていきます。
Fluxのパーツとして
- Action...Viewからイベントを処理し、Dispatcherに値を流します。
- Dispatcher...Actionから受け取った値を、Storeに流します。
- Store...Dispatcherから受け取った値を保持し、値の変更を通知します。
- View...ユーザーかの操作によってActionを実行し、Storeの変更を反映します。
があります。
それらは、View → Action → Dispather → Store → Viewという形で単一方向のデータフローになっています。
ActionはどのViewからでも実行ができ、StoreはどのViewから参照されたとしても値を取得することができます。
このサンプルの場合、Fluxのパーツの構成は
- 1つのDispatcher
- ActionとStoreが一対一の組み合わせが複数
となっています。
また、ViewControllerごとにActionやStoreがあるわけではなく
GithubのRepositoryに関するActionとStoreGithubのUserに関するActionとStore
という作りになっています。
※Action、Dispatcher、Storeの実装を簡易化するために、FluxCapacitorを利用しています。
画面遷移
UITableViewDataSourceやUITableViewDelegateの実装を、FavoriteViewControllerの場合はFavoriteViewDataSourceに移しています。
FavoriteViewDataSource内のtableView(_:didSelectRowAt:)では、RepositoryStoreから該当のGithubKit.Repositoryを取得し、RepositoryActionのselectRepository(_:)に渡されています。
final class FavoriteViewDataSource: NSObject, UITableViewDelegate, ... {
private let store: RepositoryStore
private let action: RepositoryAction
init(store: RepositoryStore = .instantiate(),
action: RepositoryAction = .init()) {
self.store = store
self.action = action
}
...
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let repository = store.favoritesValue[indexPath.row]
action.selectRepository(repository)
}
...
}
RepositoryActionは受け取ったGithubKit.RepositoryをDispatcherに渡し、RepositoryStoreはDispatcherからGithubKit.Repositoryを受け取ります。
受け取ったGithubKit.Repositoryは_selectedRepository: Variable<GithubKit.Repository?>に保持されます。
selectedRepository: Observable<GithubKit.Repository?>が外部に公開されているので、値が更新された際にイベントが流れます。
final class RepositoryAction: Actionable {
typealias DispatchValueType = Dispatcher.Repository
...
func selectRepository(_ repository: GithubKit.Repository) {
invoke(.selectedRepository(repository))
}
...
}
extension Dispatcher {
enum Repository: DispatchValue {
...
case selectedRepository(GithubKit.Repository?)
...
}
}
final class RepositoryStore: Storable {
typealias DispatchValueType = Dispatcher.Repository
...
let selectedRepository: Observable<GithubKit.Repository?>
var selectedRepositoryValue: GithubKit.Repository? {
return _selectedRepository.value
}
private let _selectedRepository = Variable<GithubKit.Repository?>(nil)
...
init(dispatcher: Dispatcher) {
...
self.selectedRepository = _selectedRepository.asObservable()
...
register { [weak self] in
switch $0 {
...
case .selectedRepository(let value):
self?._selectedRepository.value = value
...
}
}
}
}
FavoriteViewControllerでは、RepositoryStoreのselectedRepository: Observable<GithubKit.Repository?>から流れてきたイベントを受け取り、そのイベントの値をVoidに変換して、showRepository: AnyObserver<Void>にbindします。
ViewControllerの遷移時、他のデザインパターンと異なる部分になるのですが、RepositoryViewControllerのinitializerは引数としてGithubKit.Repositoryを渡していません。
※store.selectedRepositoryはviewDidAppear時にsubscribeされ、viewDidDisappear時にdisposeされる実装がされています。
final class FavoriteViewController: UIViewController {
@IBOutlet weak var tableView: UITableView!
private let dataSource = FavoriteViewDataSource()
private let disposeBag = DisposeBag()
private let store: RepositoryStore = .instantiate()
private let action = RepositoryAction()
override func viewDidLoad() {
super.viewDidLoad()
...
// observe store
rx.methodInvoked(#selector(FavoriteViewController.viewDidAppear(_:)))
.flatMapLatest { [weak self] _ -> Observable<Void> in
guard let me = self else { return .empty() }
let viewWillDisappear = me.rx
.sentMessage(#selector(FavoriteViewController.viewDidDisappear(_:)))
return me.store.selectedRepository
.takeUntil(viewWillDisappear)
.filter { $0 != nil }
.map { _ in }
}
.bind(to: showRepository)
.disposed(by: disposeBag)
...
}
private var showRepository: AnyObserver<Void> {
return UIBindingObserver(UIElement: self) { me, _ in
guard let vc = RepositoryViewController() else { return }
me.navigationController?.pushViewController(vc, animated: true)
}.asObserver()
}
...
}
GithubKit.Repositoryを引数として渡す必要がない理由として、FavoriteViewDataSource内のtableView(_:didSelectRowAt:)でRepositoryStoreの_selectedRepository: Variable<GithubKit.Repository?>に保持されています。
そのため、store.selectedRepositoryValueとして値を取り出すことで、該当のGithubKit.Repositoryを受け取ることができます。
ViewController内で実際に利用する場合、store.selectedRepositoryをObservable<GithubKit.Repository>に変換して利用します。
final class RepositoryViewController: SFSafariViewController {
private let favoriteButtonItem = UIBarButtonItem(title: nil, style: .plain, target: nil, action: nil)
private let disposeBag = DisposeBag()
private let action = RepositoryAction()
private let store: RepositoryStore
init?() {
let store = RepositoryStore.instantiate()
guard let repository = store.selectedRepositoryValue else { return nil }
self.store = store
super.init(url: repository.url, entersReaderIfAvailable: true)
}
override func viewDidLoad() {
super.viewDidLoad()
...
// observe store
let repository = store.selectedRepository
.filter { $0 != nil }
.map { $0! }
...
}
}
上記のTableViewのCellが選択されてから、次のViewControllerに遷移するまでの流れは下図のようになります。
お気に入りに追加
store.selectedRepositoryをObservable<GithubKit.Repository>に変換したnoNilRepositoryを利用します。
noNilRepositoryとstore.favoritesをcombineLatestし、favoritesにGithubKit.Repositoryが含まれているかと**GithubKit.Repository**に変換したものをcontainsRepository: Observable<(Bool, Repository)>とします。
favoriteButtonItem.rx.tapのイベントが渡ってきたら、withLatestFromでcontainsRepositoryの最新状態を取得します。
そのイベントをsubscribeし、GithubKit.Repositoryが含まれていたらaction.removeFavorite(:_)、含まれていなかったらaction.addFavorite(_:)を実行します。
final class RepositoryViewController: SFSafariViewController {
private let favoriteButtonItem = UIBarButtonItem(title: nil, style: .plain, target: nil, action: nil)
private let disposeBag = DisposeBag()
private let action: RepositoryAction
private let store: RepositoryStore
init?(action: RepositoryAction = .init(),
store: RepositoryStore = .instantiate()) {
guard let repository = store.selectedRepositoryValue else { return nil }
self.action = action
self.store = store
super.init(url: repository.url, entersReaderIfAvailable: true)
}
override func viewDidLoad() {
super.viewDidLoad()
...
// observe store
let noNilRepository = store.selectedRepository
.filter { $0 != nil }
.map { $0! }
let containsRepository = Observable.combineLatest(noNilRepository, store.favorites)
{ repo, favs in (favs.contains { $0.url == repo.url }, repo) }
favoriteButtonItem.rx.tap
.withLatestFrom(containsRepository)
.subscribe(onNext: { [weak self] contains, repository in
if contains {
self?.action.removeFavorite(repository)
} else {
self?.action.addFavorite(repository)
}
})
.disposed(by: disposeBag)
...
}
}
RepositoryActionのaddFavorite(_:)またはremoveFavorite(_:)が呼ばれるとDispatcherを介し、GithubKit.RepositoryをRepositoryStoreで受け取ります。
RepositoryStoreでは、addFavoriteの場合はfavoritesに追加をし、reomveFavoriteの場合はfavoritesから削除をします。
それらの更新イベントを、外部に公開されているfavorites: Observable<[GithubKit.Repository]>に流します。
final class RepositoryAction: Actionable {
typealias DispatchValueType = Dispatcher.Repository
...
func addFavorite(_ repository: GithubKit.Repository) {
invoke(.addFavorite(repository))
}
func removeFavorite(_ repository: GithubKit.Repository) {
invoke(.removeFavorite(repository))
}
...
}
extension Dispatcher {
enum Repository: DispatchValue {
...
case addFavorite(GithubKit.Repository)
case removeFavorite(GithubKit.Repository)
...
}
}
final class RepositoryStore: Storable {
typealias DispatchValueType = Dispatcher.Repository
...
let favorites: Observable<[GithubKit.Repository]>
var favoritesValue: [GithubKit.Repository] {
return _favorites.value
}
private let _favorites = Variable<[GithubKit.Repository]>([])
...
init(dispatcher: Dispatcher) {
...
self.favorites = _favorites.asObservable()
self.selectedRepository = _selectedRepository.asObservable()
...
register { [weak self] in
switch $0 {
...
case .addFavorite(let value):
if self?._favorites.value.index(where: { $0.url == value.url }) == nil {
self?._favorites.value.append(value)
}
case .removeFavorite(let value):
if let index = self?._favorites.value.index(where: { $0.url == value.url }) {
self?._favorites.value.remove(at: index)
}
...
}
}
}
}
FavoriteViewControllerではRepositoryStoreのfavoritesのイベントを受け取ると、Voidに変換しreloadData: AnyObserver<Void>にbindします。
するとtableView.reloadData()を実行され、FavoriteViewController上でリストが更新されます。
final class FavoriteViewController: UIViewController {
@IBOutlet weak var tableView: UITableView!
private let dataSource = FavoriteViewDataSource()
private let disposeBag = DisposeBag()
private let store: RepositoryStore = .instantiate()
private let action = RepositoryAction()
override func viewDidLoad() {
super.viewDidLoad()
...
store.favorites.map { _ in }
.bind(to: reloadData)
.disposed(by: disposeBag)
}
...
private var reloadData: AnyObserver<Void> {
return UIBindingObserver(UIElement: self) { me, _ in
me.tableView.reloadData()
}.asObserver()
}
}
お気に入りに追加/削除される流れは下図のようになります。
RepositoryStoreはどこからでも参照が可能なので、FavoriteViewControllerからの遷移時にRepositoryViewControllerのinitializerの引数として渡す必要がありません。
RepositoryViewControllerでは、RepositoryStoreのfavoritesにRepositoryStoreのselectedRepositoryが含まれているかを確認し、RepositoryActionから追加または削除を実行します。
その結果がRepositoryStoreに反映され、公開しているfavorites: Observable<[GithubKit.Repositor]>にイベントが流れます。
favoritesの更新イベントをFavoriteViewController内でObservable<Void>に変換しreloadData: AnyObserver<Void>にbindされ、tableView.reloadData()が実行されるのでFavoriteViewController上でリストが更新されます。
テスト
下記のようにXCTestなどで、RepositoryActionで渡した値がRepositorStoreに反映されるのかのテストを実行することができます。
テストメソッド内では該当するStore内のObservableをsubscribeし、closureに対して期待する結果を記述します。
その後に紐づくActionのメソットを実行しています。
class RepositoryFluxFlowTestCase: XCTestCase {
var action: RepositoryAction!
var store: RepositoryStore!
override func setUp() {
super.setUp()
self.action = RepositoryAction()
self.store = RepositoryStore.instantiate()
}
func testSelectedRepository() {
let expectation = self.expectation(description: "testSelectedRepository expectation")
let repository = Repository.mock()
let disposable = store.selectedRepository
.skip(1)
.subscribe(onNext: {
guard let selectedRepository = $0 else {
XCTFail()
return
}
XCTAssertEqual(repository.url, selectedRepository.url)
expectation.fulfill()
})
action.invoke(.selectedRepository(repository))
waitForExpectations(timeout: 1, handler: nil)
disposable.dispose()
}
...
}
またActionではメソッドの引数として受け取った値をdispatchするだけではなく、通信を行ってその結果をdispatchする場合もあります。
その場合、Actionのinitializerで通信用のモジュールをprotocolとして抽象化したものを引数として受け取ることで、通信結果の値がStoreに反映されるかのテストをすることができるようになります。
下記の例の場合は、ApiSessionTypeとして通信用のモジュールを抽象化し、func send<T: Request>(_ request: T) -> Observable<Response<T.ResponseType>>を宣言しています。
protocol ApiSessionType: class {
func send<T: Request>(_ request: T) -> Observable<Response<T.ResponseType>>
}
final class RepositoryAction: Actionable {
typealias DispatchValueType = Dispatcher.Repository
private let session: ApiSessionType
private var disposeBag = DisposeBag()
init(session: ApiSessionType = ApiSession.shared) {
self.session = session
}
...
func fetchRepositories(withUserId id: String, after: String?) {
let request = UserNodeRequest(id: id, after: after)
session.send(request)
.subscribe(onNext: { [weak self] in
self?.invoke(.lastPageInfo($0.pageInfo))
self?.invoke(.addRepositories($0.nodes))
self?.invoke(.repositoryTotalCount($0.totalCount))
})
.disposed(by: disposeBag)
}
...
}
ApiSessionTypeを採用しているApiSessionMockでは、result: ApiSession.Result<Any>?として通信の擬似的な結果を外部から設定できるようになっています。
ApiSessionMockでfunc send<T: Request>(_ request: T) -> Observable<Response<T.ResponseType>>を実装する際に、resultのAssociated ValueをObservableにラップして返しています。
class ApiSessionMock: ApiSessionType {
enum Error: Swift.Error {
case requestFailed
}
var result: ApiSession.Result<Any>?
func send<T: Request>(_ request: T) -> Observable<Response<T.ResponseType>> {
switch result {
case .success(let value as Response<T.ResponseType>)?:
return .just(value)
default:
return .error(Error.requestFailed)
}
}
}
RepositoryFluxFlowTestCaseでRepositoryActionを初期化する際に、引数としてApiSessionMockのインスタンスを渡します。
testFetchRepositories()では、まず通信の擬似的な結果をsession.resultに設定します。
そして、storeで保持している値から更新イベントを受け取った際に期待する結果をsubscribe内に記述します。
最後に、action.fetchRepositories(withUserId:, after:)を呼んでテストを行います。
class RepositoryFluxFlowTestCase: XCTestCase {
var session: ApiSessionMock!
var action: RepositoryAction!
var store: RepositoryStore!
override func setUp() {
super.setUp()
self.session = ApiSessionMock()
self.action = RepositoryAction(session: self.session)
self.store = RepositoryStore.instantiate()
}
func testFetchRepositories() {
let expectation = self.expectation(description: "testFetchRepositories expectation")
let repository = Repository.mock()
let pageInfo = PageInfo.mock()
let totalCount = 10
let response = Response<Repository>(nodes: [repository], pageInfo: pageInfo, totalCount: totalCount)
session.result = .success(response)
let disposable =
Observable.zip(store.repositories.skip(1),
store.lastPageInfo.skip(1),
store.repositoryTotalCount.skip(1))
.subscribe(onNext: { repositories, lastPageInfo, repositoryTotalCount in
guard let firstRepository = repositories.first, let lastPageInfo = lastPageInfo else {
XCTFail()
return
}
XCTAssertEqual(repository.url, firstRepository.url)
XCTAssertEqual(lastPageInfo.hasNextPage, pageInfo.hasNextPage)
XCTAssertEqual(repositoryTotalCount, totalCount)
expectation.fulfill()
})
action.fetchRepositories(withUserId: "marty-suzuki", after: nil)
waitForExpectations(timeout: 1, handler: nil)
disposable.dispose()
}
}
考察
Fluxではデータフローが単一方向になるので
- Actionではメソットの呼び出し
- Storeから値を参照
という形で責務を明確にすることができます。
そういった良い部分もある中で
- プレゼンテーション層に関するロジックがViewControllerに実装されがち
- Dispatcherはシングルトンで実装される(場合によってはStoreも)
という懸念点もあります。
To Be Continued
番外編としてFluxとMVVMを組み合わせた実装に関しての資料を紹介だけさせていただいていました。
9/7に開催されたBrooklyn Swift Developersというmeetupでの登壇資料です。
Thank you for giving me a great opportunity to talk in an international meetup!
— marty-suzuki (@marty_suzuki) September 8, 2017
This is my talk slide! @BKLNSwifthttps://t.co/NFDWMBgbwT
またFluxとMVVMを組み合わせた実装のサンプルは下記のURLからご覧いただけます。
https://github.com/marty-suzuki/FluxCapacitor/tree/master/Examples/Flux%2BMVVM
Flux & MVVM
Fluxではプレゼンテーション層に関するロジックがViewControllerに実装されがちですが、そこにViewModelを挟むことによって改善されます。
まず、FavoriteViewControllerでのFlux単体でのデータフローは下図のようになります。
そこにViewModelを挟むと下図のようになります。
FavoriteViewModelではRepositoryStoreで公開されているObservableを変換して、reloadData: Observable<Void>として外部に公開します。
final class FavoriteViewModel {
...
private let store: RepositoryStore
private let disposeBag = DisposeBag()
let reloadData: Observable<Void>
private let _reloadData = PublishSubject<Void>()
let showRepository: Observable<Void>
private let _showRepository = PublishSubject<Void>()
...
init(...,
store: RepositoryStore = .instantiate(),
...) {
self.store = store
...
self.reloadData = _reloadData
...
store.favorites
.map { _ in }
.bind(to: _reloadData)
.disposed(by: disposeBag)
}
}
FavoriteViewControllerではFavoriteViewModelで公開されているreloadDataをreloadData: AnyObserver<Void>にbindし、tableView.reloadData()を実行します。
final class FavoriteViewController: UIViewController {
...
private let disposeBag = DisposeBag()
...
private(set) lazy var viewModel: FavoriteViewModel = {
...
}()
...
override func viewDidLoad() {
super.viewDidLoad()
...
viewModel.reloadData
.bind(to: reloadData)
.disposed(by: disposeBag)
...
}
private var reloadData: AnyObserver<Void> {
return UIBindingObserver(UIElement: self) { me, _ in
me.tableView.reloadData()
}.asObserver()
}
...
}
プレゼンテーション層のロジックがViewModelに実装されているため、ViewControllerではViewModelの公開されているObservableが該当のViewにbindされる実装になります。
そのため、ViewControllerをTestCaseに置き換えることで、容易にテストするとこが可能になります。
FavoriteViewModelTestCaseでは、FavoriteViewModelのinitializerでRepositoryActionとRepositoryStoreを渡しています。
testReloadData()では、RepositoryActionのaddFavorite(_:)にRepositoryを渡すとviewModel.reloadDataが実行されるかのテストを行っています。
class FavoriteViewModelTestCase: XCTestCase {
var action: RepositoryAction!
var store: RepositoryStore!
...
var viewModel: FavoriteViewModel!
...
override func setUp() {
super.setUp()
...
self.action = RepositoryAction(...)
self.store = RepositoryStore.instantiate()
self.viewModel = FavoriteViewModel(action: action, store: store, ...)
}
func testReloadData() {
let expectation = self.expectation(description: "testReloadData expectation")
let disposable = viewModel.reloadData
.subscribe(onNext: {
expectation.fulfill()
})
action.addFavorite(Repository.mock())
waitForExpectations(timeout: 1, handler: nil)
disposable.dispose()
}
}
最後に
MVC、MVP、MVVM、Fluxを比較してきましたがどのデザインパターンが良いというわけではなく、__"開発しているサービスにとってどのデザインパターンが必要十分なのか"__が重要だと考えています。
その要因はとして、アプリの規模だったり、アプリの性質だったり、開発している人数だったりと様々あるかと思います。
また実際にプロジェクトに導入する際には、それぞれのデザインパターンの簡単なサンプルを実装してみないと良し悪しはわからず机上の空論にしかならなので、是非サンプルを作ってみてください。










