状态持有者和 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 视图中,视图本身管理此状态,因为它本质上是有状态的,并公开方法来修改或查询其状态。例如,getsetTextView 类用于其文本的方法。在 Jetpack Compose 中,状态外部于可组合项,您甚至可以将其从可组合项的直接附近提升到调用可组合函数或状态持有者。例如,ScaffoldState 用于 Scaffold 可组合项。

逻辑

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 重建中保留其状态和状态处理管道,有助于提供无缝的用户体验。在状态持有者无法保留且被重新创建的情况下(通常在 进程死亡 之后),状态持有者必须能够轻松地重新创建其最后状态以确保一致的用户体验。
拥有长生命周期的状态 业务逻辑状态持有者通常用于管理导航目的地的状态。因此,它们通常会在导航更改中保留其状态,直到它们从导航图中删除。
对其 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。这对于在您返回目标位置时立即获得先前加载的数据非常重要。对于遵循可组合屏幕生命周期的 state 持有者来说,这很难做到。
    • 当目标从后退栈中弹出时,ViewModel 也会被清除,确保您的状态会自动清理。这与监听可组合的销毁不同,可组合的销毁可能由于多种原因发生,例如转到新屏幕、由于配置更改或其他原因。
  • 与其他 Jetpack 库(例如 Hilt)集成。

UI 逻辑及其状态持有者

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

  • 生成 UI 状态并管理 UI 元素的状态.
  • 不会在 Activity 重新创建后存在:托管在 UI 逻辑中的 state 持有者通常依赖于来自 UI 自身的 data 源,并且尝试在配置更改期间保留这些信息通常会导致内存泄漏。如果 state 持有者需要在配置更改期间保留 data,则需要委托给另一个更适合在 Activity 重新创建后存在的组件。例如,在 Jetpack Compose 中,使用 remembered 函数创建的可组合 UI 元素状态通常会委托给 rememberSaveable 以在 Activity 重新创建期间保留状态。此类函数的示例包括 rememberScaffoldState()rememberLazyListState()
  • 具有对 UI 范围 data 源的引用:UI 逻辑 state 持有者与 UI 具有相同生命周期,因此可以安全地引用和读取来自 lifecycle API 和 Resources 等 data 源。
  • 可在多个 UI 中重用:同一 UI 逻辑 state 持有者的不同实例可在应用程序的不同部分重用。例如,用于管理芯片组用户输入事件的 state 持有者可用于搜索页面的筛选芯片,也可用于电子邮件接收者的“收件人”字段。

UI 逻辑 state 持有者通常使用普通类实现。这是因为 UI 本身负责创建 UI 逻辑 state 持有者,并且 UI 逻辑 state 持有者与 UI 本身具有相同生命周期。例如,在 Jetpack Compose 中,state 持有者是 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 的普通类 state 持有者进行管理

@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 是通过使用可组合函数 rememberNiaAppState 在 Composition 中 remembered 的,该函数遵循 Compose 的命名约定。在 Activity 重新创建后,先前实例会丢失,并会创建一个新的实例,其中所有依赖项都已传递进来,适合重新创建的 Activity 的新配置。这些依赖项可能是新的,也可能是从先前配置中恢复的。例如,rememberNavController()NiaAppState 构造函数中使用,它委托给 rememberSaveable 以在 Activity 重新创建期间保留状态。
  • 具有对 UI 范围 data 源的引用:对 navigationControllerResources 和其他类似生命周期范围类型的引用可以在 NiaAppState 中安全地保存,因为它们共享相同生命周期范围。

在 ViewModel 和普通类之间选择 state 持有者

从上面的部分可以看出,在 ViewModel 和普通类 state 持有者之间进行选择取决于应用于 UI state 的逻辑以及该逻辑操作的 data 源。

总之,下面的图表显示了 state 持有者在 UI State 生产管道中的位置

Data flows from the data producing layer to the UI layer
图 6:UI State 生产管道中的 state 持有者。箭头表示 data 流。

最终,您应该使用最接近使用它的 state 持有者来生成 UI 状态。不那么正式地说,您应该尽可能低地保持状态,同时保持适当的所有权。如果您需要访问业务逻辑,并且需要 UI state 持续存在,只要可以导航到屏幕,即使跨越 Activity 重新创建,ViewModel 是您的业务逻辑 state 持有者实现的绝佳选择。对于生命周期较短的 UI state 和 UI 逻辑,可以使用生命周期仅依赖于 UI 的普通类。

state 持有者是可组合的

state 持有者可以依赖于其他 state 持有者,只要依赖项的生命周期相同或更短。这方面的示例包括

  • UI 逻辑 state 持有者可以依赖于另一个 UI 逻辑 state 持有者。
  • 屏幕级 state 持有者可以依赖于 UI 逻辑 state 持有者。

以下代码片段显示了 Compose 的 DrawerState 如何依赖于另一个内部 state 持有者 SwipeableState,以及应用程序的 UI 逻辑 state 持有者如何依赖于 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)
}

超出 state 持有者生命周期的依赖项的示例将是 UI 逻辑 state 持有者依赖于屏幕级 state 持有者。这会降低生命周期较短的 state 持有者的可重用性,并使它能够访问比实际需要的更多逻辑和 state。

如果生命周期较短的 state 持有者需要来自范围更大的 state 持有者的某些信息,请将该信息作为参数传递给它,而不是传递 state 持有者实例。例如,在以下代码片段中,UI 逻辑 state 持有者类仅接收来自 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 与不同 state 持有者之间的依赖关系

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

示例

以下 Google 示例演示了在 UI 层中使用 state 持有者。探索它们以了解实践中的指南