VIPERアーキテクチャーのサンプル紹介

SwiftUIの記事ばかりでしたが、今回はUIKitとStoryboardの世界に戻って「VIPER」アーキテクチャーについての解説とサンプルプログラムを紹介します。

先日参画したiOSアプリ開発の現場で初期段階から携われる機会に恵まれたので、VIPERアーキテクチャーを推してみたら採用されました(ちょっとしかやったこと無かったのに採用してくれて感謝です)。

現場経験で少し身になってきたと思うので復習を兼ねて改めてVIPERアーキテクチャーを振り返ります。

VIPERアーキテクチャーとは

MVC、MVP、MVVM、Flux、Redux・・・などなど、iOSに限らず世の中には様々なプログラムの構造手法(アーキテクチャー)があります。その中で、「Clean Architecture」というものがありますが、それを iOS 向けにアレンジしたものが「VIPER」です

「VIPER」は以下の頭文字から来ています。

  • V:View・ViewController
  • I:Interactor
  • P:Presenter
  • E:Entity
  • R:Router

「View」は説明不要ですが、画面要素を管理するクラス(ViewController)を指します。

「Interactor」は外部データの取得・更新を行い、「Presenter」(後述)に結果を返す役割を担います。

「Presenter」は「Interactor」にデータ取得等の仕事を依頼し、「View」に結果を返します。また、画面遷移を「Router」(後述)に依頼します。全体のハブ的な役割に当たります。

「Entity」はデータ構造そのものを示し、通常は「struct」で定義することが多いです。あまりロジック的なメソッドは含めず、JSON形式からの変換などの最小限のメソッドに留めます。

「Router」は画面遷移を担います。通常、ViewControllerが行う処理を代わりに行います。

ざっくりした説明で分かりづらいと思いますので、後ほど実際のコードを紹介し深堀りしていきます。

VIPERアーキテクチャーの基本ルール

VIPERには2つの重要なルールがあります。

1つ目に、Entityを除いた各クラスは疎結合を維持するため「protocol」を定義・継承し、それに準拠した実装を行い、「protocol」のみに依存することで、お互いが実際は何者であるかはわからない状態を維持します。こうすることで、protocol に準拠したテスト用のクラスに差し替えたりすることが容易になります。

2つ目に、下の図の様に、「ViewはPresenterを強参照」、「PresenterはViewを弱参照InteractorとRouterを強参照」、「RouterはViewを弱参照」という関係性を作ります。依頼する側は依頼者を強参照、依頼される側は依頼主を弱参照する様なイメージです。

引用元:https://cheesecakelabs.com/blog/ios-project-architecture-using-viper/

図や文章で説明するのは限界があるので、早速サンプルコードを見ていきましょう。

TODOリストをVIPERアーキテクチャで作成

今回のサンプルプログラムの全ソースコードは以下のGitHubリポジトリに公開していますのでご興味ありましたらダウンロードをお願いします。

https://github.com/yururiwork/VIPER_Sample

Entity

ます、TodoデータのEntityの定義です。単純にstructでデータを包んだだけです。一般的に、DBのテーブル定義と同一のデータを定義し、JSONでやりとりする場合が多いかと思いますが、今回のデータはDBを作るまでは出来なかったのでシングルトンクラスに配列として持たせているだけとなります。

struct Todo {
    let id: Int
    let title: String
    let detail: String
    let isCompleted: Bool
    let deadLine: Date
    let createdOn: Date
    let updatedOn: Date
    
    static let `default` = Todo(id: 0, title: "", detail: "", isCompleted: false, deadLine: Date(), createdOn: Date(), updatedOn: Date())
}
class TodoStore {
    
    static let shared = TodoStore()
    
    private init() {}
    
    var todos: [Todo] = [
        Todo(id: 1, title: "カレーの具材を買う", detail: "カレールー・豚肉・人参・ジャガイモ、玉葱はあるのでいらない。", isCompleted: false, deadLine: Date(), createdOn: Date(), updatedOn: Date()),
        Todo(id: 2, title: "歯医者の予約を取る", detail: "土曜日の11:00くらい", isCompleted: true, deadLine: Date(), createdOn: Date(), updatedOn: Date()),
        Todo(id: 3, title: "友達に1000円返す", detail: "返さなくてもいいか", isCompleted: false, deadLine: Date(), createdOn: Date(), updatedOn: Date()),
        Todo(id: 4, title: "ブログを100記事書く", detail: "あと65記事", isCompleted: false, deadLine: Date(), createdOn: Date(), updatedOn: Date()),
        Todo(id: 5, title: "確定申告する", detail: "今年は4/16まで延期になった。", isCompleted: false, deadLine: Date(), createdOn: Date(), updatedOn: Date())
    ]
}

TransitionProtocol

冒頭のRouterの説明で、RouterはViewControllerの代わりに画面遷移を担う、言いましたが、UIViewController が持っている pushViewControllerpopViewController をどの様に実現するのでしょうか。

Router は View を弱参照で保持させますが、その保持している View はプロトコルなので UIViewController としての実体を持っていないため、このままでは pushViewController 等をコールすることは出来ません。

そこで、UIViewController だけに継承させるプロトコルを定義し、extension で pushViewController 等のメソッドを定義します。そのプロトコルを View プロトコルに継承させておけば、Router から View プロトコルを経由しても pushViewController 等をコールすることができる様になります。

protocol TransitionProtocol: class {
    func pushViewController(_ viewController: UIViewController, animated: Bool)
    func popViewController(animated: Bool)
    func popToViewController(_ viewController: UIViewController, animated: Bool)
    func popToRootViewController(animated: Bool)
    func present(_ viewController: UIViewController, animated: Bool)
    func dismiss(animated: Bool, completion: (() -> Void))
}

extension TransitionProtocol where Self: UIViewController {
    
    func pushViewController(_ viewController: UIViewController, animated: Bool) {
        guard let navigationController = self.navigationController else {
            return
        }
        navigationController.pushViewController(viewController, animated: animated)
    }
    
    func popViewController(animated: Bool) {
        guard let navigationController = self.navigationController else {
            return
        }
        navigationController.popViewController(animated: animated)
    }
    
    func popToViewController(_ viewController: UIViewController, animated: Bool) {
        guard let navigationController = self.navigationController else {
            return
        }
        navigationController.popToViewController(viewController, animated: animated)
    }
    
    func popToRootViewController(animated: Bool) {
        guard let navigationController = self.navigationController else {
            return
        }
        navigationController.popToRootViewController(animated: animated)
    }
    
    func present(_ viewController: UIViewController, animated: Bool) {
        self.present(viewController, animated: animated)
    }
    
    func dismiss(animated: Bool, completion: (() -> Void)) {
        self.dismiss(animated: animated, completion: completion)
    }
}

TodoListProtocol

続いてVIPERの肝となるView・Presenter・Interactor・Routerそれぞれのプロトコル定義です。

protocol TodoListViewProtocol: TransitionProtocol {
    
    var presenter: TodoListPresenterProtocol? { get set }
    
    func showTodos(_ todos: [TodoListViewData])
}

protocol TodoListPresenterProtocol: class {
    
    var view: TodoListViewProtocol? { get set }
    var interactor: TodoListInteractorInputProtocol? { get set }
    var router: TodoListRouterProtocol? { get set }
    
    func viewWillAppear()
    func didSelectRow(_ todoId: Int)
}

protocol TodoListInteractorInputProtocol: class {
    
    var presenter: TodoListInteractorOutputProtocol? { get set }
    
    func fetchTodos()
}

protocol TodoListInteractorOutputProtocol: class {
    
    func didFetchedTodos(_ todos: [Todo])
}

protocol TodoListRouterProtocol: class {
    
    static func assembleModules() -> UIViewController
    
    var view: TodoListViewProtocol? { get set }
    
    func transitionToDetailView(_ todoId: Int)
}

先ほどの TransitionProtocol を TodoListViewProtocol に継承させています。

各プロトコルの継承先は以下の様になります。

  • TodoListViewProtocol → View の実体クラス(TodoListViewController)
  • TodoListPresenterProtocol → Presenter の実体クラス(TodoListPresenter)
  • TodoListInteractorInputProtocol → Interactor の実体クラス(TodoListInteractor)
  • TodoListInteractorOutputProtocol → Presenter の実体クラス(TodoListPresenter)
  • TodoListRouterProtocol → Router の実体クラス(TodoListRouter)

TodoListViewController

final class TodoListViewController: UIViewController {

    static func instantiate() -> TodoListViewController {
        let storyboard = UIStoryboard(name: "TodoList", bundle: Bundle.main)
        let view = storyboard.instantiateViewController(withIdentifier: "TodoListViewController") as? TodoListViewController
        return view ?? TodoListViewController()
    }
    
    var presenter: TodoListPresenterProtocol?
    var viewDatas = [TodoListViewData]() {
        didSet {
            self.tableView.reloadData()
        }
    }
    
    @IBOutlet weak var tableView: UITableView!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        self.tableView.dataSource = self
        self.tableView.delegate = self
    }
    
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        
        self.presenter?.viewWillAppear()
    }
}

extension TodoListViewController: TodoListViewProtocol {
    
    func showTodos(_ todos: [TodoListViewData]) {
        self.viewDatas = todos
    }
}

extension TodoListViewController: UITableViewDataSource {
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return self.viewDatas.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard let cell = tableView.dequeueReusableCell(withIdentifier: "TodoListTableViewCell", for: indexPath) as? TodoListTableViewCell else {
            return UITableViewCell()
        }
        cell.titleLabel?.text = self.viewDatas[indexPath.row].title
        return cell
    }
}

extension TodoListViewController: UITableViewDelegate {
    
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        let todoId = self.viewDatas[indexPath.row].todoId
        self.presenter?.didSelectRow(todoId)
    }
}
struct TodoListViewData {
    let todoId: Int
    let title: String
}

原則として、ユーザーからのアクションを検知したり、画面の更新を行うだけを責務とし、ロジック的な処理は行わないのが基本です。

例えば viewWillAppear(_ animated: Bool) では Presenter に View の表示を通知し、Presenter 側で必要な処理を行い、showTodos(_ todos: [TodoListViewData]) が Presenter 側から呼ばれると画面の更新が走ります。

また、画面表示に必要なデータは TodoListViewData という構造体を定義して、Entity を直接参照する様な形を避けています。

TodoListPresenter

final class TodoListPresenter {
    
    weak var view: TodoListViewProtocol?
    var interactor: TodoListInteractorInputProtocol?
    var router: TodoListRouterProtocol?
}

extension TodoListPresenter: TodoListPresenterProtocol {
    
    func viewWillAppear() {
        self.interactor?.fetchTodos()
    }
    
    func didSelectRow(_ todoId: Int) {
        self.router?.transitionToDetailView(todoId)
    }
}

extension TodoListPresenter: TodoListInteractorOutputProtocol {
    
    func didFetchedTodos(_ todos: [Todo]) {
        var viewDatas = [TodoListViewData]()
        todos.forEach { todo in
            let viewData = TodoListViewData(todoId: todo.id, title: todo.title)
            viewDatas.append(viewData)
        }
        self.view?.showTodos(viewDatas)
    }
}

viewWillAppear() で Interactor にデータ取得を依頼します。そして、Interactor 側から didFetchedTodos(_ todos: [Todo]) が呼ばれ、データを取得し、それを ViewData に加工したのち、View に受け渡しています。

TodoListInteractor

final class TodoListInteractor {
    
    weak var presenter: TodoListInteractorOutputProtocol?
}

extension TodoListInteractor: TodoListInteractorInputProtocol {
    
    func fetchTodos() {
        let todos = TodoStore.shared.todos
        self.presenter?.didFetchedTodos(todos)
    }
}

fetchTodos() で TodoStore からデータを取得しています(一般的なアプリではここでWebAPIをコールするなどをします)。取得したものは Presenter に受け渡します。

TodoListRouter

final class TodoListRouter {
    
    weak var view: TodoListViewProtocol?
}

extension TodoListRouter: TodoListRouterProtocol {
    
    static func assembleModules() -> UIViewController {
        
        let view = TodoListViewController.instantiate()
        let presenter = TodoListPresenter()
        let interactor = TodoListInteractor()
        let router = TodoListRouter()
        
        view.presenter = presenter
        presenter.view = view
        presenter.interactor = interactor
        presenter.router = router
        interactor.presenter = presenter
        router.view = view
        
        return view
    }
    
    func transitionToDetailView(_ todoId: Int) {
        let detailView = TodoDetailRouter.assembleModules(todoId)
        self.view?.pushViewController(detailView, animated: true)
    }
}

assembleModules() の静的メソッドで、このTODOリスト画面関連モジュールの実体クラスを生成しています。transitionToDetailView(_ todoId: Int) が Presenter から呼ばれ、次の画面のモジュールを生成し、View が継承している TransitionProtocol を介して画面遷移を実現しています(TodoDetail画面のモジュールは GitHub に上がっています)。

まとめ

VIPERアーキテクチャの最大のメリットは、各モジュールが役割を分担することで一部のモジュールに処理が集中せずモジュールの肥大化を避けられることだと筆者は考えます。

MVC、MVP、MVVMと言ったアーキテクチャでもFatViewController(肥大化したViewController)やFatPresenter、FatViewModelを避けようとすることは出来ますが、規模が大きいアプリや一画面に機能が多いアプリだと限界があります。

VIPERが完璧というわけではないですが、Protocol を介した疎結合な構造によって「SOLID原則(単一責任の原則)」に沿った独立性の高いモジュール構成を実現しやすいところも高評価できるところです。

ただ、その代わり一画面に最低4〜5個のモジュールが必要になりファイル管理が多くなることはデメリットにもなります。ただし、この点に関してはモジュールを自動生成してくれる Generamba 等の便利なツールが存在しているので画面数が多いアプリでは導入してみるのも良いかと思います。

以上、ざっとVIPERアーキテクチャについてのサンプルコードを紹介しました。

今後、現場で導入を考えている方や、初めてVIPERに触れる方などに少しでもお役に立てれば幸いです。