ホットペッパービューティーのiOSアプリのフルスクラッチSwiftリプレイス

ホットペッパービューティーのアプリを担当している外崎です。
エンジニアとして入社し、現在は開発を兼務しつつUXディレクターとして働いています。

はじめに

2017年10月にホットペッパービューティーのiOSアプリのバージョン5.0.0が配信されました。
こちらはObjective-Cで書かれていたアプリをフルスクラッチでSwift(3.2)に書き換えたものになります。

以前に配信されたインタビュー記事でもリプレイスの背景について語られています。
https://www.wantedly.com/projects/78442

今回は年間7000万件以上(2017年11月現在)の予約が行われているホットペッパービューティーでどのようにアプリのリプレイスを行ってきたのかを話したいと思います。

プロジェクトの背景

インタビュー記事にもありますが、僕が新卒で入社して最初の仕事がホットペッパービューティーのアプリのリプレイスという仕事でした。
ホットペッパービューティーの開発ではアプリに限らず、システム全体のリプレイスを進めています。
https://blog.recruit.co.jp/assets/rls/2017-10-20-ultrabeerbash-rls

リプレイスを進めている一番の理由は事業の成長にシステムが追いつくことが出来なくなってきたことにあります。
リプレイスで言語を変更するということもありますが、アプリにおいても複雑な依存関係などの設計に不安な要素がありました。
iOSアプリをSwiftにリプレイスすることは社内の他事業(Airレジ)でも実績がありましたが、今回のような規模の事業では初の試みです。
https://blog.recruit.co.jp/assets/rls/2016-05-10-regi-ios/

アーキテクチャ

言語を置き換えることだけでも多くのメリットがありますが、アプリのアーキテクチャも刷新しました。
結論としてiOSではMVPのアーキテクチャを採用することにしました。社内ではiOSのViewControllerからMVCPと呼んでいます。

アーキテクチャの主な選定理由として以下の観点を確認しました。

  • ロジックのテストが書ける
    • 従来のコードはテストコードが書けるような設計では無かった
    • リプレイスにあたって、PresenterとModelをテスト対象とする
  • 新規参画のコストが低い
    • 人の入れ替わりに対応できる方が望ましい
    • プロジェクト推進中には最大10人弱のエンジニアが関与
  • コードの属人化を防止出来る
    • 責務の分割、書き方を統一し属人化を防ぐ必要がある
    • 規模が大きく4~5人のエンジニアのチームで構成される

MVPはMVCでのiOS開発に慣れているエンジニアであれば、学習のコストは低いです。
テスタブルなコードを書くことが出来ること、過剰な責務の分離が無いこと、外部ライブラリ依存がないこともありMVPを採用しました。

MVCPについては韮澤が勉強会で発表した資料に詳しくまとまっていますので興味ある方はこちらをご参照ください。
https://www.slideshare.net/RecruitLifestyle/mvcp

簡単ですが、MVPのサンプルコードを載せておきます。

ViewController

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import UIKit
final class ViewController: UIViewController {
    private let presenter = Presenter() // 1
    private let tableView: UITableView = {
        let tableView = UITableView()
        tableView.translatesAutoresizingMaskIntoConstraints = false
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
        return tableView
    }()
    override func viewDidLoad() {
        super.viewDidLoad()
        view.addSubview(tableView)
        tableView.dataSource = self
        tableView.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
        tableView.leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true
        tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
        tableView.rightAnchor.constraint(equalTo: view.rightAnchor).isActive = true
        presenter.fetch { [weak self] in
            self?.tableView.reloadData() // 2
        }
    }
}
extension ViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return presenter.numberOfRows() // 3
    }
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell")!
        cell.textLabel?.text = presenter.texts[indexPath.row]
        return cell
    }
}
  1. ViewControllerがPresenterを保持
  2. Presenterのfetchメソッドの引数にView更新のClosureを受け渡してTableViewを更新
  3. TableViewのCellの件数などのロジックはPresenterに持たせる

Presenter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
final class Presenter {
    let model = Model() // 1
    private(set) var texts = [String]() // 2
    func fetch(completion: () -> Void) {
        model.fetch { [weak self] texts in
            self?.texts = texts
            completion()
        }
    }
    func numberOfRows() -> Int {
        return texts.count // 3
    }
}
  1. PresenterがModelを保持
  2. Presenterが画面表示のデータを保持
  3. 表示用のロジックはpresenterに持つ

Model

1
2
3
4
5
final class Model {
    func fetch(completion: ([String]) -> Void) {
        completion(["hello", "world"]) // 1
    }
}
  1. Presenterから受け渡されたClosureを実行

アーキテクチャ選定の結果

適切なアーキテクチャは選択の目的、事業の規模、チームの状況などによって異なってきますが、結果としてMVPという選択肢は正解でした。
MVPは学習コストが低く、また設計から得られる恩恵を十分に得ることができました。
プロジェクトが進むにつれてメンバーの増減がありましたが、参画コストを低く抑えることができたため、結果として無事リリースを迎えることが出来ました。

View

リプレイスプロジェクト内の大きな選択としてiOSのViewのレイアウトをすべてコードベースで管理することにしました。
言い換えるとInterface Builder(Storyboardやxib)の利用を廃止しました。
コードでレイアウトをすることのメリット・デメリットについて簡単に解説します。

メリット

  • コードレビューが可能
  • コンフリクトの解決が容易
  • UIの設定などのPropertyをコードだけで管理できる
  • Interface Builderを開くのが遅いストレスから解消される(個人的に超重要)

デメリット

  • 学習コスト
  • ビルドしないとViewを確認出来ない
  • 非エンジニアがViewを編集することが出来ない

iOSのレイアウトをコードで書くことは学習コストが高いと感じがちですが、やってみると実はそこまで高くはありません。
このプロジェクトではSnapKitというライブラリを使用しています。
https://github.com/SnapKit/SnapKit
もちろん上記のようなデメリットもありますが、チームが得られるコンフリクト解消の問題やコードレビューができるという恩恵が得られるのは大きいです。

テスト

MVPにおけるModelとPresenterをテスト対象としXCTestを用いてテストをしています。

PresenterとModel、またAPI通信に依存関係があるとテストが困難となるため、それぞれProtocolを定義してDependency Injectionを用いてテストをしています。 これによって、レイヤー間の依存関係も解消されます。

簡単にですが、サンプルコードです。

1
2
3
4
5
6
final class Presenter {
    private let model: ModelProtocol
    init(model: ModelProtocol = Model()) {
        self.model = model
    }
}

上記の例のようにInitializerでModelをDIしています。
テスト時にはModelのMockを引数に受け渡すことでPresenterのテストを書くことが出来るようになります。

OSS

プロジェクト推進中に数人のインターン生を受け入れていました。
優秀なインターン生の手によって、2つのOSSをプロジェクト中に公開することが出来ました。

また、開発したインターン生によるブログ記事はこちらです。

開発環境

プロジェクトの推進と共に、開発の環境も整備することができました。
まずはコード規約を作成しましたが、社内のコード規約があるので基本的に準拠するようにしています。
ネーミングやコメント、リソースの管理等については独自の規約を設定しています。
加えて全エンジニアは以下のLinter, Formatterを利用することを必須としています。

Linter, Formatterを必須とすることでインデントの指摘など不要なコードレビューを防ぐことが出来ています。
また、CI環境も整備しPRを上げるとSonarQubeを利用してPull Requestの問題のある行にコメントが書かれます。
sonar

Swift製のSlackbotも作りまして、レビュワーを決めることはbotに任せています。
元は僕が作りましたが、こちらもインターン生がbotの機能を改善してくれました。 bot

チームの状況

リプレイスを通してコードを置き換えましたが、チーム内の仕事の進め方も変わる良い機会となりました。
従来はそれぞれのエンジニアがそれぞれの案件付け足しの開発を続けていました。
コードの属人化を防ぐ事が出来たこともあり、現在はチームとして問題解決に取り組むことがやりやすくなりました。
また、コードレビューやLGTMを貼る文化が定着し、レビューの件数も増加しています。
他にも、バグを見つけるバグバッシュという取り組みやTestFlightを使った内部テストなど様々な試みを行っています。

リプレイスの結果

before_after

ソースコードの行数は Objective-Cの頃は15万行ほどだったものがSwiftに移行して8.4万行になりました。
社内ではSonarQubeを参考にして品質管理を行っていますが、 Code Smellsという指標で12,000個の問題がでていたのに対し、現在は300個ほどに減らしています。

最後に

iOSアプリの大規模なリプレイスのプロジェクトについて話してきました。
現在はリリースも終わりましたので、サービスの改善をスピードを上げて取り組んでいます。

また、AndroidではKotlinを採用してリプレイスを実装中です!Androidのリプレイスの記事にもご期待下さい!