UI レイヤ

UI の役割は、アプリデータを画面に表示することであり、また、ユーザー インタラクションの主要なポイントとして機能することです。ユーザー インタラクション(例: ボタンを押す)や外部入力(例: ネットワーク応答)によってデータが変更されるたびに、UI を更新してそのような変更を反映させる必要があります。UI は、実質的には、データレイヤから取得されたアプリの状態を視覚的に表したものです。

ただし、一般的に、データレイヤから取得するアプリデータの形式は、表示する必要がある情報の形式とは異なります。たとえば、UI では、データの一部だけが必要になる場合や、ユーザーに関連する情報を提示するために 2 つの異なるデータソースの統合が求められる場合があります。適用するロジックにかかわらず、UI を完全にレンダリングするために必要なすべての情報を UI に渡す必要があります。UI レイヤは、アプリデータの変更を UI が提示できる形式に変換して表示するパイプラインです。

一般的なアーキテクチャでは、UI レイヤの UI 要素は状態ホルダーに依存し、状態ホルダーはデータレイヤまたはオプションのドメインレイヤのいずれかのクラスに依存します。
図 1. アプリ アーキテクチャにおける UI レイヤの役割。

基本的なケーススタディ

ニュース記事をフェッチして読者に提供するアプリを考えてみましょう。このアプリには、ユーザーが読むことのできる記事を提示する記事画面があり、ログインしているユーザーは目を引く記事をブックマークすることもできます。常に多くの記事が続々と生まれることを考えると、読者がカテゴリごとに記事を閲覧できることも必要です。要約すると、このアプリでユーザーは次のことができます。

  • 読むことのできる記事を表示する。
  • 記事をカテゴリ別に閲覧する。
  • ログインして特定の記事をブックマークする。
  • 利用資格がある場合は、プレミアム機能を利用する。
図 2. UI のケーススタディ用のサンプル ニュースアプリ。

次のセクションでは、このサンプルをケーススタディとして使用し、単方向データフローの原則を紹介します。また、UI レイヤのアプリ アーキテクチャのコンテキストで、その原則を適用することにより解決できる問題を説明します。

UI レイヤのアーキテクチャ

「UI」という用語は、データを表示するアクティビティやフラグメントなどの UI 要素を指します。それらがデータを表示するために使用する API(View または Jetpack Compose)は関係ありません。データレイヤの役割は、アプリデータの保持と管理、そしてアプリデータへのアクセスの提供であるため、UI レイヤは次のステップを実行する必要があります。

  1. アプリデータを使用し、UI が簡単にレンダリングできるデータに変換する。
  2. UI がレンダリングできるデータを使用し、ユーザーに表示する UI 要素に変換する。
  3. このような UI 要素の集合体からのユーザー入力イベントを使用し、必要に応じてその結果を UI データに反映させる。
  4. ステップ 1~3 を必要な回数だけ繰り返す。

このガイドの残りの部分では、上記のステップを実行する UI レイヤを実装する方法について説明します。このガイドでは、特に次のタスクとコンセプトを取り上げます。

  • UI 状態を定義する方法。
  • UI 状態を生成および管理する手段としての単方向データフロー(UDF)。
  • UDF の原則に沿って監視可能なデータ型で UI 状態を公開する方法。
  • 監視可能な UI 状態を使用する UI を実装する方法。

この中で最も基本的な事項は、UI 状態の定義です。

UI 状態を定義する

前述のケーススタディをご覧ください。要約すると、UI は記事のリストに加えて、各記事のいくつかのメタデータを表示します。アプリがユーザーに提示するこれらの情報が、UI 状態です。

言い換えると、ユーザーが目にするものが UI であるとすれば、ユーザーが目にするべきであるとアプリがみなすものが UI 状態です。同じコインの両面のように、UI は UI 状態を視覚的に表したものです。UI 状態が変更されると、すぐに UI に反映されます。

UI は、画面上の UI 要素と UI 状態を足し合わせたものです。
図 3. 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 状態の定義は不変です。その重要なメリットは、不変オブジェクトがその時点でのアプリの状態を確実に提供できることです。これにより、UI は解放されて唯一の役割に集中できます。それは、状態を読み取り、それに応じて自身の UI 要素を更新することです。したがって、UI 自体がそのデータの唯一のソースでない限り、UI 内で UI 状態を直接変更すべきではありません。この原則を破ると、同じ情報について信頼できるソースが複数発生し、データの不整合やわかりにくいバグにつながります。

たとえば、ケーススタディにおいて、UI 状態の NewsItemUiState オブジェクトの bookmarked フラグが Activity クラス内で更新された場合、そのフラグは、記事の「ブックマーク済み」ステータスのソースとしてデータレイヤと競合することになります。不変のデータクラスは、この種のアンチパターンを防止するために大変役立ちます。

このガイドにおける命名規則

このガイドでは、UI 状態クラスは、画面の機能、または UI 状態クラスで記述される画面の部分に基づいて命名されます。規則は次のとおりです。

機能 + UiState。

たとえば、ニュースを表示する画面の状態の名前は NewsUiState で、ニュース アイテムのリスト内のニュース アイテムの状態の名前は NewsItemUiState です。

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

前のセクションでは、UI 状態が、UI のレンダリングに必要な詳細情報の不変のスナップショットであることを説明しました。しかし、アプリ内のデータは動的な性質を持つため、状態は時間とともに変化する可能性があります。状態の変化は、アプリデータの入力の基になるデータを変更するユーザー インタラクションなどのイベントによって発生します。

こうしたインタラクションはメディエータで処理すると便利です。たとえば、各イベントに適用するロジックを定義し、UI 状態を作成するためにバッキング データソースへの必須の変換を実行します。こうしたインタラクションとそのロジックは UI 自体に格納することもできますが、UI がその名前の示す役割を超えてデータオーナー、プロデューサ、トランスフォーマーなどを兼ねることになると、複雑で手に負えなくなります。さらに、作成されるコードが識別可能な境界がないほど密結合されたものになるので、テストのしやすさに影響する可能性があります。結局のところ、UI の負担は減らす方がよいということです。UI 状態が非常に単純である場合を除き、UI は、UI 状態を使用して表示する責任のみを負うべきです。

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

状態ホルダー

UI 状態を生成する責任を負い、そのタスクに必要なロジックを格納するクラスを、状態ホルダーと呼びます。状態ホルダーのサイズは、ボトム アプリバーのような単一のウィジェットから、画面全体またはナビゲーション デスティネーションまで、対応する管理対象の UI 要素のスコープに応じてさまざまです。

後者の場合、一般的な実装は ViewModel のインスタンスですが、アプリの要件によっては単純なクラスで十分な場合もあります。たとえば、ケーススタディのニュースアプリは、NewsViewModel クラスを状態ホルダーとして使用し、そのセクションに表示される画面の UI 状態を生成します。

UI とその状態プロデューサとの共依存関係をモデル化する方法は数多くあります。しかし、UI とその ViewModel クラスの間のインタラクションは、主にイベント入力とそれに続く状態出力として認識できるので、その関係は次の図のように表現できます。

データレイヤから ViewModel へのアプリデータのフロー。UI 状態は ViewModel から UI 要素へと流れ、イベントは逆に UI 要素から ViewModel へと流れます。
図 4. アプリ アーキテクチャにおける UDF の仕組みを示す図。

状態が下に流れ、イベントが上に流れるパターンを、単方向データフロー(UDF)と呼びます。このパターンは、アプリ アーキテクチャにおいて次のことを意味します。

  • ViewModel は、UI が使用する状態を保持し、公開します。UI 状態は、ViewModel によって変換されたアプリデータです。
  • UI は、ユーザー イベントを ViewModel に通知します。
  • ViewModel は、ユーザー アクションを処理し、状態を更新します。
  • 更新された状態は、UI にフィードバックされてレンダリングされます。
  • 上記のステップは、状態の変化を引き起こす任意のイベントで繰り返されます。

ナビゲーション デスティネーションまたは画面の場合、ViewModel はリポジトリまたはユースケース クラスと連携し、データを取得して UI 状態に変換するとともに、状態の変化を引き起こす可能性があるイベントの結果を取り込みます。前述のケーススタディにおける記事のリストでは、それぞれの記事に、タイトル、説明、出典、著者名、公開日、ブックマークされているかどうかの情報があります。各記事アイテムの UI は次のように表示されます。

図 5. ケーススタディのアプリにおける記事アイテムの UI。

ユーザーによる記事のブックマークのリクエストは、状態の変化を引き起こすイベントの一例です。UI がレンダリングを完全に行えるよう、UI 状態のすべてのフィールドに値を入力するロジック、およびインベントを処理するロジックをもれなく定義することが、状態プロデューサとしての ViewModel の役割です。

ユーザーが記事をブックマークすると、UI イベントが発生します。ViewModel が状態の変化をデータレイヤに通知します。データレイヤがデータの変更を保持し、アプリデータを更新します。ブックマークされた記事の新しいアプリデータが ViewModel に渡され、ViewModel が新しい UI 状態を生成して、表示するために UI 要素に渡します。
図 6. UDF におけるイベントとデータのサイクルを示す図。

以降のセクションでは、状態の変化を引き起こすイベントと、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 に対して、信頼できる唯一のデータソースが存在します。
  • テストのしやすさ。状態のソースが分離されるため、UI から独立してテストを行うことができます。
  • メンテナンスのしやすさ。状態の変化は、ユーザー イベントおよびデータソースからのデータ取得の結果であるという、明確に定義されたパターンに従います。

UI 状態を公開する

UI 状態を定義し、その状態の生成を管理する方法を決定したら、次のステップとして、生成された状態を UI に提示します。UDF を使用して状態の生成を管理するので、生成される状態をストリームとみなすことができます。つまり、時間の経過とともに状態の複数のバージョンが生成されます。したがって、LiveDataStateFlow などの監視可能なデータホルダーで UI 状態を公開する必要があります。これは、UI が、ViewModel から直接手動でデータを取得する手間をかけずに、状態の変更に反応できるようにするためです。このようなタイプには、常に UI 状態の最新バージョンがキャッシュに保存されるというメリットもあります。このことは、構成変更後に状態をすばやく復元するために役立ちます。

視聴回数

class NewsViewModel(...) : ViewModel() {

    val uiState: StateFlow<NewsUiState> = …
}

Compose

class NewsViewModel(...) : ViewModel() {

    val uiState: NewsUiState = …
}

監視可能なデータホルダーとしての LiveData の概要については、こちらの Codelab をご覧ください。同様の Kotlin Flow の概要については、Android での Kotlin Flow をご覧ください。

UI に公開されるデータが比較的単純である場合は、データを UI 状態タイプでラップすることがしばしば効果的です。そうすると、状態ホルダーの出力とそれに関連付けられた画面または UI 要素との関係が伝達されるからです。さらに、UI 要素が複雑になるにつれて、UI 要素のレンダリングに必要な追加情報に対応するために UI 状態の定義を拡充することが、より簡単になります。

UiState のストリームを作成する一般的な方法は、可変のバッキング ストリームを ViewModel からの不変のストリームとして公開することです。たとえば、MutableStateFlow<UiState>StateFlow<UiState> として公開します。

視聴回数

class NewsViewModel(...) : ViewModel() {

    private val _uiState = MutableStateFlow(NewsUiState())
    val uiState: StateFlow<NewsUiState> = _uiState.asStateFlow()

    ...

}

Compose

class NewsViewModel(...) : ViewModel() {

    var uiState by mutableStateOf(NewsUiState())
        private set

    ...
}

次に、ViewModel は、内部的に状態を変更するメソッドを公開して、UI が使用する更新を公開できます。たとえば、非同期アクションを実行する必要がある場合を考えてみましょう。この場合、viewModelScope を使用してコルーチンを起動することが可能で、完了したら可変の状態を更新できます。

視聴回数

class NewsViewModel(
    private val repository: NewsRepository,
    ...
) : ViewModel() {

    private val _uiState = MutableStateFlow(NewsUiState())
    val uiState: StateFlow<NewsUiState> = _uiState.asStateFlow()

    private var fetchJob: Job? = null

    fun fetchArticles(category: String) {
        fetchJob?.cancel()
        fetchJob = viewModelScope.launch {
            try {
                val newsItems = repository.newsItemsForCategory(category)
                _uiState.update {
                    it.copy(newsItems = newsItems)
                }
            } catch (ioe: IOException) {
                // Handle the error and notify the UI when appropriate.
                _uiState.update {
                    val messages = getMessagesFromThrowable(ioe)
                    it.copy(userMessages = messages)
                 }
            }
        }
    }
}

Compose

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 状態を公開する際は、上記のガイダンスに加えて、次の点も考慮してください。

  • 1 つの 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 つのストリームで公開するか複数のストリームで公開するかを選択する際の重要な指針は、前の箇条書きにあるように、出力されるアイテム間の関係です。1 つのストリームで公開する最大の利点は、利便性とデータの整合性です。状態のコンシューマは、最新の情報をいつでも利用できます。ただし、次のように、ViewModel からの個別の状態ストリームを使用することが適切な場合もあります。

    • 関連のないデータ型: UI のレンダリングに必要な状態は、互いに完全に独立している場合があります。このような場合は、完全に異なる複数の状態を一緒にまとめると、コストがメリットを上回る可能性があります。ある状態が他の状態より頻繁に更新される場合は、特にそうです。

    • UiState の差分抽出: UiState オブジェクト内のフィールドの数が多いと、その分だけフィールドが更新された結果としてストリームが出力される可能性が高くなります。ビューには、連続する出力が異なるものか同じものかを把握する差分抽出メカニズムがないため、出力ごとにビューの更新が発生します。つまり、LiveDataFlow API や distinctUntilChanged() などのメソッドを使用した緩和策が必要になる場合があります。

UI 状態を使用する

UI で UiState オブジェクトのストリームを使用するには、アプリで使用している監視可能なデータ型に終端演算子を使用します。たとえば、LiveData の場合は observe() メソッドを使用し、Kotlin Flow の場合は collect() メソッドまたはそのバリエーションを使用します。

UI で監視可能なデータホルダーを使用する際は、必ず UI のライフサイクルを考慮してください。これが重要なのは、ビューがユーザーに表示されていないとき、UI は UI 状態を監視すべきでないからです。このトピックについて詳しくは、こちらのブログ投稿をご覧ください。LiveData を使用している場合は、LifecycleOwner が暗黙的にライフサイクルに関する問題に対処します。フローを使用している場合は、適切なコルーチン スコープと repeatOnLifecycle API を使用してこの問題を処理することをおすすめします。

View

class NewsActivity : AppCompatActivity() {

    private val viewModel: NewsViewModel by viewModels()

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

        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.uiState.collect {
                    // Update UI elements
                }
            }
        }
    }
}

Compose

@Composable
fun LatestNewsScreen(
    viewModel: NewsViewModel = viewModel()
) {
    // Show UI elements based on the viewModel.uiState
}

進行中のオペレーションを表示する

UiState クラスで読み込み状態を表現する簡単な方法は、次のブール値フィールドを使用することです。

data class NewsUiState(
    val isFetchingArticles: Boolean = false,
    ...
)

このフラグの値は、UI に進行状況バーが存在するかどうかを表します。

視聴回数

class NewsActivity : AppCompatActivity() {

    private val viewModel: NewsViewModel by viewModels()

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

        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                // Bind the visibility of the progressBar to the state
                // of isFetchingArticles.
                viewModel.uiState
                    .map { it.isFetchingArticles }
                    .distinctUntilChanged()
                    .collect { progressBar.isVisible = it }
            }
        }
    }
}

Compose

@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 クラスがログインを実行した後、UiStateisSignedIn フィールドが true に設定される場合などです。このようなトリガーは、前述の UI 状態を使用するセクションで説明しているような方法で使用するべきです。ただし、例外として、その使用の実装は Navigation コンポーネントに準ずる必要があります。

ページング

Paging ライブラリは、UI では PagingData という型で使用されます。PagingData は時間とともに変化する可能性があるアイテムを表現および格納する(つまり、不変の型ではない)ため、不変の UI 状態で表現するべきではありません。代わりに、ViewModel から固有のストリームで個別に公開する必要があります。具体例については、Android ページング Codelab をご覧ください。

アニメーション

滑らかなトップレベル ナビゲーション遷移を実現するには、アニメーションを開始する前に、後続の画面でデータが読み込まれるのを待機します。Android ビュー フレームワークには、postponeEnterTransition() および startPostponedEnterTransition() API を使用して、フラグメント デスティネーション間の遷移を遅延させるフックが用意されています。これらの API により、後続の画面の UI 要素(通常はネットワークからフェッチされた画像)を表示する準備が確実に整ってから、UI がその画面への遷移アニメーションを開始するようにできます。詳細と具体的な実装については、Android Motion sample をご覧ください。

サンプル

次の Google サンプルは、UI レイヤの使用方法を示しています。このガイダンスを実践するためにご利用ください。