Slide 1

Slide 1 text

VIPER を導⼊し、単⼀責任の原則に則った モジュール化された構造を実現する Sansan iOS アプリ リニューアルの舞台裏

Slide 2

Slide 2 text

Sansan Builders Box Agenda - リニューアルについて - VIPER とは? - なぜ VIPER を選択したか? - コードから学ぶ VIPER - 既存コードとの両⽴ - まとめ

Slide 3

Slide 3 text

中川 泰夫(Yasuo Nakagawa) 2014年 3⽉ 東京電機⼤学 理⼯学部 情報システムデザイン学系 卒業 2014年 4⽉ Sansan 株式会社 ⼊社 4 年間はサーバーサイドエンジニアとして従事 2018年 9⽉ iOS エンジニアに転向 Sansan 事業部 プロダクト開発部 iOS エンジニア My favorite keyboard is Claw44. @ynakagawa33 ynakagawa33

Slide 4

Slide 4 text

リニューアルについて

Slide 5

Slide 5 text

Sansan Builders Box リニューアルについて ⾃社が主催するビジネスカンファレンス 「Sansan Innovation Project 2019」で Sansan の新たなプロダクトコンセプト 「名刺管理から、ビジネスがはじまる」を発表しました。 この新コンセプトをプロダクトで体現するために、 iOS アプリもリニューアルすることになりました。

Slide 6

Slide 6 text

Sansan Builders Box リニューアルについて - 名刺タブを追加 > アプリのホームを名刺⼀覧に > その他タブの導線は左上に > お知らせはその他画⾯内へ

Slide 7

Slide 7 text

Sansan Builders Box リニューアルについて - 同僚タブを変更 > 検索バーを隠さない > 名刺保有枚数を出さない > メッセージからメールへ

Slide 8

Slide 8 text

Sansan Builders Box リニューアルについて - Design System を導⼊ > カラーパレットを定義 > テキストスタイルを定義 > UI Component を定義

Slide 9

Slide 9 text

Sansan Builders Box リニューアルについて - VIPER を導⼊ > ディレクトリ構造を再考 > ドキュメントを作成 > 名刺⼀覧で実践

Slide 10

Slide 10 text

VIPER とは?

Slide 11

Slide 11 text

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

Slide 12

Slide 12 text

Sansan Builders Box VIPER とは?

Slide 13

Slide 13 text

Sansan Builders Box VIPER とは? - View

Slide 14

Slide 14 text

Sansan Builders Box VIPER とは? - View - IB or コードで実装された UIView または、 UIViewController を指す - Output > ユーザーの操作を Presenter に伝える - Input > Presenter から更新内容を受け取り、 ⾃⾝を更新する

Slide 15

Slide 15 text

Sansan Builders Box VIPER とは? - Presenter

Slide 16

Slide 16 text

Sansan Builders Box VIPER とは? - Presenter - 各要素間の橋渡し役 - Output > Interactor に更新のためにデータを要 求する > View に更新内容を通知する > Router に画⾯遷移を依頼する - Input > View からユーザーの操作を受け取る > Interactor から更新内容を受け取る

Slide 17

Slide 17 text

Sansan Builders Box VIPER とは? - Interactor

Slide 18

Slide 18 text

Sansan Builders Box VIPER とは? - Interactor - データ操作とユースケースを 定義する - Output > Presenter に更新内容を通知する - Input > Presenter から更新のための データの要求を受け取る

Slide 19

Slide 19 text

Sansan Builders Box VIPER とは? - Entity

Slide 20

Slide 20 text

Sansan Builders Box VIPER とは? - Entity - 各要素間でやり取りされるデータ - Limitation > データアクセスレイヤーには 属さない

Slide 21

Slide 21 text

Sansan Builders Box VIPER とは? - Router

Slide 22

Slide 22 text

Sansan Builders Box VIPER とは? - Router - 画⾯遷移と DI を担当する - Output > なし - Input > Presenter から画⾯遷移の依頼を 受け取る

Slide 23

Slide 23 text

なぜ VIPER を選択したか?

Slide 24

Slide 24 text

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

Slide 25

Slide 25 text

Sansan Builders Box なぜ VIPER を選択したか? - ⽬的 - Fat View Controller からの脱却 - 責務分離を適切に⾏うことで影響範囲を 明確にしたい

Slide 26

Slide 26 text

コードから学ぶ VIPER

Slide 27

Slide 27 text

Sansan Builders Box コードから学ぶ VIPER - 名刺⼀覧の実装から VIPER の 理解を深めましょう - 画⾯表⽰を例に説明します

Slide 28

Slide 28 text

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

Slide 29

Slide 29 text

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 で説明 } }

Slide 30

Slide 30 text

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

Slide 31

Slide 31 text

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

Slide 32

Slide 32 text

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

Slide 33

Slide 33 text

Sansan Builders Box コードから学ぶ VIPER Router は? (画⾯表⽰では登場しないので、 別途説明します)

Slide 34

Slide 34 text

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

Slide 35

Slide 35 text

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

Slide 36

Slide 36 text

既存コードとの両⽴

Slide 37

Slide 37 text

Sansan Builders Box 既存コードとの両⽴ - Router が初期化すべき View Controller を既存の Storyboard が初期化している - 遷移先の Router の不在 - 同名のオブジェクトの存在

Slide 38

Slide 38 text

Sansan Builders Box Router が初期化すべきVCをStoryboardが初期化している - 名刺⼀覧は UITabBarController の viewControllers の1つ - 名刺⼀覧の Storyboard とは 別の Storyboard で設定されて いる

Slide 39

Slide 39 text

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

Slide 40

Slide 40 text

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

Slide 41

Slide 41 text

Sansan Builders Box Router が初期化すべきVCをStoryboardが初期化している Storyboard で UINavigationController のみの Storyboard Reference を設定する

Slide 42

Slide 42 text

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

Slide 43

Slide 43 text

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

Slide 44

Slide 44 text

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

Slide 45

Slide 45 text

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

Slide 46

Slide 46 text

Sansan Builders Box 同名のオブジェクトの存在 - Entity を定義する際に既存コードで定義されていると 命名に困る - 名刺とか当然のように定義済み

Slide 47

Slide 47 text

Sansan Builders Box 同名のオブジェクトの存在 - Entity コンポーネントを Embedded Framework として、 アプリのターゲットから切り出す

Slide 48

Slide 48 text

Sansan Builders Box 同名のオブジェクトの存在 - Entity コンポーネントを Embedded Framework として、 アプリのターゲットから切り出したい >実施できておらず、サフィックスに Entity をつけるイケてない 運⽤をしてます

Slide 49

Slide 49 text

まとめ

Slide 50

Slide 50 text

Sansan Builders Box VIPER を導⼊後の変化 - Pros > Controller に記載されていたすべてのロジックが適切に 責務分離され、影響範囲調査が容易になった > 書くことが出来ていなかったユニットテストが抽象に 依存するようになったおかげで書けるようになった > メンバーが設計を意識するようになった

Slide 51

Slide 51 text

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

Slide 52

Slide 52 text

Sansan Builders Box VIPER のこれから - ボイラープレート多すぎ問題に対して、チーム内で テンプレートやスニペットを共有できる仕組みの導⼊を予定 - メンバーが気軽に試せるシンプルな VIPER のデモプロジェクト を作成し、議論できる場を提供したい

Slide 53

Slide 53 text

Sansan Builders Box We are hiring ! 使⽤技術、開発体制などを 紹介しています。

Slide 54

Slide 54 text

No content