はじめに
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とStore
Githubの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を比較してきましたがどのデザインパターンが良いというわけではなく、__"開発しているサービスにとってどのデザインパターンが必要十分なのか"__が重要だと考えています。
その要因はとして、アプリの規模だったり、アプリの性質だったり、開発している人数だったりと様々あるかと思います。
また実際にプロジェクトに導入する際には、それぞれのデザインパターンの簡単なサンプルを実装してみないと良し悪しはわからず机上の空論にしかならなので、是非サンプルを作ってみてください。