状态持有方和 UI 状态

UI 层指南》讨论了将单向数据流 (UDF) 作为生成和管理 UI 层 UI 状态的一种方法。

Data flows unidirectionally from the data layer to the UI.
图 1:单向数据流

它还强调了将 UDF 管理委托给一个名为状态持有方的特殊类的优点。您可以通过 ViewModel 或普通类来实现状态持有方。本文将深入探讨状态持有方及其在 UI 层中的作用。

阅读本文档后,您应该了解如何在 UI 层中管理应用状态;即 UI 状态生成流水线。您应该能够理解并掌握以下内容

  • 了解 UI 层中存在的 UI 状态类型。
  • 了解在 UI 层中对这些 UI 状态进行操作的逻辑类型。
  • 了解如何选择合适的状态持有方实现,例如 ViewModel 或简单类。

UI 状态生成流水线的元素

UI 状态及其生成逻辑定义了 UI 层。

UI 状态

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

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

逻辑

UI 状态不是一个静态属性,因为应用数据和用户事件会导致 UI 状态随时间变化。逻辑决定了变化的具体细节,包括 UI 状态的哪些部分发生了变化、为什么变化以及何时应该变化。

Logic produces UI state
图 2:作为 UI 状态生产者的逻辑

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

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

Android 生命周期以及 UI 状态和逻辑的类型

UI 层有两个部分:一个依赖于 UI 生命周期,另一个独立于 UI 生命周期。这种分离决定了每个部分可用的数据源,因此需要不同类型的 UI 状态和逻辑。

  • UI 生命周期独立:UI 层的这部分处理应用的数据生成层(数据层或领域层),并由业务逻辑定义。UI 中的生命周期、配置变更和 Activity 重建可能会影响 UI 状态生成流水线是否处于活动状态,但不会影响生成数据的有效性。
  • UI 生命周期依赖:UI 层的这部分处理 UI 逻辑,并直接受生命周期或配置变更的影响。这些变更直接影响其中读取的数据源的有效性,因此其状态只能在其生命周期处于活动状态时更改。这方面的例子包括运行时权限和获取依赖于配置的资源,例如本地化字符串。

以上内容可以用下表总结

UI 生命周期独立 UI 生命周期依赖
业务逻辑 UI 逻辑
屏幕 UI 状态

UI 状态生成流水线

UI 状态生成流水线是指生成 UI 状态所采取的步骤。这些步骤包括应用前面定义的逻辑类型,并且完全取决于您的 UI 需求。某些 UI 可能会受益于流水线的 UI 生命周期独立部分和 UI 生命周期依赖部分,或仅其一,或两者皆不受益

也就是说,UI 层流水线的以下排列是有效的

  • UI 自身生成和管理的 UI 状态。例如,一个简单、可重用的基本计数器

    @Composable
    fun Counter() {
        // The UI state is managed by the UI itself
        var count by remember { mutableStateOf(0) }
        Row {
            Button(onClick = { ++count }) {
                Text(text = "Increment")
            }
            Button(onClick = { --count }) {
                Text(text = "Decrement")
            }
        }
    }
    
  • UI 逻辑 → UI。例如,显示或隐藏一个按钮,允许用户跳转到列表顶部。

    @Composable
    fun ContactsList(contacts: List<Contact>) {
        val listState = rememberLazyListState()
        val isAtTopOfList by remember {
            derivedStateOf {
                listState.firstVisibleItemIndex < 3
            }
        }
    
        // Create the LazyColumn with the lazyListState
        ...
    
        // Show or hide the button (UI logic) based on the list scroll position
        AnimatedVisibility(visible = !isAtTopOfList) {
            ScrollToTopButton()
        }
    }
    
  • 业务逻辑 → UI。在屏幕上显示当前用户照片的 UI 元素。

    @Composable
    fun UserProfileScreen(viewModel: UserProfileViewModel = hiltViewModel()) {
        // Read screen UI state from the business logic state holder
        val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    
        // Call on the UserAvatar Composable to display the photo
        UserAvatar(picture = uiState.profilePicture)
    }
    
  • 业务逻辑 → UI 逻辑 → UI。一个 UI 元素,根据给定的 UI 状态滚动以在屏幕上显示正确的信息。

    @Composable
    fun ContactsList(viewModel: ContactsViewModel = hiltViewModel()) {
        // Read screen UI state from the business logic state holder
        val uiState by viewModel.uiState.collectAsStateWithLifecycle()
        val contacts = uiState.contacts
        val deepLinkedContact = uiState.deepLinkedContact
    
        val listState = rememberLazyListState()
    
        // Create the LazyColumn with the lazyListState
        ...
    
        // Perform UI logic that depends on information from business logic
        if (deepLinkedContact != null && contacts.isNotEmpty()) {
            LaunchedEffect(listState, deepLinkedContact, contacts) {
                val deepLinkedContactIndex = contacts.indexOf(deepLinkedContact)
                if (deepLinkedContactIndex >= 0) {
                  // Scroll to deep linked item
                  listState.animateScrollToItem(deepLinkedContactIndex)
                }
            }
        }
    }
    

在两种逻辑都应用于 UI 状态生成流水线的情况下,业务逻辑必须始终在 UI 逻辑之前应用。尝试在 UI 逻辑之后应用业务逻辑将意味着业务逻辑依赖于 UI 逻辑。以下部分将通过深入研究不同的逻辑类型及其状态持有方来解释为什么这是一个问题。

Data flows from the data producing layer to the UI
图 3:UI 层中逻辑的应用

状态持有方及其职责

状态持有方的职责是存储状态,以便应用可以读取它。在需要逻辑的情况下,它充当中间人,并提供对托管所需逻辑的数据源的访问。通过这种方式,状态持有方将逻辑委托给适当的数据源。

这会带来以下好处

  • 简单的 UI:UI 只需绑定其状态。
  • 可维护性:状态持有方中定义的逻辑可以迭代,而无需更改 UI 本身。
  • 可测试性:UI 及其状态生成逻辑可以独立测试。
  • 可读性:代码阅读者可以清楚地看到 UI 呈现代码和 UI 状态生成代码之间的差异。

无论其大小或范围如何,每个 UI 元素都与其对应的状态持有方具有 1:1 的关系。此外,状态持有方必须能够接受和处理任何可能导致 UI 状态更改的用户操作,并且必须生成随后的状态更改。

状态持有方的类型

与 UI 状态和逻辑的类型类似,UI 层中有两种类型的状态持有方,由它们与 UI 生命周期的关系定义

  • 业务逻辑状态持有方。
  • UI 逻辑状态持有方。

以下部分将更深入地探讨状态持有方的类型,从业务逻辑状态持有方开始。

业务逻辑及其状态持有方

业务逻辑状态持有方处理用户事件并将数据从数据层或领域层转换为屏幕 UI 状态。为了在考虑 Android 生命周期和应用配置变更时提供最佳用户体验,利用业务逻辑的状态持有方应具有以下属性

属性 详细信息
生成 UI 状态 业务逻辑状态持有方负责为其 UI 生成 UI 状态。此 UI 状态通常是处理用户事件和从领域层和数据层读取数据的结果。
通过 Activity 重建保留 业务逻辑状态持有方在 Activity 重建期间保留其状态和状态处理流水线,有助于提供无缝的用户体验。在状态持有方无法保留并被重建(通常在进程死亡之后)的情况下,状态持有方必须能够轻松地重新创建其上次状态,以确保一致的用户体验。
拥有长生命周期状态 业务逻辑状态持有方通常用于管理导航目标的状态。因此,它们通常会在导航更改时保留其状态,直到它们从导航图中移除。
对其 UI 独有且不可重用 业务逻辑状态持有方通常为某个应用功能生成状态,例如 TaskEditViewModelTaskListViewModel,因此仅适用于该应用功能。同一个状态持有方可以支持不同外形设备上的这些应用功能。例如,应用的手机、电视和平板电脑版本可以重用相同的业务逻辑状态持有方。

例如,考虑 “Now in Android” 应用中的作者导航目标

The Now in Android app demonstrates how a navigation destination representing a major app function ought to have
its own unique business logic state holder.
图 4:“Now in Android” 应用

在本例中,AuthorViewModel 作为业务逻辑状态持有方,生成 UI 状态

@HiltViewModel
class AuthorViewModel @Inject constructor(
    savedStateHandle: SavedStateHandle,
    private val authorsRepository: AuthorsRepository,
    newsRepository: NewsRepository
) : ViewModel() {

    val uiState: StateFlow<AuthorScreenUiState> = 

    // Business logic
    fun followAuthor(followed: Boolean) {
      
    }
}

请注意,AuthorViewModel 具有前面概述的属性

属性 详细信息
生成 AuthorScreenUiState AuthorViewModelAuthorsRepositoryNewsRepository 读取数据,并使用这些数据生成 AuthorScreenUiState。当用户想要关注或取消关注 Author 时,它还会通过委托给 AuthorsRepository 来应用业务逻辑。
可以访问数据层 在构造函数中将 AuthorsRepositoryNewsRepository 的实例传递给它,使其能够实现关注 Author 的业务逻辑。
Activity 重建后继续存在 因为它使用 ViewModel 实现,它将在快速的 Activity 重建中保留。在进程死亡的情况下,可以从 SavedStateHandle 对象读取,以提供从数据层恢复 UI 状态所需的最少量信息。
拥有长生命周期状态 ViewModel 的作用域限定在导航图,因此除非作者目标从导航图中移除,否则 uiState StateFlow 中的 UI 状态将保留在内存中。StateFlow 的使用还带来了额外的好处,即使生成状态的业务逻辑的应用变得惰性,因为只有在有 UI 状态的收集器时才会生成状态。
对其 UI 独有 AuthorViewModel 仅适用于作者导航目标,不能在其他任何地方重用。如果存在任何跨导航目标重用的业务逻辑,则该业务逻辑必须封装在数据层或领域层作用域的组件中。

作为业务逻辑状态持有方的 ViewModel

ViewModel 在 Android 开发中的优势使其适用于提供对业务逻辑的访问,并准备应用数据以在屏幕上呈现。这些优势包括以下几点

  • 由 ViewModel 触发的操作在配置变更后仍然存在。
  • 导航集成
    • 当屏幕位于返回堆栈时,导航会缓存 ViewModel。这对于在返回目标时立即可用先前加载的数据非常重要。对于遵循可组合屏幕生命周期的状态持有方来说,这更难实现。
    • 当目标从返回堆栈中弹出时,ViewModel 也会被清除,从而确保您的状态自动清理。这与监听可组合项销毁不同,可组合项销毁可能由于多种原因发生,例如转到新屏幕、配置变更或其他原因。
  • Hilt 等其他 Jetpack 库集成。

UI 逻辑及其状态持有方

UI 逻辑是对 UI 自身提供的数据进行操作的逻辑。这可能是在 UI 元素的状态上,或者是在 UI 数据源上,例如权限 API 或 Resources。利用 UI 逻辑的状态持有方通常具有以下属性

  • 生成 UI 状态并管理 UI 元素状态.
  • Activity 重建后不继续存在:托管在 UI 逻辑中的状态持有方通常依赖于 UI 本身的数据源,并且尝试在配置变更时保留此信息通常会导致内存泄漏。如果状态持有方需要数据在配置变更时持久化,它们需要委托给更适合在 Activity 重建后继续存在的另一个组件。例如,在 Jetpack Compose 中,使用 remembered 函数创建的可组合 UI 元素状态通常委托给 rememberSaveable,以在 Activity 重建时保留状态。此类函数的示例包括 rememberScaffoldState()rememberLazyListState()
  • 引用 UI 作用域的数据源:对 navigationControllerResources 和其他类似生命周期作用域类型的引用可以安全地保存在 NiaAppState 中,因为它们共享相同的生命周期作用域。
  • 可在多个 UI 中重用:同一个 UI 逻辑状态持有方的不同实例可以在应用的不同部分重用。例如,用于管理芯片组用户输入事件的状态持有方可以在搜索页面上用于过滤芯片,也可以用于电子邮件接收者的“收件人”字段。

UI 逻辑状态持有方通常使用普通类实现。这是因为 UI 本身负责 UI 逻辑状态持有方的创建,并且 UI 逻辑状态持有方与 UI 本身具有相同的生命周期。例如,在 Jetpack Compose 中,状态持有方是 Composition 的一部分,并遵循 Composition 的生命周期。

上面的内容可以通过 Now in Android 示例中的以下示例来说明

Now in Android uses a plain class state holder to manage UI logic
图 5:“Now in Android” 示例应用

Now in Android 示例根据设备的屏幕尺寸显示底部应用栏或导航栏。较小的屏幕使用底部应用栏,较大的屏幕使用导航栏。

由于 NiaApp 可组合函数中决定适当导航 UI 元素的逻辑不依赖于业务逻辑,因此可以由一个名为 NiaAppState 的普通类状态持有方进行管理

@Stable
class NiaAppState(
    val navController: NavHostController,
    val windowSizeClass: WindowSizeClass
) {

    // UI logic
    val shouldShowBottomBar: Boolean
        get() = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Compact ||
            windowSizeClass.heightSizeClass == WindowHeightSizeClass.Compact

    // UI logic
    val shouldShowNavRail: Boolean
        get() = !shouldShowBottomBar

   // UI State
    val currentDestination: NavDestination?
        @Composable get() = navController
            .currentBackStackEntryAsState().value?.destination

    // UI logic
    fun navigate(destination: NiaNavigationDestination, route: String? = null) { /* ... */ }

     /* ... */
}

在上面的示例中,NiaAppState 的以下细节值得注意

  • Activity 重建后不继续存在NiaAppState 通过遵循 Compose 命名约定使用可组合函数 rememberNiaAppState 创建,并在 Composition 中被 remembered。在 Activity 重建后,先前的实例会丢失,并创建一个新实例,传入所有依赖项,以适应重建的 Activity 的新配置。这些依赖项可能是新的,也可能从之前的配置中恢复。例如,rememberNavController()NiaAppState 构造函数中使用,它委托给 rememberSaveable 以在 Activity 重建时保留状态。
  • 引用 UI 作用域的数据源:对 navigationControllerResources 和其他类似生命周期作用域类型的引用可以安全地保存在 NiaAppState 中,因为它们共享相同的生命周期作用域。

在 ViewModel 和普通类之间选择状态持有方

从以上部分可以看出,选择 ViewModel 还是普通类状态持有方归结于应用于 UI 状态的逻辑以及逻辑操作的数据源。

总之,下图显示了状态持有方在 UI 状态生成流水线中的位置

Data flows from the data producing layer to the UI layer
图 6:UI 状态生成流水线中的状态持有方。箭头表示数据流。

最终,您应该使用最靠近消费点的状态持有方来生成 UI 状态。更不正式地说,您应该尽可能低地持有状态,同时保持适当的所有权。如果您需要访问业务逻辑,并且需要 UI 状态在屏幕可能被导航到时持久存在,甚至在 Activity 重建后也能持久存在,那么 ViewModel 是您的业务逻辑状态持有方实现的绝佳选择。对于生命周期较短的 UI 状态和 UI 逻辑,仅依赖于 UI 生命周期的普通类就足够了。

状态持有方可组合

状态持有方可以依赖于其他状态持有方,只要这些依赖项具有相同或更短的生命周期。示例如下:

  • 一个 UI 逻辑状态持有方可以依赖于另一个 UI 逻辑状态持有方。
  • 屏幕级状态持有方可以依赖于 UI 逻辑状态持有方。

以下代码片段展示了 Compose 的 DrawerState 如何依赖于另一个内部状态持有方 SwipeableState,以及应用的 UI 逻辑状态持有方如何依赖于 DrawerState

@Stable
class DrawerState(/* ... */) {
  internal val swipeableState = SwipeableState(/* ... */)
  // ...
}

@Stable
class MyAppState(
  private val drawerState: DrawerState,
  private val navController: NavHostController
) { /* ... */ }

@Composable
fun rememberMyAppState(
  drawerState: DrawerState = rememberDrawerState(DrawerValue.Closed),
  navController: NavHostController = rememberNavController()
): MyAppState = remember(drawerState, navController) {
  MyAppState(drawerState, navController)
}

一个生命周期比状态持有方长的依赖项的例子是 UI 逻辑状态持有方依赖于屏幕级状态持有方。那将降低生命周期较短的状态持有方的可重用性,并使其访问比实际需要更多的逻辑和状态。

如果生命周期较短的状态持有方需要来自更高级别作用域的状态持有方的某些信息,则仅将其所需的信息作为参数传递,而不是传递状态持有方实例。例如,在以下代码片段中,UI 逻辑状态持有方类仅从 ViewModel 接收其所需的参数,而不是将整个 ViewModel 实例作为依赖项传递。

class MyScreenViewModel(/* ... */) {
  val uiState: StateFlow<MyScreenUiState> = /* ... */
  fun doSomething() { /* ... */ }
  fun doAnotherThing() { /* ... */ }
  // ...
}

@Stable
class MyScreenState(
  // DO NOT pass a ViewModel instance to a plain state holder class
  // private val viewModel: MyScreenViewModel,

  // Instead, pass only what it needs as a dependency
  private val someState: StateFlow<SomeState>,
  private val doSomething: () -> Unit,

  // Other UI-scoped types
  private val scaffoldState: ScaffoldState
) {
  /* ... */
}

@Composable
fun rememberMyScreenState(
  someState: StateFlow<SomeState>,
  doSomething: () -> Unit,
  scaffoldState: ScaffoldState = rememberScaffoldState()
): MyScreenState = remember(someState, doSomething, scaffoldState) {
  MyScreenState(someState, doSomething, scaffoldState)
}

@Composable
fun MyScreen(
  modifier: Modifier = Modifier,
  viewModel: MyScreenViewModel = viewModel(),
  state: MyScreenState = rememberMyScreenState(
    someState = viewModel.uiState.map { it.toSomeState() },
    doSomething = viewModel::doSomething
  ),
  // ...
) {
  /* ... */
}

下图表示了 UI 与前面代码片段中不同状态持有方之间的依赖关系

UI depending on both UI logic state holder and screen level state holder
图 7:UI 依赖于不同的状态持有方。箭头表示依赖关系。

示例

以下 Google 示例演示了在 UI 层中使用状态持有方。请探索它们以查看此指南的实际应用