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

本文译自「Scalable UI in Android Automotive OS: From UI Embedding to System Window Orchestration」,原文链接medium.com/proandroidd...,由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。

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

多年来,AAOS 启动器一直扮演着整个主屏幕体验的单体宿主角色。想要显示地图?你需要 CarTaskView 或 SurfaceControlViewHost。想要一个媒体组件?那就意味着要集成完整的 MediaBrowser 堆栈。电话、空调和车辆组件各自都带来了不同的依赖项。

每一次集成都会带来独特的生命周期挑战和持续的维护开销。因此,启动器逐渐成为整个 AAOS 堆栈中最复杂、最脆弱、最令人畏惧的组件。

"复杂"陷阱:TDA 编排与 CTS 地狱

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

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

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

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

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

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

编排 vs. 嵌入

要理解这种转变,我们必须从抽象的角度来审视它。在我之前的文章(medium.com/@passenger6... UI 组件来解决耦合问题。

可扩展 UI 提出了一个不同的问题:我们为什么还需要宿主应用?

  • 远程组合以及所有其他 UI 嵌入技术都将应用的可见性和位置与宿主绑定。

  • 可扩展 UI 则专注于编排_实际的应用进程_。

系统不再托管内容,而是管理应用。系统 UI 会指示窗口管理器将导航任务放置在一个面板中,将媒体任务放置在另一个面板中。这就是根本区别:每个应用程序都保持自己的进程和稳定性,而框架则确保它们看起来像一个单一的、统一的界面。

可扩展 UI 的构建模块

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

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

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

xml 复制代码
<Transitions>
        <Transition onEvent="_System_TaskOpenEvent" fromVariant="@id/base" toVariant="@id/open" duration="300" interpolator="@android:anim/accelerate_decelerate_interpolator"/>
 </Transitions>

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

xml 复制代码
<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)。我想要摆脱"枯燥乏味"的标准横向模拟器,创造一些更生动有趣的东西。这个 PoC 基于 AOSP 的 android16-qpr2-release 分支构建。

我沿用了远程撰写概念验证 (Remote Compose PoC) 的布局,仅使用 Scalable UI 重新实现了整个控制面板,并添加了一些"高级功能",以证明曾经需要深入了解 Android 内部机制才能实现的功能,现在可以通过高级声明式模型完全实现:

  • 三个独立的应用程序(媒体、电话、地图): 作为原生任务在各自独立的容器中并行运行。
  • **复杂过渡效果:**通过 XML 实现,支持弹性过冲和背景模糊。

  • 零自定义 Java 代码:所有操作均通过 XML 覆盖层驱动;框架充当隐形指挥者,管理布局,但不涉及应用程序逻辑。

  • **深度 UI 堆栈:**为"设置"和"媒体播放器"应用添加了半屏覆盖层。

  • 多页面导航:添加了第二个仪表盘页面,并使用自定义 OEM 事件扩展了系统 UI,该事件可直接在主屏幕上触发分页。整个"页面切换"功能仅需几行 XML 代码即可实现。底部栏中的标准 CarSystemBarButton 会触发自定义的"show_page2"/"hide_page2"事件,Scalable UI Transitions 会捕获这些事件来切换面板样式:

xml 复制代码
<com.android.systemui.car.systembar.CarSystemBarButton
 android:id="@+id/page2_nav"
 android:contentDescription="Page 2"
 style="@style/SystemBarButton"
 systemui:icon="@drawable/car_ic_dashboard"
 systemui:selectedIcon="@drawable/car_ic_dashboard_selected"
 systemui:selectedEvent="hide_page2"
 systemui:unselectedEvent="show_page2"/>

没有 Java 代码,只有 XML 连接。

Google 官方推荐的 Scalable UI 参考目标是 sdk_car_dewd_x86_64,这是一个很好的研究起点。但是,在我的概念验证 (PoC) 中,我希望使用每个开发者都已拥有的标准横屏 AAOS 模拟器:sdk_car_x86_64。

Scalable UI 并非默认就存在于标准目标平台上。我必须弄清楚在官方 dewd 环境之外运行该框架所需的具体底层机制。原来 Scalable UI 在 Android 堆栈的两层受到限制,每一层都由一个布尔资源控制------所以我们来逐层解锁它们。

步骤 1:框架级握手

xml 复制代码
<resources>
    <bool name="config_remoteInsetsControllerControlsSystemBars">true</bool>
</resources>

_注意:此标志已可通过 RRO CarFrameworkDewdRRO(目标平台为 @android)获得。你可以直接将其包含在构建配置中。

步骤 2:系统 UI 激活和面板清单

接下来,你需要在布局 RRO 的 res/values/config.xml 文件中设置标志(目标为 @com.android.systemui):

xml 复制代码
<resources>                                                                                                                                                                                                    
      <!-- Turns on Scalable UI in SystemUI -->                                                                                                                                                                
      <bool name="config_enableScalableUI">true</bool>
                                                                                                                                                                                                                 
      <!-- Optional: SystemUI wiring -->
      <bool name="config_enableTopSystemBar">true</bool>                                                                                                                                                         
      <bool name="config_enableBottomSystemBar">true</bool>                                                                                                                                                      
      <bool name="config_enableClearBackStack">false</bool>
      <bool name="config_enableSafeAreaAndToolbarPerDisplay">false</bool>                                                                                                                                        
      <integer name="config_systemBarSuwBehavior">1</integer>                                                                                                                                                    
                                                                                                                                                                                                                 
      <!-- The panel manifest --- every panel XML listed here -->                                                                                                                                                  
      <array name="window_states">                                                                                                                                                                               
          <item>@xml/media_panel</item>                                                                                                                                                                          
          <item>@xml/phone_panel</item>                                                                                                                                                                          
          <item>@xml/map_panel</item>
          <!-- ...all your panels -->                                                                                                                                                                            
      </array>                                                                                                                                                                                                   
   
      <!-- Auto-launch activities into specific panels at boot -->                                                                                                                                               
      <string-array name="config_default_activities">                                                                                                                                                          
          <item>media_panel;com.android.car.carlauncher/.ControlBarActivity</item>                                                                                                                               
          <item>phone_panel;com.android.car.dialer/.ui.TelecomActivity</item>                                                                                                                                    
          <item>map_panel;com.android.car.mapsplaceholder/.MapsPlaceholderActivity</item>                                                                                                                        
      </string-array>                                                                                                                                                                                            
 </resources>

步骤 3:安装 StubCarLauncher

要获得空白主屏幕,你需要移除默认的 Car Launcher,并安装一个空白的 StubCarLauncher 来代替它。根据 AOSP 源代码中的注释,这只是一个临时解决方案,预计会在下一个 Android 版本中实现专用的可见性屏障。

重启。

完成!

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

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

超越 XML:面板控制器

XML 处理结构:布局、状态、转换,但 Scalable UI 还为 OEM 提供了一个代码级的扩展点。每个面板都可以由一个自定义面板控制器支持,该控制器是一个 Kotlin 或 Java 类,它继承自 BaseTaskPanelController 或 DecorPanelControllerBase,并接入框架的 Dagger 依赖注入图。控制器是 OEM 特定行为的所在:根据运行时状态决定在面板中启动哪个 Activity、触发自定义事件或实现上下文感知策略。

以下是 AOSP 源代码中的 MapsPanelController:

java 复制代码
public final class MapsPanelController extends BaseTaskPanelController {
    private static final String TAG = MapsPanelController.class.getSimpleName();
    @AssistedInject
    public MapsPanelController(Context context,
        @Assisted PanelControllerMetadata panelControllerMetadata,
        PanelUtils panelUtils) {
        super(context, panelControllerMetadata, panelUtils);
    }
    /** Creates an instance of MapsPanelController using the provided PanelControllerMetadata. */
    @AssistedFactory
    public interface Factory extends TaskPanelController.Factory < MapsPanelController > {
        MapsPanelController create(PanelControllerMetadata metadata);
    }
    @Override
    public Intent getDefaultComponent() {
        Intent mapIntent = super.getDefaultComponent();
        Intent result = TosHelper.maybeReplaceWithTosMapIntent(mContext, mapIntent,
            R.string.config_tosMapIntent, ActivityManager.getCurrentUser());
        logIfDebuggable(TAG + ", getDefaultComponent = " + result);
        return result;
    }
}

下面的 XML 连接展示了 OEM 如何将其连接到面板:

xml 复制代码
  <!-- res/xml/map_panel.xml -->
  <TaskPanel id="map_panel" controller="@xml/map_controller" ...>
                                                                                                                                                                                  
  <!-- res/xml/map_controller.xml -->
  <Controller id="map_controller">                                                                                                                                                
      <Config key="ControllerName"                                                                                                                                                
          value="com.android.systemui.car.wm.scalableui.panel.controller.MapsPanelController"/>                                                                                 
      <Config key="PersistentActivity"                                                                                                                                            
          value="com.google.android.apps.maps/com.google.android.maps.MapsActivity"/>                                                                                             
  </Controller>                                                                                                                                                                   

在我的概念验证中,我特意只使用了 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 后才能看到效果。工作流程并没有跟上框架的强大功能。

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

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

结语

Scalable UI 确实名不虚传。谷歌团队并没有提供一个权宜之计,而是提供了一个平台,将过去需要数月才能完成的 CTS 故障排除和脆弱的系统级修改,简化为 XML 格式。对于 OEM 厂商而言,这意味着可以显著节省工程工作量,加快产品上市速度,并大大降低构建复杂多窗口驾驶舱的门槛。其背后的团队功不可没。致敬!

欢迎搜索并关注 公众号「稀有猿诉」 获取更多的优质文章!

保护原创,请勿转载!

相关推荐
Cloud_Shy6182 小时前
解读《Effective Python 3rd Edition》:从练气到老魔(第一章 Item 4 - 6)
android·数据库·论文阅读·python
therese_100862 小时前
安卓面试题
android
码云骑士2 小时前
Android Launcher启动过程
android
Java面试题总结3 小时前
MySQL EXISTS 详解:存在性判断、NOT EXISTS 与实战示例
android·数据库·mysql
_李小白3 小时前
【android opencv学习笔记】Day 30: 滤波算法之拉普拉斯算子
android·opencv·学习
NiceCloud喜云11 小时前
Opus 4.8 的 Effort Control 怎么选:Low 到 Max 五档策略
android·java·大数据·前端·c++·python·spring
日光明媚15 小时前
一步生成视频!One-Forcing:DMD + 零成本 GAN,训练 200 步超越多步 SOTA
android·开发语言·kotlin
帅次16 小时前
Android 17 开发者实战:核心更新与应用场景落地指南
android·java·ios·android studio·iphone·android jetpack·webview
大鹏说大话16 小时前
SQL 排序与分组实战:解决“分组后取最新数据“
android·java·数据库