UI の役割は、アプリデータを画面に表示することです。UI は、ユーザー インタラクションの主要なポイントとしても機能します。ユーザー インタラクション(ボタンの押下など)または外部入力(ネットワーク レスポンスなど)によってデータが変更されるたびに、変更を反映するように UI を更新する必要があります。UI は、実質的には、データレイヤから取得されたアプリの状態を視覚的に表したものです。
ただし、一般的に、データレイヤから取得するアプリデータの形式は、表示する必要がある情報の形式とは異なります。たとえば、UI では、データの一部だけが必要になる場合や、ユーザーに関連する情報を提示するために 2 つの異なるデータソースの統合が求められる場合があります。適用するロジックにかかわらず、UI を完全にレンダリングするために必要なすべての情報を UI に渡す必要があります。UI レイヤは、アプリデータの変更を UI が表示できる形式に変換して表示するパイプラインです。
基本的なケーススタディ
ニュース記事をフェッチして読者に提供するアプリを考えてみましょう。このアプリには、読める記事を表示する記事画面があり、ログインしたユーザーは特に気に入った記事をブックマークできます。常に多くの記事が存在する可能性があるため、読者はカテゴリ別に記事を閲覧できる必要があります。要約すると、このアプリでユーザーは次のことができます。
- 読むことのできる記事を表示する。
- 記事をカテゴリ別に閲覧する。
- ログインして特定の記事をブックマークする。
- 利用資格がある場合は、プレミアム機能を利用する。
次のセクションでは、このサンプルをケーススタディとして使用し、単方向データフローの原則を紹介します。また、UI レイヤのアプリ アーキテクチャのコンテキストで、その原則を適用することにより解決できる問題を説明します。
UI レイヤのアーキテクチャ
UI という用語は、データを表示するコンテナやコンポーズ可能な関数などの UI 要素を指します。Android UI を構築するには、Jetpack Compose が推奨されるツールキットです。データレイヤの役割は、アプリデータの保持と管理、そしてアプリデータへのアクセスの提供であるため、UI レイヤは次のステップを実行する必要があります。
- アプリデータを使用し、UI が簡単にレンダリングできるデータに変換する。
- UI がレンダリングできるデータを使用し、ユーザーに表示する UI 要素に変換する。
- このような UI 要素の集合体からのユーザー入力イベントを使用し、必要に応じてその結果を UI データに反映させる。
- ステップ 1~3 を必要な回数だけ繰り返す。
このガイドの残りの部分では、上記のステップを実行する UI レイヤを実装する方法について説明します。このガイドでは、特に次のタスクとコンセプトを取り上げます。
- UI 状態を定義する方法
- UI 状態を生成し管理する手段としての単方向データフロー(UDF)
- UDF の原則に従ってオブザーバルなデータ型で UI の状態を公開する方法
- オブザーバブル UI 状態を使用する UI を実装する方法
この中で最も基本的な事項は、UI 状態の定義です。
UI 状態を定義する
前述のケーススタディでは、UI に記事のリストと各記事のメタデータが表示されます。アプリがユーザーに提示するこれらの情報が、UI 状態です。
つまり、ユーザーが目にするものが UI であるとすれば、ユーザーが目にするべきであるとアプリがみなすものが UI 状態です。同じコインの両面のように、UI は UI 状態を視覚的に表したものです。UI 状態が変更されると、すぐに UI に反映されます。
ニュースアプリの要件を満たすため、UI を完全にレンダリングするために必要な情報を、次のように定義された NewsUiState データクラスにカプセル化することを検討します。
data class NewsUiState( val isSignedIn: Boolean = false, val isPremium: Boolean = false, val newsItems: List<NewsItemUiState> = listOf(), val userMessages: List<Message> = listOf() ) data class NewsItemUiState( val title: String, val body: String, val bookmarked: Boolean = false, // ... )
UI の状態について詳しくは、状態と Jetpack Compose をご覧ください。
不変性
前の例では、UI 状態の定義は不変です。その重要なメリットは、不変オブジェクトがその時点でのアプリの状態を確実に提供できることです。これにより、UI は解放されて、状態を読み取り、それに応じて自身の UI 要素を更新するという唯一の役割に集中できます。UI 自体がそのデータの唯一のソースでない限り、UI 内で UI 状態を直接変更すべきではありません。この原則を破ると、同じ情報について信頼できるソースが複数発生し、データの不整合やわかりにくいバグにつながります。
たとえば、前述のケーススタディについて考えてみましょう。UI 状態の NewsItemUiState オブジェクトの bookmarked フラグが Activity クラスで更新されると、そのフラグは記事のブックマーク ステータスのソースとしてデータレイヤと競合します。不変データクラスは、このような不整合を防ぐのに非常に役立ちます。
このガイドにおける命名規則
このガイドでは、UI 状態クラスは、画面の機能、または UI 状態クラスで記述される画面の部分に基づいて命名されます。規則は次のとおりです。
機能 + UiState。
たとえば、ニュースを表示する画面の状態の名前は NewsUiState で、ニュース アイテムのリスト内のニュース アイテムの状態の名前は NewsItemUiState です。
単方向データフローで状態を管理する
前のセクションでは、UI 状態が、UI のレンダリングに必要な詳細情報の不変のスナップショットであることを説明しました。ただし、アプリのデータの動的な性質上、状態は時間の経過とともに変化する可能性があります。状態の変化は、アプリデータの入力の基になるデータを変更するユーザー インタラクションなどのイベントによって発生します。
これらのインタラクションは、メディエータを使用して処理することでメリットが得られます。メディエータは、各イベントに適用するロジックを定義し、バックエンドのデータソースを変換して UI 状態を作成します。これらのインタラクションとそのロジックは UI 自体に格納できますが、UI が過剰な責任を負うことになるため、すぐに扱いにくくなります。また、結果として得られるコードが密結合になるため、テスト可能性に影響する可能性があります。UI 状態が非常に単純な場合を除き、UI の唯一の役割は UI 状態を使用し、表示することです。
このセクションでは、こうした責任の健全な分離を実現するアーキテクチャ パターンである単方向データフロー(UDF)について説明します。
状態ホルダー
状態ホルダーは、UI 状態の生成と、その状態の生成に必要なロジックを担当するクラスです。状態ホルダーには、ボトム アプリバーなどの単一のウィジェットから画面全体やナビゲーション デスティネーションまで、管理対象の対応する UI 要素のスコープに応じてさまざまなサイズが用意されています。
後者の場合、一般的な実装は ViewModel のインスタンスですが、アプリの要件によっては単純なクラスで十分な場合もあります。たとえば、ケーススタディのニュースアプリは、NewsViewModel クラスを状態ホルダーとして使用し、そのセクションに表示される画面の UI 状態を生成します。
UI とその状態プロデューサとの共依存関係をモデル化する方法は数多くあります。しかし、UI とその ViewModel クラスの間のインタラクションは、主にイベント入力とそれに続く状態出力として認識できるので、その関係は次の図のように表現できます。
状態が下に流れ、イベントが上に流れるパターンを、単方向データフロー(UDF)と呼びます。このパターンは、アプリ アーキテクチャにおいて次のことを意味します。
- ViewModel は、UI が使用する状態を保持し、公開します。UI 状態は、ViewModel によって変換されたアプリデータです。
- UI は、ユーザー イベントを ViewModel に通知します。
- ViewModel は、ユーザー アクションを処理し、状態を更新します。
- 更新された状態は、UI にフィードバックされてレンダリングされます。
- 上記のステップは、状態の変化を引き起こす任意のイベントで繰り返されます。
ナビゲーション デスティネーションまたは画面の場合、ViewModel はリポジトリまたはユースケース クラスと連携し、データを取得して UI 状態に変換するとともに、状態の変化を引き起こす可能性があるイベントの結果を取り込みます。前述のケーススタディにおける記事のリストでは、それぞれの記事に、タイトル、説明、出典、著者名、公開日、ブックマークされているかどうかの情報があります。各記事アイテムの UI は次のように表示されます。
ユーザーによる記事のブックマークのリクエストは、状態の変化を引き起こすイベントの一例です。状態プロデューサーとして、ViewModel は、UI 状態のすべてのフィールドに入力し、UI が完全にレンダリングするために必要なイベントを処理するために必要なすべてのロジックを定義する責任があります。
以降のセクションでは、状態の変化を引き起こすイベントと、UDF を使用してイベントを処理する方法について詳しく説明します。
ロジックのタイプ
記事をブックマークする機能は、アプリの価値を高めるビジネス ロジックの一例です。詳しくは、データレイヤのページをご覧ください。しかし、定義することが重要な意味を持つロジックには、さまざまなタイプがあります。
- ビジネス ロジックは、アプリデータに関するプロダクトの要件の実装です。前述のように、ケーススタディのアプリで記事をブックマークすることはその一例です。通常、ビジネス ロジックはドメインレイヤまたはデータレイヤに配置され、UI レイヤに配置されることはありません。
- UI 動作ロジック(または UI ロジック)は、状態の変化を画面に表示する「方法」を意味します。たとえば、Android
Resourcesを使って画面に表示する適切なテキストを取得する、ユーザーがボタンをクリックしたときに特定の画面に移動する、トーストまたはスナックバーを使ってユーザー メッセージを画面に表示する、などの方法があります。
UI ロジックは、特に Context のような UI タイプを含む場合、ViewModel ではなく UI で実行する必要があります。UI が複雑になるため、テストのしやすさと関心の分離を重視して UI ロジックを別のクラスに委任したい場合は、単純なクラスを状態ホルダーとして作成することができます。UI に作成される単純なクラスは、UI のライフサイクルに従うので、Android SDK の依存関係を利用できます。ViewModel オブジェクトはより長く存在します。
状態ホルダーの詳細と、状態ホルダーが UI の構築を容易にするためにいかに役立つかについては、Jetpack Compose の状態に関するガイドをご覧ください。
UDF を使用する理由
UDF は、図 4 に示すように、状態生成のサイクルをモデル化したものです。そこでは、状態の変化が発生する場所、変換される場所、最終的に使用される場所が分かれています。この分離により、UI は、その名前が示すとおりの役割を果たすことができます。それは、状態の変化を監視して情報を表示し、そのような変化を ViewModel に渡してユーザーの意図を伝達することです。
言い換えると、UDF により次のことが実現されます。
- データの整合性。UI の信頼できる情報源は 1 つだけです。
- テストのしやすさ。状態のソースが分離されるため、UI から独立してテストを行うことができます。
- メンテナンスのしやすさ。状態の変化は、ユーザー イベントおよびデータソースからのデータ取得の結果であるという、明確に定義されたパターンに従います。
UI 状態を公開する
UI 状態を定義し、その状態の生成を管理する方法を決定したら、次は生成された状態を UI に表示します。
UDF を使用して状態の生成を管理する場合、生成された状態はストリームと見なすことができます。つまり、状態の複数のバージョンが時間の経過とともに生成されます。StateFlow などのオブザーバブル データホルダーで UI 状態を公開します。これにより、ViewModel からデータを直接手動で取得しなくても、UI は状態の変更に反応できます。また、UI 状態の最新バージョンが常にキャッシュに保存されるため、構成変更後の状態の迅速な復元に役立つというメリットもあります。
class NewsViewModel( // ... ) : ViewModel() { val uiState: NewsUiState = /* ... */ }
Kotlin Flow の概要については、Android での Kotlin Flow をご覧ください。StateFlow をオブザーバブル データホルダーとして使用する方法については、Jetpack Compose の高度な状態と副作用の Codelab をご覧ください。
UI に公開されるデータが比較的単純である場合は、データを UI 状態タイプでラップすることがしばしば効果的です。そうすると、状態ホルダーの出力とそれに関連付けられた画面または UI 要素との関係が伝達されるからです。UI 要素が複雑になるにつれて、UI 状態の定義に簡単に追加できるため、UI 要素のレンダリングに必要な追加情報を組み込むことができます。
UiState のストリームを作成する一般的な方法は、private set を使用して mutableStateOf プロパティを公開し、ViewModel 内の状態を可変に保ちながら、UI では読み取り専用にすることです。
class NewsViewModel( // ... ) : ViewModel() { var uiState by mutableStateOf(NewsUiState()) private set // ... }
次に、ViewModel は、内部的に状態を変更するメソッドを公開して、UI が使用する更新を公開できます。たとえば、非同期アクションを実行する必要がある場合を考えてみましょう。viewModelScope を使用してコルーチンを起動し、完了時に可変状態を更新できます。
class NewsViewModel( private val repository: NewsRepository, // ... ) : ViewModel() { var uiState by mutableStateOf(NewsUiState()) private set private var fetchJob: Job? = null fun fetchArticles(category: String) { fetchJob?.cancel() fetchJob = viewModelScope.launch { try { val newsItems = repository.newsItemsForCategory(category) uiState = uiState.copy(newsItems = newsItems) } catch (ioe: IOException) { // Handle the error and notify the UI when appropriate. val messages = getMessagesFromThrowable(ioe) uiState = uiState.copy(userMessages = messages) } } } }
上記の例では、NewsViewModel クラスは特定のカテゴリの記事を取得しようとし、その試行の結果(成功か失敗か)を UI 状態に反映します。UI はこの結果に適切に対応できます。エラー処理の詳細については、画面にエラーを表示するセクションをご覧ください。
その他の考慮事項
UI 状態を公開する際は、上記のガイダンスに加えて、次の点も考慮してください。
相互に関連する状態を処理するには、単一の UI 状態オブジェクトを使用します。そうすれば、データの不整合が減少し、コードが理解しやすくなります。ニュース アイテムのリストとブックマーク数を 2 つの異なるストリームで公開すると、一方が更新され、もう一方が更新されない状況が生じる可能性があります。使用するストリームを 1 つにすれば、両方の要素が常に最新の状態に保たれます。さらに、一部のビジネス ロジックでは、ソースの組み合わせが必要になる場合があります。たとえば、ブックマーク ボタンを表示する必要があるのは、ユーザーがログイン済みで、かつプレミアム ニュース サービスを定期購読している場合のみとします。UI 状態クラスは次のように定義できます。
data class NewsUiState( val isSignedIn: Boolean = false, val isPremium: Boolean = false, val newsItems: List<NewsItemUiState> = listOf() ) val NewsUiState.canBookmarkNews: Boolean get() = isSignedIn && isPremium
この宣言において、ブックマーク ボタンを表示するかどうかは、他の 2 つのプロパティの派生プロパティです。ビジネス ロジックが複雑になるにつれて、すべてのプロパティを直接利用できる唯一の
UiStateクラスを作成することがいっそう重要になります。UI 状態: 1 つのストリームか複数のストリームか。UI 状態を 1 つのストリームで公開するか、複数のストリームで公開するかを選択する際の重要な原則は、出力されるアイテム間の関係です。単一ストリームの公開の最大のメリットは、利便性とデータの整合性です。状態のコンシューマーは、いつでも最新の情報を利用できます。ただし、次のように、ViewModel からの個別の状態ストリームを使用することが適切な場合もあります。
関連のないデータ型: UI のレンダリングに必要な状態は、互いに完全に独立している場合があります。このような場合、特に一方の状態が他方よりも頻繁に更新される場合は、これらの異なる状態をバンドルするコストがメリットを上回る可能性があります。
UiStateの差分:UiStateオブジェクトのフィールドが多いほど、フィールドの 1 つが更新された結果としてストリームが放出される可能性が高くなります。UI 要素には、連続するエミッションが異なるか同じかを判断する差分メカニズムがないため、エミッションごとに UI 要素が更新されます。つまり、distinctUntilChanged()などのFlowAPI メソッドを使用した緩和策が必要になる可能性があります。
レンダリングと UI 状態の詳細については、コンポーザブルのライフサイクルをご覧ください。
UI 状態を使用する
UI で UiState オブジェクトのストリームを使用するには、使用している観測可能なデータ型のターミナル オペレータを使用します。たとえば、Kotlin フローの場合は、collect() メソッドまたはそのバリエーションを使用します。
UI でオブザーバブルなデータホルダーを使用する場合は、UI のライフサイクルを考慮してください。コンポーザブルがユーザーに表示されていないときは、UI が UI 状態を監視しないようにします。このトピックについて詳しくは、こちらのブログ投稿をご覧ください。フローを使用する場合は、適切なコルーチン スコープと collectAsStateWithLifecycle API を使用してライフサイクルに関する問題を処理することをおすすめします。
@Composable private fun ConversationScreen( conversationViewModel: ConversationViewModel = viewModel() ) { val messages by conversationViewModel.messages.collectAsStateWithLifecycle() ConversationScreen( messages = messages, onSendMessage = { message: Message -> conversationViewModel.sendMessage(message) } ) } @Composable private fun ConversationScreen( messages: List<Message>, onSendMessage: (Message) -> Unit ) { MessagesList(messages, onSendMessage) /* ... */ }
進行中の操作を表示する
UiState クラスで読み込み状態を表現する簡単な方法は、次のブール値フィールドを使用することです。
data class NewsUiState( val isFetchingArticles: Boolean = false, // ... )
このフラグの値は、UI に進行状況バーが存在するかどうかを表します。
@Composable fun LatestNewsScreen( modifier: Modifier = Modifier, viewModel: NewsViewModel = viewModel() ) { Box(modifier.fillMaxSize()) { if (viewModel.uiState.isFetchingArticles) { CircularProgressIndicator(Modifier.align(Alignment.Center)) } // Add other UI elements. For example, the list. } }
画面にエラーを表示する
UI にエラーを表示する処理は、進行中のオペレーションを表示する処理と似ています。どちらも、存在するかどうかを表すブール値で簡単に表現できるからです。ただし、エラー処理には、ユーザーに返す関連メッセージや、失敗したオペレーションを再試行する関連アクションが含まれることもあります。したがって、進行中のオペレーションは読み込み中かそうでないかのいずれかであるのに対し、エラー状態は、エラーのコンテキストに合ったメタデータをホストするデータクラスでモデル化する必要があります。
記事の取得中にプログレス バーを表示する前の例を考えてみましょう。このオペレーションでエラーが発生した場合、通常は、ユーザーに問題の詳細を示す 1 つ以上のメッセージを表示します。
data class Message(val id: Long, val message: String) data class NewsUiState( val userMessages: List<Message> = listOf(), // ... )
エラー メッセージは、スナックバーなどの UI 要素の形式でユーザーに表示できます。UI イベントの生成と使用の方法について詳しくは、UI イベントをご覧ください。
スレッド化と同時実行
ViewModel で実行されるすべての作業がメインセーフ(メインスレッドから安全に呼び出せること)であることを確認します。データレイヤとドメインレイヤは、作業を別のスレッドに移動する役割を担います。
ViewModel は、長時間実行オペレーションを実行する場合、そのロジックをバックグラウンド スレッドに移動する責任も負います。同時実行オペレーションを管理する効果的な手段として、Kotlin コルーチンがあります。Jetpack アーキテクチャ コンポーネントには、それらに対するサポートが組み込まれています。Android アプリでコルーチンを使用する方法について詳しくは、Android での Kotlin コルーチンをご覧ください。
ナビゲーション
アプリのナビゲーションの変更は、多くの場合、イベントのような出力によって引き起こされます。たとえば、SignInViewModel クラスがログインを実行した後、UiState で isSignedIn フィールドが true に設定される場合などです。このようなトリガーは、前のUI 状態を使用するセクションで説明したトリガーと同様に使用しますが、使用の実装は Navigation コンポーネントに委ねます。
UI ナビゲーションについて詳しくは、ナビゲーション 3 をご覧ください。
Paging
Paging ライブラリは、UI では PagingData という型で使用されます。PagingData は時間とともに変化する可能性のあるアイテムを表し、それらを含んでいるため(つまり、不変型ではないため)、不変の UI 状態では表さないでください。代わりに、ViewModel から独自のストリームで独立して公開します。
次の例は、Paging ライブラリの Compose API を示しています。
@Composable fun MyScreen(flow: Flow<PagingData<String>>) { val lazyPagingItems = flow.collectAsLazyPagingItems() LazyColumn { items( lazyPagingItems.itemCount, key = lazyPagingItems.itemKey { it } ) { index -> val item = lazyPagingItems[index] Text("Item is $item") } } }
アニメーション
スムーズなトップレベル ナビゲーションの切り替えを実現するため、2 つ目の画面でデータを読み込んでからアニメーションを開始することをおすすめします。
ナビゲーション トランジションの詳細については、Navigation 3 と Compose の共有要素トランジションをご覧ください。
参考情報
コンテンツの視聴回数
サンプル
次の Google サンプルは、UI レイヤの使用方法を示しています。このガイダンスを実践するためにご利用ください。
あなたへのおすすめ
- 注: JavaScript がオフになっている場合はリンクテキストが表示されます
- UI 状態生成
- 状態ホルダーと UI の状態 {:#mad-arch}
- アプリ アーキテクチャ ガイド