アプリの機能拡充を支える新たな設計方針の追加

こんにちは。ギフトモールで Android アプリの開発をしている @KeithYokoma です。

前回の記事では最初の技術選定や設計にあった課題のうち、ViewModel の肥大化についてどのように解決したかを解説しました。今回は次の課題として浮上した FragmentActivity の連携について解説します。

ギフトモールアプリにおける Fragment と Activity の役割

ギフトモールアプリでは Jetpack Navigation を用いて Fragment ベースの画面遷移を実装しています。このため、様々な画面のレイアウトや UI の振る舞いは Fragment 単位で分離され、Activity は BottomNavigationView のタブに対応する画面を切り替える設定を持つ構成になります。

開発開始当初は ToolbarActivity に持たせていましたが、画面ごとに Toolbar の振る舞いが異なったり、リストのスクロールに合わせて Toolbar に動きをもたせたりしたくなったことで ToolbarFragment へと移設しました。

その後、Fragment 内部にある UI に合わせて BottomNavigationView の動きも制御したくなってきました。ギフトモールアプリはギフトを探すことに重点を置いたアプリで、画面内にできるだけたくさんのギフトを表示できるよう、ギフトの一覧をスクロールしながら眺めている状況下では画面を切り替える BottomNavigationView を隠し、少しでも一覧の表示領域を増やすことを考えたのです。

ところが BottomNavigationViewToolbar とは異なり、Jetpack Navigation との連携があるため Fragment 内部への移設はできません。そのため、何らかの形で Fragment 内部から Activity へ指示を出し、BottomNavigationView の表示を切り替えるための仕組みが必要になりました。

Fragment から Activity に指示を出すための設計

Fragment から Activity に対して指示を出す方法にはいくつかの実装が考えられます。

1. リスナーインタフェースを経由して指示を出す

Fragment から Activity に対して何らかの指示を出すときの実装パターンとして、Fragment で定義したリスナーインタフェースを Activity が実装するパターンがあります。このパターンは Fragment が登場して以来頻繁に用いられてきた実装パターンです。

仕組み自体は単純で、Fragment#onAttachActivity がインタフェースを実装していればそれを使うようにします。

interface SampleFragmentCallback {
  fun showNavigation()
  fun hideNavigation()
}

class SampleFragment : Fragment() {

  private var callback: SampleFragmentCallback? = null

  override fun onAttach(context: Context) {
    super.onAttach(context)
    if (context is SampleFragmentCallback) {
      callback = context as SampleFragmentCallback
    }
  }
}

class SampleActivity : AppCompatActivity(), SampleFragmentCallback {
  override fun showNavigation() { /* BottomNavigationView を表示する */ }
  override fun hideNavigation() { /* BottomNavigationView を隠す */ }
}

この方法は非常に単純な実装で FragmentActivity の協調動作が実現できますが、課題もあります。

BottomNavigationView の表示・非表示を切り替えたい画面がひとつとは限らないため、複数の Fragment がそれぞれにインタフェースを定義し始めると、どれも結局は同じことをしたいだけにも関わらずActivity が実装すべきインタフェースも増えていってしまいます。インタフェースを共通化すれば乱立は解決できますが、複数の Fragment で上記の onAttach ですべきボイラープレートのような実装を手書きしなければいけないことにはかわりありません。また実行時の型チェックに頼るため、万が一何らかの実装エラーがあった場合コンパイル時に気づけなくなることにも注意が必要です。

使い古された手法ではあるもののすこしナイーブな実装になるため、他の方法を考えることにしました。

2. 共通の ViewModel を経由して指示を出す

ちょうどギフトモールアプリでは Android Jetpack が提供する ViewModel を使っており、Dagger-Hilt による DI の仕組みも導入していたため、Activity のスコープで保持する ViewModelFragment でもすぐに利用可能な状態でした。Activity のスコープにある ViewModelactivityViewModels を使って Fragment に注入します。この方法なら、BottomNavigationView の表示・非表示の状態を扱うことに特化した ViewModelActivityFragment で共有でき、Fragment での手続きも簡略化できます。

ViewModel の役割は View の状態を扱うことです。BottomNavigationView の表示・非表示の状態を変更する役割を ViewModel に持たせることも自然です。

data class SampleViewState(
  val bottomNavigationVisibility: BottomNavigationVisibility = VISIBLE
) {
  enum class BottomNavigationVisibility {
    VISIBLE, HIDDEN
  }
}

class SampleViewModel : ViewModel() {
  fun showNavigation() { /* SampleViewState.bottomNavigationVisibility を VISIBLE にする */ }
  fun hideNavigation() { /* SampleViewState.bottomNavigationVisibility を HIDDEN にする */ }
}

何より、FragmentonAttach で引数の Context をキャストするボイラープレートがすべて ViewModel の注入だけに置き換えられます。onAttach で引数の Context がインタフェースを実装しているかどうかを考慮する必要もなく、SampleViewModel に定義してある関数を呼び出すだけで指示を出せるようになります。

class SampleFragment : Fragment() {
  private val sampleViewModel: SampleViewModel by activityViewModels()
}

また Activity から見ると、View の操作はすべて ViewModel の状態の変更に合わせて実行するよう実装します。この実装はどの Fragment でも共通の実装方法であり、ActivityFragment の両方で設計・実装を統一できます。

class SampleActivity : AppCompatActivity() {
  private val sampleViewModel: SampleViewModel by viewModels()

  override fun onCreate(savedInstanceState: Bundle) {
    // ...

    sampleViewModel.states
      .map { state -> state.bottomNavigationVisibility }
      .distinctUntilChanged()
      .subscribe { bottomNavigationVisibility ->
        when (bottomNavigationVisibility) {
          VISIBLE -> // BottomNavigationView を表示する
          HIDDEN -> // BottomNavigationView を隠す
        }
      }
  }
}

ギフトモールアプリの設計思想にあわせて ViewModelBottomNavigationView の表示状態を管理でき、仕組みとしても ViewModel を直接 Fragment に注入できることから、Fragment から Activity へ指示を出す方法として共通の ViewModel を使うことにしました。

まとめ

今回は FragmentActivity の役割の違いと、Fragment の UI の振る舞いから Activity に指示を出すための設計について解説しました。

Fragment から Activity に対して指示を出す実装には、コールバックインタフェースを用いる実装と共通の ViewModel を使う実装の 2 通りの実装があります。

従来からあるコールバックインタフェースを用いた実装は使い古されている分定番の実装パターンではありますが、実行時の型チェックに依存した実装を必要とし、さらにその型チェックのコードがボイラープレートとなって様々な画面にあらわれてくるなど課題もあります。 一方、共通の ViewModel を使う場合はJetpack の仕組みで FragmentActivity で同じ ViewModel のインスタンスを利用できます。また UI の状態管理を ViewModel にまとめることで、他の Fragment と共通の設計思想を適用でき、コードの統一性が保てます。

DI や ViewModel の仕組みがなければコールバックインタフェースを使う他によい方法はなかったかもしれません。あるいは、Event Bus の考え方を RxJava や Coroutines で実現して使う方法も考えられましたが、実現したいことを整理していくと、うまく UI の状態を ViewModel でまとめて管理するシンプルな構成で必要十分でした。ViewModel はすでにアプリ全体で導入しており設計方針も固まっていたため、UI の操作に関する部分の設計や実装の一貫性を保つことにも繋がりました。

次回は、「素のままの RecyclerView ではコードが複雑になってきた」という課題について解説します。


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

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

ギフトモールCulture Deck

speakerdeck.com

募集職種一覧

open.talentio.com