状态持有者和 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 Views 中,View 本身管理此状态,因为它本身就是有状态的,公开了修改或查询其状态的方法。例如,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 重建后保留 业务逻辑状态持有者在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 作为业务逻辑状态持有者

在 Android 开发中,ViewModel 的好处使其适合于访问业务逻辑并准备应用数据以在屏幕上呈现。这些好处包括:

  • ViewModel 触发的操作会在配置更改后继续存在。
  • 导航集成
    • 当屏幕位于返回堆栈中时,导航会缓存 ViewModel。这对于在返回目的地时立即获得先前加载的数据非常重要。对于遵循可组合屏幕生命周期的状态持有者来说,实现这一点更为困难。
    • 当目的地从返回堆栈中弹出时,ViewModel 也会被清除,确保自动清理状态。这与侦听可组合项的处置不同,可组合项的处置可能由于多种原因发生,例如转到新屏幕、配置更改或其他原因。
  • 与其他 Jetpack 库(例如Hilt)集成。

UI 逻辑及其状态持有者

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

  • 生成 UI 状态并管理 UI 元素状态.
  • 不会在Activity重建后继续存在:托管在 UI 逻辑中的状态持有者通常依赖于 UI 自身的数据源,并且尝试在配置更改中保留此信息往往会导致内存泄漏。如果状态持有者需要数据在配置更改后继续存在,则需要委托给另一个更适合于在Activity重建后继续存在的组件。例如,在 Jetpack Compose 中,使用remembered函数创建的 Composable UI 元素状态通常委托给rememberSaveable以在Activity重建后保留状态。此类函数的示例包括rememberScaffoldState()rememberLazyListState()
  • 具有对 UI 范围数据源的引用:由于 UI 逻辑状态持有者与 UI 具有相同的生命周期,因此可以安全地引用和读取生命周期 API 和 Resources 等数据源。
  • 可在多个 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 通过使用可组合函数rememberNiaAppState(遵循 Compose 命名约定)在 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 层中使用状态持有者的方法。请探索这些示例,以了解此指南的实践应用。