状态容器和界面状态

单向数据流 (UDF) 可作为为界面层提供和管理界面状态的方式,界面层指南介绍了这种方式。

数据从数据层单向流向界面。
图 1:单向数据流

此外,该指南还重点介绍了将 UDF 管理委托给名为状态容器的特殊类的好处。您可以通过 ViewModel 或普通类实现状态容器。本文档详细介绍了状态容器及其在界面层中的作用。

学完本文档后,您应了解如何在界面层中管理应用状态;这就是界面状态生成流水线。您应该能够了解和掌握以下内容:

  • 了解界面层中存在的界面状态类型。
  • 了解在界面层中对这些界面状态执行的逻辑类型。
  • 知道如何选择合适的状态容器实现方式,例如 ViewModel 或简单类。

界面状态生成流水线的元素

界面状态以及生成该状态的逻辑定义了界面层。

界面状态

界面状态是描述界面的属性。界面状态有两种类型:

  • 屏幕界面状态是需要在屏幕上显示的内容。例如,NewsUiState 类可以包含呈现界面所需的新闻报道和其他信息。由于该状态包含应用数据,因此通常会与层次结构中的其他层相关联。
  • 界面元素状态是指界面元素的固有属性,这些属性会影响界面元素的呈现方式。界面元素可能处于显示或隐藏状态,并且可能具有特定的字体、字号或颜色。在 Android View 中,View 会自行管理此状态(因为它本身是有状态的),并公开用于修改或查询其状态的方法。例如,TextView 类的 getset 方法用于显示该类的文本。在 Jetpack Compose 中,状态在可组合项之外,您甚至可以将状态从可组合项附近提升到执行调用的可组合函数或状态容器中。例如,Scaffold 可组合项的 ScaffoldState

逻辑

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

生成界面状态的逻辑
图 2:作为界面状态生成方的逻辑

应用中的逻辑可以是业务逻辑或界面逻辑:

  • 业务逻辑决定着应用数据的产品要求的实现。例如,在新闻阅读器应用中,当用户点按相应按钮时,就会为报道添加书签。这种用于将书签保存到文件或数据库的逻辑通常放置在网域层或数据层中。状态容器通常通过调用这类层公开的方法,将此逻辑委托给相应的层。
  • 界面逻辑决定着如何在屏幕上显示界面状态。例如,在用户选择了某个类别时获取正确的搜索栏提示、滚动至列表中的特定项,或者在用户点击某按钮时便进入特定屏幕的导航逻辑。

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

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

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

上述内容可用下表总结:

不依赖于界面生命周期 依赖于界面生命周期
业务逻辑 界面逻辑
屏幕界面状态

界面状态生成流水线

界面状态生成流水线是指为生成界面状态而执行的步骤。相关步骤包括应用上文定义的各类逻辑,并且完全取决于界面的需求。有些界面可能会受益于流水线中不依赖于界面生命周期的部分和/或依赖于界面生命周期的部分,也可能不会受益于其中任一部分

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

  • 由界面本身生成和管理的界面状态。例如,一个简单且可重复使用的基本计数器:

    @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")
            }
        }
    }
    
  • 界面逻辑 → 界面。例如,显示或隐藏允许用户跳转到列表顶部的按钮。

    @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()
        }
    }
    
  • 业务逻辑 → 界面。在屏幕上展示当前用户的照片的界面元素。

    @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)
    }
    
  • 业务逻辑 → 界面逻辑 → 界面。会针对给定界面状态在屏幕上滚动以显示正确信息的界面元素。

    @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)
                }
            }
        }
    }
    

如果将这两种逻辑都应用于界面状态生成流水线,则必须始终先应用业务逻辑,然后再应用界面逻辑。如果尝试先应用界面逻辑,再应用业务逻辑,则意味着业务逻辑依赖于界面逻辑。下面几部分将深入探讨不同类型的逻辑及其状态容器,从而解释为什么这样做会带来问题。

从数据生成层流向界面的数据流
图 3:界面层中的逻辑应用

状态容器及其责任

状态容器的责任是存储状态,以便应用读取状态。 在需要逻辑时,它会充当中介,并提供对托管所需逻辑的数据源的访问权限。这样,状态容器就会将逻辑委托给相应的数据源。

这会带来以下好处:

  • 简单的界面:界面仅绑定了其状态。
  • 可维护性:可以对状态容器中定义的逻辑进行迭代,而无需更改界面本身。
  • 可测试性:界面及其状态生成逻辑可独立进行测试。
  • 可读性:代码读者可以清楚地看出界面呈现代码与界面状态生成代码之间的差异。

无论大小或作用域如何,每个界面元素都与其对应的状态容器具有 1 对 1 关系。此外,状态容器必须能够接受和处理任何可能导致界面状态发生变化的用户操作,并且必须生成随后的状态变化。

状态容器的类型

与界面状态和逻辑的类型类似,界面层中有两种类型的状态容器,它们根据自身与界面生命周期的关系而定义:

  • 业务逻辑状态容器。
  • 界面逻辑状态容器。

以下几个部分更详细地介绍了状态容器的类型,首先讲的就是业务逻辑状态容器。

业务逻辑及其状态容器

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

属性 详细信息
生成界面状态 业务逻辑状态容器负责为其界面提供界面状态。此界面状态通常是处理用户事件以及从网域层和数据层读取数据的结果。
在 activity 重新创建后保留下来 业务逻辑状态容器会在 Activity 重新创建后保留其状态和状态处理流水线,从而帮助提供无缝的用户体验。如果会重新创建(通常是在进程终止后)状态容器,但无法保留其状态,则状态容器必须能够轻松地重新创建最近一个状态,以确保一致的用户体验。
具有长期存在的状态 业务逻辑状态容器通常用于管理导航目的地的状态。因此,它们往往会在导航发生变化时保留其状态,直到从导航图中移除它们为止。
对界面来说独一无二,且不可重复使用 业务逻辑状态容器通常会针对某个应用函数(如 TaskEditViewModelTaskListViewModel)生成状态,因此仅适用于该应用函数。同一状态容器可以支持在不同外形规格的设备上使用这些应用函数。例如,应用的手机版本、电视版本和平板电脑版本都可以重复使用同一个业务逻辑状态容器。

例如,设想一下“Now in Android”应用中的作者导航目的地:

Now in Android 应用演示了代表主要应用函数的导航目的地应该具有自己的独特业务逻辑状态容器。
图 4:Now in Android 应用

在本例中,作为业务逻辑状态容器,AuthorViewModel 会生成界面状态:

@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。通过委托给 AuthorsRepository,它还会在用户希望关注或取消关注 Author 时应用业务逻辑。
有权访问数据层 AuthorsRepositoryNewsRepository 的实例传递至其构造函数,从而让其实现遵循 Author 的业务逻辑。
Activity 重新创建后仍然有效 由于它是使用 ViewModel 实现的,因此会在快速重新创建 Activity 后保留下来。在进程终止情形中,可以读取 SavedStateHandle 对象,以提供从数据层恢复界面状态所需的最少量信息。
具有长期存在的状态 ViewModel 的作用域限定为导航图,因此,除非从导航图中移除作者目标位置,否则 uiState StateFlow 中的界面状态会保留在内存中。使用 StateFlow 还增加了通过应用业务逻辑来产生状态延迟的优势,因为只有当存在界面状态的收集器时才会生成状态。
对界面来说独一无二 AuthorViewModel 仅适用于作者导航目的地,不能在其他地方重复使用。如果存在任何可为不同导航目的地重复使用的业务逻辑,该业务逻辑必须封装在数据层或网域层范围的组件中。

将 ViewModel 用作业务逻辑状态容器

ViewModel 在 Android 开发中的优势使其适用于提供对业务逻辑的访问权限以及准备要在屏幕上呈现的应用数据。这些优势包括如下各项:

  • ViewModel 触发的操作在配置发生变化后仍然有效。
  • Navigation 集成:
    • 当屏幕位于返回堆栈中时,Navigation 会缓存 ViewModel。这对在返回目标位置时即时提供之前加载的数据非常重要。使用遵循可组合项屏幕的生命周期的状态容器时,这种情况会更难处理。
    • 当目标位置从返回堆栈弹出后,ViewModel 也会被一并清除,以确保自动清理状态。这不同于监听可组合项的处理,监听的原因可能有多种,例如转到新屏幕、配置发生变化等。
  • 与其他 Jetpack 库(如 Hilt)集成。

界面逻辑及其状态容器

界面逻辑是对界面本身提供的数据执行操作的逻辑。它可能依赖于界面元素的状态或界面数据源(如权限 API 或 Resources)。利用界面逻辑的状态容器通常具有以下属性:

  • 生成界面状态并管理界面元素状态
  • Activity 重新创建后不再有效:托管在界面逻辑中的状态容器通常依赖于界面本身的数据源,并且在很多情况下,尝试在配置发生变化后保留此信息会导致内存泄漏。如果状态容器需要数据在配置发生变化后保持不变,则需要将其委托给更适合在 Activity 重新创建后继续留存的其他组件。例如,在 Jetpack Compose 中,使用 remembered 函数创建的可组合界面元素状态通常会委托给 rememberSaveable,以便在 Activity 重新创建后保留状态。此类函数的示例包括 rememberScaffoldState()rememberLazyListState()
  • 引用了界面范围的数据源:生命周期 API 和资源等数据源可以安全地引用和读取,因为界面逻辑状态容器与界面具有相同的生命周期。
  • 可在多个不同的界面中重复使用:同一界面逻辑状态容器的不同实例可以在应用的不同部分中重复使用。例如,用于管理条状标签组的用户输入事件的状态容器可用在过滤条件块的搜索页上,也可以用于表示电子邮件接收者的“收件人”字段。

界面逻辑状态容器通常使用普通类实现。这是因为界面本身负责创建界面逻辑状态容器,而界面逻辑状态容器与界面本身具有相同的生命周期。例如,在 Jetpack Compose 中,状态容器是组合的一部分,并遵循组合的生命周期。

在下面的示例中,Now in Android 示例演示了上述操作:

Now in Android 使用普通类状态容器管理界面逻辑
图 5:Now in Android 示例应用

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

由于决定 NiaApp 可组合函数中使用的适当导航界面元素的逻辑不依赖于业务逻辑,因此可以通过名称为 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 重新创建后不再有效:通过使用遵循 Compose 命名惯例的可组合函数 rememberNiaAppState 创建 NiaAppState,在组合中 remembered 了该容器。重新创建 Activity 后,之前的实例会丢失,并会使用传入的所有依赖项(适用于重新创建的 Activity 的新配置)创建一个新实例。这些依赖项可能是新的,也可能是根据以前的配置恢复的。例如,NiaAppState 构造函数中使用了 rememberNavController(),后者会委托给 rememberSaveable 以在重新创建 Activity 的过程中保留状态。
  • 引用了界面范围的数据源:对 navigationControllerResources 和其他类似生命周期范围的类型的引用可以安全地保存在 NiaAppState 中,因为它们具有相同的生命周期作用域。

为状态容器选择 ViewModel 和普通类

在上面几部分中,选择 ViewModel 还是普通类状态容器取决于对界面状态应用的逻辑以及执行该逻辑的数据源。

总而言之,下图显示了状态容器在界面状态生成流水线中的位置:

从数据生成层流向界面层的数据流
图 6:界面状态生成流水线中的状态容器。箭头表示数据流。

最终,您应根据离使用界面状态的位置最近的状态容器生成界面状态。一项不太正式的原则是,在尽可能低的位置存储状态,同时保留适当的所有权。如果您需要使用业务逻辑并且希望只要导航到某个屏幕,界面状态就会持续存在(即使是在 Activity 重新创建后),那么 ViewModel 是实现业务逻辑状态容器的合适之选。对于存在时间较短的界面状态和界面逻辑,使用生命周期仅依赖于界面的普通类应该就足够了。

状态容器可组合

状态容器可以依赖于另一个状态容器,前提是依赖项的生命周期与状态容器相同或更短。示例如下:

  • 界面逻辑状态容器可以依赖于另一个界面逻辑状态容器。
  • 屏幕级状态容器可以依赖于界面逻辑状态容器。

以下代码段展示了 Compose 的 DrawerState 如何依赖于另一个内部状态容器,即 SwipeableState;还展示了应用的界面逻辑状态容器可以如何依赖于 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)
}

举例来说,如果界面逻辑状态容器依赖于屏幕级状态容器,那么依赖项的生命周期就比状态容器更长。这会降低生命周期较短的状态容器的可重用性,并使其能够访问超出实际需要的逻辑和状态。

如果生命周期较短的状态容器需要来自较高层级范围的状态容器的某些信息,请仅将它需要的信息作为参数传递,而不是传递状态容器实例。例如,在以下代码段中,界面逻辑状态容器类仅从 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
  ),
  // ...
) {
  /* ... */
}

下图显示了上一个代码段中界面与不同状态容器之间的依赖关系:

同时依赖于界面逻辑状态容器和屏幕级状态容器的界面
图 7:依赖于不同状态容器的界面。箭头表示依赖关系。

示例

以下 Google 示例演示了如何使用界面层中的状态容器。请查看这些示例,了解如何实际运用本指南: