状態ホルダーと UI 状態

UI レイヤのガイドでは、UI レイヤの UI 状態を生成し管理する手段としての単方向データフロー(UDF)を取り上げています。

データレイヤから UI への単方向データフロー。
図 1: 単方向データフロー

また、UDF 管理を状態ホルダーという特別なクラスにデリゲートするメリットについても紹介されています。状態ホルダーは、ViewModel またはプレーンなクラスを通じて実装できます。このドキュメントでは、状態ホルダーと、それらが UI レイヤで果たす役割について詳しく説明します。

このドキュメントを最後まで読むと、UI レイヤでアプリの状態を管理する方法(UI 状態生成パイプライン)について理解できます。内容は次のとおりです。

  • UI レイヤに存在する UI 状態の種類を理解する。
  • UI レイヤの UI 状態を操作するロジックの種類を理解する。
  • ViewModel や単純なクラスなど、状態ホルダーの適切な実装を選択する方法を把握する。

UI 状態生成パイプラインの要素

UI 状態と、それを生成するロジックが UI レイヤを定義します。

UI 状態

UI 状態は UI を表すプロパティです。UI 状態には次の 2 種類があります。

  • 画面 UI 状態: 画面に表示する必要があるもの。たとえば NewsUiState クラスには、ニュース記事や、UI のレンダリングに必要なその他の情報を含めることができます。この状態にはアプリデータが含まれるため、通常は階層の他のレイヤに接続されます。
  • UI 要素状態: レンダリング結果に影響する UI 要素固有のプロパティ。UI 要素は、表示または非表示となり、特定のフォント、フォントサイズ、またはフォントの色となる可能性があります。Android ビューでは、ビューは本質的にステートフルであるため、ビューがこの状態自体を管理し、状態を変更またはクエリするためのメソッドを公開します。たとえばテキストの場合、TextView クラスの get メソッドや set メソッドです。Jetpack Compose では、状態はコンポーザブルの外部にあります。コンポーザブルのすぐ近くから、呼び出し元のコンポーズ可能な関数または状態ホルダーにホイスティングすることもできます。たとえば Scaffold コンポーザブルの場合、ScaffoldState です。

ロジック

UI 状態は、アプリデータとユーザー イベントによって徐々に変化するため、静的なプロパティではありません。UI 状態のどの部分を変更するか、なぜ変更するのか、いつ変更すべきかなど、変更の詳細はロジックが決定します。

ロジックは UI 状態を生成します
図 2: UI 状態のプロデューサとしてのロジック

アプリ内のロジックは、ビジネス ロジックまたは UI ロジックのいずれかです。

  • ビジネス ロジックは、アプリデータに関するプロダクトの要件の実装です。たとえば、ニュース リーダー アプリでユーザーがボタンをタップしたときに記事をブックマークする、などです。ブックマークをファイルまたはデータベースに保存するこのロジックは、通常、ドメインレイヤまたはデータレイヤに配置されます。状態ホルダーは通常、こうしたレイヤが公開するメソッドを呼び出すことで、このロジックをレイヤにデリゲートします。
  • UI ロジック: 画面に UI 状態を表示する方法に関連します。たとえば、ユーザーがカテゴリを選択したときに適切な検索バーのヒントを得ることや、リスト内の特定の項目までスクロールすること、ユーザーがボタンをクリックしたときの特定の画面へのナビゲーション ロジックなどです。

Android ライフサイクル、UI 状態とロジックの種類

UI レイヤには、UI ライフサイクルに依存する部分と依存しない部分という 2 つの部分があります。この分離によって各部分で利用できるデータソースが決まるため、種々の UI 状態とロジックが必要となります。

  • UI ライフサイクルに依存しない: UI レイヤのこの部分は、アプリのデータ生成レイヤ(データレイヤまたはドメインレイヤ)を扱います。ビジネス ロジックによって定義されます。UI のライフサイクル、構成変更、Activity の再作成は、UI 状態生成パイプラインがアクティブかどうかに影響することがありますが、生成されるデータの有効性には影響しません。
  • UI ライフサイクルに依存する: UI レイヤのこの部分は UI ロジックを扱います。ライフサイクルや構成変更の影響を直接受けます。こうした変更は、その中で読み取られるデータソースの有効性に直接影響します。結果的に状態は、ライフサイクルがアクティブである場合にのみ変化します。この例としては、実行時の権限や、ローカライズされた文字列のような構成に依存するリソースの取得が挙げられます。

上記をまとめると下表のようになります。

UI ライフサイクルに依存しない UI ライフサイクルに依存する
ビジネス ロジック UI ロジック
画面 UI 状態

UI 状態生成パイプライン

UI 状態生成パイプラインとは、UI 状態を生成するために実施される手順のことです。手順は、前に定義した種類のロジックの適用を含み、UI のニーズに完全に依存します。UI によっては、パイプラインの UI ライフサイクルに依存しない部分と UI ライフサイクルに依存する部分の両方を活用する場合、いずれかを活用する場合、どちらも活用しない場合があります。

つまり、UI レイヤ パイプラインには次の順列が考えられます。

  • UI 自体が生成、管理する UI 状態。たとえば、シンプルで再利用可能な基本のカウンタは次のようになります。

    @Composable
    fun Counter() {
        // The UI state is managed by the UI itself
        var count by remember { mutableStateOf(0) }
        Row {
            Button(onClick = { ++count }) {
                Text(text = "Increment")
            }
            Button(onClick = { --count }) {
                Text(text = "Decrement")
            }
        }
    }
    
  • UI ロジック → UI。たとえば、ユーザーがリストの一番上に移動するためのボタンを表示または非表示にします。

    @Composable
    fun ContactsList(contacts: List<Contact>) {
        val listState = rememberLazyListState()
        val isAtTopOfList by remember {
            derivedStateOf {
                listState.firstVisibleItemIndex < 3
            }
        }
    
        // Create the LazyColumn with the lazyListState
        ...
    
        // Show or hide the button (UI logic) based on the list scroll position
        AnimatedVisibility(visible = !isAtTopOfList) {
            ScrollToTopButton()
        }
    }
    
  • ビジネス ロジック → UI。現在のユーザーの写真を画面に表示する UI 要素。

    @Composable
    fun UserProfileScreen(viewModel: UserProfileViewModel = hiltViewModel()) {
        // Read screen UI state from the business logic state holder
        val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    
        // Call on the UserAvatar Composable to display the photo
        UserAvatar(picture = uiState.profilePicture)
    }
    
  • ビジネス ロジック → UI ロジック → UI。特定の UI 状態について適切な情報を画面に表示するためにスクロールする UI 要素。

    @Composable
    fun ContactsList(viewModel: ContactsViewModel = hiltViewModel()) {
        // Read screen UI state from the business logic state holder
        val uiState by viewModel.uiState.collectAsStateWithLifecycle()
        val contacts = uiState.contacts
        val deepLinkedContact = uiState.deepLinkedContact
    
        val listState = rememberLazyListState()
    
        // Create the LazyColumn with the lazyListState
        ...
    
        // Perform UI logic that depends on information from business logic
        if (deepLinkedContact != null && contacts.isNotEmpty()) {
            LaunchedEffect(listState, deepLinkedContact, contacts) {
                val deepLinkedContactIndex = contacts.indexOf(deepLinkedContact)
                if (deepLinkedContactIndex >= 0) {
                  // Scroll to deep linked item
                  listState.animateScrollToItem(deepLinkedContactIndex)
                }
            }
        }
    }
    

両方の種類のロジックを UI 状態生成パイプラインに適用する場合、必ずビジネス ロジックを UI ロジックより前に適用する必要があります。ビジネス ロジックを UI ロジックより後に適用しようとした場合、これはビジネス ロジックが UI ロジックに依存するということになります。以降のセクションでは、種々のロジックとその状態ホルダーについて詳しく説明します。

データ生成レイヤから UI へのデータフロー
図 3: UI レイヤにおけるロジックの適用

状態ホルダーとその役割

状態ホルダーの役割は、アプリが状態を読み取ることができるように状態を保存することです。ロジックが必要な場合は仲介役として機能し、必要なロジックをホストするデータソースにアクセスできるようにします。このように、状態ホルダーはロジックを適切なデータソースにデリゲートします。

メリットは次のとおりです。

  • シンプルな UI: UI は状態をバインドするだけです。
  • メンテナンス性: UI 自体を変更せずに、状態ホルダーで定義されたロジックを繰り返し使用できます。
  • テストのしやすさ: UI と UI 状態生成ロジックを個別にテストできます。
  • 読みやすさ: コードを読むときに UI 表示コードと UI 状態生成コードを見分けやすくなります。

サイズやスコープに関係なく、UI 要素はすべて、対応する状態ホルダーと 1 対 1 の関係にあります。さらに状態ホルダーは、UI 状態を変化させる可能性のあるユーザー アクションを受け入れて処理できる必要があり、それに続く状態の変化を生成する必要があります。

状態ホルダーの種類

UI 状態やロジックの種類と同様に、UI レイヤにも、UI ライフサイクルとの関係で定義される 2 種類の状態ホルダーがあります。

  • ビジネス ロジック状態ホルダー
  • UI ロジック状態ホルダー

以降のセクションでは、状態ホルダーの種類について詳しく説明します。まずはビジネス ロジック状態ホルダーについて説明します。

ビジネス ロジックとその状態ホルダー

ビジネス ロジック状態ホルダーはユーザー イベントを処理し、データレイヤまたはドメインレイヤのデータを画面 UI 状態に変換します。Android のライフサイクルとアプリの構成変更を検討する際に最適なユーザー エクスペリエンスを提供するために、ビジネス ロジックを利用する状態ホルダーには次の特性が必要です。

特性 詳細
UI 状態を生成する ビジネス ロジック状態ホルダーは、その UI の UI 状態の生成を担当します。この UI 状態は多くの場合、ユーザー イベントを処理し、ドメインレイヤとデータレイヤからデータを読み取った結果です。
アクティビティの再作成を通して保持される ビジネス ロジック状態ホルダーは Activity の再作成の間、その状態と状態処理パイプラインを保持し、シームレスなユーザー エクスペリエンスを実現します。状態ホルダーを保持できず再作成する場合(通常はプロセス終了後)、状態ホルダーは、一貫したユーザー エクスペリエンスを確保するために最後の状態を簡単に再作成できる必要があります。
長期的な状態を保持する ビジネス ロジック状態ホルダーは、ナビゲーション デスティネーションの状態を管理するためによく使用されます。そのため多くの場合、ナビゲーション グラフから削除されるまで、ナビゲーションの変更後も状態を保持します。
UI に固有で再利用できない ビジネス ロジック状態ホルダーは通常、特定のアプリ機能(TaskEditViewModelTaskListViewModel など)のために状態を生成するため、そのアプリ機能にしか適用できません。同じ状態ホルダーが、さまざまなフォーム ファクタでこれらのアプリ機能をサポートできます。たとえば、アプリのモバイル版、テレビ版、タブレット版で、同じビジネス ロジック状態ホルダーを再利用できます。

たとえば、「Now in Android」アプリの著者のナビゲーション デスティネーションについて考えてみます。

Now in Android アプリは、アプリの主要な機能を表すナビゲーション デスティネーションに、独自のビジネス ロジック状態ホルダーがいかに必要であるかを示しています。
図 4: Now in Android アプリ

AuthorViewModel はビジネス ロジック状態ホルダーとして機能し、この場合、UI 状態を生成します。

@HiltViewModel
class AuthorViewModel @Inject constructor(
    savedStateHandle: SavedStateHandle,
    private val authorsRepository: AuthorsRepository,
    newsRepository: NewsRepository
) : ViewModel() {

    val uiState: StateFlow<AuthorScreenUiState> = …

    // Business logic
    fun followAuthor(followed: Boolean) {
      …
    }
}

AuthorViewModel には前述の属性があります。

特性 詳細
AuthorScreenUiState を生成する AuthorViewModelAuthorsRepositoryNewsRepository からデータを読み取り、そのデータを使用して AuthorScreenUiState を生成します。また、ユーザーが Author をフォローまたはフォロー解除するときは、AuthorsRepository にデリゲートしてビジネス ロジックを適用します。
データレイヤにアクセスできる AuthorsRepositoryNewsRepository のインスタンスがコンストラクタで渡され、Author をフォローするビジネス ロジックを実装できるようになります。
Activity の再作成後も存続する ViewModel で実装されるため、迅速な Activity の再作成後も保持されます。プロセスが終了した場合、SavedStateHandle オブジェクトを読み取ることで、データレイヤから UI 状態を復元するために必要な最小限の情報を提供できます。
長期的な状態を保持する ViewModel のスコープはナビゲーション グラフに設定されているため、著者のデスティネーションがナビゲーション グラフから削除されない限り、uiState StateFlow の UI 状態がメモリに残ります。StateFlow を使用すると、UI 状態のコレクタがある場合にのみ状態が生成されるため、状態を遅延させるビジネス ロジックを適用するというメリットも加わります。
UI に固有 AuthorViewModel は、著者のナビゲーション デスティネーションにのみ適用され、他の場所で再利用できません。ナビゲーション デスティネーション間で再利用されるビジネス ロジックがある場合は、そのビジネス ロジックを、データレイヤまたはドメインレイヤにスコープ設定されたコンポーネントにカプセル化する必要があります。

ビジネス ロジック状態ホルダーとしての ViewModel

ビジネス ロジックへのアクセスを提供し、画面に表示するアプリデータを準備するのには ViewModel が適しています。これは、ViewModel の使用が Android 開発においてメリットがあるためです。メリットは次のとおりです。

  • ViewModel によってトリガーされるオペレーションは、構成変更後も存続します。
  • Navigation との統合:
    • 画面がバックスタックにある間、ナビゲーションが ViewModel をキャッシュに保存します。これは、デスティネーションに戻るときに以前に読み込んだデータをすぐに利用可能にするために重要です。コンポーザブル画面のライフサイクルに従う状態ホルダーでは困難です。
    • デスティネーションがバックスタックからポップオフされたときも ViewModel がクリアされ、状態が自動的にクリーンアップされるようになります。これは、さまざまな理由(新しい画面に移動する、構成が変更されたなど)で発生する可能性があるコンポーザブルの廃棄のリッスンとは異なります。
  • Hilt など、他の Jetpack ライブラリとの統合。

UI ロジックとその状態ホルダー

UI ロジックは、UI 自体が提供するデータに対して動作するロジックです。これは、UI 要素の状態、または権限 API や Resources などの UI データソースにあります。UI ロジックを使用する状態ホルダーには通常、次の特性があります。

  • UI 状態を生成し、UI 要素の状態を管理する
  • Activity の再作成後まで保持されない: UI ロジックでホストされている状態ホルダーは UI 自体のデータソースに依存していることが多く、構成変更後もその情報を保持しようとすると、たいていメモリリークが発生します。状態ホルダーが構成変更後もデータを保持する必要がある場合は、Activity の再作成後も存続するために最適な別のコンポーネントにデリゲートする必要があります。たとえば Jetpack Compose では、remembered 関数で作成された Composable UI 要素の状態は多くの場合、Activity の再作成後も状態を保持するために、rememberSaveable にデリゲートします。このような関数の例としては、rememberScaffoldState()rememberLazyListState() などがあります。
  • UI にスコープ設定されたデータソースへの参照がある: UI ロジック状態ホルダーのライフサイクルが UI と同じであるため、ライフサイクル API やリソースなどのデータソースの参照と読み取りを安全に行うことができます。
  • 複数の UI で再利用できる: 同じ UI ロジック状態ホルダーのさまざまなインスタンスを、アプリのさまざまな部分で再利用できます。たとえば、チップグループのユーザー入力イベントを管理するための状態ホルダーを、フィルタチップの検索ページや、メール受信者の「宛先」フィールドでも使用できます。

UI ロジック状態ホルダーは通常、プレーンなクラスで実装されます。これは、UI 自体が UI ロジック状態ホルダーの作成を担当しており、UI ロジック状態ホルダーのライフサイクルが UI 自体と同じであるためです。たとえば Jetpack Compose では、状態ホルダーは Composition の一部であり、Composition のライフサイクルに従います。

以上のことは、Now in Android サンプルで次のように説明できます。

Now in Android は、プレーンなクラスの状態ホルダーを使用して UI ロジックを管理します。
図 5: Now in Android サンプルアプリ

Now in Android のサンプルでは、デバイスの画面サイズに応じて、ボトム アプリバーまたはナビゲーション レールのいずれかをナビゲーションに表示します。小さな画面ではボトム アプリバーが使用され、大きな画面ではナビゲーション レールが使用されます。

コンポーズ可能な関数 NiaApp で使用される適切なナビゲーション UI 要素を決定するためのロジックは、ビジネス ロジックに依存しないため、プレーンなクラスの状態ホルダー(NiaAppState)で管理できます。

@Stable
class NiaAppState(
    val navController: NavHostController,
    val windowSizeClass: WindowSizeClass
) {

    // UI logic
    val shouldShowBottomBar: Boolean
        get() = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Compact ||
            windowSizeClass.heightSizeClass == WindowHeightSizeClass.Compact

    // UI logic
    val shouldShowNavRail: Boolean
        get() = !shouldShowBottomBar

   // UI State
    val currentDestination: NavDestination?
        @Composable get() = navController
            .currentBackStackEntryAsState().value?.destination

    // UI logic
    fun navigate(destination: NiaNavigationDestination, route: String? = null) { /* ... */ }

     /* ... */
}

上記の例では、NiaAppState に関する次の詳細に注目してください。

  • Activity の再作成後まで存続しない: NiaAppState は、Compose の命名規則に従ってコンポーズ可能な関数 rememberNiaAppState で作成することで、Composition に remembered されます。Activity が再作成されると、以前のインスタンスは失われ、再作成された Activity の新しい構成に適した、すべての依存関係が渡された新しいインスタンスが作成されます。これらの依存関係は、新しいものでも、以前の構成から復元されたものでも構いません。たとえば、rememberNavController()NiaAppState コンストラクタで使用され、Activity の再作成後も状態を保持するために rememberSaveable にデリゲートします。
  • UI にスコープ設定されたデータソースへの参照がある: navigationControllerResources、その他の同様のライフサイクルにスコープ設定されたタイプへの参照は、同じライフサイクル スコープを共有するため、NiaAppState で安全に保持できます。

状態ホルダーに ViewModel とプレーンなクラスのどちらを選ぶか

以上のセクションから、ViewModel とプレーンなクラスの状態ホルダーのどちらを選ぶかは、UI 状態に適用されるロジックと、ロジックが動作しているデータソースによって決まります。

まとめると、UI 状態生成パイプラインにおける状態ホルダーの位置付けは下図のようになります。

データ生成レイヤから UI レイヤへのデータフロー
図 6: UI 状態生成パイプラインにおける状態ホルダー。矢印はデータフローを表します。

最終的には、消費される場所に最も近い状態ホルダーを使用して UI 状態を生成する必要があります。その際には、適切な所有権を維持しながら状態をできるだけ低くします。ビジネス ロジックにアクセスする必要があり、Activity の再作成後も画面がナビゲートされる限り UI 状態を保持する必要がある場合、ビジネス ロジック状態ホルダーの実装には ViewModel が最適です。存続期間が短い UI 状態と UI ロジックの場合は、ライフサイクルが UI にのみ依存するプレーンなクラスで十分です。

状態ホルダーは複合可能

依存関係の存続期間が同じか、状態ホルダーより短い限り、状態ホルダーは他の状態ホルダーに依存できます。たとえば次のようになります。

  • UI ロジック状態ホルダーは、別の UI ロジック状態ホルダーに依存できます。
  • 画面レベルの状態ホルダーは、UI ロジック状態ホルダーに依存できます。

次のコード スニペットは、Compose の DrawerState が別の内部の状態ホルダー SwipeableState に依存する方法と、アプリの UI ロジック状態ホルダーが DrawerState に依存する方法を示しています。

@Stable
class DrawerState(/* ... */) {
  internal val swipeableState = SwipeableState(/* ... */)
  // ...
}

@Stable
class MyAppState(
  private val drawerState: DrawerState,
  private val navController: NavHostController
) { /* ... */ }

@Composable
fun rememberMyAppState(
  drawerState: DrawerState = rememberDrawerState(DrawerValue.Closed),
  navController: NavHostController = rememberNavController()
): MyAppState = remember(drawerState, navController) {
  MyAppState(drawerState, navController)
}

存続期間が状態ホルダーよりも長い依存関係の例としては、UI ロジック状態ホルダーが画面レベルの状態ホルダーに依存するケースが考えられます。このケースでは、存続期間が短い状態ホルダーの再利用性が低下し、必要以上のロジックと状態へのアクセス権を状態ホルダーに付与することになります。

存続期間が短い状態ホルダーが、上位スコープの状態ホルダーの特定の情報を必要とする場合は、状態ホルダーのインスタンスを渡すのではなく、必要な情報だけをパラメータとして渡します。たとえば、次のコード スニペットでは、UI ロジック状態ホルダークラスに、ViewModel インスタンス全体を依存関係として渡すのではなく、ViewModel から必要な情報だけをパラメータとして渡しています。

class MyScreenViewModel(/* ... */) {
  val uiState: StateFlow<MyScreenUiState> = /* ... */
  fun doSomething() { /* ... */ }
  fun doAnotherThing() { /* ... */ }
  // ...
}

@Stable
class MyScreenState(
  // DO NOT pass a ViewModel instance to a plain state holder class
  // private val viewModel: MyScreenViewModel,

  // Instead, pass only what it needs as a dependency
  private val someState: StateFlow<SomeState>,
  private val doSomething: () -> Unit,

  // Other UI-scoped types
  private val scaffoldState: ScaffoldState
) {
  /* ... */
}

@Composable
fun rememberMyScreenState(
  someState: StateFlow<SomeState>,
  doSomething: () -> Unit,
  scaffoldState: ScaffoldState = rememberScaffoldState()
): MyScreenState = remember(someState, doSomething, scaffoldState) {
  MyScreenState(someState, doSomething, scaffoldState)
}

@Composable
fun MyScreen(
  modifier: Modifier = Modifier,
  viewModel: MyScreenViewModel = viewModel(),
  state: MyScreenState = rememberMyScreenState(
    someState = viewModel.uiState.map { it.toSomeState() },
    doSomething = viewModel::doSomething
  ),
  // ...
) {
  /* ... */
}

次の図は、UI と前のコード スニペットのさまざまな状態ホルダーとの依存関係を示しています。

UI ロジック状態ホルダーと画面レベルの状態ホルダーの両方に依存している UI
図 7: さまざまな状態ホルダーに依存している UI。矢印は依存関係を表しています。

サンプル

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