本文译自「UI layer architecture for persistent UI elements」,原文链接https://www.tunjid.com/articles/ui-layer-architecture-for-persistent-ui-elements-68248e8ecc8e85f53ce1aa46,由TJ Dahunsi
发布于2025年5月14日。
在移动应用中,某些 UI 元素会在多种上下文中贯穿整个用户界面。对于用于导航的 UI 元素尤其如此,例如:
- 导航栏和导航栏。
- 顶部和底部应用栏。
- 浮动操作按钮。
在为这些屏幕构建 UI 时,通常有两种布局方式:
- 根级 UI 元素:整个应用在根级脚手架布局中共享这些元素的单个实例。在 Android 上,这通常处于 Activity 或 NavHostFragment 级别。
- 每个屏幕的 UI 元素:每个导航目标负责绘制自己的 UI 元素。
因此,问题是,一种方法通常比另一种更好吗?它们可以共存吗?令人满意的是,我认为这是经典的软件工程“视情况而定”答案不适用的少数情况之一。我坚信,对于 Jetpack Compose 应用,应该始终优先采用每个屏幕的 UI 元素方法。让我们简要回顾一下过去。
根级 UI 元素
在 Android 上,根级 UI 元素的起源可以追溯到最初的 Activity ActionBar API 以及随后引入的 Fragment API。Fragment 可以调用:
- getSupportActionBar() 在其父 Activity 中设置标题和其他 ActionBar 属性。
- setHasOptionsMenu() 在其父 Activity 中更新菜单项。
由于 Activity 拥有 ActionBar,这隐式地建立了层级关系。这与转向单 Activity 架构相结合,为在某个根级别管理顶级装饰(例如浮动操作按钮、AppBar 和导航栏)奠定了基调。当然,这有利有弊:
优点
- 真正的持久性:UI 元素是同一个实例,保证了视觉一致性,无需在实例之间进行复杂的过渡。这对于使用浮动操作按钮的应用尤其有利,因为屏幕过渡不会因为每个屏幕使用不同的 FAB 实例而引入 UI/UX 噪音。
缺点
- 紧密耦合:屏幕与宿主 Activity 的实现细节和框架 API 紧密耦合。
- 复杂的状态管理:宿主 Activity 成为瓶颈,需要复杂的逻辑来更新每个特定屏幕的标题、菜单选项、FAB 可见性/图标/操作,尤其是在屏幕进行动画处理时。这会导致扩展性不佳。
- 灵活性受限:更改工具栏样式、在特定屏幕上彻底移除工具栏或处理边缘情况(如果不给主机增加更多条件复杂性)变得困难。
- 测试挑战:由于屏幕依赖于主机 Activity 提供必要的 UI 组件和配置钩子,因此单独测试屏幕变得更加困难。
每个屏幕的 UI 元素
根级 UI 元素的弊端非常严重,以至于 fragment 的 setHasOptionsMenu() 方法在 2022 年被弃用。尽管 MenuHost 和 MenuProvider API 中提供了替代方案,但这主要是为了保持向后兼容性。
至关重要的是,Jetpack Compose 已经发布了 1.1.1 版本,值得注意的是,它没有提供 ActionBar 或类似 MenuHost 的 API。事实上,最接近于提供如何管理导航目的地通用框架功能的 API 是 Scaffold 可组合组件。有趣的是,它:
- material3 Compose 库中的一个自成体系的实现。
- 是一个基于每个屏幕的 UI 元素实现,为应用栏、浮动操作按钮等提供插槽。
- 隐式地鼓励应用中的导航目的地替换整个屏幕内容,包括其特定的应用栏、浮动操作按钮等。
同样,我们可以列出优缺点:
优点
- 封装和模块化:每个导航目的地都是独立的,并管理其自身的UI元素及其状态。这符合单一职责原则。
- 高度灵活性:导航目的地可以轻松自定义持久UI元素,而不会影响其他元素。需要完全自定义的应用栏?没问题。不需要浮动操作按钮?那就不要添加它。
- 简化状态管理:UI状态(标题、菜单项、导航栏状态)在屏幕的ViewModel或可组合状态中进行本地管理,使其更易于推理。
- 提高可测试性:导航目的地可以更轻松地进行独立测试。
- 解耦:导航目的地与宿主Activity在这些特定的UI元素方面解耦。
缺点
- 代码重复:如果没有适当的结构化,导航目的地之间可能会重复使用常用的UI元素。
- 沉浸感受损:要使相同的应用栏或浮动操作按钮在屏幕之间顺利保持/转换,需要明确的过渡处理。
持久 UI 的 UI 层架构
通过比较这两种实现方式的优缺点,我们可以得出这样的印象:无论我们在 Compose 中使用每个屏幕的 UI 来降低复杂性方面获得了什么好处,我们都需要付出代价才能在 UX 中真正传达出 UI 元素是持久的,如下面的屏幕记录所示。
不过,有一个简单的解决方案:Compose 共享元素过渡和动画修改器 API,以及精心设计的 UI 层架构。
持久化 UI 和 UI 逻辑
上述动画 API 的入口点位于它们提供的作用域中:SharedTransitionScope 和 AnimatedVisibilityScope。对于导航目的地过渡,两者几乎总是串联使用,因此创建一个继承自两者的 UI 逻辑状态持有者非常有用。我个人喜欢将其称为 ScaffoldState。此 ScaffoldState 应该位于一个公共模块中,应用中所有显示导航目的地的模块都可以访问。我通常将此模块称为脚手架。
注意: 请勿将此处提到的 ScaffoldState 与原始 Material Compose 库中的 ScaffoldState 混淆。它是一个遗留实现,在 Compose Material3 中没有等效实现。
ScaffoldState 的定义可以简单如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
|
注意: canShowBottomNavigation 和 canShowNavRail 并不一定意味着它们各自的界面元素一定会显示;这取决于在脚手架内实际调用 Composable 的情况(如下所述)。相反,它们定义了界面元素在被调用时能够显示的界面逻辑。
ScaffoldState 有一个内部构造函数,其他模块可能无法创建它。相反,它们会使用同样在 scaffold 模块中定义的实用方法来记住它在组合中的实例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
|
上文中,定义显示哪个导航 UI 元素的 UI 逻辑是通过 ScaffoldState 中的 WindowSizeClass 实现的。AnimatedVisibilityScope 和 SharedTransitionScope 参数被显式传入。前者通常由导航库在定义目的地时提供,后者则由 CompositionLocal 或 prop drilling 提供。后面会介绍 CompositionLocal 的使用示例。
持久化 UI 脚手架
通过定义记住组合中 ScaffoldState 的方法,实际的 PersistentScaffold Composable 如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
|
注意: 在上面的定义中,持久 UI 元素的每个可组合插槽都以 ScaffoldState 作为接收器,并且当 navigationRail 或 navigationBar 隐藏或显示时,使用 Modifier.animateBounds() 对 Scaffold 进行动画处理。
上面使用了一个中间可组合项:NavigationRailScaffold。这只是一个简单的 Row,包含两个项目:navigationRail 和 content:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
|
注意: 导航轨道的 z 索引比内容更高。
遗憾的是,NavigationSuiteScaffold 的 API 集无法在此处使用,因为与普通的 Scaffold 可组合函数不同,它们不允许通过插槽访问构成脚手架的持久化 UI 元素,因此无法将 Modifier 实例传递给它们。这使得本文的其余部分不适用于 NavigationSuiteScaffold。
持久化 UI 元素作为 UI 逻辑的扩展
为了营造这些 UI 元素在导航目标之间持久化的视觉效果,可以将这些 UI 元素的可组合函数编写为 ScaffoldState 的扩展,从而提供对 SharedTransitionScope 的访问权限,以便为元素提供共享元素修饰符。这些定义也应位于脚手架模块中。例如,PersistentNavigationAppBar 可以是:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
|
注意: canShowBottomNavigation 是从 ScaffoldState 读取的,用于 AnimatedVisibility。
可以为其他持久性 UI 元素(例如导航栏或浮动操作按钮)编写同类扩展。功能模块可以依赖于脚手架模块,并按如下方式使用脚手架:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
|
每个使用的 API 以及它们支持的 UI/UX 如下所示:
从上到下:跨导航目的地的持久 UI 共享元素、跨导航目的地的持久 UI 进入和退出以及同一目的地内的持久 UI 进入和退出。
如上表所示,使用 ScaffoldState 作为界面状态容器,可以根据 Jetpack Compose 动画 API 的优势进行定制,同时将它们组合成一个整体。更详细地说:
- ScaffoldState 和 Modifier.sharedElement():在不同导航目标上调用的持久化界面元素将使用共享元素 API 来保持视觉连续性。在所示的示例中,每个导航目标都负责其自身的浮动操作按钮,但仍保持了持久化的效果。在此特定示例中,图库目标脚手架与详情目标完全相同,只是它使用不同的参数调用 PersistentFab。
- ScaffoldState 和 AnimatedContent:持久化界面元素的调用可以使用 Modifier.animateEnterExit() 来定义 EnterTransition 和/或 ExitTransition 来用于导航更改。在此示例中,持久化导航栏通过垂直滑动来动画化地显示和隐藏。在此特定示例中,详情目标脚手架与动态目标完全相同,但以下几点不同:
- 不调用 PersistentNavigationAppBar。
- 调用 PersistentFab。
- ScaffoldState、AnimatedVisibility 和 Modifier.animateBounds():调用 PersistentNavigationAppBar 和/或 PersistentNavigationNavRail 会根据当前 WindowSizeClass(即本地屏幕变化)自动隐藏或显示。当其中一个显示或隐藏时,内容可组合项会根据变化调整其大小和位置。用户还可以直接将 EnterTransition 或 ExitTransition 传递给可组合项来自定义动画。
持久的 UI 和业务逻辑
像导航栏或导航栏这样的持久性 UI 有时需要显示与其本地上下文相距甚远的状态;这就是业务逻辑。有时,这些信息可能位于不同的模块中。例如:
- 未显示通知的通知标记。
- 消息的未读计数。
- 用户个人资料警报或提醒。
在这种情况下,你的应用应该定义一个 AppState。在 Now In Android 示例中,这个 AppState 是 NiaAppState。在跨导航目标使用持久性 UI 时,这个 AppState 至关重要,因为它可以访问应用的导航语义以及填充导航栏中当前项目所需的所有资源。最简单的 AppState 可能如下所示:
1 2 3 4 5 6 7 8 9 10 |
|
注意: 在上面,导航状态由 Compose 状态支持,但是当使用带有 NavigationController 的导航 API 时,这仍然适用。
AppState 是应用层级服务的入口点。应用的导航状态、应用能够显示的窗格数量等等都驻留在 AppState 中。它可能需要访问业务逻辑,并且通常需要依赖注入的数据源。在 Now In Android 示例中,NiaAppState 使用数据源来确定哪些选项卡带有通知标记。
为了从 AppState 为持久化 UI 元素提供状态,ScaffoldState 应该依赖 AppState 作为内部实现细节,并作为状态持有者复合的示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
|
要检索要使用的 AppState,脚手架模块中应该有一个内部 LocalAppState 定义以及一个 App Composable。然后,此 AppState 会在应用的入口点提供给组合树,以便进行 Composing。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
|
在 Android 应用程序中,上述内容将在活动中使用,类似于以下内容:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
|
接下来,用于记住 ScaffoldState 的调用站点将更新为:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
|
注意: LocalAppState 应该位于脚手架模块内部。
只需指定要运行的修饰符或转换即可声明 PersistentNavigationAppBar:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
|
真正的持久化 UI
如果持久化 UI 元素中存在一些无法通过共享元素保存的局部瞬时状态,则可以使用可移动共享元素。请考虑此处描述和实现的导航栏设计,其中选中某个选项卡时,选项卡之间会呈现动画效果:
幻想只能到此为止,此时需要真正的持久性。这可以通过添加可移动内容 API 来创建可移动的共享元素来实现。也就是说,架构将提升的不仅仅是 UI 状态,而是整个 UI 元素及其状态。要了解更多关于为什么此时需要可移动内容 API 的信息,请阅读以下详细介绍组合持久性概念的文章。
在 Compose 中使用 movedContentOf 时,必须注意确保可移动内容每次只在一个位置进行组合。例如,从目的地 A 导航到目的地 B 时,在动画持续时间内,有两个目的地可组合项共存:
- 目的地 A 动画退出。
- 目的地 B 动画进入。
动画启动后,可移动导航栏必须立即与目的地 A 中的诱饵导航栏进行交换,同时,必须立即在目的地 B 开始组合。以下概述了实现此目的的界面逻辑。
定义可移动持久界面
ScaffoldState 导航栏首先将其声明拆分。仅依赖于 AppState 的部分作为 AppState 的扩展单独编写:
1 2 3 4 5 6 7 8 9 10 11 12 |
|
提升可移动持久化 UI
真正持久化的导航栏 (NavigationBar) 可以使用可移动内容 (movableContentOf) 提升到 AppState 中:
1 2 3 4 5 6 7 8 9 |
|
可移动持久 UI 的 UI 逻辑
定义可移动导航栏后,将确定何时可以安全组合的逻辑添加到 ScaffoldState 中:
1 2 3 4 5 6 7 8 9 10 11 12 |
|
构建可移动的持久化 UI
最后,在 ScaffoldState 的公共扩展方法中,根据 ScaffoldState 中定义的 UI 逻辑,在 AppState 中的持久化 UI 元素与其诱饵元素之间进行切换:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
|
其结果如下所示:
总结
以上描述了一种用于应用持久化 UI 的架构模式。该架构的一大优势在于它能够扩展到平板电脑和桌面设备;这将在后续的博客文章中介绍。
总结一下,该架构支持以下功能:
- 导航目标控制持久化 UI 动画,用于导航目标内部的局部变化,例如窗口大小变化。
- 导航目标控制持久化 UI 动画,用于跨导航目标进行导航变化。
- 完全自定义每个导航目标上哪些 UI 元素是持久化的,哪些不是。
- 一种将持久化 UI 元素描述为导航目标 ScaffoldState 函数的模式。
- 能够将具有内部瞬态状态的 UI 元素提升到 AppState,这些 UI 元素驱动的动画无法通过不同实例之间的共享元素过渡来近似实现。
使用脚手架模块或类似组件,将 AppState 的细粒度控制分发到导航目标。
这里可以看到一个带有脚手架模块、AppState 和每个导航目的地定制的应用程序中上述内容的示例。