【iOSDC2017】MVC→MVP→MVVM→Fluxの実装の違いを比較してみる

  • 192
    いいね
  • 0
    コメント

はじめに

iOSDC2017にてMVC→MVP→MVVM→Fluxの実装の違いを比較してみるという内容で、Githubのユーザー検索のデモアプリをベースにした発表資料で登壇させていただきました。
登壇枠が15分だったため、ViewControllerを跨いだ(FavoriteViewController <-> RepositoryViewController)4つのデザインパターンの実装の違いにフォーカスした内容となっているので、画面遷移やテストの書き方などについても補足説明を書いていきます。
※MVP、MVVM、Fluxでの補足説明という形で書いていきます。
登壇資料は下記になります。

また登壇時に利用したサンプルソースは、こちらのリポジトリで公開されています。
https://github.com/marty-suzuki/iOSDesignPatternSamples

デモアプリのアプリの動きはこのようになります。

app.gif

  • SearchViewController... 文字列検索でGithub上のユーザーのリストを取得し、表示します。
  • UserRepositoryViewController... SearchViewControllerで選択されたユーザーのリポジトリ一覧を取得し、表示します。
  • RepositoryViewController... UserRepositoryViewControllerまたはFavoriteViewControllerで選択されたリポジトリをSafariViewで表示します。また、右上のお気に入りボタンから、レポジトリをFavoriteViewControllerに追加・削除ができます。
  • FavoriteViewController... お気に入りされたリポジトリの一覧が表示されます。

structure.png

MVP

それでは、まずMVPによる実装について書いていきます。

ViewControllerに実装されるプレゼンテーション層に関するロジックをPresenterに実装することで、責務を明確にすることができます。
流れとしては、Viewはユーザーからのアクションを受け取ってPresenterに渡します。
そして、そのアクションによってPresenterはModelの取得や更新を行います。
Presenterは更新された情報をもとにViewの反映メソッドを呼び、Viewに更新結果を反映させます。

画面遷移

UITableViewDataSourceUITableViewDelegateの実装を、FavoriteViewControllerの場合はFavoriteViewDataSourceに移しています。
FavoriteViewDataSource内のtableView(_:didSelectRowAt:)では、FavoritePresentershowFavoriteRepository(at:)が呼び出されています。

FavoriteViewDataSource
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を取得し、FavoriteViewshowRepository(with:)を呼び出しています。

FavoritePresenter
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することで遷移させます。

FavoriteView
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に遷移するまでの流れは下図のようになります。

MVP.002.jpeg

お気に入りに追加

favoriteButtonItemがタップされるとfavoriteButtonTap(_:)が呼び出されるので、RepositoryPresenterfavoriteButtonTap()を呼び出します。
また、presenterのviewがinitializerの引数に含まれていない理由としては、repositoryとfavoritePresenterをViewControllerで保持せずに済むようにするためです。

RepositoryView
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でも保持してしまうという問題もあるかと思います。

RepositoryViewController
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で保持しているRepositoryFavoritePresenter内に含まれているかを確認し、その結果によってお気に入りの一覧に追加または削除をしています。

RepositoryPresenter
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]が更新されると、FavoriteViewreloadData()を呼び出します。

FavoritePresenter
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()が実行され、お気に入り一覧の画面が更新されます。

FavoriteView
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の配列の状態によってRepositoryViewControllerRepositoryが追加または削除されます。
Repositoryの配列に変更があるとview?.reloadData()が実行されるので、FavoriteViewController上でリストが更新されます。

MVP.003.jpeg

テスト

FavoriteViewはprotocolなので、モック化にすることでFavoritePresenterのテストを行うことができます。
FavoriteViewMockは下記のようになります。
該当するメソッドが呼び出されたとき用のclosureをpropertyで定義し、それぞれのメソッドが呼び出された際に実行します。

FavoriteViewMock
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のメソッドを呼び出しています。

FavoriteViewTests
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を利用しています。

画面遷移

UITableViewDataSourceUITableViewDelegateの実装を、FavoriteViewControllerの場合はFavoriteViewDataSourceに移しています。
FavoriteViewDataSource内のtableView(_:didSelectRowAt:)では、_selectedIndexPath: PublishSubject<IndexPath>が呼び出されています。
そして、selectedIndexPath: Observable<IndexPath>によって外部に公開されています。

FavoriteViewDataSource
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のイベントを外部に公開するために利用しています。

FavoriteViewModel
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の引数として渡されていきます。

FavoriteViewController
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に遷移するまでの流れは下図のようになります。

MVVM.002.jpeg

お気に入りに追加

favoriteButtonItemのタップイベントをfavoriteButtonItem.rx.tapとして、RepositoryViewModelのinitializerに渡します。
そうすることによって、テスト時にPublishSubjectを利用することでタップを再現することができるようになります。

RepositoryViewController
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]>にその配列を流します。

RepositoryViewModel
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>として外部に公開します。

FavoriteViewModel
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をFavoriteViewControllerreloadData: AnyObserver<Void>にbindするとtableView.reloadData()が実行されるので、FavoriteViewController上でリストが更新されます。

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

お気に入りに追加/削除される流れは下記のようになります。

FavoriteViewControllerfavoriteInput: 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`上でリストが更新されます。

MVVM.003.jpeg

テスト

FavoriteViewModelはinitializerの引数として、favoritesObservable: Observable<[GithubKit.Repository]>selectedIndexPath: Observable<IndexPath>を受け取っています。
XCTestなどでテストを行う際に、それらを外部から注入してイベントを送ることで、ViewModelのプレゼンテーション層に関するロジックのテストが可能になります。
テストメソッド内では該当するsubscribeのclosureに対して期待する結果を記述し、その後に紐づくPublishSubjectからイベントを送っています。

FavoriteViewModelTests
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を利用しています。

画面遷移

UITableViewDataSourceUITableViewDelegateの実装を、FavoriteViewControllerの場合はFavoriteViewDataSourceに移しています。
FavoriteViewDataSource内のtableView(_:didSelectRowAt:)では、RepositoryStoreから該当のGithubKit.Repositoryを取得し、RepositoryActionselectRepository(_:)に渡されています。

FavoriteViewDataSource
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.RepositoryDispatcherに渡し、RepositoryStoreDispatcherからGithubKit.Repositoryを受け取ります。
受け取ったGithubKit.Repository_selectedRepository: Variable<GithubKit.Repository?>に保持されます。
selectedRepository: Observable<GithubKit.Repository?>が外部に公開されているので、値が更新された際にイベントが流れます。

RepositoryFluxFlow
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では、RepositoryStoreselectedRepository: Observable<GithubKit.Repository?>から流れてきたイベントを受け取り、そのイベントの値をVoidに変換して、showRepository: AnyObserver<Void>にbindします。
ViewControllerの遷移時、他のデザインパターンと異なる部分になるのですが、RepositoryViewControllerのinitializerは引数としてGithubKit.Repositoryを渡していません。

※store.selectedRepositoryはviewDidAppear時にsubscribeされ、viewDidDisappear時にdisposeされる実装がされています。

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()
        ...
        // 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.selectedRepositoryObservable<GithubKit.Repository>に変換して利用します。

RepositoryViewController
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に遷移するまでの流れは下図のようになります。

Flux.002.jpeg

お気に入りに追加

store.selectedRepositoryObservable<GithubKit.Repository>に変換したnoNilRepositoryを利用します。
noNilRepositorystore.favoritesをcombineLatestし、favoritesにGithubKit.Repositoryが含まれているかGithubKit.Repositoryに変換したものをcontainsRepository: Observable<(Bool, Repository)>とします。
favoriteButtonItem.rx.tapのイベントが渡ってきたら、withLatestFromでcontainsRepositoryの最新状態を取得します。
そのイベントをsubscribeし、GithubKit.Repositoryが含まれていたらaction.removeFavorite(:_)、含まれていなかったらaction.addFavorite(_:)を実行します。

RepositoryViewController
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)
        ...
    }
}

RepositoryActionaddFavorite(_:)またはremoveFavorite(_:)が呼ばれるとDispatcherを介し、GithubKit.RepositoryRepositoryStoreで受け取ります。
RepositoryStoreでは、addFavoriteの場合はfavoritesに追加をし、reomveFavoriteの場合はfavoritesから削除をします。
それらの更新イベントを、外部に公開されているfavorites: Observable<[GithubKit.Repository]>に流します。

RepositoryFluxFlow
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ではRepositoryStorefavoritesのイベントを受け取ると、Voidに変換しreloadData: AnyObserver<Void>にbindします。
するとtableView.reloadData()を実行され、FavoriteViewController上でリストが更新されます。

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では、RepositoryStorefavoritesRepositoryStoreselectedRepositoryが含まれているかを確認し、RepositoryActionから追加または削除を実行します。
その結果がRepositoryStoreに反映され、公開しているfavorites: Observable<[GithubKit.Repositor]>にイベントが流れます。
favoritesの更新イベントをFavoriteViewController内でObservable<Void>に変換しreloadData: AnyObserver<Void>にbindされ、tableView.reloadData()が実行されるのでFavoriteViewController上でリストが更新されます。

Flux.003.jpeg

テスト

下記のように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>?として通信の擬似的な結果を外部から設定できるようになっています。
ApiSessionMockfunc 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)
        }
    }
}

RepositoryFluxFlowTestCaseRepositoryActionを初期化する際に、引数として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での登壇資料です。

またFluxとMVVMを組み合わせた実装のサンプルは下記のURLからご覧いただけます。
https://github.com/marty-suzuki/FluxCapacitor/tree/master/Examples/Flux%2BMVVM

Flux & MVVM

Fluxではプレゼンテーション層に関するロジックがViewControllerに実装されがちですが、そこにViewModelを挟むことによって改善されます。
まず、FavoriteViewControllerでのFlux単体でのデータフローは下図のようになります。

flux_mvvm.001.jpeg

そこにViewModelを挟むと下図のようになります。

flux_mvvm.002.jpeg

FavoriteViewModelではRepositoryStoreで公開されているObservableを変換して、reloadData: Observable<Void>として外部に公開します。

FavoriteViewModel
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()を実行します。

FavoriteViewController
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に置き換えることで、容易にテストするとこが可能になります。

flux_mvvm.003.jpeg

FavoriteViewModelTestCaseでは、FavoriteViewModelのinitializerでRepositoryActionRepositoryStoreを渡しています。
testReloadData()では、RepositoryActionaddFavorite(_:)Repositoryを渡すとviewModel.reloadDataが実行されるかのテストを行っています。

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