状态提升的位置

在 Compose 应用程序中,是否需要将 UI 状态 上移取决于 UI 逻辑或业务逻辑的需求。本文档阐述了这两种主要场景。

最佳实践

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

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

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

UI 状态和 UI 逻辑的类型

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

UI 状态

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

  • 屏幕 UI 状态 是您需要在屏幕上显示的内容。例如,NewsUiState 类可以包含新闻文章和其他渲染 UI 所需的信息。此状态通常与层次结构的其他层相关联,因为它包含应用程序数据。
  • UI 元素状态 指的是影响 UI 元素渲染方式的 UI 元素固有属性。UI 元素可以显示或隐藏,并且可以具有特定的字体、字体大小或字体颜色。在 Android 视图中,View 本身管理此状态,因为它本质上是有状态的,公开了修改或查询其状态的方法。例如 getset 方法,它们是 TextView 类用于其文本的方法。在 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 中,遵循其生命周期。

请注意,lazyListStateMessagesList 方法中定义,其默认值为 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 作为状态所有者

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

当你将 UI 状态提升到ViewModel中时,你将它移出了组合。

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

ViewModels 并不作为组合的一部分存储。它们由框架提供,并且作用域限定到一个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的运行时异常,其消息为“在此CoroutineContext”中不可用MonotonicFrameClock”。

要解决此问题,请使用作用域限定到组合的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

视频