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

    View Slide

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

    View Slide

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

    View Slide

  4. リニューアルについて

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  10. VIPER とは?

    View Slide

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

    View Slide

  12. Sansan Builders Box
    VIPER とは?

    View Slide

  13. Sansan Builders Box
    VIPER とは? - View

    View Slide

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

    View Slide

  15. Sansan Builders Box
    VIPER とは? - Presenter

    View Slide

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

    View Slide

  17. Sansan Builders Box
    VIPER とは? - Interactor

    View Slide

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

    View Slide

  19. Sansan Builders Box
    VIPER とは? - Entity

    View Slide

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

    View Slide

  21. Sansan Builders Box
    VIPER とは? - Router

    View Slide

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

    View Slide

  23. なぜ VIPER を選択したか?

    View Slide

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

    View Slide

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

    View Slide

  26. コードから学ぶ VIPER

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  36. 既存コードとの両⽴

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  49. まとめ

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  54. View Slide