状態をホイスティングする場所

Compose アプリケーションでは、UI の状態をホイスティングする場所は、UI ロジックとビジネス ロジックのどちらがそれを必要としているかによって異なります。このドキュメントでは、この 2 つの主要なシナリオについて説明します。

ベスト プラクティス

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 状態を表示する方法に関連します。たとえば、ユーザーがカテゴリを選択したときに適切な検索バーのヒントを得ることや、リスト内の特定の項目までスクロールすること、ユーザーがボタンをクリックしたときの特定の画面へのナビゲーション ロジックなどです。

UI ロジック

UI ロジックで状態を読み書きする必要がある場合は、そのライフサイクルに従って状態を UI のスコープにする必要があります。そのためには、コンポーズ可能な関数で状態を正しいレベルでホイスティングする必要があります。または、UI ライフサイクルをスコープとするプレーンな状態ホルダークラスで行うこともできます。

以下に、2 つの方法の説明と、いつ、どちらを使うかの説明を示します。

状態のオーナーとしてのコンポーザブル

状態とロジックが単純な場合、コンポーザブルに UI ロジックと UI 要素を保有するのが最適なアプローチです。必要に応じて、状態をコンポーザブルの内部に残すことも、ホイスティングすることもできます。

状態ホイスティングは不要

状態をホイスティングする必要がない場合もあります。他のコンポーザブルが制御する必要がない場合、状態をコンポーザブル内に保持できます。このスニペットには、タップで展開と折りたたみを行うコンポーザブルがあります。

@Composable
fun ChatBubble(
    message: Message
) {
    var showDetails by rememberSaveable { mutableStateOf(false) } // Define the UI element expanded state

    ClickableText(
        text = AnnotatedString(message.content),
        onClick = { showDetails = !showDetails } // Apply simple UI logic
    )

    if (showDetails) {
        Text(message.timestamp)
    }
}

変数 showDetails は、この UI 要素の内部状態です。このコンポーザブルでは読み取りと変更のみが行われるため、適用されるロジックは非常にシンプルです。この場合、状態をホイスティングしても大きなメリットがないため、内部に残したままにできます。このようにすることで、このコンポーザブルが展開された状態のオーナー、および信頼できる単一の情報源になります。

コンポーザブル内のホイスティング

UI 要素の状態を他のコンポーザブルと共有し、UI ロジックをさまざまな場所に適用する必要がある場合は、UI 階層の上位にホイスティングできます。これにより、コンポーザブルを再利用しやすくなり、テストも簡単になります。

次の例は、2 つの機能を実装するチャットアプリです。

  • JumpToBottom ボタンを押すと、メッセージ リストが一番下までスクロールされます。このボタンは、リスト状態に対して UI ロジックを実行します。
  • ユーザーが新しいメッセージを送信すると、MessagesList リストが一番下までスクロールされます。UserInput はリスト状態に対して UI ロジックを実行します。
JumpToBottom ボタンと、新しいメッセージを送信すると一番下までスクロールする機能を持つチャットアプリ
図 1. JumpToBottom ボタンと、新しいメッセージで一番下までスクロールするチャットアプリ

コンポーザブルの階層は次のとおりです。

チャット コンポーザブル ツリー
図 2. チャット コンポーザブル ツリー

LazyColumn の状態は会話画面にホイスティングされるため、アプリは UI ロジックを実行して、それを必要とするすべてのコンポーザブルから状態を読み取ることができます。

LazyColumn の状態の LazyColumn から ConversationScreen へのホイスティング
図 3. LazyColumn の状態を LazyColumn から ConversationScreen にホイスティングする

最終的にコンポーザブルは次のようになります。

LazyListState が ConversationScreen にホイスティングされたチャット コンポーザブル ツリー
図 4. LazyListStateConversationScreen にホイスティングされたチャット コンポーザブル ツリー

コードは次のとおりです。

@Composable
private fun ConversationScreen(/*...*/) {
    val scope = rememberCoroutineScope()

    val lazyListState = rememberLazyListState() // State hoisted to the ConversationScreen

    MessagesList(messages, lazyListState) // Reuse same state in MessageList

    UserInput(
        onMessageSent = { // Apply UI logic to lazyListState
            scope.launch {
                lazyListState.scrollToItem(0)
            }
        },
    )
}

@Composable
private fun MessagesList(
    messages: List<Message>,
    lazyListState: LazyListState = rememberLazyListState() // LazyListState has a default value
) {

    LazyColumn(
        state = lazyListState // Pass hoisted state to LazyColumn
    ) {
        items(messages, key = { message -> message.id }) { item ->
            Message(/*...*/)
        }
    }

    val scope = rememberCoroutineScope()

    JumpToBottom(onClicked = {
        scope.launch {
            lazyListState.scrollToItem(0) // UI logic being applied to lazyListState
        }
    })
}

LazyListState は、適用する必要がある UI ロジックに必要な分だけホイスティングされます。これはコンポーズ可能な関数で初期化されるため、ライフサイクルに沿ってコンポジションに格納されます。

lazyListStateMessagesList メソッドで定義され、デフォルト値は rememberLazyListState() です。これは Compose の一般的なパターンです。これにより、コンポーザブルの再利用性と柔軟性が向上します。このコンポーザブルは、状態の管理を必要としないアプリのさまざまな部分で使用できるようになります。これは、通常、コンポーザブルのテストまたはプレビューの際に発生します。LazyColumn が状態を定義するのはこのとおりです。

LazyListState の最下位の共通の祖先は ConversationScreen
図 5. LazyListState の最下位の共通の祖先は ConversationScreen です

状態のオーナーとしてのプレーン状態ホルダークラス

コンポーザブルに、UI 要素の 1 つ以上の状態フィールドを含む複雑な UI ロジックが含まれている場合、その役割をプレーンな状態ホルダークラスなど、状態ホルダーにデリゲートする必要があります。これにより、コンポーザブルのロジックを単独でテストできるようになり、複雑さが軽減されます。このアプローチは、関心の分離の原則に則ったアプローチです。つまり、コンポーザブルは UI 要素の出力を管理し、状態ホルダーには UI ロジックと UI 要素の状態が含まれます

プレーンな状態ホルダークラスがコンポーズ可能な関数の呼び出し元に便利な関数を提供するため、このロジックを自身で記述する必要がありません。

これらのプレーンなクラスはコンポジション内に作成され、記憶されます。コンポーザブルのライフサイクルに従うため、rememberNavController()rememberLazyListState() など、Compose ライブラリで提供される型を取ることができます。

この例は、LazyColumnLazyRow の UI の複雑さを制御するための、LazyListState Compose に実装されたプレーンな状態ホルダークラスです。

// LazyListState.kt

@Stable
class LazyListState constructor(
    firstVisibleItemIndex: Int = 0,
    firstVisibleItemScrollOffset: Int = 0
) : ScrollableState {
    /**
     *   The holder class for the current scroll position.
     */
    private val scrollPosition = LazyListScrollPosition(
        firstVisibleItemIndex, firstVisibleItemScrollOffset
    )

    suspend fun scrollToItem(/*...*/) { /*...*/ }

    override suspend fun scroll() { /*...*/ }

    suspend fun animateScrollToItem() { /*...*/ }
}

LazyListState は、この UI 要素の scrollPosition を格納する LazyColumn の状態をカプセル化します。また、特定の項目にスクロールするなどして、スクロール位置を変更するメソッドも公開します。

このように、コンポーザブルの役割を増やすことで、状態ホルダーの必要性が高まります。状態ホルダーの役割は、UI ロジックに対応するまたは、トラッキングする状態の量の増加に対応することにあります。

もう 1 つの一般的なパターンでは、プレーンな状態ホルダークラスを使用して、アプリ内の複雑なルート コンポーザブル関数を処理します。このようなクラスを使用して、ナビゲーション状態や画面サイズなどのアプリレベルの状態をカプセル化できます。詳しくは、UI ロジックとその状態ホルダーのページをご覧ください。

ビジネス ロジック

コンポーザブルとプレーンな状態ホルダークラスが UI ロジックと UI 要素の状態を処理する場合、画面レベルの状態ホルダーが以下のタスクを処理します。

  • ビジネスレイヤやデータレイヤなど、通常は階層の他のレイヤに配置されるアプリのビジネス ロジックへのアクセスを提供する。
  • 特定の画面に表示するアプリデータ(画面 UI 状態)を準備する。

状態のオーナーとしての ViewModel

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

ViewModel で UI の状態をホイスティングする場合、コンポジションの外部に移動されます。

ViewModel にホイスティングされた状態がコンポジションの外部に保存される
図 6. ViewModel にホイスティングされた状態は、コンポジションの外部に保存されます。

ViewModel はコンポジションの一部として保存されません。フレームワークにより提供され、アクティビティ、フラグメント、ナビゲーション グラフ、あるいはナビゲーション グラフの宛先である ViewModelStoreOwner にスコープされます。ViewModel スコープの詳細については、ドキュメントをご覧ください。

ViewModel は信頼できる情報源であり、UI 状態の最下位の共通の祖先です。

画面 UI 状態

上記の定義通り、画面 UI 状態はビジネスルールを適用することで生成されます。画面レベルの状態ホルダーがその役割を果たすため、画面 UI 状態は通常、画面レベルの状態ホルダー(この場合は ViewModel)にホイスティングされます。

チャットアプリの ConversationViewModel と、画面 UI 状態とイベントを公開してそれを変更する方法を考えてみましょう。

class ConversationViewModel(
    channelId: String,
    messagesRepository: MessagesRepository
) : ViewModel() {

    val messages = messagesRepository
        .getLatestMessages(channelId)
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = emptyList()
        )

    // Business logic
    fun sendMessage(message: Message) { /* ... */ }
}

コンポーザブルは、ViewModel にホイスティングされた画面 UI 状態を使用します。ビジネス ロジックにアクセスできるように、ViewModel インスタンスを画面レベルのコンポーザブルに挿入する必要があります。

画面レベルのコンポーザブルで使用される ViewModel の例を次に示します。ここで、コンポーザブル ConversationScreen() は、ViewModel でホイスティングされた画面 UI 状態を使用します。

@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)
    /* ... */
}

プロパティ ドリルダウン

「プロパティ ドリルダウン」とは、複数のネストされた子コンポーネントを介して、読み取りが発生する場所にデータを渡すことです。

Compose でのプロパティ ドリルダウンの典型的な場合として、画面レベルの状態ホルダーをトップレベルに挿入し、状態とイベントを子コンポーザブルに渡す場合があります。これにより、コンポーズ可能な関数のシグネチャのオーバーロードが追加で発生する場合があります。

イベントを個別のラムダ パラメータとして公開することで、関数シグネチャがオーバーロードされる可能性がありますが、コンポーズ可能な関数で行う処理が最大限に可視化されます。その関数の機能が一目でわかります。

プロパティ ドリルダウンは、状態とイベントを 1 か所にカプセル化するラッパークラスを作成するよりも推奨されます。これにより、コンポーザブルの責任の可視性が低下するためです。また、ラッパークラスを指定しないことで、必要なパラメータのみをコンポーザブルに渡せる可能性が高くなります。これは、おすすめの方法です。

これらのイベントがナビゲーション イベントである場合にも、同じおすすめの方法が適用されます。詳細については、ナビゲーションのドキュメントをご覧ください。

パフォーマンスの問題が特定した場合に、状態の読み取りを延期することもできます。詳しくは、パフォーマンスに関するドキュメントをご覧ください。

UI 要素の状態

読み取りや書き込みが必要なビジネス ロジックがある場合は、UI 要素の状態を画面レベルの状態ホルダーにホイスティングできます。

チャットアプリの同じ例で、ユーザーが @ とヒントを入力すると、グループ チャットにユーザー候補が表示されます。これらの候補はデータレイヤから取得され、ユーザー候補のリストを計算するロジックはビジネス ロジックと見なされます。この機能は次のようになります。

ユーザーが「@」とヒントをタイプすると、グループ チャットにユーザー候補を表示する機能
図 7. ユーザーが @ とヒントをタイプすると、グループ チャットにユーザー候補を表示する機能

この機能を実装する ViewModel は次のようになります。

class ConversationViewModel(/*...*/) : ViewModel() {

    // Hoisted state
    var inputMessage by mutableStateOf("")
        private set

    val suggestions: StateFlow<List<Suggestion>> =
        snapshotFlow { inputMessage }
            .filter { hasSocialHandleHint(it) }
            .mapLatest { getHandle(it) }
            .mapLatest { repository.getSuggestions(it) }
            .stateIn(
                scope = viewModelScope,
                started = SharingStarted.WhileSubscribed(5_000),
                initialValue = emptyList()
            )

    fun updateInput(newInput: String) {
        inputMessage = newInput
    }
}

inputMessageTextField の状態を格納する変数です。ユーザーが新しい入力を行うたびに、アプリがビジネス ロジックを呼び出して suggestions を生成します。

suggestions は画面 UI 状態であり、StateFlow から収集することによって Compose UI から使用されます。

注意点

Compose UI 要素の状態によっては、ViewModel にホイスティングする場合に特別な考慮が必要になる場合があります。たとえば、Compose UI 要素の一部の状態ホルダーは、状態を変更するメソッドを公開します。その一部が、アニメーションをトリガーする suspend 関数である場合もあります。コンポジションをスコープとしない CoroutineScope から呼び出すと、suspend 関数が例外をスローすることがあります。

アプリドロワーのコンテンツが動的で、閉じた後にデータレイヤから取得して更新する必要があるとします。ドロワーの状態を ViewModel にホイスティングして、状態のオーナーからこの要素の UI とビジネス ロジックの両方を呼び出すことができるようにする必要があります。

ただし、DrawerStateclose() メソッドを viewModelScope を使用して Compose UI から呼び出すと、タイプ IllegalStateException のランタイム例外が「a MonotonicFrameClock is not available in this CoroutineContext”」というメッセージで発生します。

これを修正するには、コンポジションをスコープとする CoroutineScope を使用します。CoroutineContext で suspend 関数を動作させるために必要な MonotonicFrameClock を提供します。

このクラッシュを修正するには、ViewModel 内のコルーチンの CoroutineContext をコンポジションをスコープとするものに切り替えます。次のようになります。

class ConversationViewModel(/*...*/) : ViewModel() {

    val drawerState = DrawerState(initialValue = DrawerValue.Closed)

    private val _drawerContent = MutableStateFlow(DrawerContent.Empty)
    val drawerContent: StateFlow<DrawerContent> = _drawerContent.asStateFlow()

    fun closeDrawer(uiScope: CoroutineScope) {
        viewModelScope.launch {
            withContext(uiScope.coroutineContext) { // Use instead of the default context
                drawerState.close()
            }
            // Fetch drawer content and update state
            _drawerContent.update { content }
        }
    }
}

// in Compose
@Composable
private fun ConversationScreen(
    conversationViewModel: ConversationViewModel = viewModel()
) {
    val scope = rememberCoroutineScope()

    ConversationScreen(onCloseDrawer = { conversationViewModel.closeDrawer(uiScope = scope) })
}

詳細

状態と Jetpack Compose の詳細については、以下の参考情報をご覧ください。

サンプル

Codelab

動画