2023年のコインチェックモバイルアプリのアーキテクチャー

MobileグループでiOSアプリの設計と実装を担当している山口と申します。

今回は2021年にリニューアルを行なったモバイルアプリのアーキテクチャーをリニューアルの過程を踏まえてご紹介したいと思います。

リニューアル前

リニューアル前の課題

2021年以前のCoincheckアプリは、MVVMライクなアーキテクチャーを採用していましたが、以下の点を課題として抱えていました。

  • 各ViewModelごとに責務が異なっていて凝集度が低い
  • API通信部分や複雑なロジックを持つ部分はManagerクラスに分けるなど大まかな責務の分離は整備されていたもののシステムアーキテクチャーは導入されていないため明確なルールが存在せず、どこに何の処理がまとまっているかが分かりにくい
  • Androidアプリとの実装差分があり、内部設計・UI共に異なっている

特にAndroidアプリとの実装差分については、機能追加のたびにデザインを2タイプ用意しないといけないなど、開発以外のコストも高く、リリースのスピードを下げる要因として課題となっていました。

上記のような課題からメンテナンスの観点で限界を迎えていたため iOS / Android のコードを全て作り直す判断をしました。

リニューアルプロジェクトについて

リニューアルの方法は色々あると思いますが、Androidアプリとの実装差分を考慮して徐々にリニューアルする方法では足並みを揃えるのが困難だったため、コインチェックでは新規でリニューアルする方法を選択しました。

リニューアル前の課題に対しては次のとおりアプローチしました。

  • 各ViewModelごとに責務が異なっていて凝集度が低い
  • API通信部分や複雑なロジックを持つ部分はManagerクラスに分けるなど大まかな責務の分離は整備されていたもののシステムアーキテクチャーは導入されていないため明確なルールが存在せず、どこに何の処理がまとまっているかが分かりにくい

iOS/Android で共通したシステムアーキテクチャーの導入による解決を目指しました。

  • Androidアプリとの実装差分があり、内部設計・UI共に異なっている

iOS / Androidで内部設計・UIを統一することによる解決を目指しました。

対象

iOS / Androidアプリ

言語

  • iOS: Swift
  • Android: Kotlin

期間

約一年

体制

リニューアル版の開発と既存アプリの改修対応を行うメンバーでチームを分けました。 基本的には担当するアプリの対応をしますが、既存アプリのタスク状況に応じてリニューアル作業を一旦止めて改修を手伝ったり、逆に既存アプリのタスクが落ち着いた場合はリニューアル版の開発をメンバー全員で行なっていました。

  • リニューアル版
    • iOS: 1人, Android: 1人
  • 既存アプリ
    • iOS: 1人, Android: 1人

開発フロー

以下のフローを基本として作業を進めていました。

  1. iOS版で先行実装を行う
  2. Android担当者がiOS版をコードレビュー
  3. iOS版を修正
  4. iOSの実装を元にAndroidの開発に着手
  5. iOS担当者がAndroid版をコードレビュー

1, 2, 3については通常通りのコストですが、4, 5に関しては設計・実装・レビューコストを大きく下げることができていました。 両OSを約一年でリニューアルできたのは、この開発フローが上手くいっていたのが大きかったと思います。

※ RxSwift / RxJava(RxKotlin)を採用して設計を統一しているため、担当ではないOSの習熟度があまり高くなくても問題なくレビューや実装を行うことができていました。Rxの採用については後ほど説明します。

リニューアル着手前に行ったこと

以下の作業を行いました。

  • メンバー内で設計の認識を揃える
    • 実際にサンプルコードを書いてチーム内で認識合わせました
  • 見積もり
    • 既存アプリをさわり基本的な機能を一覧にまとめてタスクとして切り出し見積もりを行いました
  • 既存アプリのコードリーディング
    • Model周りを確認してアプリで取り扱われている概念の確認をしたり、新・旧アプリの整合性をとるために何が永続化されているかを調べてまとめおきました

リニューアル作業中に出てきた課題

既存アプリに新規追加された機能の反映

業務上既存アプリのアップデートは必須の場合もあり、都度機能追加が行われていきます。 リニューアル版に並行実装するのは困難と判断して後から実装する形にしました。 また業務上必須ではなく作業ボリュームの大きい機能追加に関しては実装時期を相談させてもらいリニューアル後の対応としてスケジュールを調整してもらいました。

リニューアル直後

リニューアル後のアプリについてはこちらの記事で紹介していますので併せてご覧ください。

tech.coincheck.blog

アーキテクチャー

VIPERアーキテクチャーをベースにしたアーキテクチャーを採用しています。

アーキテクチャーの選定については以下の観点を重視しました。

  • コードの属人化を防止できるレイヤ構造であること
  • ユニットテストを記述できる構造であること
  • 複数人で開発できる構造であること
  • iOS / Androidで設計を揃えることができること

レイヤ構造は以下のような形になっています

※ EntityはUseCase, ViewModel, View, Routerで参照されます

RxSwift / RxJava(RxKotlin)を採用してGUIアーキテクチャーはMVVMに変更、 Interactorレイヤからの戻り値をObservableに変更しています。

レイヤ構造は「クリーンアーキテクチャー」や「4層レイヤードアーキテクチャー + DIP(依存性逆転の原則)」のレイヤ構造を参考にInteractorレイヤを分割して DIP(依存性逆転の原則)を用いて依存関係を逆転させた構成に変更しています。

Rxを採用した理由についてですが、両OS間でほぼ同一のRxのオペレーターを利用できるためです。View以外のレイヤのコードを iOS / Android 間で統一する目的があります。

ja.wikipedia.org

各レイヤの説明

※ 記事内のソースコードについてはサンプル用に書き直したもので実際のプロダクトコードとは異なります。またSwiftでのサンプルコードと解説になります。

View

取得したEntityを元にアプリケーションのUIの表現します。またライフサイクル、UIの操作をViewModelに伝達します

final class ViewController: UIViewController {
    var viewModel: ViewModel?
    
    private let disposeBag = DisposeBag()
    @IBOutlet private weak var button: PrimaryButton!
    @IBOutlet private weak var labelA: UILabel!
    @IBOutlet private weak var labelB: UILabel!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        bind()
    }
    
    private func bind() {
        guard let viewModel = viewModel else { fatalError("viewModel is nil") }

        let input = ViewModel.Input(willAppearStream: viewWillAppearTrigger,
                                    buyButtonPressed: button.rx.tap.asObservable)
        let output = viewModel.transform(input: input)
        
        disposeBag.insert(
            output.internals.drive(),
            output.entityA.drive(labelA.text),
            output.entityB.drive(labelB.text),
            output.processing.drive(ProgressHUD.rx.isAnimation),
        )
    }
}
ViewModel

ViewとInteractorの橋渡しをするレイヤー。UseCaseの呼び出しを行い結果をViewに返します

  • Viewから受け取ったイベントを元に他のクラスに処理を委譲する
    • Viewに対してOuput経由でEntityを返して画面更新を依頼
    • UseCaseに対してデータの取得を依頼
    • Routerに対して画面遷移・アラート表示を依頼
final class ViewModel: Injectable {
    struct Input {
        let willAppearTrigger: Observable<Void>
        let nextButtonPressed: Observable<Void>
    }

    struct Output {
        let internals: Driver<Void>
        let entityA: Driver<entityA>
        let processing: Driver<Bool>
    }

    struct Dependency {
        let router: RouterProtocol
        let usecaseA: UseCaseAProtocol
        let usecaseB: UseCaseBProtocol
    }

    private let dependency: Dependency

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

    func transform(input: Input) -> Output {
        let dependency = dependency
        let errorTracker = ErrorTracker()
        //  UseCaseに対してデータの取得を依頼
        let entityAStream = input.willAppearTrigger
          .flatMap {
              dependency.usecaseA.getEntityA()
                  .trackActivity(indicator)
                  .trackError(errorTracker)
                  .asDriverOnErrorJustComplete()
          }

        // Routerに対して画面遷移やアラート表示を依頼
        let showNextStream = input.nextButtonPressed
            .flatMap {
                dependency.router.showAlert()
            }
            .filter { $0 == .ok }
            .map {
                dependency.router.showNext()
            }

        // Routerに対してエラートースト表示を依頼
        let errorToastStream = errorTracker.asObservable()
            .map { error in
                dependency.router.showErrorToast(error: error)
            }

        return Output(
            internals: Observable.merge(
                showNextStream,
                errorToastStream
            ).asDriverOnErrorJustComplete(),
            entityA: entityAStream.asDriverOnErrorJustComplete(),
            processing: indicator.asDriver()
        )
    }
}
Router

画面生成, 画面遷移, アラートの表示、DIを行います

※DIはiOSのみ。DIを行う関係上Routerは全てのレイヤに依存します。DI周りは後ほど説明します

protocol RouterProtocol {
    func showNext()
    func showAlert() -> Single<UIAlertController.AlertAction>
}

final class Router: RouterProtocol {
    private(set) weak var viewController: UIViewController?

    init() {
        let viewController = R.storyboard.detail.instantiateInitialViewController()!
        let viewModel = ViewModel(with: ViewModel.Dependency(router: self,
                                                             usecaseA: UseCaseAssembler().resolve(),
                                                             usecaseB: UseCaseAssembler().resolve()))
        viewController.viewModel = viewModel
        self.viewController = viewController
    }

    func showNext() {
        // 画面遷移
    }
    func showAlert() -> Single<UIAlertController.AlertAction> {
        // アラートを表示
    }
}
Interactor

アプリケーションのビジネスロジックを担当するレイヤー

CoincheckアプリではInteractorを以下の3つにレイヤ(UseCase, Repository, DataStore)に分割してそれぞれに責務を持たせています。 データの取得は ViewModel -> UseCase -> Repository -> DataStore の順にアクセスを行い必要なデータをViewModelにデータ返します。

本家VIPERではView, Interactor, Presenter, Routerを1ModuleとしていてView(あるいはPresenter)とInteractorは 1 : 1 もしくは 1 : 0 の関係性となっていますが、Coincheckアプリの場合はView(あるいはPresenter)とInteractor(UseCase)の関係を n : n としていて、ViewModelが必要なデータを利用シーンごとに分割した複数のUseCaseに問い合わせる構成にしています。

WebAPIの仕様がモバイルアプリの画面仕様に特化していたりアプリの規模が小さい場合は 1 : 1 の関係でも問題ないと思いますが、Coincheckアプリの場合は 1 : 1 の関係性だと同じ処理を何度も記述する必要があり効率的ではないのと、一画面を表示するのに複数のWebAPIの呼び出しが必要なため結果を統合するレイヤが必要だったこともありInteractorを分割する設計に変更しました。

UseCase

Repositoryからデータを集めて加工。ViewModelからリクエストを受けて結果をViewModelに返します

  • UseCaseの単位は利用シーンごと
  • 必要なデータをRepositoryから取得
    • 必要であれば複数のRepositoryからデータを集めて新たにEntityを生成
protocol UseCaseProtocol {
    func getEntityA() -> Single<EntityA>
    func getEntityC() -> Single<EntityC>
}

struct UseCase: UseCaseProtocol, Injectable {
    struct Dependency {
        let repositoryA: RepositoryAProtocol
        let repositoryB: RepositoryBProtocol
    }

    private let dependency: Dependency

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

    // 必要なデータをRepositoryから取得する
    func getEntityA() -> Single<EntityA> {
        dependency.repositoryA.XXX()
    }

    // 必要であれば複数のRepositoryからデータを集めて新たにEntityを生成する
    func getEntityC() -> Single<EntityC> {
        Single.zip(
            repositoryA.getEntityA()
            repositoryB.getEntityB()
        )
        .map { EntityC(a: $0, b: $1) }
    }
}
Repository

WebAPI、ストレージへのCRUD操作を行う。UseCaseからリクエストを受けて結果をUseCaseに返します

  • WebAPI、ストレージから取得したデータをEntityに変換する
    • WebAPIのレスポンスはEntityとは別に用意しているので、それをEntityに変換
    • ストレージは基本的にプリミティブな値を返すので、それをEntityに変換

※ RepositoryのInterfaceがUseCaseレイヤにあるのでRepositoryレイヤがEntityを扱えるようにしています

protocol RepositoryProtocol {
    func getEntityA() -> Single<EntityA>
    func getEntityB() -> Single<EntityB>
}

struct Repository: RepositoryProtocol, Injectable {
    struct Dependency {
        let apiClient: CoinCheckApiProtocol
        let dataStore: DataStoreProtocol
    }

    private let dependency: Dependency

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

    func getEntityA() -> Single<EntityA> {
        dependency.apiClient.send(request: CoinCheckApiClient.xxApis.Get())
            .map { EntityA($0) }
    }

    func getEntityB() -> Single<EntityB> {
        dependency.dataStore.getValues()
            .map { EntityB($0) }
    }
}
DataStore (WebAPI / ストレージ)

WebAPI / ストレージ へのアクセスを行う。Repositoryからリクエストを受けて結果をRepositoryに返します

※ 必要なデータを取得するだけの処理になるのでサンプル実装は省略します

Entity

このアーキテクチャーで利用するデータを表現するレイヤー

  • interactor, ViewModel, Viewで扱うアプリケーションのデータ構造
  • 加工・判断・計算ロジックを扱う

※ データ型の表現のみのためこちらもサンプル実装を省略します

DI戦略 (iOSのみ)

InteractorレイヤにあるUseCaseは利用シーンに応じてクラス分割されているため複数のRouterから参照されます。 そのためUseCase以下(UseCase,Repository, DataStore)のDI周りを変更すると修正対象のクラスを利用している全てのRouterに対して修正が必要になってしまうのでコインチェックでは以下のクラスを用意してUseCaseの依存関係を解決しています。

なおAndroidはKoinを用いて、依存関係を解決しています。

iOSにもSwinjectなどのDIライブラリは存在していますが、UseCaseのDI周りのみを切り出せば目的は達成できるため、なるべくシンプルな方法を選択しました。

struct UseCaseAssembler {
    func resolve() -> UseCaseA {
        let dataStore = DataStore()
        let repositoryA = RepositoryA(with: RepositoryA.Dependency(apiClient: CoinCheckApiClient()))
        let repositoryB = RepositoryA(with: RepositoryA.Dependency(dataStore: DataStore()))
        return UseCaseA(with: UseCaseA.Dependency(repositoryA: repositoryA, repositoryB: repositoryB))
    }

    func resolve() -> UseCaseB {
        let dataStore = DataStore()
        let repositoryC = RepositoryC(with: RepositoryA.Dependency(apiClient: CoinCheckApiClient()))
        let repositoryD = RepositoryD(with: RepositoryA.Dependency(dataStore: DataStore()))
        return UseCaseA(with: UseCaseA.Dependency(repositoryC: repositoryC, repositoryD: repositoryD))
    }
}

// Routerでの利用は以下のような形になります
final class Router: RouterProtocol {
    private(set) weak var viewController: UIViewController?

    init() {
        let viewController = R.storyboard.detail.instantiateInitialViewController()!
        let viewModel = ViewModel(with: ViewModel.Dependency(router: self,
                                                             usecaseA: UseCaseAssembler().resolve(),
                                                             usecaseB: UseCaseAssembler().resolve()))
        viewController.viewModel = viewModel
        self.viewController = viewController
    }
}

リニューアルの成果

解決したかった課題はほぼほぼクリアした状態になりました。 リニューアル後に開発メンバーの数も増えましたがコードの属人化の問題などはなく、ユニットテストも記述できています。 解決のアプローチの中でも今回は特徴的な iOS / Android で設計統一した話を紹介したいと思います。

iOS / Androidで設計統一した話

リニューアルを行ったことでView以外のレイヤに関してはほぼ両OSで統一することができました。 現状運用している上で感じているメリット・デメリットは以下になります。

メリット
デザイナーの作業量の低下

デザイナーが、OSごとに異なる画面を作成しなくてもよくなったため、UI作成に関わるコストが減少しました。

設計コストの低下、不具合の早期検出

iOSエンジニアまたはAndroidエンジニアのどちらか一方だけが内部設計・実装を先行し、もう一方がそれを模倣する、というフローにおいて、コードリーディングのコストやエンジニア間のコミュニケーションコストが格段に低下しました。

もちろん担当機能に関しては担当していない方のOSのコードレビューも行う必要があるため完全に設計コストをゼロにすることはできませんが、 バラバラに実装して両OSで仕様が違うといったことはなくなりOS間による機能の差分もなく足並みを揃えた開発ができています。 また片方のOSが後から開発に着手することでダブルチェックを行なっている状態になっているのでレビューでは拾えきれなかった不具合の早期発見やより良い設計への変更などにもつながっています。

担当OS以外の開発コストの低下

設計を統一したことでコードリーディングのコストが下がりiOS担当者がAndroidの実装を行うなど、タスク状況に応じて担当OS以外の開発を行うコストが少し下がりました。 自分はiOSエンジニアですが、チームの状況を見てAndroidの開発を担当している時期もありました。

※ 別のOSを担当することに関してはチーム内で積極的に推奨しているわけではないので希望者のみになります。

デメリット
両OSで設計を統一することによるコスト

複雑な機能追加の場合は設計を統一するために事前に両OSの担当者で設計相談を行う必要があるのでそのためのコストがかかっています。 ただこれに関しては設計相談の段階で自分が想定していた設計とは違う新たな発見があったりプラスになる点も多いなと感じています。

2023年6月現在

アーキテクチャーはリニューアル直後から変更はありませんが、開発を続けていく中で次の課題が出ていきました。

リニューアル後の新たな課題

Entityの実装

一部、アプリの仕様への理解が浅く、仕様を上手く表現した構造になっていない部分があるためメンバー内で相談を行いながら現在リファクタリングを進めています。

ViewModelの実装

画面仕様が複雑な画面のViewModelの話になりますがFat ViewModelになっているViewModelがいくつかあります。 実装ルールの定義が甘かったのもあってViewModelのストリーム内に加工・判断・計算ロジックが混ざっていたり、画面仕様の複雑さからストリームの結合や待ち合わせが頻繁に行われているのが複雑度を上げる原因となっているのでその部分の修正を進めています。Entityを最適化することでの解消を目指していますがそれでも解決できない場合は「画面名ViewData」という命名のクラスを追加してロジックの分散を行っています。

リファクタリングへの取り組みはこちらの記事で紹介していますので併せてご覧ください。

tech.coincheck.blog

SwiftUIの対応 (iOSのみ)

こちらはもう少し先の課題となりますが現状のiOSアプリはUIKitを用いて画面の構築を行っていますが、SwiftUIへの対応が必須になった場合その対応を行う必要が出てきそうです。現状移行することへのメリットはあまり感じていませんが技術のキャッチアップを含め動向を追っておく必要がありそうです。

おわりに

今回は現状のモバイルアプリのアーキテクチャーを紹介させて頂きました。 Mobileグループではメンバー間で意見を交換しながら、より良いサービス・改善しやすい設計を目指し日々改善に取り組んでいます。

最後にコインチェックではエンジニアを積極募集中です。今話題のWeb3、ブロックチェーンの領域で勝負したいエンジニアの方などご興味ある方はお気軽にお申し込みください! engineer-recruit.coincheck.com

またコインチェックに興味がある方、転職を検討している方に向けて社内の情報をゆるく発信していくCoincheck FMもやっておりますので、よろしければ聞いてみていただけると幸いです。

open.spotify.com