【现代 Android APP 架构】07. State holders(状态管理者) 与 UI State(UI 状态)

377 阅读12分钟

这里有两个重要的理念。首先是 受控,即程序的备份由经理负责,他可以独立地授权程序的变更。其次是使发布的进展变得 正式,以及开发库与集成、发布的正式分离。——《人月神话》

UI 层由 UI State 和 UI Logic 组成

前文的 UI 层分析讨论了使用单向数据流生产和管理 UI State 的逻辑。

对于单向数据流(UDF)而言,它的管理者统一称为 State HolderViewModel 则是 State Holder 的一种具体实现。

数据化的思维模式下,APP 内部一切都可以视为数据。因此,作为交互页面的 UI 在某种程度上,也是由“状态”进行管理的,一个“状态”可以映射为一个 UI 页面的某种呈现方式。

UI State

UI State 是描述 UI 的属性,有两种 UI State。

  • 页面 State 定义页面所展示数据内容,例如 NewsUiState 包含了新闻流页面所展示的新闻内容列表数据。由于页面 State 包含了 APP 的业务数据,通常它会与其它层存在依赖关系。页面 State 与具体的 UI 页面相解耦,不关心页面绘制的实现细节,例如是基于 View 还是 Composable 来实现。
  • 元素 State 则描述了 UI 元素的展现状态,例如 TextView 展开/收起,页面卡片化 or 平铺,展示主题是拟物化还是极简风格等等。这些状态独立于 APP 数据而存在

UI Logic

UI State 不是静态的,而是在用户使用 APP 过程中、随着页面跳转等不断发生变化。其变化由 UI Logic 控制,例如 页面中的哪部分需要变化在什么时候进行变化变成什么样式 等等。从旧的 UI State 变化成新的 UI State,这就是 UI 逻辑(Logic)的功能。

UI 逻辑同样可以分为两种,业务逻辑纯 UI 逻辑

  • 业务逻辑:是产品对于应用数据的需求,例如在新闻流页面点击“收藏”按钮,APP 需要将新闻 id 保存在已收藏的数据库中,数据逻辑通常由 domain 或者是 data 层处理,而 UI 逻辑则交给 State holder 实现,State holder 负责调用 domain/data 层提供的接口
  • 纯 UI 逻辑:不涉及到数据变化的逻辑,例如页面跳转、TextView 展开收起、页面上下滑动等等,由 State holder 自身处理

UI State 与 Android 生命周期的关系

根据是否与页面生命周期相绑定,可以把 UI 层(State、Logic)分为 生命周期无关生命周期相关 两部分。

  • 生命周期无关: 这部分数据和逻辑,主要用来与数据生产者(data 层、domain 层)做对接,与页面生命周期、configuration 变化、Activity 重建等无关。
  • 生命周期相关: 这部分则与 Activity 生命周期相绑定,页面、配置等发生变化,会直接影响这部分的数据和逻辑。同样地,这部分 UI 数据和逻辑,仅当 Activity 处于有效生命周期时才生效。例如运行时权限申请、深色模式等。
生命周期无关生命周期相关
Business logicUI Logic
Screen UI state

UI State 的生成和管理

  • UI 自身数据 -> UI State,例如计数器。
@Composable
fun Counter() {
    // ===> UI 自身生成和管理 UI State
    var count by remember { mutableStateOf(0) }
    Row {
        Button(onClick = { ++count }) {
            Text(text = "Increment")
        }
        Button(onClick = { --count }) {
            Text(text = "Decrement")
        }
    }
}
  • UI 自身逻辑 -> UI State,例如在列表页面上滑时才出现的跳转按钮。
@Composable
fun ContactsList(contacts: List<Contact>) {
    val listState = rememberLazyListState()
    val isAtTopOfList by remember {
        derivedStateOf {
            listState.firstVisibleItemIndex < 3
        }
    }

    // Create the LazyColumn with the lazyListState
    ...

    // ===> 根据滑动位置,决定是否显示按钮
    AnimatedVisibility(visible = !isAtTopOfList) {
        ScrollToTopButton()
    }
}
  • 业务逻辑 -> UI State,例如展示当前用户的头像。业务逻辑通常来自于 ViewModel
@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 State,UI 层在 ViewModel 基础上增加逻辑判断。
@Composable
fun ContactsList(viewModel: ContactsViewModel = hiltViewModel()) {
    // 从业务逻辑中取值
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    val contacts = uiState.contacts
    val deepLinkedContact = uiState.deepLinkedContact

    val listState = rememberLazyListState()

    // Create the LazyColumn with the lazyListState
    ...

    // 基于业务逻辑,进行 UI 逻辑计算
    if (deepLinkedContact != null && contacts.isNotEmpty()) {
        LaunchedEffect(listState, deepLinkedContact, contacts) {
            val deepLinkedContactIndex = contacts.indexOf(deepLinkedContact)
            if (deepLinkedContactIndex >= 0) {
              // UI 变化,滚动至对应 item
              listState.animateScrollToItem(deepLinkedContactIndex)
            }
        }
    }
}

记住一个原则:业务逻辑要先于 UI 逻辑,一旦业务逻辑在 UI 逻辑之后才运行,这说明业务逻辑是依赖于 UI 逻辑的,在这种情况下要慎重思考,是不是错把 UI 逻辑当成了业务逻辑。

State holder 与其分类

State holder (View Models)的职责是保存 State,以供 APP 读取。它封装了底层的数据源实现,并提供对应接口给到 UI 层。这样做有如下好处。

  • 更简单的 UI:UI 仅仅关注它的 State
  • 容易维护:State holder 内部逻辑更新,对 UI 层而言是无感知的
  • 可测试性:UI 和 State holder 各自允许独立测试
  • 代码可读性:UI 自身逻辑和 UI State 生成逻辑彼此独立,降低理解成本

UI 元素与相应的 State holder 是一对一的关系,一个 State holder 理论上应当可以处理它所匹配的 UI 的任意用户事件,并生成新的 UI State。

State holder 的两个分类

  • 业务逻辑 State holder
  • UI 逻辑 State holder

值得一提的是,对于 UI 逻辑而言,如果它直接依赖到了 data/domain 层,这是不合理的,应当将依赖的部分抽出,放在业务逻辑层中,因为 业务逻辑对象的存活时间大于 UI 逻辑对象

业务逻辑 State holder

业务逻辑 State holder 的职责是,向上(视图层)处理 UI 事件、用户输入等;向下(数据层)调用 data/domain 层接口,生成 UI state。它具备以下特性。

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

业务逻辑 State holder 是一个概念,它的具体实现通常是 ViewModel

接下来用实例进行讲解,考虑 NowInAndroid 中的新闻作者页面。

image.png

为其设计 AuthorViewModel,用于生成 UI State。

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

    val uiState: StateFlow<AuthorScreenUiState> = …

    // ===> 用户操作,业务逻辑
    fun followAuthor(followed: Boolean) {
      …
    }
}

与上表一一对应。

特性说明
生成 AuthorScreenUiStateAuthorViewModelAuthorsRepositoryNewsRepository 中读取数据,生成 AuthorScreenUiState。同时,当用户想要关注 Author 时,作为代理,AuthorViewModel 将此行为转发给 AuthorsRepository
拥有对数据层的访问权ViewModel 持有 AuthorsRepositoryNewsRepository 的实例,因此它可以实现关注作者的业务逻辑
Activity 销毁时仍然存活ViewModel 天生不与 Activity 生命周期绑定,不受后者销毁/重建影响;当进程销毁时,使用 SavedStateHandle 从最小数据集里完成 UI 状态重建
具有长期存在的 StateViewModel 的作用域限定在导航图内,因此除非作者目标从导航图中删除,否则 uiState StateFlow 中的 UI 状态将保留在内存中。使用 StateFlow 还带来了一个好处,只有当 Collector 开始收集时,才生成 UI State
与 UI 一一对应且不可复用AuthorViewModel 仅适用于作者信息页这一个目的地页面,不能用于其它页面。如果有为复用设计的业务逻辑,该逻辑不应当存在于 ViewModel,而应将其放置在 data/domain 层

警告:请勿将 ViewModel 实例向下传递给其他 Composable 函数 / UI 组件。这样做会将 Composable 函数与 ViewModel 类型耦合,从而降低其可复用性,并使其更难测试和预览。此外,还会失去一个 清晰的单一可信来源 (SSOT)来管理 ViewModel 实例。向下传递 ViewModel 会导致多个 Composable 函数调用 ViewModel 函数并修改其状态,从而使错误更难调试。请遵循 UDF 最佳实践,仅向下传递必要的状态。同样,将事件向上传递,直到它们到达 ViewModel 的可组合 SSOT。SSOT 负责处理事件并调用相应的 ViewModel 方法。

使用 ViewModel 作为业务逻辑管理者(business logic state holder)

ViewModel 并不是业务逻辑管理器的唯一实现,但它是最推荐的一种实现,使用 ViewModel 带来的好处有:

  • ViewModel 触发的操作独立于 configuration 变化,不受页面自动销毁重建影响。
  • 与 Navigation 良好集成
    • Navigation 导航组件可以将 ViewModel 缓存在回退栈(back stack)中,因此当用户退回上级页面时,可以自动获取到上一次使用的数据,进而加载页面。
    • 当页面从回退栈(back stack)出栈时,可以自动对 ViewModel 执行清理操作,这与其它监听方式(例如跳转到新页面、configuration 变化导致销毁)有所不同,是明确地反映用户操作意图的页面导航操作
  • 与其它 Jetpack 组件良好集成,如 Hilt

UI 逻辑与其状态管理器(State holder)

UI 逻辑是处理 UI 自身数据的逻辑,具备以下特性:

特性说明
生产和管理 UI 元素状态UI 元素在展示过程中具备不同状态,这些状态与数据层无关,只跟 UI 层相关
会跟随 Activity 生命周期同步销毁UI 逻辑会随着 Activity 销毁(例如 configuration 变化)而回收,因此可以在其中保存生命周期相关的对象,有效防止泄露。如果有需求在页面销毁时保存数据,可以使用 rememberSaveable 来保存当前状态,例如 rememberScaffoldState()remembeLazyListState()
持有 UI 数据引用例如 lifecycle 对象、Resource 对象等,由于 UI 逻辑 State holder 具备同 UI 一样的生命周期,因此可以直接使用这些对象
在多个 UI 组件之间复用由于它跟 UI 组件同步销毁,因此可以大胆复用而不用担心其耦合

UI 逻辑 State holder 通常由 普通类 Plain Class 进行实现,不需要特别对它的生命周期进行管理,这是因为 UI 本身会处理它的创建,并且将它的生命周期与 UI 绑定。

同样以 NowInAndroid 项目为例

根据屏幕尺寸不同,页面可以自行决定将导航栏显示在底部(小屏)或者侧面(大屏),这部分 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) { /* ... */ }

     /* ... */
}

这个例子里面所表现出 UI Logic State Holder 特性如下:

特性说明
会跟随 Activity 生命周期同步销毁NiaAppState 通过 remembeNiaAppState 进行状态保存和恢复
持有 UI 数据引用可以放心大胆地使用 navigationControllerResources 而不用担心其泄漏

选择正确的 State Holder —— ViewModel 还是普通类(plain class)

下图展示了 State Holder 在数据传输体系中所处的位置。

最终,应该使用 最靠近 UI 状态使用位置 的 State Holder 来生成 UI 状态。通俗地说,应该在保持适当所有权的同时,尽可能降低状态的占用。如果需要访问业务逻辑,并且需要 UI State 在屏幕导航过程中持续存在,甚至在 Activity 重建过程中也如此,那么 ViewModel 是实现业务逻辑 State Holder 的理想选择。对于生命周期较短的 UI 状态和 UI 逻辑,一个生命周期仅依赖于 UI 的 普通类 就足够了。

State Holder 的组合

State Holder 相互之间可以组成 依赖关系

  • 一个 UI 逻辑 State Holder 可以依赖于另一个 UI 逻辑 State Holder。
  • 一个 Screen State Holder 可以依赖于一个控件级 UI 逻辑 State Holder。

这里展示一个例子,DrawerState 依赖于内部其它的 State HolderSwipeableState,同时,页面 UI State Holder 依赖 DrawerState

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

@Stable
class MyAppState(
  private val drawerState: DrawerState, // ===> 页面 UI State 依赖 DrawerState
  private val navController: NavHostController
) { /* ... */ }

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

注意页面级的 State Holder 可以依赖控件级 State Holder,但反向依赖是不允许的 —— 这会导致生命周期短的控件对象持有生命周期长的页面对象,造成泄漏

如果生命周期较短的 State Holder 需要从范围更大的 State Holder 获取某些信息,则 仅将其所需的信息作为参数传递,而不是传递 State Holder 实例。例如,在以下代码片段中,UI 逻辑 State Holder 仅从 ViewModel 接收其所需的参数,而不是将整个 ViewModel 实例作为依赖项传递。

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

@Stable
class MyScreenState(
  // ===> 下面注释掉这一行:不要把 ViewModel 传递给更小作用域的 State Holder
  // private val viewModel: MyScreenViewModel,

  // ===> 只传递必要信息(最小可用原则)
  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() }, // ===> 从 ViewModel 中提取最小可用部分,传递给 MyScreenState。而不是传递整个 ViewModel
    doSomething = viewModel::doSomething
  ),
  // ...
) {
  /* ... */
}

下图描述了代码对应的依赖关系,ViewModel(即图中的 Screen level state holder)并不属于 UI Lifecycle dependent(绿色填充部分),而只是取出其一部分逻辑放入其中。

参考资料