■イベント Sansan Builders Box 2019 https://jp.corp-sansan.com/sbb2019/
■登壇概要 タイトル: SansaniOS アプリリニューアルの舞台裏 - VIPER を導入し、単一責任の原則に則ったモジュール化された構造を実現する -
登壇者: プロダクト開発部 中川泰夫
▼Sansan Builders Box https://buildersbox.corp-sansan.com/
VIPER を導⼊し、単⼀責任の原則に則ったモジュール化された構造を実現するSansan iOS アプリリニューアルの舞台裏
View Slide
Sansan Builders BoxAgenda- リニューアルについて- VIPER とは?- なぜ VIPER を選択したか?- コードから学ぶ VIPER- 既存コードとの両⽴- まとめ
中川 泰夫(Yasuo Nakagawa)2014年 3⽉ 東京電機⼤学 理⼯学部 情報システムデザイン学系 卒業2014年 4⽉ Sansan 株式会社 ⼊社4 年間はサーバーサイドエンジニアとして従事2018年 9⽉ iOS エンジニアに転向Sansan 事業部 プロダクト開発部iOS エンジニアMy favorite keyboard is Claw44.@ynakagawa33 ynakagawa33
リニューアルについて
Sansan Builders Boxリニューアルについて⾃社が主催するビジネスカンファレンス「Sansan Innovation Project 2019」でSansan の新たなプロダクトコンセプト「名刺管理から、ビジネスがはじまる」を発表しました。この新コンセプトをプロダクトで体現するために、iOS アプリもリニューアルすることになりました。
Sansan Builders Boxリニューアルについて- 名刺タブを追加> アプリのホームを名刺⼀覧に> その他タブの導線は左上に> お知らせはその他画⾯内へ
Sansan Builders Boxリニューアルについて- 同僚タブを変更> 検索バーを隠さない> 名刺保有枚数を出さない> メッセージからメールへ
Sansan Builders Boxリニューアルについて- Design System を導⼊> カラーパレットを定義> テキストスタイルを定義> UI Component を定義
Sansan Builders Boxリニューアルについて- VIPER を導⼊> ディレクトリ構造を再考> ドキュメントを作成> 名刺⼀覧で実践
VIPER とは?
Sansan Builders BoxVIPER とは?- View, Interactor, Presenter, Entity, Router の頭⽂字を組み合わせて、 VIPER- 単⼀責任の原則に則り、 Clean でモジュール化された構造を実現する- アプリの依存関係を分離し、責任の委任のバランスを取る
Sansan Builders BoxVIPER とは?
Sansan Builders BoxVIPER とは? - View
Sansan Builders BoxVIPER とは? - View- IB or コードで実装された UIView または、UIViewController を指す- Output> ユーザーの操作を Presenter に伝える- Input> Presenter から更新内容を受け取り、⾃⾝を更新する
Sansan Builders BoxVIPER とは? - Presenter
Sansan Builders BoxVIPER とは? - Presenter- 各要素間の橋渡し役- Output> Interactor に更新のためにデータを要求する> View に更新内容を通知する> Router に画⾯遷移を依頼する- Input> View からユーザーの操作を受け取る> Interactor から更新内容を受け取る
Sansan Builders BoxVIPER とは? - Interactor
Sansan Builders BoxVIPER とは? - Interactor- データ操作とユースケースを定義する- Output> Presenter に更新内容を通知する- Input> Presenter から更新のためのデータの要求を受け取る
Sansan Builders BoxVIPER とは? - Entity
Sansan Builders BoxVIPER とは? - Entity- 各要素間でやり取りされるデータ- Limitation> データアクセスレイヤーには属さない
Sansan Builders BoxVIPER とは? - Router
Sansan Builders BoxVIPER とは? - Router- 画⾯遷移と DI を担当する- Output> なし- Input> Presenter から画⾯遷移の依頼を受け取る
なぜ VIPER を選択したか?
Sansan Builders Boxなぜ VIPER を選択したか? - before Renewal- Fat View Controller> すべてのロジックが View Controller に記載されていた- 変更をした際に影響がどこに波及するのか調査しないと分からない
Sansan Builders Boxなぜ VIPER を選択したか? - ⽬的- Fat View Controller からの脱却- 責務分離を適切に⾏うことで影響範囲を明確にしたい
コードから学ぶ VIPER
Sansan Builders Boxコードから学ぶ VIPER- 名刺⼀覧の実装から VIPER の理解を深めましょう- 画⾯表⽰を例に説明します
Sansan Builders Boxコードから学ぶ VIPER - View Input// BizCardListViewController.swiftfinal class BizCardListViewController:UIViewController {override func viewDidLoad() {super.viewDidLoad()// View に閉じた設定を適⽤configureNavigationItems()configureBizCardListContentView()// Presenter へイベントを伝えるpresenter.viewDidLoad()}
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(buildBizCardsSearchCondition()) { [weak self] response in// Presenter Output で説明}}
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(condition: condition) { response inDispatchQueue.main.async {// Presenter へ Closure を通して、更新内容を通知completion(response)}}}}
Sansan Builders Boxコードから学ぶ VIPER - Presenter Outputextension BizCardListPresenter: BizCardListPresenterInterface {func viewDidLoad() {// (中略)interactor.fetchBizCards(buildBizCardsSearchCondition()){ [weak self] response inguard let self = self else { return }// API から取得した結果に応じて、表⽰内容を切り替えるプレゼンテーションロジックswitch response {case let .success(searchReuslt):self.displayStatus = .dataFetchSuccessself.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 = .dataFetchFailedself.view.hideSortMenuButton()self.items = []}// テーブルビューの更新を View に依頼するself.view.reload()}}
Sansan Builders Boxコードから学ぶ VIPER - View Output// Presenter からの更新通知を受け取り、⾃⾝の更新を⾏うメソッドを公開protocol BizCardListViewInterface {func reload()}extension BizCardListViewController:BizCardListViewInterface {func reload() {bizCardListContentView.tableView.reloadData()}
Sansan Builders Boxコードから学ぶ VIPERRouter は?(画⾯表⽰では登場しないので、別途説明します)
Sansan Builders Boxコードから学ぶ VIPER - Router DIfinal class BizCardListRouter: BaseRouter {init() {let viewController =R.storyboard.bizCardList.instantiateInitialViewController()!super.init(viewController: viewController)// Interactor / Presenter は Constructor Injectionlet interactor = BizCardListInteractor()let presenter = BizCardListPresenter(router: self,view: viewController, interactor: interactor)// Storyboard から⽣成した View Controller はConstructor Injection が出来ないため、 Property InjectionviewController.presenter = presenter}}
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 = bizCardbizCardDetailViewController.userID = userIDnavigationController?.pushViewController(bizCardDetailViewController, animated: true)}}
既存コードとの両⽴
Sansan Builders Box既存コードとの両⽴- Router が初期化すべき View Controller を既存のStoryboard が初期化している- 遷移先の Router の不在- 同名のオブジェクトの存在
Sansan Builders BoxRouter が初期化すべきVCをStoryboardが初期化している- 名刺⼀覧は UITabBarControllerの viewControllers の1つ- 名刺⼀覧の Storyboard とは別の Storyboard で設定されている
Sansan Builders BoxRouter が初期化すべきVCをStoryboardが初期化している- 検討した解決策は以下> Router の初期化時に外から Storyboard が初期化した ViewController を渡す> Storyboard で初期化するのは UINavigationControllerだけにして、この UINavigationController に Router が初期化した View Controller を setViewControllers する
Sansan Builders BoxRouter が初期化すべきVCをStoryboardが初期化しているStoryboard でUINavigationController のみのStoryboard Reference を設定する
Sansan Builders BoxRouter が初期化すべきVCをStoryboardが初期化している1. 遷移元 VC で Storyboard が初期化した UINavigationController を取得2. UINavigationController に遷移先 VC が含まれていなかったら、 Routerを初期化して、依存性の注⼊が完了した遷移先 VC をsetViewControllers1. 上記を実現するために、 UINavigationController を拡張if let bizCardListNavigationController = self.selectedViewController as? BizCardListNavigationController {if !bizCardListNavigationController.viewControllers.contains { $0 is BizCardListViewController } {bizCardListNavigationController.setRootRouter(BizCardListRouter(), animated: false)}}
Sansan Builders BoxRouter が初期化すべきVCをStoryboardが初期化している- どの Router にも共通の処理を BaseRouter に記載しており、モジュールごとの Router はすべて BaseRouter を継承している- UINavigationController を拡張し、 Router の所有している VC をsetViewControllers できるメソッドを⽤意// BaseRouter.swiftextension UINavigationController {func setRootRouter(_ router: BaseRouter, animated: Bool = true) {setViewControllers([router.viewController], animated: animated)}}
Sansan Builders Box遷移先の Router の不在- 途中から導⼊したため、遷移先の画⾯は Cocoa MVC- Router がいるわけない- ⽤意した拡張も使えない…// BaseRouter.swiftextension 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)}}
Sansan Builders Box遷移先の Router の不在- 遷移元の Router で遷移先の VC を初期化、 DI を実施- 遷移元の Router のコードで画⾯遷移- これでも画⾯遷移のロジックは Router に集まるので許容// BizCardListRouter.swiftprivate func pushBizCardDetail(with bizCard: BizCard, userID: String) {guard let bizCardDetailViewController =R.storyboard.bizCardDetail.instantiateInitialViewController() else { return }bizCardDetailViewController.bizCard = bizCardbizCardDetailViewController.userID = userIDnavigationController?.pushViewController(bizCardDetailViewController,animated: true)}
Sansan Builders Box同名のオブジェクトの存在- Entity を定義する際に既存コードで定義されていると命名に困る- 名刺とか当然のように定義済み
Sansan Builders Box同名のオブジェクトの存在- Entity コンポーネントを Embedded Framework として、アプリのターゲットから切り出す
Sansan Builders Box同名のオブジェクトの存在- Entity コンポーネントを Embedded Framework として、アプリのターゲットから切り出したい>実施できておらず、サフィックスに Entity をつけるイケてない運⽤をしてます
まとめ
Sansan Builders BoxVIPER を導⼊後の変化- Pros> Controller に記載されていたすべてのロジックが適切に責務分離され、影響範囲調査が容易になった> 書くことが出来ていなかったユニットテストが抽象に依存するようになったおかげで書けるようになった> メンバーが設計を意識するようになった
Sansan Builders BoxVIPER を導⼊後の変化- Cons> UIKit のボイラープレートに加え、 VIPER のボイラープレートも増えたため、コーディングに時間がかかるようになった> 新規メンバーが増えた際に VIPER の知識がないと、正しく責務分離できてないことがあり、レビューコストが増えた
Sansan Builders BoxVIPER のこれから- ボイラープレート多すぎ問題に対して、チーム内でテンプレートやスニペットを共有できる仕組みの導⼊を予定- メンバーが気軽に試せるシンプルな VIPER のデモプロジェクトを作成し、議論できる場を提供したい
Sansan Builders BoxWe are hiring !使⽤技術、開発体制などを紹介しています。