UI の複雑化にともなう画面実装の改善の取り組み

こんにちは。ギフトモールで Android アプリの開発をしている @KeithYokoma です。 前回の記事ではアプリの UI の進化に合わせた設計の見直しについて解説しました。今回も UI の設計について解説しますが、特に View レイヤーに相当する部分の設計について Groupie や Jetpack Compose といった仕組みを使った画面実装の改善について解説します。

素のままの RecyclerView を使った実装の複雑化

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

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

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

Groupie を用いて複雑な画面構成をつくる

RecyclerView を用いた複雑な UI の実装を補助・簡潔化してくれるライブラリとしてはじめに Groupie を利用してみることにしました。

RecyclerView をベースにした複雑な UI を実現するためのライブラリ選定

RecyclerView をベースとした UI 構築を補助してくれるライブラリには Groupie のほか EpoxyStatik など様々なものがあります。 数あるライブラリのなかで Groupie を選定した理由は次の通りです。

  1. できる限り依存は少なくしたい Epoxy は annotation processor を利用したコード生成の仕組みを持っているが、これが少し重厚長大に感じた
  2. 動的に UI の表示を更新したい Statik は過去に利用経験があり、名前の示す通り静的なリスト表示であれば簡単な DSL で実現できたが、動的に表示が変わる UI では少し工夫が必要だった
  3. Groupie の評判をよく聞いたことがあり、知見もたまっていそうだった RecyclerView による UI 構築を簡単にしてくれるライブラリを初めて利用するタイミングでもあったので、できる限り知見にアクセスしやすいものがよかった

Groupie と ViewModel を組み合わせる

Groupie では BindableItem で 1 種類の View を生成するロジックを実装し、BindableItem をリストにして GroupAdapter にわたすことで RecyclerView の UI を作ります。

次の例では、商品の説明と商品に対するレビューの UI を作るための BindableItem を実装しています。それぞれ data class となっているので、ProductDescriptionSectionProductReviewItem のインスタンスをリストにまとめて GroupAdapter#update に渡せば UI の表示が完成します。この実装であれば、UI のパターンは Int の定数ではなく型によって表現可能になります。そのため、RecyclerView.AdapterView Type をみて処理を振り分けたり、UI のパターンごとに View Type を表す定数を増やしたりする手間がなくなり、IDE の機能を使ったコード検索も型をたよりに探せるようになります。

data class ProductDescriptionSection(
  private val productName: String,
  private val description: String,
  private val price: Int,
) : BindableItem<ViewProductDescriptionSectionBinding>() {
  override fun bind(viewBinding: ViewProductDescriptionSectionBinding, position: Int) {
    // viewBinding を使ってテキストを表示したりなど…
  }

  override fun getLayout(): Int = R.layout.view_product_review_item

  override fun initializeViewBinding(view: View): ViewProductDescriptionSectionBinding = ViewProductDescriptionSectionBinding.bind(view)
}

data class ProductReviewItem(
  private val reviewerNamet: String,
  private val reviewText: String,
  private val reviewRating: Float,
) : BindableItem<ViewProductReviewItemBinding>() {
  override fun bind(viewBinding: ViewProductDescriptionSectionBinding, position: Int) {
    // viewBinding を使ってテキストを表示したりなど…
  }

  override fun getLayout(): Int = R.layout.view_product_review_item

  override fun initializeViewBinding(view: View): ViewProductDescriptionSectionBinding = ViewProductDescriptionSectionBinding.bind(view)
}

// 上記の BindableItem を使って GroupAdapter にわたす

val productViewList: List<Group> = listOf(
  ProductDescriptionSection(
    productName = "Product A",
    description = "Super nice gift",
    price = 100,
  ),
  ProductReviewItem(
    reviewerName = "John Doe",
    reviewText = "This is super nice gift.",
    reviewRating = 5.0f,
  ),
  ProductReviewItem(
    reviewerName = "Jane Doe",
    reviewText = "Enjoyed a lot.",
    reviewRating = 5.0f,
  ),
)
val adapter = ProductDetailAdapter()
adapter.update(productViewList)

Groupie を用いた UI の構成が固まれば、あとは ViewModel で管理している画面の状態オブジェクトから BindableItem を生成すれば画面の実装が完成します。ViewModel は状態オブジェクトの変更を View に通知するよう作っているため、View は通知された新しい状態オブジェクトから BindableItem を生成する関数を呼び GroupAdapter#update に渡します。

data class ProductDetailViewState(
  val description: ProductDescription,
  val reviews: List<ProductReview>,
) : ViewState {
  fun generateGroup(): List<Group> = buildList {
    add(
      ProductDescriptionSection(
        productName = description.productName,
        description = description.body,
        price = description.price,
      )
    )
    addAll(
      reviews.map { review ->
        ProductReviewItem(
          reviewerName = review.reviewer.name,
          reviewText = review.body,
          reviewRating = review.rating,
        ),
      }
    )
  }
}

Groupie の強み

Groupie の強みは主に RecyclerView のボイラープレートを簡素化できることだと思います。

素の RecyclerView でも複数の ViewHolder を作れば UI のパターンを切り分けて実装可能ですが、どうしても冗長なコードが増えたり、大本の Adapter にパターンの数だけ分岐ができたりなどコードの見通しが悪化しやすくなります。

Groupie は型の仕組みをうまく使ってこれらのボイラープレートを隠蔽し、各 UI パターンの実装だけに注力できるようになりました。

ギフトモールアプリにおける BindableItem の実装は Jetpack Compose の Composable 関数の役割とほぼ同じになるようにしています。将来的に Jetpack Compose に移行するには BindableItem#bind の処理を Composable 関数へ置き換えていくことになります。

Groupie でも難しい課題

一方で Groupie だけでは解決しきれない課題もありました。

イベントハンドリング(クリックイベントやテキスト入力など)の実装を例にあげると、RecyclerView の仕組みに則って正しくリスナーオブジェクトを管理する必要があります。 次の例では、EditText の入力を監視する TextWatcher を取り扱っており、画面に表示される段階の bind 時に TextWatcher を登録し、画面外に移動した段階の unbind 時に TextWatcher を解除しています。実際には差分更新のためのコードも実装しており、TextWatcher に関する部分も一工夫加えないと TextWatcher に間違った文字列が渡されてしまう場合があるためもっと複雑なコードになりますが、どうあれ RecyclerView だからこその作法があることに違いはありません。この点はどれだけ Groupie に慣れていても未だに難しいと感じる部分です。

data class TextInputSection(
  // ...
) : BindableItem<ViewTextInputSectionBinding>() {
  var onUpdateTextInputListener: OnUpdateTextInputListener? = null

  private val textWatcher: TextWatcher = object : TextWatcher {
    override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
    override fun afterTextChanged(s: Editable?) = Unit

    override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
      onUpdateTextInputListener?.onUpdateTextInput(s.toString())
    }
  }

  override fun bind(viewBinding: ViewTextInputSectionBinding, position: Int) {
    viewBinding.editText.addTextChangedListener(textWatcher)
  }

  override fun unbind(viewHolder: com.xwray.groupie.viewbinding.GroupieViewHolder<ViewTextInputSectionBinding>) {
    super.unbind(viewHolder)
    viewHolder.binding.editText.removeTextChangedListener(textWatcher)
  }

  fun interface OnUpdateTextInputListener {
    fun onUpdateTextInput(text: String)
  }
}

当初は Groupie 導入後にアプリ全体にわたって Groupie を利用していくつもりでいましたが、イベントハンドリングの実装の難しさのように Groupie だけでは解決の難しい課題に直面したことや Jetpack Compose の最初の安定版リリースからある程度時間が経過しバージョン 1.1 まで進んでいたことも重なり、Jetpack Compose へ移行することに決めました。

Jetpack Compose への移行

Jetpack Compose への移行は Groupie を使っているかどうかに関わらず、ギフトモールアプリ全体として移行していくことにしています。

Jetpack Compose への移行手順

運良くはじめの設計時点で画面を Fragment で分割していたので、Jetpack Compose の移行は Fragment ごとに実施できます。

また以前の記事でも紹介したとおり、画面の状態は ViewModel を利用してUnidirectional なデータフローによって更新しています。この Unidirectional なデータフローの設計は Jetpack Compose に移行してもそのまま活用できるため、Jetpack Compose への移行にあたっては基本的に ViewModel の設計には触れず、XML ベースの画面実装を Jetpack Compose へ移行することに注力できます。

ギフトモールアプリでは大まかに1つの画面につき次のような Composable 関数を用意しています。この Composable 関数は Fragment#onCreateView から ComposeView を使って呼び出します。

class SampleFragment : Fragment() {
  private val viewModel: SampleViewModel by viewModels()

  override fun onCreateView(
    inflater: LayoutInflater,
    container: ViewGroup?,
    savedInstanceState: Bundle?,
  ): View = ComposeView(requireContext()).apply {
    setContent {
      AppCompatTheme {
        SampleScreen(viewModel)
      }
    }
  }
}

@Composable
fun SampleScreen(
  viewModel: SampleViewModel
) {
      val state: State<SampleViewState> = viewModel.states.subscribeAsState(SampleViewState())

    // state を使って Jetpack Compose で画面を構築
}

RxJava vs Kotlin Coroutines

ViewModel について強いて変更を加えるとすると、状態の更新を伝達する部分を RxJava から Kotlin Coroutines に置き換えることが考えられます。Jetpack Compose は RxJava も Kotlin Coroutiens (Flow) も両方サポートしており、コードの書き方が大きく変わることはありません。

次のコード例では RxJava と Flow のそれぞれを使ったときの Composable 関数の記述を比較しています。 コード上は Observable に対応する subscribeAsStateFlow に対応する collectAsState の違いだけで、ともに Jetpack Compose の State<T> が得られます。ObservableFlow に変換して collectAsState とすることでも同様にできます。

@Composable
fun RxSampleScreen(
  viewModel: SampleViewModel
) {
    // Observable<SampleViewState> を State<SampleViewState> として subscribe
      val state: State<SampleViewState> = viewModel.states.subscribeAsState(SampleViewState())

    // state を使って Jetpack Compose で画面を構築
}

@Composable
fun RxToFlowSampleScreen(
  viewModel: SampleViewModel
) {
    // Observable<SampleViewState> を Flow<SampleViewState> に変換し State<SampleViewState として collect
      val state: State<SampleViewState> = viewModel.states.asFlow().collectAsState(SampleViewState())

    // state を使って Jetpack Compose で画面を構築
}

@Composable
fun FlowSampleScreen(
  viewModel: SampleViewModel
) {
    // Flow<SampleViewState> を State<SampleViewState として collect
      val state: State<SampleViewState> = viewModel.states.collectAsState()

    // state を使って Jetpack Compose で画面を構築
}

Jetpack Compose へ移行するだけであれば、RxJava のままでも問題なく移行可能です。

ただし、複雑な機能を有する画面ほど ViewModel 内部の実装もまた複雑化する傾向にあり、RxJava を用いた ViewModel のコードが次第に難解になる問題も起きていました。特に RxJava のオペレータを複数組み合わせる箇所は実装時点でも難しさを感じており、後々コードを見返しても何をしているのか理解するのに時間を要してしまうこともありました。

それ以外にも、多くの RxJava のオペレータによる処理は Kotlin Coroutines では簡単な手続き的記述で実現できるため、できればどこかのタイミングで Kotlin Coroutines を使いたいとも思っていました。

この問題は本来 Jetpack Compose への移行とは完全に別の問題で別々に取り組むこともできましたが、今後の開発要件も踏まえ Jetpack Compose への移行とともに Kotlin Coroutines も積極的に使っていくことにしました。

RxJava から Kotlin Coroutines への移行はまた別の記事で解説できればと思います。

おわりに

全 4 回にわたって、ギフトモールアプリが立ち上がってから現在に至るまでの技術的な取り組みについて解説してきました(1回目, 2回目, 3回目)。

すべてが当初の計画通りではありませんでしたが、振り返ってみると最初に考えていた設計方針が大きく揺らぐこともなく、細かい修正を積み上げることでここまで進んで来られたように感じています。

改めて、ギフトモールアプリ開発開始当初の設計方針は次の3つです。

  1. Android Jetpack を最大限活用したアプリ開発
  2. マルチモジュール構成によるレイヤー・ドメイン分割
  3. Unidirectional なデータフローによる画面の状態の更新

Android Jetpack を最大限活用したアプリ開発は今も継続しており、特に Jetpack Compose の移行による UI 開発は新規メンバーが参加して以後より加速しているように感じています。途中で Groupie を導入した箇所がまだ Jetpack Compose に移行しきれていませんが、Unidirectional なデータフローによる画面の状態の更新は Groupie でも Jetpack Compose でも共通しているので、十分に移行可能です(単純に元の実装量がとても多いだけ!)。

マルチモジュール構成によるレイヤー・ドメイン分割についても、一部レイヤー・モジュールについて見直しをしつつも基本的なルールは変わっていません。

正直なところ Unidirectional なデータフローによる画面の状態の更新を実現するためにはじめに作った設計が Jetpack Compose でどれだけ役立つかはあまり自信がなく、なんとなくそのまま使えたらいいな程度に思っていましたが、実際に移行してみるとそのままでも問題ないことが分かってほっとしています。

今後も折を見てギフトモールアプリの開発における様々な工夫や Tips などを投稿していきたいと思います。


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

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

ギフトモールCulture Deck

speakerdeck.com

募集職種一覧

open.talentio.com