Upgrade to Pro — share decks privately, control downloads, hide ads and more …

SansaniOS アプリリニューアルの舞台裏 / Implement VIPER to create a modularized structure that follows the principle of single responsibility

Sansan
October 23, 2019

SansaniOS アプリリニューアルの舞台裏 / Implement VIPER to create a modularized structure that follows the principle of single responsibility

■イベント
Sansan Builders Box 2019
https://jp.corp-sansan.com/sbb2019/

■登壇概要
タイトル:
SansaniOS アプリリニューアルの舞台裏
- VIPER を導入し、単一責任の原則に則ったモジュール化された構造を実現する -

登壇者:
プロダクト開発部 中川泰夫

▼Sansan Builders Box
https://buildersbox.corp-sansan.com/

Sansan

October 23, 2019
Tweet

More Decks by Sansan

Other Decks in Technology

Transcript

  1. Sansan Builders Box Agenda - リニューアルについて - VIPER とは? -

    なぜ VIPER を選択したか? - コードから学ぶ VIPER - 既存コードとの両⽴ - まとめ
  2. 中川 泰夫(Yasuo Nakagawa) 2014年 3⽉ 東京電機⼤学 理⼯学部 情報システムデザイン学系 卒業 2014年

    4⽉ Sansan 株式会社 ⼊社 4 年間はサーバーサイドエンジニアとして従事 2018年 9⽉ iOS エンジニアに転向 Sansan 事業部 プロダクト開発部 iOS エンジニア My favorite keyboard is Claw44. @ynakagawa33 ynakagawa33
  3. Sansan Builders Box リニューアルについて ⾃社が主催するビジネスカンファレンス 「Sansan Innovation Project 2019」で Sansan

    の新たなプロダクトコンセプト 「名刺管理から、ビジネスがはじまる」を発表しました。 この新コンセプトをプロダクトで体現するために、 iOS アプリもリニューアルすることになりました。
  4. Sansan Builders Box VIPER とは? - View, Interactor, Presenter, Entity,

    Router の 頭⽂字を組み合わせて、 VIPER - 単⼀責任の原則に則り、 Clean でモジュール化された構造を 実現する - アプリの依存関係を分離し、責任の委任のバランスを取る
  5. Sansan Builders Box VIPER とは? - View - IB or

    コードで実装された UIView または、 UIViewController を指す - Output > ユーザーの操作を Presenter に伝える - Input > Presenter から更新内容を受け取り、 ⾃⾝を更新する
  6. Sansan Builders Box VIPER とは? - Presenter - 各要素間の橋渡し役 -

    Output > Interactor に更新のためにデータを要 求する > View に更新内容を通知する > Router に画⾯遷移を依頼する - Input > View からユーザーの操作を受け取る > Interactor から更新内容を受け取る
  7. Sansan Builders Box VIPER とは? - Interactor - データ操作とユースケースを 定義する

    - Output > Presenter に更新内容を通知する - Input > Presenter から更新のための データの要求を受け取る
  8. Sansan Builders Box VIPER とは? - Entity - 各要素間でやり取りされるデータ -

    Limitation > データアクセスレイヤーには 属さない
  9. Sansan Builders Box VIPER とは? - Router - 画⾯遷移と DI

    を担当する - Output > なし - Input > Presenter から画⾯遷移の依頼を 受け取る
  10. Sansan Builders Box なぜ VIPER を選択したか? - before Renewal -

    Fat View Controller > すべてのロジックが View Controller に記載されていた - 変更をした際に影響がどこに波及するのか 調査しないと分からない
  11. Sansan Builders Box なぜ VIPER を選択したか? - ⽬的 - Fat

    View Controller からの脱却 - 責務分離を適切に⾏うことで影響範囲を 明確にしたい
  12. Sansan Builders Box コードから学ぶ VIPER - View Input // BizCardListViewController.swift

    final class BizCardListViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() // View に閉じた設定を適⽤ configureNavigationItems() configureBizCardListContentView() // Presenter へイベントを伝える presenter.viewDidLoad() }
  13. Sansan Builders Box コードから学ぶ VIPER - Presenter Input // View

    からのイベントを受け取るメソッドを公開 protocol BizCardListPresenterInterface { func viewDidLoad() } extension BizCardListPresenter: BizCardListPresenterInterface { func viewDidLoad() { // Presenter が管理している状態を更新 items = [] displayStatus = .dataFetching // データ取得前の View へ更新 view.setSortMenuButtonEnabled(false) // Interactor へデータを要求 interactor.fetchBizCards(buildBizCards SearchCondition()) { [weak self] response in // Presenter Output で説明 } }
  14. Sansan Builders Box コードから学ぶ VIPER - Interactor Output // Presenter

    からのデータ要求を受け付けるメソッドを公開 protocol BizCardListInteractorInterface { func fetchBizCards(_ condition:BizCardsSearchCondition, _ completion: @escaping(_ response:BizCardsSearchAPIRequest.Response) -> Void) } extension BizCardListInteractor: BizCardListInteractorInterface { func fetchBizCards(_ condition: BizCardsSearchCondition, _ completion: @escaping(_ result:BizCardsSearchAPIRequest.Response) -> Void) { AppDispatchQueue.concurrentQueue().async { self.bizCardsSearchAPI.fetchBizCards(condit ion: condition) { response in DispatchQueue.main.async { // Presenter へ Closure を通して、更新 内容を通知 completion(response) } } } }
  15. Sansan Builders Box コードから学ぶ VIPER - Presenter Output extension BizCardListPresenter:

    BizCardListPresenterInterface { func viewDidLoad() { // (中略) interactor.fetchBizCards(buildBizCardsSearchCondition()) { [weak self] response in guard let self = self else { return } // API から取得した結果に応じて、表⽰内容を切り替えるプレゼン テーションロジック switch response { case let .success(searchReuslt): self.displayStatus = .dataFetchSuccess self.view.setSortMenuButtonEnabled(true) if self.fetchCondition.isFirstPage { self.items = searchReuslt.bizCardSearchResults.map { self.translate($0) } } else { self.items += searchReuslt.bizCardSearchResults.map { self.translate($0) } } case .failure: self.displayStatus = .dataFetchFailed self.view.hideSortMenuButton() self.items = [] } // テーブルビューの更新を View に依頼する self.view.reload() } }
  16. Sansan Builders Box コードから学ぶ VIPER - View Output // Presenter

    からの更新通知を受け取り、⾃⾝の更新を⾏うメソッドを公開 protocol BizCardListViewInterface { func reload() } extension BizCardListViewController: BizCardListViewInterface { func reload() { bizCardListContentView.tableView.reloadData() }
  17. Sansan Builders Box コードから学ぶ VIPER - Router DI final class

    BizCardListRouter: BaseRouter { init() { let viewController = R.storyboard.bizCardList.instantiateInitialViewController()! super.init(viewController: viewController) // Interactor / Presenter は Constructor Injection let interactor = BizCardListInteractor() let presenter = BizCardListPresenter(router: self, view: viewController, interactor: interactor) // Storyboard から⽣成した View Controller は Constructor Injection が出来ないため、 Property Injection viewController.presenter = presenter } }
  18. Sansan Builders Box コードから学ぶ VIPER - Router Navigation // Presenter

    からの画⾯遷移の依頼を受け付けるメソッドを公開 protocol BizCardListRouterInterface { func navigate(to option: BizCardListNavigationOption) } // 遷移先とパラメータを列挙体で表現 enum BizCardListNavigationOption { // (中略) case bizCardDetail(BizCard, String) } extension BizCardListRouter: BizCardListRouterInterface { func navigate(to option: BizCardListNavigationOption) { switch option { case let .bizCardDetail(bizCard, userID): pushBizCardDetail(with: bizCard, userID: userID) } } private func pushBizCardDetail(with bizCard: BizCard, userID: String) { guard let bizCardDetailViewController = R.storyboard.bizCardDetail.instantiateInitialViewController() else { return } bizCardDetailViewController.bizCard = bizCard bizCardDetailViewController.userID = userID navigationController?.pushViewController(bizCardDetailViewController, animated: true) } }
  19. Sansan Builders Box 既存コードとの両⽴ - Router が初期化すべき View Controller を既存の

    Storyboard が初期化している - 遷移先の Router の不在 - 同名のオブジェクトの存在
  20. Sansan Builders Box Router が初期化すべきVCをStoryboardが初期化している - 検討した解決策は以下 > Router の初期化時に外から

    Storyboard が初期化した View Controller を渡す > Storyboard で初期化するのは UINavigationController だけにして、この UINavigationController に Router が 初期化した View Controller を setViewControllers する
  21. Sansan Builders Box Router が初期化すべきVCをStoryboardが初期化している - 検討した解決策は以下 > Router の初期化時に外から

    Storyboard が初期化した View Controller を渡す > Storyboard で初期化するのは UINavigationController だけにして、この UINavigationController に Router が 初期化した View Controller を setViewControllers する
  22. Sansan Builders Box Router が初期化すべきVCをStoryboardが初期化している 1. 遷移元 VC で Storyboard

    が初期化した UINavigationController を取得 2. UINavigationController に遷移先 VC が含まれていなかったら、 Router を初期化して、依存性の注⼊が完了した遷移先 VC を setViewControllers 1. 上記を実現するために、 UINavigationController を拡張 if let bizCardListNavigationController = self.selectedViewController as? BizCardListNavigationController { if !bizCardListNavigationController.viewControllers.contains { $0 is BizCardListViewController } { bizCardListNavigationController.setRootRouter(BizCardListRouter(), animated: false) } }
  23. Sansan Builders Box Router が初期化すべきVCをStoryboardが初期化している - どの Router にも共通の処理を BaseRouter

    に記載しており、モ ジュールごとの Router はすべて BaseRouter を継承している - UINavigationController を拡張し、 Router の所有している VC を setViewControllers できるメソッドを⽤意 // BaseRouter.swift extension UINavigationController { func setRootRouter(_ router: BaseRouter, animated: Bool = true) { setViewControllers([router.viewController], animated: animated) } }
  24. Sansan Builders Box 遷移先の Router の不在 - 途中から導⼊したため、遷移先の画⾯は Cocoa MVC

    - Router がいるわけない - ⽤意した拡張も使えない… // BaseRouter.swift extension UIViewController { func presentRouter(_ router: BaseRouter, animated: Bool = true, completion: (() -> Void)? = nil) { present(router.viewController, animated: animated, completion: completion) } } extension UINavigationController { func pushRouter(_ router: BaseRouter, animated: Bool = true) { pushViewController(router.viewController, animated: animated) } }
  25. Sansan Builders Box 遷移先の Router の不在 - 遷移元の Router で遷移先の

    VC を初期化、 DI を実施 - 遷移元の Router のコードで画⾯遷移 - これでも画⾯遷移のロジックは Router に集まるので許容 // BizCardListRouter.swift private func pushBizCardDetail(with bizCard: BizCard, userID: String) { guard let bizCardDetailViewController = R.storyboard.bizCardDetail.instantiateInitialViewController() else { return } bizCardDetailViewController.bizCard = bizCard bizCardDetailViewController.userID = userID navigationController?.pushViewController(bizCardDetailViewController, animated: true) }
  26. Sansan Builders Box 同名のオブジェクトの存在 - Entity コンポーネントを Embedded Framework として、

    アプリのターゲットから切り出したい >実施できておらず、サフィックスに Entity をつけるイケてない 運⽤をしてます
  27. Sansan Builders Box VIPER を導⼊後の変化 - Pros > Controller に記載されていたすべてのロジックが適切に

    責務分離され、影響範囲調査が容易になった > 書くことが出来ていなかったユニットテストが抽象に 依存するようになったおかげで書けるようになった > メンバーが設計を意識するようになった
  28. Sansan Builders Box VIPER を導⼊後の変化 - Cons > UIKit のボイラープレートに加え、

    VIPER のボイラープレート も増えたため、コーディングに時間がかかるようになった > 新規メンバーが増えた際に VIPER の知識がないと、正しく責 務分離できてないことがあり、レビューコストが増えた