dely tech blog

クラシル・TRILLを運営するdely株式会社の開発ブログです

Guide to "kurashiru android" app architecture vol.2 UI layer編

はじめに

android-developers.googleblog.com

12/14に新しいアプリアーキテクチャガイドがAndroid公式からアナウンスされました。読まれた方もいらっしゃると思いますが、非常によくまとまったアーキテクチャガイドであり、新しくアプリを作る際も、既存のアプリのアーキテクチャを整理する際にも役に立ちそうな文章です。

クラシルのAndroidチームは去年の2月にAndroidアプリをリアーキテクチャしたのですが、そのアーキテクチャがアプリアーキテクチャガイドと似通った個所が多く、クラシルのアプリアーキテクチャを説明するのにもちょうど良さそうな文章だと思いました。

ですので、今回は新しいアプリアーキテクチャガイドとほとんど同じ構成で、クラシルのアプリアーキテクチャについて解説してみようと思います。なお、元の文章は一般的なアーキテクチャについてのTipsや考え方について説明している個所もあり、そういった個所については記述を割愛しますので、是非元の文章を読んでみてください。

この記事のタイトルは、アプリアーキテクチャガイドの Guide to app architecture から拝借して Guide to "kurashiru android" app architecture とさせていただいてます。

この記事は

tech.dely.jp

の続きの記事(vol.2)です。(vol.1から5カ月くらい経ってしまった…)

UI layer

概要

UIの役割は、アプリケーションのデータを画面に表示することと、ユーザーとのインタラクションの主な窓口となることです。ユーザーの操作(ボタンを押すなど)や外部からの入力(ネットワークのレスポンスなど)によってデータが変更されるたびに、UIはそれらの変更を反映して更新されなければなりません。実質的には、UIはData layerから取得されたアプリケーションの状態を視覚的に表現したものです。

しかし、Data layerから取得するアプリケーションデータは、表示に必要な情報とは異なる形式であることがほとんどです。例えば、UIに必要なのはデータの一部だけかもしれませんし、2つの異なるデータソースをマージしてユーザーに関連する情報を提示する必要があるかもしれません。どのようなロジックを適用するかに関わらず、UIを完全にレンダリングするために必要なすべての情報を渡す必要があります。UIレイヤーは、アプリケーションのデータの変化をUIが表現できる形に変換し、表示するパイプラインです。

基本的なケーススタディ

ユーザーが料理を作るためのレシピを取得するためのアプリを考えてみましょう。このアプリはレシピ一覧画面があり、見ることができるレシピが表示され、サインインしているユーザーは、気に入ったレシピをブックマークすることが出来ます。また、レシピの数が多い場合は、カテゴリー別にレシピを閲覧することも出来ます。まとめると、このアプリでは次のようなことが出来ます。

  • レシピを表示する。
  • カテゴリー別にレシピを閲覧する。
  • サインインし、気に入った記事をブックマークする。
  • プレミアム機能を利用する。

次のセクションでは、この例をケーススタディとして、単方向データフロー (UDF) の原則を紹介し、この原則がUI layerのアーキテクチャという文脈で解決できる問題を説明します。

UI layerのアーキテクチャー

クラシルのUI layerは、以下の責務を果たすためにあります。現在はAndroid Viewsを使っていますが、Jetpack Composeに移行してもその責務は変わりません。Data layerの役割が、アプリケーションのデータを保持、管理し、アクセスのインターフェースを提供することから、UI layerは以下のステップを実行する必要があります。

  1. アプリケーションのデータを受け取り、UIがレンダリングしやすい形に変換する。
  2. UIがレンダリングしやすい形のデータをユーザーに表示するためのUI要素に変換する。
  3. 構築されたUI要素から入力イベントを受け取り、必要に応じてUIデータに反映させる。
  4. 1から3のステップを必要なだけ繰り返す。

このセクションの残りの部分では、いくつかのステップに分けてUI layerをどのように実装するかについて説明します。特に、以下のタスクやコンセプトについて説明します。

  • UI Stateをどのように定義するか
  • UI Stateを生成・管理する手段としての単方向データフロー (UDF)
  • UDFの原則に従って、observable data typesとしてUI Stateを扱う方法
  • observable UI Stateを使ってどのようにUIを実装するか

その中で最も基本的なことが、UI Stateを定義することです。

UI Stateを定義する

前述のケーススタディを参照してください。一言で言えば、UIはレシピのリストと各レシピのメタデータを表示しています。この、アプリがユーザーに見せる情報がUI Stateです。

言い換えるなら、UIがユーザーが見るものであるならば、UI Stateはアプリがユーザーに見せるべきものです。同じコインの裏表のように、UIはUI Stateを視覚的に表現したものです。UI Stateに変更があれば、すぐにUIに反映されます。

ケーススタディを考えてみましょう。Recipeアプリの要件を満たすために、UIを完全にレンダリングするために必要な情報は、次のように定義されたRecipeState data classに定義することが出来ます。

data class RecipeState(
    val isSignedIn: Boolean = false,
    val isPremium: Boolean = false,
    val recipeItems: List<RecipeItem> = listOf(),
    val userMessages: List<Message> = listOf()
)

data class RecipeItem(
    val title: String,
    val body: String,
    val bookmarked: Boolean = false,
    ...
)

イミュータビリティ

上の例で定義されたUI Stateの定義はイミュータブルです。このことの重要な利点は、イミュータブルオブジェクトがある時点でのアプリケーションの状態に関する保証を提供することです。これにより、UIはStateを読み取り、それにしたがってUI要素を更新するという単一の役割に集中することが出来ます。そのため、UI自身がデータの唯一のデータソースである場合を除き、UI StateをUIで直接変更するべきではありません。この原則に反すると、同じ情報に対して複数の情報源(sources of truth)が存在することになり、データの不整合や厄介なバグが発生する原因になります。

例えば、ケーススタディのRecipeItemオブジェクトのbookmarkedフラグがUI Componentで更新されると、データソースとして利用しているData layerのブックマーク状態と競合することになります。Immutable data classは、このようなアンチパターンを防ぐのに非常に有効です。

このガイドの命名規則

クラシルでは、UI Stateクラスは記述される画面に基づいて命名されます。規約は以下の通りです。

(機能) + State です。

例えば、レシピを表示する画面の状態はRecipeStateとなります。

画面の一部をあらわすUI Stateについて(例えばリストのアイテムなど)は、

(機能)+ Argument と名付けられています。

クラシルでは原則、画面の一部を示すStateを管理するView HolderがState自体を更新することはなく、Argumentといった形の方が呼び方として適切だからという理由があります。ただ、この区別は将来的には廃止される可能性があります。

単方向データフローで状態を管理する

以前のセクションで、UI StateはUIのレンダリングに必要な詳細の、イミュータブルなスナップショットであることを説明しました。しかし、アプリケーションデータは動的で、時間の経過とともにStateが変化する可能性があります。これは、ユーザーの操作やその他のイベントによって、アプリケーションを構成している基礎的なデータが変更されてしまうことによって引き起こされます。

こういった処理はクラシルではMVI Modelに格納され、イベントに応じてDomain layerのUseCaseを呼び出す形や、UI Stateを即座に更新する処理を書く形で処理を定義することになります。基本的にUI ModelはUI ElementとDomain layerの仲立ちを行うような処理に徹するべきで、それ以上の責務を負うべきではありません。UI Modelが多くの責務を負ってしまうと、コードが密結合になってしまい、テストのしやすさにも影響してくることになります。UIの負担を減らし、あくまでUI Stateを表示する責任のみを負う形で実装するべきです。

このセクションでは、健全な責務分離を実現するためのアーキテクチャーパターンである、単方向データフロー (UDF) について説明します。

State holders

UI Stateの生成を担当し、そのタスクに必要なロジックを含むクラスをState holderと呼びます。State holderには、対応するUI Elementのスコープに応じて様々なサイズがあり、Bottom Navigation Barのような単一のウィジェットから、画面全体やナビゲーション先まで多岐にわたります。

クラシルでは、対応するUI要素のスコープ(画面、ページ、あるいはリストの一要素)に合わせてUI Componentというものを作成するようにしており、それがState holderにあたります。UI Componentは、UI Stateの生成や、UIイベントや外部イベントに応じてUI Stateを更新する責務、UI StateをUI Elementに反映する責務を負っており、UI ElementからUIイベントを発火させる責務をIntent、UIイベントや外部イベントに応じてUI Stateの生成/更新を行う責務をModel、UI StateをUI Elementに反映する責務をViewが負うMVIアーキテクチャーを採用しています。例えば、ケーススタディのRecipeアプリでは、RecipeListComponentクラスをState holderとして使用し、そのセクションで表示される画面のUI Stateを生成しています。

クラシルにおける、UIとUI Componentクラス間の相互作用は、UIイベントの入力とStateの出力を中心に整理すると以下の図のようにあらわすことが出来ます。

Stateが下に、イベントが上に流れていくパターンを単方向データフロー (UDF)と呼びます。このパターンをクラシルアプリアーキテクチャーで説明すると、次のようになります。

  • UI ComponentはUIが扱うStateを保持し、公開します。UI StateはUI Componentによって変換されたアプリケーションデータです。
  • UIは、ユーザーイベントをMVI Intentに通知します。
  • MVI Modelは、MVI Intentが発火したユーザーのActionを処理し、Stateを更新します。
  • 更新されたStateは、MVI Viewを通してUIにフィードバックされてレンダリングされます。
  • 以上のことを、Stateを変化させる全てのイベントが起きるたびに繰り返します。

画面の場合、UI ComponentはDomain Featureと連携してデータを取得し、UIイベントによる状態の変更を取り入れながらUI Stateに変換します。先ほどの事例では、レシピのリストがあり、それぞれにタイトル、サムネイル、ブックマークされているかどうかが表示されています。各レシピアイテムのUIは次のようになっています。

ユーザーがレシピのブックマークを要求することは、状態変化を引き起こす可能性のあるイベントの一例です。UI Stateを提供するComponentは、UI Stateの全てのフィールドに情報を入力し、UIが完全にレンダリングされるために必要なすべてのイベントを処理するロジックを定義する責務があります。

次のセクションでは、状態変化を引き起こすイベントと、それをUDFでどのように処理するかについて詳しく説明します。

ロジックの種類

レシピをお気に入りすることは、アプリに価値を与えることなので、ビジネスロジックの一例です。この点については、Data layerのセクションを参照してください。しかし、ここで一旦定義しておくべき二つのロジックの種類があります。

  • ビジネスロジックとは状態の変化を用いて何(what)をしたいかということです。既に述べたように、レシピアプリでのレシピのお気に入りがその一例です。ビジネスロジックは通常、Domain layerやData layerに置かれますが、UI layerに置かれることはありません。
  • UI behaviorロジックまたはUIロジックとは、状態の変化どのように(how)画面に表示するかということです。例えば、Androidリソースを使って画面に表示する適切なテキストを取得したり、ユーザーがボタンをクリックすると特定の画面に移動したり、トーストやスナックバーを使ってユーザーのメッセージを画面に表示したりします。

UIロジック、特にContextのようなUI関連の型に依存するロジックは、クラシルの場合は現状Componentに配置するべきです。ComponentはUIのライフサイクルに従うため、Android SDKに依存することが出来ますし、MVIアーキテクチャーによって関心の分離を行うこともできますが、将来的にはUI関連の型に依存するロジックを分離できるように、UI Stateをラップする薄いState Holderクラスを導入する予定があります。

何故単方向データフローを使うのか?

UDFは、図4に示すように、State生成のサイクルを設計しています。また、Stateの変化が発生する場所、変換される場所、最終的に消費される場所を分離しています。この分離により、UIはその名の通り、状態の変化をObserveすることで情報を表示し、ユーザーの意図をイベントとしてComponentに伝えることが出来ます。

つまり、UDFでは次のようなことを可能にします。

  • データの一貫性。UIには Single Source of Truth があります。
  • テスト可能性。Stateの生成、変換はUIから分離されており、UIとは独立してテスト可能です。
  • メンテナンス性。Stateの変化は、ユーザーのイベントとデータソースの両方に起因する、明確に定められたパターンに従って引き起こされます。

UI StateをViewに適用する

UI Stateを定義し、UI Componentを定義したら、次のステップは生成されたStateを使ってUIを表示することです。Stateの生成を管理するためのUDFを私用しているので、生成されるStateはストリームであると考えることが出来ます。つまり、Stateの複数のバージョンが時間経過によって生成されます。クラシルアーキテクチャーでは、UI ComponentのModelからUI Stateをdispatchすると、MVI View関数に順番に最新のUI Stateが通知されます。UI Stateはdispatchされる度にキャッシュされるので、UI Elementの状態復元時にも前回のUI Stateを使って即座に状態復元を来なうことが出来ます。

class RecipeView(...) {

  fun view(state: State, updater: ViewUpdater,...) {
    updater.update(state.title) { layout, title ->
      layout.titleLabel.text = title
    }
  }
}

クラシルではUI Componentは単一のUI Stateを保持するようになっています。Modelで取得したデータは、UI StateでwrapすることでUI Elementや画面からアクセスできるようになっています。以下のようなコードでModelからUI Stateをdispatchします。

class RecipeModel(
  val recipeFeature: RecipeFeature
) {

  private val recipeListUseCase = recipeFeature.recipeListUseCase

  fun model(dispatcher: StateDispatcher,...) {
    recipeListUseCase.fetchRecipeItems().subscribe { recipeItems ->
      dispatcher.dispatchState(
        copy(
          recipeItems = recipeItems
        )
      )
    }
  }
}

上の例では、RecipeComponentModelクラスが特定のカテゴリのレシピを取得しようと試み、その結果(成功か失敗か)をUIの状態に反映させ、UIが適切に反応できるようにしています。

その他の考慮事項についてのクラシルの方針について

  • UI Stateは画面、及びUI Elementごとに単一のUI Stateを使う設計を採用しており、Modelからdispatchされる際は必ず全ての情報が一度にdispatchされます。これにより、互いに関連したStateが同時に更新されることが保証されており、全ての情報が最新の情報に保たれます。さらに、ビジネスロジックによっては、複数の情報ソースを組み合わせる必要がある場合もあります。例えば、ユーザーがサインインしていて、そのユーザーがプレミアムレシピの購読者である場合にのみ、お気に入りボタンを表示する必要があるかもしれません。複数の情報ソースから取得した情報を組み合わせる必要がある場合は、次のようにUI Stateクラスを定義することが出来ます。
class RecipeState(
    val isSignedIn: Boolean = false,
    val isPremium: Boolean = false,
    ...
)  {

  val shouldShowBookmarkButton = isSignedIn && isPremium
}
  • クラシルアーキテクチャーでは単一のUI Stateストリームを採用しています。これは常に最新の情報をMVI View関数から参照するためであり、実装を単純にするのが目的ですが、次にあげるような副作用を回避できる仕組みを別に用意しているからです。
    • 関連性のないデータをまとめることによるデメリットの回避: 単一のUI Stateストリームを使うことで、関連性のないデータを一か所にまとめることになりますが、それを束ねたことによるデメリットはクラシルアーキテクチャーでは大きくありません。UI Stateのdispatchは軽量な動作であり、頻度高く更新されてもアプリケーションのパフォーマンスへの影響は微々たるものです。そして、MVI View関数では、単一のUI Stateから関連性のないデータを簡単に個別に取り出せるようになっています。
    • UI Stateを使った差分更新の採用: クラシルアーキテクチャーでは、MVI View関数内でUI Stateのフィールドを使った差分更新を容易に行うことが出来るようになっています。以下のように書くことで、distinctUntilChanged & combineLatestを行うことが出来るため、実装者は特別なことを意識せずに単一のUI Stateからそれぞれ別の情報を使った差分更新を行うことが出来ます。
class RecipeItemView(...) {

  fun view(context: Context, argument: Argument, updater: ViewUpdater,...) {
    updater.update(argument.title, argument.shouldShowBookmarkButton) { layout, title, shouldShowBookmarkButton ->
      layout.bookmarkButton.isVisible = shouldShowBookmarkButton
      layout.bookmarkButton.text = context.getString(R.string.bookmark_button_message, title)
    }

    updater.update(argument.title) { layout, title ->
      layout.titleLabel.text = title
    }
  }
}

処理中の状態を表示する

UI Stateで処理中の状態を表現するもっとも単純な方法は、booleanのフィールドを作ることです。

class RecipeState(
  val isFetchingRecipes: Boolean = false
  ...
)

この値は、UIにおけるプログレスバーの有無をあらわします。

class RecipeModel(
  val recipeFeature: RecipeFeature
) {

  private val recipeListUseCase = recipeFeature.recipeListUseCase

  fun model(action: Action, dispatcher: StateDispatcher,...) {
    when (action) {
      is FetchAction -> {
        recipeListUseCase.fetchRecipeItems()
          .doOnSubscribe {
            dispatcher.dispatchState(
              copy(
                isFetchingRecipes = true
              )
            )
          }
          .doFinally {
            dispatcher.dispatchState(
              copy(
                isFetchingRecipes = false
              )
            )
          }
          .subscribe { recipeItems ->
            dispatcher.dispatchState(
              copy(
                recipeItems = recipeItems
              )
            )
          }
      }
    }
  }
}
class RecipeView(...) {

  fun view(context: Context, state: State, updater: ViewUpdater,...) {
    updater.update(state.isFetchingRecipes) { layout,  isFetchingRecipes ->
      layout.progressBar.isVisible = isFetchingRecipes
    }
  }
}

UIイベント

UIイベントは、UI layerでMVI Intentによって処理されるべきアクションです。もっとも一般的なイベントの種類は、ユーザーイベントです。ユーザーは例えば画面をタップしたり、ジェスチャー操作を行うことでユーザーイベントを発生させます。そして、UIは onClick() といったイベントリスナーを使ってイベントを受け取ります。

キーワード:

  • UI: ViewかComposeで書かれ、ユーザーインターフェースをハンドリングする。
  • UIイベント: UI layerによってハンドリングされるべきアクション。
  • ユーザーイベント: ユーザーがアプリを操作することによって発生するイベント。

MVI Modelは通常、特定のユーザーイベント(例えば、ユーザーがボタンをクリックしてデータを更新する)のビジネスロジックを処理する責任を持ちます。クラシルでは、MVI ModelがMVI Intentがユーザーイベントを処理して発火したイベントに対する処理を記述することにより、ユーザーイベントを処理します。ユーザーイベントは、UIが直接処理することが出来るUI behaviorロジックを持つ場合もあります。例えば、別の画面へ移動したりSnackbarを表示したりする場合です。

ビジネスロジックは、同じアプリが違うモバイルプラットフォームやフォームファクターで提供されても変わることはありませんが、UI behaviorロジックはプラットフォームやフォームファクターで変わってくる可能性がある実装の詳細のことをあらわしています。UI layerのページでは、これらのタイプを次のように定義しています。

  • ビジネスロジックとは、例えば決済やユーザー設定の保存など、状態の変化をどのように処理するかということを指します。通常、Domain layerとData layerがこのロジックを処理します。クラシルでは、基本的にビジネスロジックを処理するクラスとして、UseCaseクラスが使用されます。
  • UI behaviorロジックまたはUIロジックとは、状態の変化をどのように表示するか、例えば画面遷移をどのように行うかやユーザーにどのようにメッセージを見せるかなどを指します。UIがこのロジックを処理します。

UIイベント決定木

次の図は、特定のイベントのユースケースを処理するための最適なアプローチを見つけるための決定木を表しています。このガイドの残りの部分では、これらのアプローチについて詳しく説明します。

ユーザーイベントをハンドリングする

UI Elementの状態変更に関連するイベントであれば、UIが直接ユーザーイベントを処理します。(例えば、開閉できるアイテムの状態など)そのイベントが、画面上のデータをリフレッシュするなどのビジネスロジックを実行する必要がある場合は、UseCaseによって処理されるべきです。

次の例では、UIの開閉(UIロジック)と画面上のデータのリフレッシュ(ビジネスロジック)のために、異なるボタンがどのように使用されるかを示しています。

class RecipeIntent {
  fun intent(layout: RecipeBinding, actionDispatcher: ActionDispatcher) {
    layout.expandButton.setOnClickListener {
      actionDispatcher.dispatch(ExpandAction())
    }
    layout.refreshButton.setOnClickListener {
      actionDispatcher.dispatch(RefreshAction())
    }
  }
}
class RecipeModel(
  val recipeFeature: RecipeFeature
) {

  private val recipeListUseCase = recipeFeature.recipeListUseCase

  fun model(action: Action, dispatcher: StateDispatcher,...) {
    when (action) {
      is ExpandAction -> {
        dispatcher.dispatchState {
          copy(expanded = true)
        }
      }
      is RefreshAction -> {
        recipeListUseCase.fetchRecipeItems()
          .subscribe { recipeItems ->
            dispatcher.dispatchState(
              copy(
                recipeItems = recipeItems
              )
            )
          }
      }
    }
  }
}
RecyclerView内のユーザーイベント

Actionが、RecyclerViewのアイテムや子UI Componentのように、UIツリーの下流から生成される場合においても、MVI Modelは依然としてユーザーイベントを処理するものであるべきです。

例えば、RecipeComponentの全てのレシピアイテムがブックマークボタンを含んでいるとします。MVI Modelは、ブックマークされたレシピアイテムのIDを知る必要があります。ユーザーがレシピアイテムをブックマークする時、RecyclerViewのアイテム用のComponentはブックマーク用のActionを発火し、親ComponentへレシピアイテムのIDを通知します。その結果、子Componentは親ComponentのMVI Modelに直接依存することなく、ユーザーイベントを移譲することが出来ています。

class RecipeItemIntent {
  fun intent(layout: RecipeItemBinding, actionDispatcher: ActionDispatcher) {
    layout.bookmarkButton.setOnClickListener {
      actionDispatcher.dispatch { argument ->
        BookmarkAction(recipeId = argument.recipeId)
      }
    }
  }
}
class RecipeModel(
  val recipeFeature: RecipeFeature
) {

  private val bookmarkUseCase = recipeFeature.bookmarkUseCase

  fun model(action: Action, dispatcher: StateDispatcher,...) {
    when (action) {
      is BookmarkAction -> {
        bookmarkUseCase.bookmarkRecipe(action.recipeId)
      }
    }
  }
}

この方法では、RecyclerViewのアイテム用のComponentは必要なデータ、つまりRecipeItemオブジェクトのリストのみを扱います。子Componntは親Componentにアクセスできないので、親ComponentがアクセスしているUseCaseが公開する機能を悪用する可能性は低くなります。画面のMVI ModelのみがUseCaseを操作できるようにすると、責任を分離できるようになります。クラシルではViewやRecyclerViewのAdapterなど、UI固有のオブジェクトはUseCaseを直接操作できないようになっています。

Domainイベントをハンドリングする

Domain layerに由来するUIアクション、Domainイベントは常にUI stateを更新する必要があります。これは、UnidirectionalData Flowの原則に従ったものです。これは、設定変更のイベントに再現性を持たせ、UIアクションが必ず処理されることを保証します。クラシルでは、UI stateは必ずState bundleに保存されるので、プロセスが終了した後でもDomainイベントの副作用を再現できるようにすることが出来ます。

class RecipeModel(
  val recipeFeature: RecipeFeature
) {

  private val bookmarkUseCase = recipeFeature.bookmarkUseCase

  private val recipeListUseCase = recipeFeature.recipeListUseCase

  fun model(action: Action, dispatcher: StateDispatcher,...) {
    when (action) {
      is BookmarkAction -> {
        bookmarkUseCase.bookmarkRecipe(action.recipeId)
          .flatMap {
            recipeListUseCase.fetchRecipeItems()
          }
          .subscribe { recipeItems ->
            dispatcher.dispatchState(
              copy(
                recipeItems = recipeItems
              )
            )
          }
      }
    }
  }
}

おわりに

実は、今回この記事を書くにあたって、既存のクラシルのアーキテクチャをAndroid公式のものに沿っていくつか再解釈したところがあります。今まで作ってきたアプリケーションの構造としては変わらないものの、今まで明確にそういった役割を持たせていなかった部分に対して名前を付け、整理するのに、アプリアーキテクチャガイドはとても役に立ちました。皆さんも是非、Guide to app architectureを使って自分たちのアーキテクチャを見直してみてはいかがでしょうか。

クラシルのAndroidチームでは、生産性の高いアーキテクチャを維持・アップデートしつつも楽しくプロダクト開発できる人、したい人を求めています。 今回の記事を読んで興味が出てきた方、是非お話ししましょう! 下記の採用リンクから応募いただいてもOKですし、Twitterから気軽にDM頂いてもOKです!

dely.jp

twitter.com