はじめに
最近、Adaptive Layoutというライブラリを開発しています。
このライブラリを開発していて、UI StateをViewModelで管理すべきなのか、それとも他の方法で管理すべきなのか迷っていました。
というのも、このライブラリではHiltを使ってリポジトリなどをDIすることが基本的にはないため、そういった場合にViewModelは少し冗長なのではないかと感じていました。
そこで、改めてAndroidにおけるUIとはどう定義されているのか、そしてAndroidでは状態をどう捉えていてどのパターンではどの管理方法がベストなのかを少し調べてみたのでメモとしてまとめてみました。
参考程度に読んでいただけると幸いです。
UIとは
データレイヤから取得されたアプリの状態を視覚的に表現したものを指します。
具体的にいうと、この言葉はアクティビティやフラグメントなどのUI要素を指しています。
ここでいうUI要素は具体的な実装からは独立した用語です。それを実現するAPIがJetPack ComposeなのかViewなのかは、この用語には関係ありません。
そしてUI Elements (UI要素) とUI State (UI状態) を組み合わせたものを公式ドキュメントではUIとして定義されています。このことから、UI Stateを視覚的に表したものがUIとなり、UI Stateが変更されれば、直ちにUIに反映されます。
UI State(UI状態)とは
UI Stateは、ユーザーが目にするべきであるとアプリがみなすものとを指すと公式ドキュメントでは書かれています。
具体的に言うと、UIを完全にレンダリングするために必要な情報をUI Stateと呼びます。そして実装時には、データクラスを使って必要な情報をUiStateとしてまとめることでカプセル化を行うことが推奨されています。
UI Stateの不変性(Immutability)
UI Stateを定義する際、必ず不変性を担保した実装を行う必要があります。
この不変性がUI Stateに存在することで、ある瞬間を切り取った時のアプリケーションの状態(スナップショット)を保証してくれます。
その結果、UIがUI Stateを読み取り、UI Elementsを更新するという唯一の最も重要な役割にのみ集中することができるようになります。
UI Stateの不変性を担保するためにやってはいけないこと
上記で説明した不変性を担保するにあたり、やってはいけないことがあります。
それは、UIがUI Stateを直接変更するということです。なぜなら、唯一の情報源であるデータレイヤ以外の情報源の存在を発生させてしまうことになるからです。
これにより、複数の情報源が誕生してしまうので、競合が発生し、データの不整合や予期せぬバグを引き起こしてしまうおそれがあります。
データレイヤが情報源となるUI Stateに関しては、絶対にデータレイヤから取得したデータの反映以外によるUI Stateの更新を行わないようにしましょう。
もし、UIそれ自体が、UI Stateの情報源である場合は直接変更しても問題はありません。なぜなら、その場合はデータレイヤと競合しないからです。
UI Stateデータクラスを実装する際の命名規則
UI Stateのデータクラスを定義する際の命名規則は次のルールに従います。
- UI Stateが構築するスクリーンの機能を表す名前 +
UiState
- UI Stateが構築するスクリーンの一部の機能を表す名前 +
UiState
公式ドキュメントでは、Newsアプリを元に命名規則について解説しており、ニュースを表示する画面のUI StateをNewsUiState
データクラスとして定義しています。
そして、そのNewsUiState
に存在するニュースのリストの要素を表すUI StateをNewsItemUiState
と定義しています。
data class NewsUiState(
val isSignedIn: Boolean = false,
val isPremium: Boolean = false,
val newsItems: List<NewsItemUiState> = listOf(),
)
data class NewsItemUiState(
val title: String,
val body: String,
)
UI Stateの種類
UI Stateを細分化していくと次の2種類に分けられます。
- Screen UI State
- UI element State
Screen UI Stateとは
その名の通り、画面上に表示するべきプロパティを表します。たとえば、ニュースアプリの場合は、NewsUiState
データクラスを作成すると思います。このデータクラスのプロパティとして、ニュースの記事やその他のメタ情報などUIをレンダリングするために不可欠な情報などが定義されます。
通常、Screen UI Stateはアプリケーションデータの内容を含んでいることが多いです。そのため、このUI Stateはドメインレイヤーやデータレイヤーといった他のレイヤーに間接的に接続されるはずです。
UI element Stateとは
Screen UI Stateと違い、UI element Stateは何ではなくどのようにを表すプロパティを表します。具体的に言うとレンダリング時に影響を受ける内容です。表示・非表示を表すプロパティや、フォントの種類、フォントのサイズ、フォントのカラーなどの装飾部や表示を司るプロパティがこのUI element Stateに該当します。
UIレイヤとは
データレイヤから取得したデータは、そのままの形式では表示することができません。
これをまとめたり、加工したり、一部だけを抜き取ったりして、UIが求めるデータの形式に変換してあげる必要があります。
そして取得したデータを変換してUIで表示できるフォーマットに変換し、UIにて表示します。
この一連の流れを表すパイプラインのことを、まとめてUIレイヤと呼びます。
具体的には次のような構造になっていて、UIレイヤにはUI要素と状態ホルダーが含まれています。
- UI レイヤ
- UI Elements (UI要素)
- State Holder (状態ホルダー)
- ドメインレイヤ
- データレイヤ
この構造では、上から下に依存している状態を表しています
UIレイヤが行うべきこと
公式ドキュメントでは、次のステップ1からステップ4までの行程が、UIレイヤの役割として定義されています。
- データレイヤから取得したデータをUIがレンダリングしやすいフォーマットに変換する
- ステップ1で変換したデータを使って、実際のUI要素に変換する
- ステップ2を通して生成されたUI要素から受け取った入力イベントの内容に基づいて、必要であればそれによって発生した新しい結果をステップ1で変換されたデータに反映させる
- 状況に合わせて、ステップ1からステップ3までの作業を繰り返す
テストのしやすさを求めたUI設計
UIは上記で説明した役割を超えて、データのオーナー、プロデューサー、トランスフォーマーなどの役割や責任を与えてしまうことで、複雑性が増してしまい、密結合を生み出してしまいます。その結果、テストのしやすさは半減し、設計としてはボロボロになってしまうことが予想されます。
そのため、UIを設計する際は、UIの負担を減らす方向に注力し、元々与えられたUI Stateを使ってデータをユーザーに表示するという本来のシンプルな役割にのみ徹することが責務の健全な分離の実現につながります。
このアーキテクチャパターンの一つとして、UDF(単方向データフロー)が存在しています。
State Holder(状態ホルダー)について
UDFを説明する前に、このアーキテクチャパターンを実装するのに必要なState Holder(状態ホルダー)について説明します。
State Holderとは、公式ドキュメントによると、UI Stateを生成する責務を負い、UI Stateが関係しているUIのタスクの実現に必要なロジックを格納するクラスのことをいいます。
具体的なState Holderとしては、次の2つが代表的なものです。
- ViewModel
- プレーンなState Holderクラス
State Holderクラスの責務のサイズは、対応するUI要素のスコープや規模に応じてさまざまです。
UDFについて
UIとState Holderの関係としては、UI側からイベント入力を受け取り、State Holder側ではそれに基づいて状態をUIに出力するといった関係になっています。
この関係のように、Stateが下に流れて、イベントが上に流れるようなパターンをUDF(単方向データフロー)と呼びます。
そして、UDFを実装する場合は次のルールに従います。
- State Holderは、UI Stateを保持します。そしてそれを公開します。UI Stateは、State Holderによって変換されたアプリケーションデータです。
- UIは、ユーザーからのイベントをState Holderに通知します。
- State Holderは、ユーザーからのアクションを処理することで、UI Stateを更新します。
- 更新されたUI Stateは、UIに公開されフィードバックされることでレンダリングされます。
- ステップ1からステップ4を、UI Stateの変化に関わるイベントごとに繰り返します。
実は、このルールは、上記で説明したUIレイヤーが行うべきこととそっくりな、いやほとんど同じ内容です。つまりUIレイヤーを実装すると言うことは、UDFに沿ってUI設計を行うことと同じ意味を表します。
State Holderが実装すべきロジックの種類
State Holderにて定義すべきロジック(機能)の種類には次の二つがあります。
- ビジネスロジック
- UIロジック(UI behavior ロジック)
次の章では、具体的に上記のロジックが何を表しているのかを見ていきます。
ビジネスロジック
具体的にどんな実装があてはまるかというと、アプリが求められる、アプリケーションデータに対して実行される機能の実装などです。つまり、記事というアプリケーションデータをブックマークしたりブックマークを外したりといった機能の実装がビジネスロジックとしてあてはまります。
ビジネスロジックは、通常ドメインレイヤーかデータレイヤーのみに定義されます。UIレイヤーには定義されません。なぜなら、このロジックはファイルまたはデータベースに保存するという処理が含まれているからです。
なので、実際にViewModelでビジネスロジックを実装する際には、ドメインレイヤーに存在するリポジトリにロジックを委任することがほとんどです。ViewModelではリポジトリのメソッドを呼び出して結果を受け取り、UI Stateに反映させます。
UIロジック
UI Stateの変化を何かしらの方法を使って画面上に表示する場合の、この何かしらの方法にあたるのがUIロジックです。
たとえば、Android Resourcesを使って、画面に表示するテキストを取得したりするといった実装もUIロジックに当てはまります。ボタンをクリックして特定の画面に遷移するといった実装もUIロジックです。そしてトーストやスナックバーを使ってユーザーにメッセージを表示するといった実装もUIロジックとみなされます。リスト内の特定の項目にスクロールすることもUIロジックと呼べるでしょう。
UIロジックがContextに依存する場合はプレーンなステートホルダークラスにUIロジックを委任する
UIロジックがContextなどに依存した実装になっている場合は、ViewModelではなく、プレーンなステートホルダークラスを作成することが推奨されています。
理由としては、Contextに依存した場合はUIでロジックを実行する必要が出てくるからです。加えて、UIにそのままロジックを記述してしまうと複雑になってしまい、結果的にテストがしにくくなるからです。これを回避するための関心の分離の原則に従いUIロジックを別のクラスに委任する必要性が出てくるため、プレーンなステートホルダークラスの作成が推奨されるのです。
プレーンなステートホルダークラスは、UIにて作成されることになるため、UIのライフサイクルに従います。これは、Android SDKの依存関係を利用できることを意味していますが、ViewModelよりは寿命が短くなることも意味しているため、注意が必要です。
UI State生成パイプライン
UI State生成パイプラインとは、UI Stateを生成するために実施される手順のことであると、公式ドキュメントでは紹介されています。手順には上記で説明したビジネスロジックやUIロジックなどが含まれます。
具体的には、次の4種類のパイプラインの流れが有効であると考えられています。
- UI自体が生成・管理するパターン
- UIロジックからUIに渡されるパターン
- ビジネスロジックからUIに渡されるパターン
- ビジネスロジックからUIロジックに渡されて、その後UIに渡されるパターン
そして、UI Stat生成パイプラインは次の構造になっています。
- データレイヤー
- UIレイヤー
- ビジネスロジック
- Screen UI State
- UI Lifecycle依存
- UIロジック
- UI Element State
- UI (Views / Compose)
この構造では、下から上に依存関係が発生しています。なので、UIはUI Element Stateに依存していて、UI Element StateはUIロジックに依存しているといった順番で依存関係が続きます。
具体的にはどういったパターンなのか詳しく見ていきましょう。
UI自体が生成・管理するパターン
このパターンでは、コンポーザブル内で、直接remember()
を使ってUI Stateを管理するといったシンプルな方法です。
UIロジックからUIに渡されるパターン
このパターンでは、rememberLazyListState()
などの、UI State
を取得できるremember()
を呼び出します。
そして、取得したUI Stateを元に表示・非表示などをコンポーザブル内で実装します。この実装がU
Iロジックにあたります。
そして、その結果を用いてUIをレンダリングするといった方法がこのパターンにあたります。ビジネスロジックを使用していないため、UI Stateの管理としてViewModel
ではなく、プレーンなステートホルダークラスの実装で問題ありません。
ビジネスロジックからUIに渡されるパターン
こちらは、そのままViewModelがビジネスロジックを用いて用意したUI StateをcollectAsState()
などを呼び出して取得して、コンポーザブルにて使用するパターンがあてはまります。
ビジネスロジックからUIロジックに渡されて、その後UIに渡されるパターン
このパターンは上記のパターンと内容は同じです。唯一の違いは取得したUI Stateに基づいて表示・非表示を行うというUIロジックをコンポーザブルで実装するステップが間に追加されるといったところです。それ以外はViewModelとビジネスロジックに依存していると言う点で違いはありません。
ビジネスロジックとUIロジックを両方使用する場合は、必ずビジネスロジックの呼び出しをUIロジックよりも前に行わないといけないことに注意してください。ビジネスロジックはUIロジックに依存してはいけません。
UIライフサイクルへの依存性
UIライフサイクルに依存するかしないかで、State Holderの実装方法が大きく変わります。
まず、UIライフサイクルに依存しないUI Stateとロジックには次の二つが存在します。
- ビジネスロジック
- Screen UI State
そして、UIライフサイクルに依存するロジックは次のものです。
- UIロジック
まとめると次のようになります。
UIライフサイクルに依存しない | UIライフサイクルに依存する | |
---|---|---|
ロジック | ビジネスロジック | UIロジック |
UI State | Screen UI State |
では、UIライフサイクルに依存しないとはどいういうことなのか見ていきましょう。
UIライフサイクルに依存しないとはどういうことを指すか?
UIのライフサイクルや、Configurationの変更、またはアクティビティの再生成などUI上で発生するさまざまな変化は、生成されるデータの有効性に影響はしないことを意味します。
しかし、UI State 生成パイプラインが有効な場合は生成されるデータの有効性以外の点について影響を受ける可能性がありますので、それに関する処理については注意が必要です。
UIライフサイクルに依存するとはどいういうことを指すか?
続いて、UIライフサイクルに依存するとはどういうことなのか見ていきましょう。
簡単に言うと、その名の通り、UIのライフサイクル、Configurationの変更、アクティビティの再生成などに直接影響を受けます。もちろん、UIロジックによって読み出されたデータの有効性にも直接影響を受けます。
つまり、UIライフサイクルに依存しているということは、ライフサイクルがアクティブな状態のみ変化を発生させることができることを意味します。これには、実行時権限やローカライズされた文字列などの設定依存のデータも含まれます。
State Holderの実装
State Holderには、UIライフサイクルの依存性に合わせて次の2種類が存在します。
- ビジネスロジック State Holder
- UIロジック State Holder
ビジネスロジック Satte Holderは、通常ViewModelインスタンスを使って実装されます。
UIロジック State Holderは、通常プレーンな状態ホルダークラスを使って実装されます。
ViewModelの説明について記し始めると、それだけで1記事書けてしまうので、今回はビジネスロジック State Holderの実装については割愛させていただきます。
ViewModelについて知りたい方は、こちらのドキュメントを参照してください。
それでは、UIロジック State Holderの実装に使われるプレーンなステートホルダークラスについて見ていきましょう。
プレーンな状態ホルダークラス
UIロジックが、処理を行うデータの対象としては次のものがあげられます。
- UI element state
- 権限API
- Resources
そして、UIロジックを使用するState Holderには、次の4つの特性があります。
- UI stateとUI element stateを生成・管理する
- Activityの再生成後まで生き残れない
- UIスコープのデータへの参照を保持している
- 複数のUIで再利用可能
Jetpack Composeでは、UIロジック State Holderは、Compositionの一部であり、Compositionのライフサイクルに従います。
プレーンな状態ホルダークラスを作成する判断の基準
UIロジックが複雑になりすぎて、UIの外に移動させないといけない状態になった場合にプレーンなステートホルダークラスを実装して、そこでUIロジックを実装します。それ以外の場合はコンポーザブル内に直接ロジックを実装して問題ありません。
まとめ
今回、いろいろドキュメントを読み漁ってまとめてみた結果、AdaptiveLayoutAppState
というプレーンな状態ホルダークラスを作成して、それをコンポーザブルから呼び出すためのrememberAdaptiveLayoutAppState()
というコンポーザブルを作成しました。
そして、折りたたみの状態を確認するためにFlow
を使用しているため、それに関しては別途ViewModel
を実装することにしました。
なんとなくで理解していたViewModel
やステートホルダーの役割や存在意義を、改めて知ることができたので良い機会でした。
参考にしたドキュメント
参考にした記事