アプリの成長とともに形を変えたレイヤー構造の進化の過程

こんにちは。ギフトモールで Android アプリの開発をしている @KeithYokoma です。 前回の記事では新規アプリ開発にあたってひとりチーム体制でどのような技術を選び設計に落とし込んだか、またその体制で開発をスタートしリリースを続けていく中で見えてきた初期の技術選定と設計にある課題について解説しました。 今回はその課題をもとにどのような取り組みを実施したかについて解説します。

初期リリース後の振り返り

アプリ開発の初期段階では Android で標準的な技術である Android Jetpack を最大限活用したり、Google が示しているガイドに沿った設計にするなど Android アプリ開発における共通の概念をベースに、これまでの自分自身の経験をかけ合わせた設計を導入しました。これにより開発に必要な知見が得やすくなり、困っても解決のヒントを探しやすい状況になりましたが、初期リリースを終えアプリの機能をどんどん増やしていく段階になると、徐々にコードの複雑度もあがっていき、設計的に苦しいと感じるようにもなってきました。

具体的には次の 3 つのことについて、設計的な課題があるように見えてきました。

1. ViewModel が思ったよりも早く太ってしまった

ViewModel には画面の状態を表す ViewState オブジェクトに変更を加える手続きを記述しています。 ViewModel は Web API のアクセスを隠蔽している Repository レイヤのクラスを参照しデータを取得していたため、そのデータを加工して ViewState オブジェクトに適用するまでの手順がすべて ViewModel に詰め込まれていました。

Web API から取得したデータをそのまま画面に表示するようなシンプルな構成であれば ViewModel の肥大化も気になるほどではありませんが、画面によっては複数の Web API を呼び出してデータを並べ替えて表示したり、場合によっては表示しない要素もあったりなど、様々な表示のパターンがあるために ViewModel の手続きが複雑化し責務も大きくなっていくことで顕著に肥大化していった ViewModel もありました。

ViewModel の肥大化による最も大きな悩みのタネは ViewModel のテストでした。本来の ViewModel は State Holder の責務を持つもののはずでしたが、様々な Web API の呼び出しなど UI の状態を更新する以前の手続きまで ViewModel がもっていたことにより、ViewModel のテストコードには適切な状態の更新をチェックするテストだけでなく、手続きが想定通り実装されているかをチェックするテストも含まれるようになりました。UI の状態を更新する以前の手続きに条件分岐が含まれることも多く、その分だけテストを書き足していったことにより、1,000 行をゆうに超えるテストも出てきました。テストの行数が増えるにつれテストそのものが難解になったり、エディタが重たくなったりなどの弊害が出てきてしまいました。

2. Fragment から Activity に対して指示を出したくなってきた

アプリ開発当初はどの画面もシンプルな UI を持っており、BottomNavigationViewNavHostFragment をもった単一 Activity によるタブ切り替え UI を実現していました。この構成では各種機能は Fragment に切り出して作っていきます。どの Fragment もそれぞれ独立しており、Activity のことも基本的には気にしないでもよいように作っています。

しかしアプリ開発を進めていくにつれ Fragment がもつ UI の複雑さも増し、画面によっては表示領域を少しでも多く確保したり、UX の観点から ToolbarBottomNavigationView を隠したい画面が出てきました。 ToolbarFragment へ移管することで画面ごと自由に挙動を実装可能ですが、BottomNavigationView は同じようにはできません。何らかの形で BottomNavigationView を保持する Activity に対して Fragment から表示・非表示を切り替えるための手段が必要になりました。

3. 素のままの RecyclerView ではコードが複雑になってきた

ギフトモールのアプリは様々な種類のコンテンツを一覧形式でスクロールしながら閲覧する UI を持っています。ひとつの List の中で様々な表示を実現するため、RecyclerView の実装も複雑になりがちです。

アプリ開発開始時点ではユーザインタラクションが少なく、ほぼ表示のみに徹することができたため素のままの RecyclerView で十分に実装が可能でした。しかしアプリの機能が増えお気に入りボタンを配置したり、画面によっては UI の一部の折りたたみや展開を実現したり、ユーザインタラクションが増えてくると素の RecyclerView の API では実装量が格段に増えて見通しが悪くなることが分かってきました。またそもそも 1 つのリストで扱う表示の種類が非常に多くなったことも、コードの見通しの悪化を招いてしまいました。

このような課題を解決するため、RecyclerView を使った複雑な UI の実装を補助したり簡単にしてくれる仕組みやライブラリが必要になりました。

レイヤー構成を再定義し、ViewModel の肥大化を防ぐ

今回は上記の振り返りの中から特に 1. ViewModel が思ったよりも早く太ってしまった ことについて、ViewModel の肥大化が最も顕著だった箇所を例にあげ、肥大化によって生まれる課題とその解消方法について説明します。

もっとも ViewModel の肥大化が顕著だったホーム画面

ViewModel の肥大化が特に顕著だったのがアプリのホーム画面です。この画面では複数の Web API を呼び出しており、その結果をひとつの List にまとめて RecyclerView で表示しています。ホーム画面の ViewModel では複数の Web API の応答を待ち合わせ、すべてのデータがそろったら UI に表示するためのデータへ変換し、表示したい順序で List を作って状態を更新しています。概ね次のコード例で示すような作りになっていました1

data class HomeViewState(
  val homeSections: List<HomeSection> = listOf(),
)

class HomeViewModel(
  private val fooDataSource: FooDataSource,
  private val barDataSource: BarDataSource,
  savedStateHandle: SavedStateHandle,
) : StateSavingViewModel(defaultState = HomeViewState(), savedStateHandle = savedStateHandle) {
  private val disposables = CompositeDisposable()

  fun load() {
    disposable += Single.zip(
      fooDataSource.fetchFoo(),
      barDataSource.fetchBar(),
    ) { foo, bar ->
      listOf<HomeContent>(FooSection(foo), BarSection(bar))
    }.subscribeOn(Schedulers.io)
    .observeOn(AndroidSchedulers.mainThread())
    .subscribe(
      { homeSections ->
        // HomeViewState の homeSections を書き換えて UI を更新する
      },
      { exp ->
        // エラーハンドリング。ViewState を使ってエラーがあったことを UI に伝える。
      }
    )
  }
}

ここでホーム画面に新しい Web API をつかった表示を作り、A/B テストで新しい表示を切り替えることを考えてみます。

まずは新しい表示を作るための新しい Web API の呼び出しが必要になるため、load 関数内の Single.zip で実行する Web API の呼び出しと A/B テストの条件を取得する処理の呼び出しを増やします。さらに A/B テストの状態によって新しく増やした結果の表示を切り替えるため、zip のラムダ内で条件分岐を書き、List の中身の作り方を変えます。 擬似的に示すと次のようなコードになります。

  fun load() {
    disposable += Single.zip(
      fooDataSource.fetchFoo(),
      barDataSource.fetchBar(),
      bazDataSource.fetchBaz(),
      abTestDataSource.fetchBazTestVariant(),
    ) { foo, bar, baz, bazTestVariant ->
      if (bazTestVariant.isEnabled) {
        listOf<HomeContent>(FooSection(foo), BarSection(bar), BazSection(baz))
      } else {
        listOf<HomeContent>(FooSection(foo), BarSection(bar))
      }
    }
    // ...
  }

実際のアプリではこの数倍の表示パターンが存在しており、特に zip のラムダ内で実装しているリストの構成は非常に複雑化していました。また上記の例の load 関数をテストを考えただけでも、少なくとも bazTestVariant が取り得る値の数(この場合は truefalse の 2 値)だけテストのパターンが必要になります。ここからさらに、特定の条件を満たす場合のみ表示する要素を増やすなどしたら、その分だけ掛け算でテストすべきパターンが増えていきます。

特に ViewModel に対するテストでは、関数の呼び出しをしたら期待する順番で状態を表すオブジェクトが更新されることを確認します。今回の load 関数であれば、呼び出し前は HomeViewState.homeSections は空で、呼び出した後に HomeViewState.homeSections に要素が入っていることを確認するテストを記述します。 これに加えて、load 関数のテストを動作させる前準備として、DataSource インタフェースのモックを作ったり A/B テストのパターンをモックしたりといったコードも必要ですし、 HomeViewState.homeSections の中身を構成するロジックも HomeViewModel にあるため、 HomeViewState.homeSections の中身が期待した順番かどうかもテストすべきです。

// kotest による ViewModel のテスト
class HomeViewModelTest : BehaviorSpec() {
  init {
    Given("HomeViewModel to load home contents") {
      val fooDataSource: FooDataSource = mockk { /* create a mock */}
      val barDataSource: BarDataSource = mockk { /* create a mock */}
      val bazDataSource: BazDataSource = mockk { /* create a mock */}
      val abTestDataSource: AbTestDataSource = mockk { /* create a mock */}
      val viewModel = HomeViewModel(
        fooDataSource,
        barDataSource,
        bazDataSource,
        abTestDataSource,
        SavedStateHandle(),
      )
      val observer: TestObserver<HomeViewState> = viewModel.states.test()

      When("HomeViewModel#load") {
        homeViewModel.load()

        Then("Await until reaching expected state update count") {
          observer.awaitCount(2).assertValueCount(2)
        }

        Then("1st state should have empty home sections") {
          observer.assertValueAt(0) { value ->
            value.homeSections.shouldBeEmpty()
            true
          }
        }

        Then("2nd state should not have empty home sections") {
          observer.assertValueAt(1) { value ->
            value.homeSections.shouldNotBeEmpty()
            true
          }
        }

        Then("Verify the order of the composed sections") {
          observer.assertValueAt(1) { value ->
            value.homeSections[0].shouldBe(/* expected */)
            value.homeSections[1].shouldBe(/* expected */)
            value.homeSections[2].shouldBe(/* expected */)
            // ...
            true
          }
        }
      }
    }
  }
}

状態の変更をどのようにすべきかという手順も ViewModel にある以上仕方のないことではありますが、HomeViewState のもつ状態が他にも増えてくるとどんどんテストコードがわかりづらくなってしまいそうです。

新しいレイヤーを導入して業務ロジックを切り出す

ホーム画面の ViewModel の例では、次の 3 点が業務ロジックとして切り出せそうです。

  1. 複数の Web API の応答を待ち合わせる
  2. すべてのデータがそろったら UI に表示するためのデータへ変換しする
  3. 表示したい順序で List を作る

この 3 点を切り出したインタフェースを定義すれば、ホーム画面の ViewModel はそのインタフェースから List をもらって状態を更新するだけになります。

ここで Guide to app architecture を振り返ってみると、この業務ロジックを切り出すのにぴったりのレイヤーとして Domain レイヤー が定義してあります。この Domain レイヤーを用いて次のようなレイヤーへ改めることで、業務ロジックを専門に扱うクラスが作れそうです。

既存のレイヤー構成に Domain レイヤーを新しく定義した

新しく作った Domain レイヤーでは次のようなインタフェースを定義し Feature レイヤーに公開します。このインタフェースは必要なデータを Repository レイヤーから取得し別の構造にまとめて ViewModel に返す役割を持ちます。さきほどまで見てきた HomeViewModel を例にすると、複数の WebAPI の呼び出しと A/B テストの設定値の読み込みと、A/B テストの設定値に基づく表示したいデータの条件分岐部分が UseCase にまとめられます。

interface FetchHomeSectionsUseCase {
  fun fetch(): Single<List<HomeSections>>
}

class FetchHomeSectionsUseCaseImpl {
  override fun fetch(): Single<List<HomeSections>> = Single.zip(
      fooDataSource.fetchFoo(),
      barDataSource.fetchBar(),
      bazDataSource.fetchBaz(),
      abTestDataSource.fetchBazTestVariant(),
    ) { foo, bar, baz, bazTestVariant ->
      if (bazTestVariant.isEnabled) {
        listOf<HomeContent>(FooSection(foo), BarSection(bar), BazSection(baz))
      } else {
        listOf<HomeContent>(FooSection(foo), BarSection(bar))
      }
    }
}

これでホーム画面の ViewModel は上記の UseCase インタフェースを使うことで業務ロジックがきりはなされるため、画面の状態を更新する責務にフォーカスするだけになります。

  fun load() {
    disposable += fetchHomeSectionsUseCase.fetch()
      .observeOn(AndroidSchedulers.mainThread())
      .subscribe(
        { homeSections ->
          // HomeViewState の homeSections を書き換えて UI を更新する
        },
        { exp ->
          // エラーハンドリング。ViewState を使ってエラーがあったことを UI に伝える。
        }
      )
  }

複雑な条件分岐がなくなったため、テストも単純化できます。A/B テストのパターンはここで準備しなくてもよくなっていますし、新たな HomeSection を増やすことがあっても、それは UseCase で実装することになるため ViewModel のテストは状態を表すオブジェクトを更新することにフォーカスするだけになります。

// kotest による ViewModel のテスト
class HomeViewModelTest : BehaviorSpec() {
  init {
    Given("HomeViewModel to load home contents") {
      val usecase: FetchHomeSectionsUseCase = mockk { /* create a mock */ }
      val viewModel = HomeViewModel(
        usecase,
        SavedStateHandle(),
      )
      val observer: TestObserver<HomeViewState> = viewModel.states.test()

      When("HomeViewModel#load") {
        homeViewModel.load()

        Then("Await until reaching expected state update count") {
          observer.awaitCount(2).assertValueCount(2)
        }

        Then("1st state should have empty home sections") {
          observer.assertValueAt(0) { value ->
            value.homeSections.shouldBeEmpty()
            true
          }
        }

        Then("2nd state should have empty home sections") {
          observer.assertValueAt(1) { value ->
            value.homeSections.shouldNotBeEmpty()
            true
          }
        }
      }
    }
  }
}

まとめ

今回は想定以上の速さでコードが増え複雑化していった画面の ViewModel を例に、初回リリース時点で設計では足りなかったことを表す Domain レイヤーを追加することで ViewModel の肥大化を防ぎ、見通しよくテストも書きやすいコードとするまでの過程を解説しました。

初期の設計段階である程度 ViewModel が複雑化しやすい部分であることは分かっていたので、極力 UI の状態を表すオブジェクトを操作する手順を ViewModel に詰め込むようにしていました。これによって、今回のリファクタリングでは ViewModel にあるロジックを UseCase という形でインタフェースを切り出して引っ越す作業が中心になりました。

既存のレイヤー構造に新たに Domain レイヤーを加えるようなレイヤー構造の改訂にあたっては、ViewModel のテストを書いていたことも非常に役に立ちました。ViewModel 内のロジックで分岐が増えれば増えるほどテストの量も膨大になります。あまりに多くなりすぎると何をテストしているのかわかりづらくなったり、テストパターンの実装漏れが発生したりします。Domain レイヤーを導入することで ViewModel の複雑さを抑えつつ、テストコードの目的をわかりやすく必要最小限に留めることができました。

振り返りで洗い出した他の設計の課題についても、今後個別に記事を作っていきます。


ギフトモールでは、一緒に働く仲間を募集しています!

まずは一度話してみるだけなどのカジュアル面談も歓迎ですので、少しでも興味を持った方はお気軽に連絡ください!

ギフトモールCulture Deck

speakerdeck.com

募集職種一覧

open.talentio.com


  1. このコード例にある Single.zip の使い方はあまりよくありません。zip でまとめている個別の Single のいずれかが error となると Single.zip も error 状態となり dispose されますが、zip でまとめている他の Single は中断されないため、あとから error になったとき subscribe のエラーハンドリングができず UndeliverableException になります。