提升状态的场景

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

最佳实践

您应将界面状态提升到读取和写入状态的所有可组合项之间的最低共同祖先实体。您应使状态尽可能靠近其使用位置。通过状态所有者,向使用者公开不可变状态和事件,以修改状态。

最低共同祖先实体也可以在组合之外。例如,因涉及业务逻辑而在 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 是此界面元素的内部状态。其读取和修改仅发生在此可组合项中,而且所应用的逻辑也非常简单。在这种情况下,提升状态不会带来很多好处,因此您可以将其留在内部。这样做会使此可组合项成为展开状态的所有者和单一可信来源。

在可组合项中提升

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

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

  • JumpToBottom 按钮可将消息列表滚动到底部。该按钮会对列表状态执行界面逻辑。
  • 当用户发送新消息后,MessagesList 列表会滚动到底部。用户输入会对列表状态执行界面逻辑。
带有 JumpToBottom 按钮的聊天应用,可在收到新消息时滚动到底部
图 1. 带有 JumpToBottom 按钮的 Chat 扩展应用,可在收到新消息时滚动到底部

可组合项层次结构如下所示:

聊天可组合项树
图 2. 聊天可组合项树

LazyColumn 状态提升到了对话界面,这样应用便可执行界面逻辑,并从需要相应状态的所有可组合项中读取状态:

将 LazyColumn 状态从 LazyColumn 提升到 ConversationScreen
图 3.LazyColumn 状态从 LazyColumn 提升到 ConversationScreen

最后,这些可组合项如下:

聊天可组合项树,其中 LazyListState 提升到了 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 会提升到需要应用的界面逻辑所要求的高度。由于其初始化发生在可组合函数中,因此会按照其生命周期存储在组合中。

请注意,lazyListState 是在 MessagesList 方法中定义的,默认值为 rememberLazyListState()。这是 Compose 中的一种常见模式。这会让可组合项具有更高的可重用性和灵活性。然后,您可以在应用的不同部分(可能不需要控制状态)使用可组合项。测试或预览可组合项时通常就属于这种情况。这就是 LazyColumn 定义其状态。

LazyListState 的最低共同祖先实体是 ConversationScreen
图 5. LazyListState 的最低共同祖先实体为 ConversationScreen

以普通状态容器类作为状态所有者

当可组合项包含涉及界面元素的一个或多个状态字段的复杂界面逻辑时,应将这种责任委托给状态容器,例如普通状态容器类。这样做更易于单独对可组合项的逻辑进行测试,还可以降低复杂性。该方法支持关注点分离原则可组合项负责发出界面元素,而状态容器包含界面逻辑和界面元素的状态

普通状态容器类为可组合函数的调用方提供了一些便捷的函数,这样他们就无需自行编写此逻辑。

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

这种类型的一个示例是 Compose 中实现的 LazyListState 普通状态容器类,用于控制 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 封装用于存储此界面元素的 scrollPositionLazyColumn 的状态。它还公开了修改滚动位置的方法,例如滚动到给定项。

如您所见,增加可组合项的责任会增加对状态容器的需求。这些责任可能存在于界面逻辑中,也可能仅与要跟踪的状态数相关。

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

业务逻辑

如果可组合项和普通状态容器类负责界面逻辑和界面元素状态,则屏幕级别状态容器负责以下任务:

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

ViewModel 作为状态所有者

AAC ViewModels 在 Android 开发中的优势使其适用于提供对业务逻辑的访问权限以及准备要在屏幕上呈现的应用数据。

ViewModel 中提升界面状态时,您会将其移出到组合之外。

提升到 ViewModel 的状态存储在组合之外。
图 6. 提升到 ViewModel 的状态存储在组合之外。

ViewModel 不会作为组合的一部分进行存储。它们由框架提供,其作用域限定为 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 参数公开可能会使函数签名过载,但它可以最大限度提高可组合函数功能的可见性。您可以一目了然地了解它的功能。

相较于创建封装容器类来将状态和事件封装到一个位置,属性深入分析是一种更可取的方式,因为这样可以降低可组合责任的可见性。此外,如果没有封装容器类,您更有可能只向可组合项传递其所需的参数,这属于最佳实践

如果这些事件是导航事件,则同样符合最佳实践,您可以在导航文档中了解详情。

如果您发现性能问题,还可以选择延迟读取状态。如需了解详情,请参阅性能文档

界面元素状态

如果存在需要读取或写入的业务逻辑,您可以将界面元素状态提升至屏幕级状态容器。

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

当用户输入“@”和提示时,此功能可在群聊中显示用户建议
图 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 是屏幕界面状态;Compose 界面通过从 StateFlow 进行收集来使用该状态。

警告

对于某些 Compose 界面元素状态,升级到 ViewModel 可能需要特别注意。例如,Compose 界面元素的某些状态容器公开了修改状态的方法。其中一些可能是触发动画的挂起函数。如果您从作用域未限定于组合的 CoroutineScope 调用这些挂起函数,则可能会抛出异常。

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

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

如需修复此问题,请使用作用域限定为组合的 CoroutineScope。这会在 CoroutineContext 中提供 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

视频