稀有猿诉

十年磨一剑,历炼出锋芒,说话千百句,不如码二行。

车载系统中的可扩展UI:从UI嵌入到系统窗口编排

本文译自「Scalable UI in Android Automotive OS: From UI Embedding to System Window Orchestration」,原文链接https://medium.com/proandroiddev/scalable-ui-in-aaos-from-ui-embedding-to-system-window-orchestration-dae03b335eee,由Daniel Georg发布于20264月23日。

在 Android Automotive 的原生主屏幕上,所有内容都位于同一个 Activity 中。CarLauncher 会同时绘制地图、媒体卡片和状态小部件。切换应用意味着需要替换整个 UI。 Scalable UI 框架采用了不同的方法:每个面板都拥有自己独立的 Activity,所有 Activity 并排运行,完全由 RRO 覆盖层中的声明式 XML 驱动。

这源于另一个问题。在我之前的文章中,我探讨了远程组合 (Remote Compose) 作为在 AAOS 中跨应用边界共享 UI 的新方法。它提供了一种解决方案,避免了通常的跨进程开销和耦合。然而,来自 Ralph Thomas (Google) 的一条评论 改变了我的想法更广阔的视野引领我深入探索,彻底改变了我的视角。

我一直密切关注着可扩展用户界面(Scalable UI),但那条评论让我停下来思考另一个问题。远程配置(Remote Compose)解决了嵌入外部用户界面而不耦合的问题,但如果嵌入本身是一种错误的抽象方式呢?

我们不再需要在启动器内部构建组件,因为在可扩展用户界面时代,应用程序本身就是组件

OEM厂商现在不再使用启动器来协调嵌入的内容,而是通过RRO覆盖层在系统UI配置中声明布局。传统意义上的“主页”Activity已不复存在,因为系统本身就成为了启动器。每个应用程序都拥有自己的域,独立运行,无需共享界面、进程间通信(IPC)或嵌入。

在这种模式下,包括面板大小、Activity位置和过渡效果在内的所有配置都用XML声明。从 OEM 的角度来看,这意味着无需编写任何 Java/Kotlin 代码,只需 XML 覆盖层,即可实现复杂的窗口行为。

可扩展 UI 背后的完整愿景有一个名称,至少我个人认为它应该叫:状态驱动的驾驶舱编排。整个驾驶舱,包括应用程序、系统栏、HUN 和覆盖层,都变成了一个协调一致的界面,在同步状态之间切换,而不是一系列独立管理的窗口。系统 UI 在 XML 中声明状态;窗口管理器驱动每个应用程序、每个系统栏和每个覆盖层同步完成状态转换。

本文的其余部分将介绍这在实践中是如何实现的:构建模块、完全使用 XML 构建的概念验证仪表板,以及这种架构带来的权衡取舍。我还会介绍我开发的用于创建可扩展 UI 的可视化编辑器,无需手动编写 XML。

沉重的遗留问题:启动器为何沦为噩梦

当标准的嵌入技术无法满足复杂的多窗口布局需求时,下一步就是深入系统内部,例如,创建一个 CustomDisplayAreaProvider 并直接编排 TaskDisplayArea (TDA)。

我花了几个月的时间来实现这种自定义的底层窗口逻辑。要实际放置和动画化这些显示区域,你必须在系统 UI 中注册一个自定义的 TaskDisplayAreaOrganizer。设置布局需要一个 WindowContainerTransaction 来告知 WindowManager 新的 TDA 边界,而动画则必须逐帧驱动,直接使用 SurfaceControl.Transaction 控制显示区域的边界。

每个过渡效果都是一个手动编写的 ValueAnimator,它插值边界并将其应用到 SurfaceFlinger,可以直接应用,也可以通过扩展 WMShell 过渡框架并添加自定义的 TransitionHandler 来实现。到了这一步,你不再是在编写应用程序代码,而是在编写窗口管理管道的一部分。

WinScope 成了你在这个过程中唯一的帮手。它是唯一能让你直观地理解窗口管理器中层、焦点转移和可见性标志等复杂机制的工具。没有它,你基本上就是在盲目摸索。

经过数月的工程开发,你终于运行了兼容性测试套件 (CTS)。几个小时后,结果显示你的信息娱乐系统不再符合 CTS 标准。这标志着一场痛苦的 bug 修复马拉松的开始,每次修复 Google 强制测试中的 bug 都会破坏你自定义的 UX。自定义窗口管理逻辑是一把双刃剑,很容易导致认证进度被打乱。

在分析了旧方法失效的原因之后,下一个问题显而易见:是否存在更好的抽象方案?

编排 vs. 嵌入

具体来说,可扩展 UI 由四个基本元素构成,全部以 XML 声明。“驾驶舱状态”是唯一的数据源——你不再需要编写动画代码,而是定义:

  • 任务面板: 矩形容器,映射到专用的根任务栈,用于承载完整的应用程序(导航或媒体),作为功能性组件。
1
2
<TaskPanel id="navigation" defaultVariant="@id/base" displayId="0">
</TaskPanel>
  • 装饰面板: 用于注入自定义 UI/覆盖层的通用容器,可与应用程序任务完美同步。
1
2
<DecorPanel id="navigation_decor" defaultVariant="@id/base" displayId="0">
</DecorPanel>
  • 变体: 管理精确边界、Z 轴顺序和可见性的视觉状态。
1
<Variant id="@+id/base"> </Variant>

过渡: 由窗口管理器协调的同步动画,用于根据系统事件在变体之间切换面板。 OEM 厂商还可以定义自己的自定义事件,并通过面板控制器触发这些事件,或将其绑定到意图,从而将系统扩展到内置的 System* 事件之外。

1
2
3
<Transitions>
        <Transition onEvent="_System_TaskOpenEvent" fromVariant="@id/base" toVariant="@id/open" duration="300" interpolator="@android:anim/accelerate_decelerate_interpolator"/>
 </Transitions>

以下是一个导航面板的实际示例,该面板定义了两种视觉状态(变体)和一个动画过渡:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<TaskPanel id="navigation" defaultVariant="@id/base" displayId="0">
    <Variant id="@+id/base">
        <Layer layer="2"/>
        <Visibility isVisible="true"/>
        <Alpha alpha="1.0"/>
        <Bounds left="0dp" top="0dp" right="850dp" bottom="1600dp"/>
    </Variant>
    <Variant id="@+id/open">
        <Layer layer="2"/>
        <Visibility isVisible="true"/>
        <Alpha alpha="1.0"/>
        <Bounds left="0dp" top="0dp" right="2480dp" bottom="1600dp"/>
    </Variant>
    <Transitions>
        <Transition onEvent="_System_TaskOpenEvent" fromVariant="@id/base" toVariant="@id/open" duration="300" interpolator="@android:anim/accelerate_decelerate_interpolator"/>
    </Transitions>
</TaskPanel>

重要性: 请注意,计算 850dp 和 2480dp 之间的插值无需任何代码。可扩展 UI 框架基于此 XML 协调过渡,确保窗口管理器能够与容器完美同步地缩放底层任务。

这就是 状态驱动的驾驶舱编排 的实际应用——系统栏和 HUN 也是可扩展 UI 面板,由与应用程序相同的驾驶舱状态驱动。

这直接解决了之前提到的 CTS 问题。由于该框架已预先通过认证并符合 CTS 标准,因此消除了后期测试失败的主要风险。过去需要数月时间进行自定义窗口逻辑设置以及可能出现的认证回归才能实现的功能,现在开箱即用

以上是核心组件。我不会在这里重写手册——如果你需要完整的技术规格、库源代码,或者想了解它是如何集成到 SystemUI 中的,请查看以下资源:

验证:无需 Kotlin/Java 代码即可实现的 3 列仪表盘

一旦所有三个部分都就位,框架就会启动,发现你的面板清单,并开始进行编排。你现在可以构建自己的自定义多窗口信息娱乐系统了。

完整的 PoC,包括配置叠加层和所有面板 XML,也可以在我的 GitHub 上找到。

超越 XML:面板控制器

任何设计都存在权衡取舍,而我想在此深入探讨的,是你在决定使用 Scalable UI 构建生产级控制面板之前,务必了解的一点。我通过自身实验观察到的行为,可能是出于有意设计,也可能是集中式编排带来的副作用。我并不总是能确定究竟是哪一种,而且在实践中,区分二者远不如理解它如何影响实际部署重要。

系统 UI 成为单点故障

这种架构转变从根本上改变了控制面板的故障模型。由于系统本身充当了编排器的角色,系统 UI 实际上成为了整个 UI 的单点故障。

在 Scalable UI 出现之前,启动器崩溃可能会导致主屏幕消失,但状态栏、HVAC 控制、通知和自定义 OEM SystemUIOverlayWindows 仍会继续独立运行。相反,系统 UI 崩溃会导致这些系统界面瘫痪,而启动器和托管应用程序则“幸存”下来。

使用 Scalable UI 时,当系统 UI 崩溃时,框架会移除系统 UI 作为 TaskOrganizer 创建的所有面板根任务。这些面板内的所有应用,无论是导航、电话、设置还是其他正在运行的活动,都会连同其任务一起被终止。系统 UI 会快速重启,但由于 Scalable UI 不会保存上次的活动状态,它会从初始配置而非用户离开时的状态重建面板布局。只有配置为自动启动的应用才会自动重启,并且只会重启到其默认的面板位置。所有应用状态、导航历史记录和滚动位置都会丢失。例如,在我的概念验证中,无论用户重新排列了哪些面板或哪个面板处于活动状态,系统始终以三列布局重启。

对于 OEM 厂商而言,这改变了系统 UI 故障时的风险。添加到系统 UI 的每个新功能、每个面板控制器、过渡动画器和事件分发器都会扩大单次崩溃的影响范围。面板控制器中一个未被捕获的异常,不仅会导致驾驶员失去状态栏,还会导致整个信息娱乐体验的丧失。

我希望添加的功能

除了上述权衡之外,我还希望 Scalable UI 能添加以下几个功能:

  • Scalable UI 配置的热重载。 目前,应用更新后的面板布局需要终止 System UI,这会销毁所有面板根任务并终止其中托管的所有应用程序。如果有一种热重载机制,能够在不重启 System UI 的情况下获取新的面板定义、变体更改和过渡更新,那么 OEM 厂商就可以在运行时更新其仪表板布局,而无需终止正在运行的会话。

  • 面板动画的缩放和矩阵变换。 SurfaceControl.Transaction 已经在框架级别支持 setScale()setMatrix(),但 Scalable UI 框架并未公开这些方法。如果在 Variant 模型和 Panel 接口中添加缩放和旋转属性,就可以在过渡期间实现缩放、缩小和倾斜效果,而无需自定义窗口管理技巧。

  • 每个属性的动画时序。 目前,所有动画属性共享同一个持续时间和插值器。框架的自定义动画路径虽然存在,但由于缺少属性设置器、缺少起始值/目标值注入以及任务预定位,因此对边界动画不起作用。修复这三个问题将允许每个属性的交错过渡,例如边界滑动时带有过冲,而透明度则线性淡入淡出。

  • 同步应用过渡。 Scalable UI 在内部处理所有任务过渡,没有为外部动画参与者提供钩子。集成 RemoteAnimationAdapter 支持将允许 OEM 启动器在应用进入面板时为其表面添加动画,例如将应用从控件图标变形到目标面板,而不是简单地出现在目标边界处。

  • 重启后的状态持久性。 Scalable UI 不会持久化上次活动的变体状态。每次重启后,每个面板都会恢复到其默认配置。为每个面板保留当前变体可以让信息娱乐系统从用户离开的地方继续运行。

隆重推出 Scalable UI 编辑器

在过去几个月使用 Scalable UI 的过程中,我发现一个问题反复出现:手动编写 Scalable UI 意味着需要在多个 XML 文件中处理变体、Z 图层和过渡效果,而且只有在刷入 RRO 并重启 SystemUI 后才能看到效果。工作流程并没有跟上框架的强大功能。

captionless image

因此,我开始开发自己的工具——一个 Scalable UI 可视化编辑器,可以直接从布局设计导出 RRO 项目。你可以将 TaskPanel 放置在画布上,切换变体来定义状态,并调整过渡效果,所有操作均可实时预览。只需单击一下,即可将 RRO 直接写入正在运行的模拟器或设备。输出为标准的 RRO 文件,与手动工作流程完全兼容。

如果你的团队正在基于 Scalable UI 进行开发,欢迎与我们联系。

结语

Comments