Android车载OS中的Remote Compose

本文译自「Remote Compose in Android Automotive OS: Embed rich UI without the usual pain」,原文链接proandroiddev.com/remote-comp...,由Daniel Georg发布于2026年5月31日。

在 Android Automotive OS 上构建信息娱乐系统时,一个问题会反复出现:你需要将一个应用程序的用户界面显示在另一个应用程序的界面中。例如,在启动器中添加手机小部件,在仪表盘中添加媒体卡片,或者在 OEM 系统界面中嵌入第三方应用程序的内容。听起来很简单------直到你真正尝试实现它。

Android 提供了多种跨应用 UI 共享机制,每种机制都有其优缺点。让我们来看看目前有哪些机制:

内部组件/视图 ------这是显而易见的首选:所有内容都直接构建在宿主应用内部(例如启动器)。没有跨进程开销。缺点是需要掌握相关知识,并且存在耦合问题。在汽车领域,要构建一个媒体卡片,你的团队需要学习并理解完整的 MediaSession/MediaBrowser 堆栈。要构建一个电话组件,他们需要深入了解 Telephony API。再加上汽车 API、OEM 内部 API 以及每个领域特有的特性------你的团队就变成了十几个互不相关领域的专家。每个组件都与其依赖的 API 紧密耦合,而当 API 发生变化时,没有人记得当初为何如此设计。

TaskView / CarTaskView --- 在系统级别运行,将另一个应用的完整 Activity 嵌入到你的窗口层级结构中。它仅限于系统应用,需要特权权限,会在主机和客户端之间建立紧密的生命周期耦合,并添加一个专用的 SurfaceFlinger 层------会迅速消耗 Hardware Composer 的叠加层预算,尤其是在多用户多显示器 (MUMD) 设置中,例如主机、乘客显示屏和后排屏幕/用户同时运行的情况下。

SurfaceControlViewHost --- 避免了特权权限,并共享相同的层开销,但引入了脆弱的进程间通信 (IPC) 边界。虽然基本渲染功能正常,但同步结构更改、处理客户端进程崩溃以及管理 AAOS 旋转焦点或无缝触摸手势切换都迫使主机采用复杂的手动变通方案。

RemoteViews --- 任何应用均可使用,无需特殊权限。但它的组件库过于原始,无法构建任何严肃的用户界面:只有寥寥几个 TextView、ImageView 和 Button。除此之外,其他功能都无法实现。

Remote Compose方案

每种方法都存在无法避免的权衡取舍。远程组合打破了这种模式。它将丰富的用户界面意图序列化为紧凑的声明式二进制流------绘制指令,而非界面。宿主程序充当浏览器:它接收这些指令,并在其自身的层内独立渲染,无需了解提供程序应用程序的生命周期、进程状态或权限。点击和交互在本地处理并返回给提供程序,因此即使提供程序进程负载较高,用户界面也能保持响应。完整的组合表达能力,以数据形式交付。

Remote Compose的工作原理

从本质上讲,RemoteCompose 文档是 Canvas 绘制调用的序列化记录------与 Android 用于渲染任何 UI 的原语(绘制矩形、设置颜色、剪辑区域)相同。提供程序不会立即执行这些调用,而是将它们打包成紧凑的、独立的二进制格式。它包含构建 UI 所需的一切:形状、带有字体和样式的文本、图像,甚至动画表达式。

当主机收到此文档时,它只需根据其本地 Canvas 迭代指令即可。主机不需要了解数据来自何处的领域知识------它只需回放数据即可。虽然这就像浏览器呈现静态页面一样,但提供者可以以 IPC 允许的速度推送更新的文档。这将流程转变为高频流,从而实现流畅、状态驱动的 UI 更新,完全无需 XML 膨胀的开销。

动画通过在记录时间嵌入到文档中的表达式来工作。提供程序不是对值进行硬编码,而是编写一个类似于 ContinuousSec() * 360 的表达式 - 主机在每一帧上本地对其进行计算,从而生成流畅的动画,而无需与提供程序进程进行任何往返。

PoC:将理论付诸实践

我想看看 RemoteCompose 作为跨应用程序 UI 机制可以发展到什么程度。在 AAOS 堆栈中工作多年后,我看到 OEM 编写了数千行样板文件只是为了共享一个简单的小部件。汽车发射器是完美的试验场。

模拟环境

为了测试实际性能,我在多显示器 AAOS 环境中运行了 PoC。

  • 主显示屏: 主机(CarLauncher)采用三列布局。
  • 第二个显示器: 专用的呼叫提供商应用程序。

该模拟器允许我触发各种呼叫状态,例如来电或活动呼叫,并实时观察主机的反应。这是"字节输入,整数输出"合约的终极压力测试。 Provider 应用程序位于完全不同的显示上下文中,但启动器中的 UI 仍然保持流畅。

共存证明:一个屏幕,三个建筑世界

目标还在于证明 RemoteCompose 可以与现有 AAOS 渲染技术共存。新的三列启动器布局完美地证明了这一点。

从左到右查看主显示屏。这是一个实时共存测试:

  • 第 1 列:Native Widget。 直接在 Launcher 进程中实现的标准视图。在传统设置中,每个小部件都会像这样,迫使启动器管理电话 API 和复杂的观察者。
  • 第 2 列:RemoteCompose。 我们的 PoC 电话小部件。通过将其转移到 RemoteCompose,启动器在设计上仍然是愚蠢的。它仅管理布局槽并回放传入的 UI 描述。 Phone 团队可以独立更新其 UI,而无需触及任何 Launcher 代码。
  • 第 3 列:CarTaskView。 嵌入在启动器表面中的另一个应用程序的完整映射活动。

此次集成确认 RemoteCompose 可以与传统的基于 View 的系统和复杂的 CarTaskView 环境共存,而不会产生副作用。这验证了无中断的采用路径:原始设备制造商可以将现代的远程驱动组件逐步引入其生产堆栈,从而避免整个系统重写的风险和成本。

PoC 架构

核心拆分: 该架构围绕两个在不同进程中运行的独立应用程序构建,通过 AIDL 绑定服务进行通信:

  • 主机(汽车启动器): 包含一个 RC 播放器小部件,可在其自己的画布上本地呈现 UI。它对电话逻辑一无所知。
  • 提供程序(电话应用程序): 保存所有业务逻辑并将 UI 状态构建为 RC 文档。该文档是一个紧凑的序列化字节数组,代表完整的 UI 树。

**快乐之路:**生命周期是完全被动的。每当状态发生变化(例如,传入呼叫)时,提供程序会将 RC 文档字节推送到主机,主机立即反序列化并呈现它们。当用户点击按钮(接受、拒绝)时,主机不处理逻辑。它只是将轻量级用户操作(整数 ID)发送回提供者。提供者处理业务逻辑、更新状态并推送新文档。

边缘案例 --- 弹性生命周期管理: 与 SurfaceControlViewHost 或 TaskView 不同,来宾进程崩溃可能会留下"死"表面或视觉"黑洞",Remote Compose 可确保整个生命周期隔离。在此 PoC 中,Host 监视 AIDL 链路是否有 Binder 死亡。如果提供程序进程崩溃,主机会检测到故障并在相同的帧预算内将 UI 插槽交换到占位符或闪烁状态。由于主机拥有绘图权限,因此不存在表面闪烁或过时的帧,过渡是无缝的,并且在重新建立连接时 UI 保持完美响应。

这在两个域之间创建了一个精美的最小契约:只有字节输入,整数输出。这种方法允许两个应用程序独立发展而不破坏用户界面。

传输层

RemoteCompose 没有提及如何传送二进制流。它仅定义格式,这意味着传输完全取决于你。以下是一些在 AAOS 环境中有意义的选项:

  • AIDL: 低延迟,直接进程间通信,非常适合频繁更新。这就是 PoC 使用的内容。
  • ContentProvider: 如果小部件数据已经建模为内容(如媒体元数据或联系人),那么这是一个自然的选择。它使用基于拉动的模型,非常适合静态或缓慢变化的 UI。
  • BroadcastReceiver: 提供简单的推送模型。然而,缺乏背压和严格的有效负载大小限制使得除了微小更新之外的任何事情都变得尴尬。
  • WebSocket: 对于服务器驱动的 UI 场景来说,这是一个有趣的选项,其中文档直接来自后端而不是本地进程。

对于 PoC,AIDL 是显而易见的选择。它提供低开销、基于回调的推送传递,并直接控制提供者何时将新文档推送到主机。

我之前提到过,这两个应用程序之间的契约非常小。这是证据。我们来看看实际的实现。

PoC 中的 AIDL 合约

主机和提供商之间的整个合同可以归结为两个简单的接口。提供者实现一个服务来管理回调并接收用户操作。主机实现回调以接收字节数组形式的 UI**。**

kotlin 复制代码
// Implemented by the Provider
interface ICallProviderService {
    void registerCallback(IDocumentCallback cb);
    void unregisterCallback(IDocumentCallback cb);
    void sendAction(int actionId); 
}
// Implemented by the Host
oneway interface IDocumentCallback {
    void onDocumentUpdated(in byte[] documentBytes, int transition); 
}

字节输入,整数输出。字面意思就是这样。

提供者:生成 UI

提供者使用熟悉的 Compose 语法编写 UI。它使用远程等效项,而不是列、行或文本。结果永远不会呈现到屏幕上。相反,它使用 captureSingleRemoteDocument 直接打包到字节数组中。

kotlin 复制代码
// 1. Build the document familiar Compose syntax, but with Remote* components
 suspend fun createIdleDocument(context: Context, callLog: List<CallLogEntry>): ByteArray {                                                      
      return captureSingleRemoteDocument(context) { IdleScreen(callLog) }.bytes                                                                   
  }       
                                                                                                                                      
// 2. The composable itself looks like regular Compose                                                                                                                                                 
  @RemoteComposable                                                                                                                               
  @Composable                                                                                                                                     
  private fun IdleScreen(entries: List<CallLogEntry>) {                                                                                           
      RemoteColumn(modifier = RemoteModifier.fillMaxSize().padding(16.dp)) {                                                                      
          RemoteText(text = "Recent Calls", fontSize = 24.rsp)                                                                                    
                                                                                                                                                
          entries.forEachIndexed { index, entry ->                                                                                              
              RemoteRow(                                                                                                                          
                  modifier = RemoteModifier   
                      .fillMaxWidth()                                                                                                             
                      .clickable(HostAction(RemoteString("${2000 + index}")))                                                                   
              ) {                                                                                                                                 
                  RemoteText(text = entry.name, fontSize = 16.rsp)
                  RemoteText(text = entry.number, fontSize = 12.rsp)                                                                              
              }                                                                                                                                 
          }                                                                                                                                     
      }                                                                                                                                           
  }

语法几乎与标准 Compose 相同。唯一的区别是 Remote 前缀以及输出是 ByteArray 而不是屏幕上的像素这一事实。

主持人:渲染

在主机方面,占地面积极小。主机通过 AIDL 回调接收字节,将它们包装在 RemoteDocument 中,然后将它们传递给 RemoteDocumentPlayer。播放器自动处理所有解析和绘制。

kotlin 复制代码
RemoteDocumentPlayer(
    document = doc.document,
    onNamedAction = { actionId, _, _ ->
        viewModel.sendAction(actionId.toInt()) 
    }
)

主机对提供者用户界面一无所知。它不知道布局结构或业务逻辑。它只是接受字节并渲染它们。当用户点击按钮时,该操作会以简单整数的形式飞回 IPC 边界。

务实之路:无需迁移

现代 UI 框架的一个常见问题是采用成本。对于此 PoC,我使用标准 AAOS CarLauncher 作为主机。与大部分核心 Automotive 堆栈一样,它构建在传统 Android 视图系统上:XML 布局、片段和自定义视图。对于 OEM 而言,仅仅为了支持一些"小部件"而将生产级系统 shell 迁移到 Jetpack Compose 很少是现实的选择。

幸运的是,RemoteCompose 就是针对这一现实而设计的。它附带两个不同的玩家工件:

  • remote-player-compose: 对于已在 Jetpack Compose 上运行的主机。
  • remote-player-view: 对于传统的基于视图的应用程序。

由于参考 CarLauncher 是基于视图的,因此我使用了远程播放器视图工件。 RemoteDocumentPlayer 是一个普通的 FrameLayout,你可以将其直接放入任何 XML 布局中。你只需调用 setDocument(bytes),它就会通过 Canvas 呈现提供者的 UI。

主机端不需要 Compose 依赖项,不需要 ComposeView 包装器,并且迁移工作量为零。提供者端保持完全相同:无论谁渲染它,它都会推送相同的字节数组。这证明了行业采用的关键点:一个提供商可以以零耦合的方式为现代和传统主机提供服务。

关于 Android 框架 (API 35) 的说明 如果你仔细查看最新的 Android 开源项目 (AOSP) 更新,你会发现 Android 实际上从 API 35 开始将其自己的本机 RemoteCompose 播放器直接烘焙到框架中。它为标准系统小部件的新 DrawInstructions 提供支持。然而,依赖框架播放器意味着将你的主机与操作系统更新周期联系起来。通过使用上述 AndroidX 播放器工件,第三方启动器和 OEM 主机现在就可以采用这种下一代架构,即使在较旧的 Android 版本上也是如此,同时支持比基本系统更新的操作。

局限性和悬而未决的问题

虽然 RemoteCompose 作为跨应用程序 UI 机制显示出强大的潜力,但重要的是要承认其当前的局限性和需要进一步验证的领域。

API 稳定性(Alpha 状态): RemoteCompose 仍在不断发展。 API 表面尚未稳定,预计版本之间会发生变化。这使得在没有额外抽象层的情况下立即进行生产采用存在风险。

调试和工具: 传统的 UI 调试工具(Layout Inspector、Compose 工具)并不完全适用。由于 UI 是作为二进制文档传输的,因此调试渲染问题、布局问题或状态不匹配需要自定义检测和日志记录。这给开发团队带来了额外的复杂性。

CVAA 和可访问性: RemoteCompose 支持基本语义(内容描述、角色),但画布绘制的元素不产生语义节点,并且该框架缺乏 liveRegion、焦点顺序控制和自定义可访问性操作。合规性要求主机端解决方法不能超出有限的 UI 状态。盲文显示支持未经验证。实际上,这将可访问性责任从 UI 生产者转移到了主机,从而在不同层重新引入了复杂性。

旋转导航: 播放器当前没有公开对焦点管理原语或旋转输入处理(例如,onRotaryScrollEvent)的明确支持。主机可以在包装器级别捕获旋转事件,但无法将它们路由到文档内的可滚动内容。

结论

RemoteCompose 将思维模型从共享表面转变为共享意图。你无需将外部进程嵌入到你的 UI 中,而是交换应呈现内容的紧凑的声明性描述。这种区别很微妙,但在大型 AAOS 系统中,这是根本性的。它减少耦合,简化所有权边界 ,并使团队能够独立发展而不会互相破坏。

PoC 表明,这种方法不仅可行,而且可以实用地集成到现有的基于视图的堆栈中,而无需中断迁移。

RemoteCompose 尚未做好生产准备,工具、性能和系统集成方面仍存在重要问题。然而,它是第一个有意义地解决汽车系统中跨应用程序 UI 核心问题的方法,而不继承以前解决方案的复杂性。

我花了数年时间研究跨多个品牌和大型信息娱乐系统的 AAOS 平台架构,我已经看到这些挑战如何反复导致紧密耦合和不必要的复杂性。这个 PoC 试图从基本原则出发重新思考这个问题。

如果你正在应对类似的挑战,我将非常有兴趣交流想法。
欢迎搜索并关注 公众号「稀有猿诉」 获取更多的优质文章!

保护原创,请勿转载!

相关推荐
落魄Android在线炒饭11 小时前
Android 自定义HAL开发篇之 HIDL篇——从入门到实战(上)
android
plainGeekDev12 小时前
广播接收器 → Flow + Lifecycle
android·java·kotlin
plainGeekDev12 小时前
EventBus → SharedFlow
android·java·kotlin
37手游移动客户端团队1 天前
招聘-高级安卓开发工程师
android·客户端
用户41659673693551 天前
WebView 请求异常排查操作手册
android·前端
Kapaseker2 天前
学不动了,入门 Compose Styles API
android·kotlin
墨狂之逸才2 天前
Android TV WebView 遥控器按键处理:从全透传到白名单
android
plainGeekDev2 天前
MVC 写法 → MVVM
android·java·kotlin
恋猫de小郭2 天前
Flutter Patchwork,不用 Fork 改依赖包源码的第三方工具
android·前端·flutter