Atrae Tech Blog

People Tech Companyの株式会社アトラエのテックブログです。

マッチングアプリYentaのiOSアプリをフルスクラッチで作り直した時に意識したこと

f:id:atrae_tech:20210322135924p:plain

ビジネスマッチングアプリ "Yenta" 事業でアプリケーション開発をしているエンジニアの青野 @ysk_aono です。

Yentaは、昨年2020年にロゴやデザインを一新しました。また、アプリの基本的なコンセプトは変わらないものの、サービスとしての機能や有料メニューにも変更が施され、プロダクトの内外ともにリニューアルされた状態となっています。利用いただけるエリアが東京限定から日本全国(その後インドも)になったのもこのタイミングでした。

yenta.talentbase.io

iOSアプリは (ちなみにAndroidアプリも)、このリニューアルでフルスクラッチでコードを書き直しました。今回の記事では、この書き直しにあたってiOSアプリ実装をどのように行ったか、いくつか意識したポイントを書いていこうと思います。

なぜフルスクラッチで書き直すことにしたのか

YentaのiOS版は2015年12月にテストローンチされ、以降上記の作り直し決定まで4年弱、機能追加, 削減, リファクタなどを行ってきました。この間、開発をメインで担当していたのは私で、プロダクト戦略に追従する形でコード上のいわゆる"負債"を生み出してきたまさに当事者です。当初私のネイティブアプリ開発経験がほぼ無かったこともあり、負債の増えるペースは小さくなかったと思います。

例えばアーキテクチャの文脈で言えば、素朴なMVCで作られていました。とにかくリリースを早めて検証するタイミングでは最悪の選択肢ではないと思いますし後悔はしていないのですが、年を経て同じ画面内での機能追加・仕様変更が重なるようになってくると、開発スピードの維持が難しくなってきました。データバインディングなどの仕組みもなく、処理を素直?な手続き的に書いていたのもあり、画面の状態数とそれに応じた条件分岐が増えていくほどメンテナンスの難易度も上がってしまっていました。

先述したリニューアルは、デザインの一新や仕様の見直しなどを行う、ある種0からプロダクトを見直す機会でした。既存のコードベースをリファクタしながら少しずつ変更を入れていくアプローチは、こういった状況で難易度の高いテーマです。APIも含めシステムとしての変更が生じることは分かっていたので、結局それなりの割合のコードを書き直す未来は見えていました。

  • 現時点で開発生産性の低下が顕著で、プロダクトとしてリリースが遅くなってしまっている
  • 今後のアプリ拡張 (日常的にチームメンバーで議論はしている) を見越したシステムの土台作りをしないと、さらにスピードは低下するだろう
  • 旧アプリもアップデートしながら並行して新アプリを開発していく体制となるため、スイッチを明確に分離しておいた方が引きずられずに速く進める可能性が高い

これらの考えをエンジニアだけでなく他のメンバーにも説明し議論して、結果としてフルスクラッチで書き直すことへのトライをチームで決めました。大きな時間を消費するリスク認識は共有しつつ、そのメリットもしっかり伝わったのではないかと思います。

書き直しにあたり実現したかったこと

例えば以下のような点を考えていました。

  • どのメンバーが書いても、ある程度同じように書いて作れるようなアーキテクチャにすること。一定の規約を作り、メンバー間の書き方のブレの差異を小さくする
  • 少なくともビジネスとしての成功が見えるまでは、もう二度と全体を作り直す必要がないようにしたい

「それなりのコストを使ってせっかく書き直すわけなので、できるだけ長く戦えそうな構造にしていきたい」というのは、当時一緒にリニューアルを進めるAndroidエンジニアとよく話していたことです。

意識したポイント

1 / View ⇄ ViewModelのデータバインディングを取り入れ、Viewの責務を減らす

先述したようにもともとはMVCで画面を作っていたため、いわゆるファットVCの状態になっていました。画面の表示、ユーザの操作や入力、モデルへの処理要求、全てがまとまった世界です。責務の分離ができておらず見通しが悪くなってきたため、解消したいと考えました。

  • 「画面の描画」と「プレゼンテーションロジック」を分けたい
  • 手続き的な描画指示をできるだけ書きたくない

といった要望が自分の中には少なくともあり、UIKit かつ RxSwiftを活用したView+ViewModelの構成にすることを決めました。(SwiftUI + Combineで実現する可能性も今このタイミングならあるのかもしれませんが、当時は安定性もAPIも不足していた印象で、アプリの基幹を任せるに足るレベルになっていなかったと思います)

具体的には、ViewModelは以下のプロトコルを必ず実装するようにし、ViewとViewModelをバインディングするような形で進めました。

protocol ViewModelType {
    associatedtype Dependency
    associatedtype Input
    associatedtype Output

    init(dependency: Dependency)

    func transform(input: Input) -> Output
}
// ViewModel
class ViewModel {
    init(dependency: Dependency) { ... }
}

extension ViewModel: ViewModelType {
    struct Dependency { ... }
    struct Input {
        let userName: Observable<String>
    }
    struct Output {
        let isButtonEnabled: Driver<Bool>
    }

    func transform(input: Input) -> Output {
        ...
        return Output(
            isButtonEnabled: ...
        )
    }
}

// ViewController
class ViewController: UIViewController {
    private var viewModel: ViewModel!

    override func viewDidLoad() {
        super.viewDidLoad()
        bindViewModel()
    }

    private func bindViewModel() {
        let input = ViewModel.Input(
            userName: textField.rx.text.orEmpty.asObservable()     
        )
        let output = viewModel.transform(input: input)
        output.isButtonEnabled.drive(button.rx.isEnabled).disposed(by: disposeBag)
    }
}

上記は、ユーザがTextFieldに入力した文字列を元にバリデーションを行い、特定のボタンのisEnabledの値を切り替える、という例です。

ViewControllerは「入力をViewModelに渡す」「ViewModelから返ってきた出力を使って画面の描画を宣言的に行う」という責務のみに集中し、ViewModelは「ViewControllerからの入力を、画面で扱えるような出力に変換する」ところにフォーカスできるようになりました。

またプロトコルによる制約を作ることで、入出力が分離・識別しやすくなり、バインディングの場所が例外なく1箇所に集約されるようになっています。複雑な画面だとUIViewController内の bindViewModel関数の中の処理が膨らんでしまうのがたまに瑕ですが、入力が他の場所から読み出されることもないですしコードの見通しは良くなったと考えています。

これにより、最初に書いた "責務分離", "手続き的な画面描画指示の削減" が実現されました。

こちらは、以下のリポジトリの設計をかなり参考にしています。

github.com

2 / 未来のためにテスト導入を容易にしておく

1でプレゼンテーション層の分離について述べましたが、さらに後の層でもレイヤー分けを行いました。ビジネスロジック (というざっくりしたレイヤ) を分離し疎結合にしておくことで、例えばAPIのスタブ化などを実現したいと考えました。APIがまだ完成していない状態で開発を進めるのに役立ちましたし、自動テストを整備していく際にも有用だと思っています。(テスト書く際、モック/スタブと本物を簡単に切り替える仕組みがないと、手がかかって書く前に萎えてしまいますよね...)

具体的には、以下のようにアプリ全体を大きく3つのレイヤ (Embedded Framework) に分け、それぞれの役割を配置しています。

- <Yenta>
    - Builder
    - ViewController
    - ViewModel
    - Router
- <YentaDomain>
    - Repository
    - UseCase
    - Translator
    - Model
    - その他
- <YentaData>
    - DataStore
    - Entity

ViewModelからのデータフローを表現すると、

ViewModel ⇄ UseCase ⇄ Repository ⇄ DataStore (⇄ API/Realm/KeyChain/UserDefaults)

といった形です。基本的には下記のような役割を果たしています。

  • DataStoreが各種データソースとやりとりを行いEntityを取得
  • RepositoryはEntityを取得, 保存, 更新, 削除するI/Fとして機能
  • UseCaseは複数のRepositoryを集約 & Translatorを使って取得したEntityを画面で利用しやすいModelに変換
  • ViewModelは何か処理をしたいときにUseCaseに処理を依頼
  • (本筋とは関係ありませんが)
    • Routerは画面遷移のみを担当
    • Builderは全登場人物を集めてinjectionする役

また、Embedded Frameworkを使うことで依存の方向も絡み合わないようにし、↓のような制約を作っています。

  • YentaからはYentaDomain, YentaDataをimportできる
  • YentaDomainからはYentaDataをimportできる
  • YentaDataは他のFrameworkをimportできない

おかしな方向での依存は起こらなくなり、依存する際も import XXX と明示的に書くことが必要になりました。面倒ごとが増えたといえば、Frameworkの境界線を超えて利用する場合は、全て構造体や関数の宣言に全て public をつける必要が出てしまった、というところでしょうか...

実際のコードは以下のようになっています。後から剥がすのがすごく大変そうなので悩んだのですが、基本的にRxSwiftのObservableを介してデータの受け渡しを行います。

// DataStore
public struct UserDataStoreImpl: UserDataStore {
    public init() {}

    public func getProfile() -> Observable<User> { 
        // APIからデータ取得
    }
}

public struct UserDataStoreMockImpl: UserDataStore {
    ...
    public func getProfile() -> Observable<User> { 
        // 例:ローカルのJSONファイルからデータ取得
    }
}

public protocol UserDataStore {
    func getProfile() -> Observable<User>
}

// Repository
public struct UserRepositoryImpl: UserRepository {
    private let dataStore: UserDataStore

    public init(dataStore: UserDataStore) {
        self.dataStore = dataStore
    }

    func getProfile() -> Observable<User> {
        dataStore.getProfile()
    }
}

public protocol UserRepository {
    func getProfile() -> Observable<User>
}
// UseCase
public struct ProfileBrowseUseCaseImpl: ProfileBrowseUseCase {
    private let userRepo: UserRepository
    private let userTranslator = UserTranslator()

    public init(userRepo: UserRepository) {
        self.userRepo = userRepo
    }

    public func getOwnProfile() -> Observable<UserProfileModel> {
        userRepo.getProfile().map { user in 
            // Translatorで画面で利用しやすいデータ形式に変換
            self.userTranslator.translate(user)
        }
    }
}

public protocol ProfileBrowseUseCase {
    func getOwnProfile() -> Observable<UserProfileModel>
}
// ViewModel
class ProfileBrowseViewModel {
    private let useCase: ProfileBrowseUseCase

    init(dependency: Dependency) {
        self.useCase = dependency.useCase
    }
}

extension ProfileBrowseViewModel: ViewModelType {
    struct Dependency {
        let useCase: ProfileBrowseUseCase
    }

    func transform(input: Input) -> Output {
        let user = useCase.getOwnProfile()...
        ...
    }
}

依存関係逆転の原則を適用し、UseCaseより後ろの層は全てprotocolで抽象化したもので依存関係を組んでいます。軽く触れた通り、Builderが全ての依存性注入を行っているため、Builderの部分で注入するデータストアをUserDataStoreImplからUserDataStoreMockImplに差し替えれば、簡単にダミーデータを表示したりすることが可能になります。

以上により、当初述べたビジネスロジック疎結合化、テスト導入時の容易化を実現することができました。

3 / 異なる画面間の状態同期を追いやすくする

1と2の内容で、複雑な状態を1つの画面で実現する際のコードはかなり書きやすくなりました。

加えて解決したかったのは、アプリでよくある "画面をまたいだ状態共有" でした。分かりやすい例を挙げると、メッセージ画面でつけた「ピン」を戻った時のマッチング一覧画面にも反映させたい、などがあるかと思います。

NotificationCenterもRxSwiftのObservable化できますし、シンプルにそれを使ってデータを受け渡しをするのでも良いかと考えたのですが、購読時に送られてきたデータの型が分からないのが嫌です。そこで、Fluxライクな役割を果たすクラスを容易し、それを介して一方向 (アクション発火→購読) のデータフローを用意しました。Storeでは RxProperty を利用し、読み取り専用のBehaviorRelayを監視・購読できるようにしています。

class MatchDispatcher {
    static let shared = MatchDispatcher()

    let pinUpdate = PublishRelay<Match>()

    private init() {}
}

public class MatchActionCreator {
    static let shared = MatchActionCreator()

    private let dispatcher: MatchDispatcher

    private init(dispatcher: MatchDispatcher = .shared) {
        self.dispatcher = dispatcher
    }

    public func setPinUpdate(matchModel: Match) {
        dispatcher.pinUpdate.accept(matchModel)
    }
}

public class MatchStore {
    static let shared = MatchStore()

    public let pinUpdate: Property<Match?>
    let _pinUpdate = PublishRelay<Match?>(value: nil)

    private init(dispatcher: MatchDispatcher = .shared) {
        self.pinUpdate = Property(_pinUpdate)
        dispatcher.pinUpdate.bind(to: _pinUpdate).disposed(by: disposeBag)
    }
}

public class MatchFlux {
    public static let shared = MatchFlux()

    private let dispatcher: MatchDispatcher
    public let actionCreator: MatchActionCreator
    public let store: MatchStore

    init(
        dispatcher: MatchDispatcher = .shared,
        actionCreator: MatchActionCreator = .shared,
        store: MatchStore = .shared
    ) {
        self.dispatcher = dispatcher
        self.actionCreator = actionCreator
        self.store = store
    }
}

これらを以下のように利用します。言ってしまえばPub/Subのような仕組みなのですが、受け取る型が明確になっているためIDEの補助を受けやすいこと、storeサイドのプロパティはread-onlyであるため間違った利用はされないこと、などがメリットしてあるかと思います。

class MatchViewModel {
    private let flux: MatchFlux
    ...

    func transform(input: Input) -> Output {
        flux.store.pinUpdate.changed
            .withLatestFrom(matches) { (updated: $0, all: $1) }
            .map { (updated: Match, all: [Match]) in
                // 送られてきた更新データを見て一覧を更新!!
            }
            .bind(to: ...
    }
}

class MessageViewModel {
    private let flux: MatchFlux

    func transform(input: Input) -> Output {
        input.pinButtonDidTap
            .subscribe(onNext: { [weak self] _ in 
                ...
                let model = ...
                self?.flux.actionCreator.setPinUpdate(model)
            })
            .disposed(by: disposeBag)
    }
}

ActionCreatorで発火 + Storeを監視 のセットを使うことで、ユーザのアクション結果が別のどこかの画面で購読され反映される、という状態を作ることができました。

こちらの仕組みを同一画面含めアプリ全体で利用するというアプローチもあるとは思うのですが、管理がかなり難しくなると予想し、現時点では「異なる画面間の状態同期」のみに適用しています。

4 / サードパーティーライブラリを活用しすぎない

こちらは手短に書きますが、特にUI系のライブラリにはできるだけ頼らないようにしました。

例えばリニューアル前は、Yentaのメイン機能であるカードスワイプのUI、メッセージ画面のUIなどを、3rdパーティーのライブラリを利用することで実現していました。手軽に速く作るという意味では良かったのですが、我々の実現したい機能要件がライブラリの想定を超えてしまうと、その拡張が非常に難しいという課題にぶつかりました。(あるあるだと思います...)

当初はForkしたりすることで対処していた部分もあったのですが、良くも悪くもブラックボックス化されており (ライブラリとしては当然でしょうが)、そのカスタマイズがしにくいことがネックになりそうでした。そこで、もともと使っていたライブラリのコードから学ばせてもらいつつ、全て自前で実装することにしました。変化が頻繁に起きる可能性のある画面については自分たちで実装することで、「何ができて何が難しいのか?」の把握やそのコントロールが非常にしやすくなったと考えています。

現状の感想と課題感

他にもいろいろ書きたいことはあったのですが、これで一旦終わりにします。

今回紹介したリニューアルは最初に述べたように昨年完了しており、現在無事に運用されている状態です。1年近く経ちますが、当初の目的であった

"どのメンバーが書いても、ある程度同じように書いて作れるようなアーキテクチャにすること。一定の規約を作り、メンバー間の書き方のブレの差異を小さくする"

"もう二度と全体を作り直す必要がないようにしたい"

は今のところ達成できている気がします。特に前者に関しては効果があったと考えており、一度構造を把握してしまえばある種頭をそこまで使わずに (表現が適切かは分かりませんが) 画面を作っていくことができますし、他のメンバーが書いたコードも把握がしやすくなりました。

一方で感じるのが、実装していく上で作成するファイルが多くて疲れる...という個人的な所感と、新規メンバーの開発参入障壁の高さです。前者は半分冗談ではありますが、後者は現在まさに実感しているところです。ちょうど弊社内定者のエンジニアがiOS版の開発を手伝ってくれているのですが、RxSwiftという初学者殺しのライブラリを活用していることも相まって、コード全体の把握がなかなか難しくなってしまっているなと思います (クリーンアーキテクチャetc.の概念に慣れているなら話は変わってくると思うのですが) 。

新しい画面の実装をする際、ボイラープレート的なファイルをそれなりの数作成するというプロセスが発生するので、自動生成できるような仕組みを用意したりした方がいいかな...🤔 と考えたり。まだ明確な答を持てているわけではないのですが、堅牢さと手軽さをうまく両立できるようになるとより良い開発体験になるので、改善の可能性があるポイントなのは間違いなさそうです。

RxSwiftに全betしたアーキテクチャになってしまっていることも一つの懸念点ではあります。これだけデファクトスタンダードになっているとすぐに廃れる...ということにはならないと信じていますが、Combineも登場した現在のiOSエコシステムを考慮すると、いずれこの部分を書き変える必要性が出てきてしまうのかもしれません。プロダクトの価値向上・ビジネスの成功の阻害になりそうであれば検討しようと考えています。

※今回書いたアーキテクチャを検討する上で、以下の本には本当にお世話になりました。全iOSエンジニアが読んでおくべき良書だと思っています!

peaks.cc

最後に

ビジネスマッチングアプリ "Yenta" をリニューアルした際に意識・実践した内容を書いてきました。

こういった技術的なお題をオーナーとして自分でどんどん進めていけるのが、エンジニアとしてアトラエで働く楽しさの一つだと考えています。自分の意思でシステム、そしてその先のプロダクトとビジネスを作っていきたいエンジニアの方と、とてもとても一緒に働きたいです。

ということで、お決まりではありますが最後に求人の宣伝で終わります!PeopleTech領域でプロダクトを展開していく上での重要なキーパーソンはエンジニアです。この記事や他の記事を読んで、アトラエで "ものづくり" することに少しでも興味を持ってくださった方は、ぜひコンタクトいただければと思います。

speakerdeck.com

note.com