状态提升位置

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

最佳实践

您应该将 UI 状态提升到所有读取和写入它的可组合项之间的最近公共祖先。您应该将状态保持在其被使用位置的附近。从状态所有者处,向使用者公开不可变状态和修改状态的事件。

最近公共祖先也可以位于 Composition 之外。例如,当在ViewModel中提升状态,因为涉及业务逻辑。

此页面详细解释了此最佳实践以及需要注意的一个警告。

UI 状态和 UI 逻辑的类型

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

UI 状态

UI 状态是描述 UI 的属性。UI 状态有两种类型

  • 屏幕 UI 状态是您需要在屏幕上显示的内容。例如,NewsUiState 类可以包含新闻文章和其他渲染 UI 所需的信息。此状态通常与层次结构的其他层连接,因为它包含应用程序数据。
  • UI 元素状态是指影响 UI 元素渲染方式的 UI 元素的内在属性。UI 元素可以显示或隐藏,并且可以具有特定的字体、字体大小或字体颜色。在 Android Views 中,View 本身管理此状态,因为它本质上是有状态的,并公开方法来修改或查询其状态。例如,getsetTextView 类文本的的方法。在 Jetpack Compose 中,状态位于可组合项外部,您甚至可以将其从可组合项的直接附近提升到调用可组合函数或状态持有者中。例如,ScaffoldState 用于 Scaffold 可组合项。

逻辑

应用程序中的逻辑可以是业务逻辑或 UI 逻辑

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

UI 逻辑

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

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

可组合项作为状态所有者

如果状态和逻辑很简单,那么在可组合项中拥有 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 层次结构中更高的位置。这还可以使您的可组合项更易于重用和测试。

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

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

可组合层次结构如下所示

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

LazyColumn 状态被提升到会话屏幕,以便应用程序可以执行 UI 逻辑并从所有需要它的可组合项读取状态

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被提升到 UI 逻辑必须应用的所需高度。由于它是在可组合函数中初始化的,因此它存储在 Composition 中,遵循其生命周期。

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

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

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

当可组合项包含涉及 UI 元素的一个或多个状态字段的复杂 UI 逻辑时,它应该将此责任委托给状态持有者,例如普通状态持有者类。这使得可组合项的逻辑更容易单独进行测试,并降低了其复杂性。这种方法有利于关注点分离原则可组合项负责发出 UI 元素,而状态持有者包含 UI 逻辑和 UI 元素状态

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

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

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

// 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的状态,存储此 UI 元素的scrollPosition。它还公开方法通过例如滚动到给定项目来修改滚动位置。

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

另一种常见模式是使用简单的状态持有者类来处理应用中根可组合函数的复杂性。您可以使用这样的类来封装应用级状态,例如导航状态和屏幕大小。可以在UI 逻辑及其状态持有者页面中找到对此的完整描述。

业务逻辑

如果可组合函数和简单的状态持有者类负责 UI 逻辑和 UI 元素状态,则屏幕级状态持有者负责以下任务:

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

ViewModel 作为状态所有者

Android 开发中 AAC ViewModel 的优势使其适合于访问业务逻辑和准备应用程序数据以在屏幕上呈现。

当您在ViewModel中提升 UI 状态时,您会将其移出组合。

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

ViewModel 并不作为组合的一部分存储。它们由框架提供,并且它们的范围限定为一个ViewModelStoreOwner,它可以是 Activity、Fragment、导航图或导航图的目标。有关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 中出现属性穿透的一个典型示例是,当您在顶级注入屏幕级状态持有者并将状态和事件传递给子可组合函数时。这还可能导致可组合函数签名的过载。

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

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

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

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

UI 元素状态

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

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

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是屏幕 UI 状态,并通过从StateFlow收集来由 Compose UI 使用。

警告

对于某些 Compose UI 元素状态,提升到ViewModel可能需要特别考虑。例如,某些 Compose UI 元素的状态持有者公开了修改状态的方法。其中一些可能是挂起函数,可以触发动画。如果您从未限定组合范围的CoroutineScope调用这些挂起函数,则可能会引发异常。

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

但是,使用 Compose UI 中的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 的更多信息,请参阅以下其他资源。

示例

Codelabs

视频