状态提升的位置

在 Compose 应用中,界面状态提升的位置取决于界面逻辑还是业务逻辑需要它。本文档阐述了这两种主要场景。

最佳实践

您应该将界面状态提升到所有读取和写入该状态的可组合项之间的**最低共同祖先**。您应该将状态尽可能地靠近其被使用的地方。从状态所有者,向消费者暴露不可变状态和修改状态的事件。

最低共同祖先也可以在 Composition 之外。例如,当提升状态到 ViewModel 中时,因为这涉及到业务逻辑。

本页面将详细解释此最佳实践,并提醒您注意一个注意事项。

界面状态和界面逻辑的类型

下面是本文档中使用的界面状态和逻辑类型的定义。

界面状态

界面状态是描述界面的属性。界面状态有两种类型:

  • **屏幕界面状态**是您需要在屏幕上显示*什么*。例如,NewsUiState 类可以包含新闻文章和渲染界面所需的其他信息。这种状态通常与层次结构的其他层相关联,因为它包含应用数据。
  • **界面元素状态**是指界面元素固有的属性,这些属性会影响它们的呈现方式。界面元素可以显示或隐藏,并且可以具有特定的字体、字体大小或字体颜色。在 Android View 中,View 本身管理此状态,因为它本质上是有状态的,并公开了修改或查询其状态的方法。例如,TextView 类的文本的 getset 方法。在 Jetpack Compose 中,状态在可组合项之外,您甚至可以将其从可组合项的直接附近提升到调用可组合函数或状态持有者。一个例子是 Scaffold 可组合项的 ScaffoldState

逻辑

应用中的逻辑可以是业务逻辑或界面逻辑

  • **业务逻辑**是应用数据产品需求的实现。例如,当用户点击按钮时,在新闻阅读应用中收藏一篇文章。这种将书签保存到文件或数据库的逻辑通常放在领域层或数据层。状态持有者通常通过调用这些层公开的方法来将此逻辑委托给它们。
  • **界面逻辑**与在屏幕上显示界面状态*如何*相关。例如,当用户选择类别时获取正确的搜索栏提示,滚动到列表中的特定项,或者当用户点击按钮时导航到特定屏幕的逻辑。

界面逻辑

界面逻辑需要读取或写入状态时,您应该将状态的作用域限定到界面,并遵循其生命周期。为此,您应该在可组合函数中将状态提升到正确的级别。或者,您也可以在普通状态持有者类中进行此操作,该类也限定在界面生命周期内。

下面是对这两种解决方案的描述以及何时使用它们的说明。

可组合项作为状态所有者

如果状态和逻辑简单,将界面逻辑和界面元素状态放在可组合项中是一个好方法。您可以将状态保留在可组合项内部,或根据需要进行提升。

无需状态提升

并非总是需要状态提升。当没有其他可组合项需要控制状态时,状态可以保留在可组合项内部。在此代码段中,有一个可组合项在点击时展开和折叠

@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 是此界面元素的内部状态。它仅在此可组合项中读取和修改,并且应用于它的逻辑非常简单。因此,在这种情况下提升状态不会带来太多好处,所以您可以将其保留在内部。这样做使此可组合项成为展开状态的所有者和单一事实来源。

在可组合项内部提升

如果您需要与其他可组合项共享您的界面元素状态并在不同位置对其应用界面逻辑,您可以将其提升到界面层次结构的更高位置。这也会使您的可组合项更具可重用性且更易于测试。

以下示例是一个聊天应用,它实现了两项功能

  • HJumpToBottom 按钮将消息列表滚动到底部。该按钮对列表状态执行界面逻辑。
  • HMessagesList 列表在用户发送新消息后滚动到底部。UserInput 对列表状态执行界面逻辑。
Chat app with a JumpToBottom button and scroll to bottom on new messages
图 1. 带有 JumpToBottom 按钮并在收到新消息时滚动到底部的聊天应用

可组合层次结构如下

Chat composable tree
图 2. 聊天可组合树

HLazyColumn 状态提升到对话屏幕,以便应用可以执行界面逻辑并从所有需要它的可组合项中读取状态

Hoisting LazyColumn state from the LazyColumn to the ConversationScreen
图 3.LazyColumn 状态从 LazyColumn 提升到 ConversationScreen

最终的可组合项是

Chat composable tree with LazyListState hoisted to ConversationScreen
图 4.LazyListState 提升到 ConversationScreen 的聊天可组合树

代码如下

@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 提升到需要应用界面逻辑的最高层。由于它是在可组合函数中初始化的,因此它会存储在 Composition 中,并遵循其生命周期。

请注意,lazyListStateMessagesList 方法中定义,默认值为 rememberLazyListState()。这是 Compose 中的常见模式。它使可组合项更具可重用性和灵活性。然后,您可以在应用的各个部分使用该可组合项,这些部分可能不需要控制状态。这通常在测试或预览可组合项时发生。这正是 LazyColumn 定义其状态的方式。

Lowest common ancestor for LazyListState is ConversationScreen
图 5. LazyListState 的最低共同祖先是 ConversationScreen

普通状态持有者类作为状态所有者

当可组合项包含涉及界面元素的一个或多个状态字段的复杂界面逻辑时,它应该将该职责委托给状态持有者,例如普通状态持有者类。这使得可组合项的逻辑在隔离测试中更具可测试性,并降低其复杂性。这种方法有利于关注点分离原则:**可组合项负责发出界面元素,而状态持有者包含界面逻辑和界面元素状态**。

普通状态持有者类为您的可组合函数的调用者提供了方便的函数,因此他们不必自己编写此逻辑。

这些普通类在 Composition 中创建并记住。由于它们遵循可组合项的生命周期,因此它们可以接受 Compose 库提供的类型,例如 rememberNavController()rememberLazyListState()

一个例子是 LazyListState 普通状态持有者类,它在 Compose 中实现,用于控制 LazyColumnLazyRow 的界面复杂性。

// 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 封装了 LazyColumn 的状态,存储此界面元素的 scrollPosition。它还公开了修改滚动位置的方法,例如滚动到给定项。

如您所见,**增加可组合项的职责会增加对状态持有者的需求**。职责可能在于界面逻辑,或者只是在于需要跟踪的状态量。

另一种常见模式是使用普通状态持有者类来处理应用中根可组合函数的复杂性。您可以使用此类来封装应用级状态,例如导航状态和屏幕大小。有关此内容的完整描述,请参见界面逻辑及其状态持有者页面

业务逻辑

如果可组合项和普通状态持有者类负责界面逻辑和界面元素状态,那么屏幕级状态持有者则负责以下任务

  • 提供对应用业务逻辑的访问,这些逻辑通常位于层次结构的其他层,例如业务层和数据层。
  • 准备应用数据以在特定屏幕中呈现,这成为屏幕界面状态。

ViewModel 作为状态所有者

AAC ViewModel 在 Android 开发中的优势使其适合提供对业务逻辑的访问,并准备应用数据以便在屏幕上显示。

当您在 ViewModel 中提升界面状态时,您将其移到 Composition 之外。

State hoisted to the ViewModel is stored outside of the Composition.
图 6. 提升到 ViewModel 的状态存储在 Composition 之外。

ViewModel 不作为 Composition 的一部分存储。它们由框架提供,并作用域限定到 ViewModelStoreOwner,它可以是 Activity、Fragment、导航图或导航图的目的地。有关 ViewModel 作用域的更多信息,您可以查阅文档。

因此,ViewModel 是界面状态的单一事实来源和**最低共同祖先**。

屏幕界面状态

根据上述定义,屏幕界面状态是通过应用业务规则生成的。鉴于屏幕级状态持有者对此负责,这意味着屏幕界面状态通常在屏幕级状态持有者中提升,在本例中是 ViewModel

考虑聊天应用的 ConversationViewModel 以及它如何暴露屏幕界面状态和修改它的事件

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 中的屏幕界面状态。您应该在屏幕级可组合项中注入 ViewModel 实例,以提供对业务逻辑的访问。

以下是屏幕级可组合项中使用的 ViewModel 示例。在这里,可组合项 ConversationScreen() 使用提升到 ViewModel 中的屏幕界面状态

@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 中出现属性向下传递的一个典型示例是,当您在顶层注入屏幕级状态持有者并将状态和事件传递给子可组合项时。这可能还会导致可组合函数签名过载。

即使将事件作为单独的 lambda 参数公开可能会使函数签名过载,但它最大限度地提高了可组合函数职责的可见性。您可以一目了然地看到它的作用。

属性向下传递优于创建包装类来将状态和事件封装在一个地方,因为这会降低可组合项职责的可见性。通过不使用包装类,您也更有可能只将可组合项所需的参数传递给它们,这是一种最佳实践

如果这些事件是导航事件,也适用相同的最佳实践,您可以在导航文档中了解更多信息。

如果您发现了性能问题,您也可以选择推迟读取状态。您可以查看性能文档以了解更多信息。

界面元素状态

如果有业务逻辑需要读取或写入界面元素状态,您可以将其提升到屏幕级状态持有者。

继续以聊天应用为例,当用户输入 @ 和提示时,应用会在群聊中显示用户建议。这些建议来自数据层,计算用户建议列表的逻辑被视为业务逻辑。该功能如下所示:

Feature that displays user suggestions in a group chat when the user types `@` and a hint
图 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
    }
}

inputMessage 是存储 TextField 状态的变量。每当用户输入新内容时,应用都会调用业务逻辑来生成 suggestions

suggestions 是屏幕界面状态,通过从 StateFlow 收集来从 Compose UI 消费。

注意事项

对于某些 Compose 界面元素状态,提升到 ViewModel 可能需要特殊考虑。例如,一些 Compose 界面元素的状态持有者暴露了修改状态的方法。其中一些可能是触发动画的 suspend 函数。如果您从未限定到 Composition 的 CoroutineScope 调用这些 suspend 函数,它们可能会抛出异常。

假设应用抽屉的内容是动态的,并且您需要在其关闭后从数据层获取并刷新它。您应该将抽屉状态提升到 ViewModel,以便您可以从状态所有者调用此元素的界面逻辑和业务逻辑。

然而,从 Compose UI 使用 viewModelScope 调用 DrawerStateclose() 方法会导致类型为 IllegalStateException 的运行时异常,并显示消息“a MonotonicFrameClock is not available in this CoroutineContext”

要解决此问题,请使用限定到 Composition 的 CoroutineScope。它在 CoroutineContext 中提供了一个 MonotonicFrameClock,这是 suspend 函数工作所必需的。

要修复此崩溃,请将 ViewModel 中协程的 CoroutineContext 切换到限定到 Composition 的协程。它可能看起来像这样

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 的更多信息,请查阅以下其他资源。

示例

Codelabs

视频