AAOS AppCard 实践:怎么把自己的 App 塞进别人的卡片里

车机系统里经常能看到这种场景:天气信息出现在桌面的一张小卡片上,导航的下一步提示嵌在主界面的一角,用户没有打开这些 App,但它们的内容已经显示在别的地方了。这套机制在 AAOS 里有官方名字------AppCard ,代码在 packages/apps/Car/libs 下的 car-app-card-lib 和 car-app-card-host-lib 两个模块里。

这篇东西记录一下怎么实现一个 AppCard Provider、系统怎么把它跑起来、以及数据到 UI 这条链路具体是怎么走的。

先说清楚它解决什么问题

AppCard 管的是"别人进程里的内容怎么画到我的界面上"。一个天气 App 想让自己的信息出现在系统桌面上AppCard 思路跟 Android 桌面 Widget 很像,只是换了一套车规的协议和渲染层。

怎么实现一个 Provider

写一个 Provider 就是继承 AppCardContentProvider,这是个 abstract class,自带 ContentProvider 的能力,你只需要实现几个方法:

kotlin 复制代码
class SimpleAppCardContentProvider : AppCardContentProvider() {

    override val authority = "com.example.appcard.sample.weather"

    override val appCardIds: List<String>
        get() = listOf(WEATHER_ID)

    override fun onAppCardAdded(id: String, appCardContext: AppCardContext): AppCard {
        return ImageAppCard.newBuilder(id)
            .setPrimaryText("22°C")
            .setSecondaryText("多云")
            .setImage(buildWeatherIcon(appCardContext))
            .build()
    }

    override fun onAppCardRemoved(id: String) {
        // 停止对应的数据监听
    }

    override fun onAppCardContextChanged(id: String, appCardContext: AppCardContext) {
        // Host 屏幕尺寸/刷新率变了,重新生成一张卡片
    }
}

几个要点:

  • onAppCardAdded 返回的是一个 AppCard(具体到天气这种场景是 ImageAppCard),里面装的是纯数据------文字、一张已经解码好的 Bitmap、按钮列表,没有任何布局信息。卡片长什么样、多大、字体多粗,这些完全不归 Provider 管。
  • 数据变化了怎么通知出去?调 sendAppCardUpdate(appCard) 或者只更新某个组件时调 sendAppCardComponentUpdate(id, component),Provider 侧不用关心 Host 什么时候来读,推送就行。
  • 按钮的点击逻辑是 Provider 自己写的,通过 Button 的 onClickListener 挂上去,Host 只是负责把点击事件转发回来。

manifest 里要声明这个 Provider,权限这块是最容易踩坑的地方:

xml 复制代码
<provider
    android:name=".SimpleAppCardContentProvider"
    android:authorities="com.example.appcard.sample.weather"
    android:exported="true"
    android:permission="@string/host_permission" />

host_permission 这个字符串资源不是你自己定义的,它由 car-app-card-lib 的 config.xml 决定,实际指向:

  • API < 34:android.permission.INTERACT_ACROSS_USERS_FULL
  • API ≥ 34:android.car.permission.BIND_APP_CARD_PROVIDER

这两个都是 signature 级权限,只有跟系统镜像用同一个签名证书打包的 App 才能拿到。这意味着你在自己电脑上用 debug key 编译出来的 Host,无论代码写得多干净,都拿不到读这个 Provider 的权限------这不是能靠改代码绕过去的东西,而是平台层面的安全限制。真要自己搭一套能跑通的 Host,要么从源码编译 AOSP 并用仓库自带的测试证书签名、刷到自己的镜像上测试,要么放弃跨进程的真实链路,退而用同进程的方式验证渲染逻辑。Provider 本身的开发和调试不受这个限制影响,该怎么写还是怎么写。

依赖上要注意一点:car-app-card-lib 这个模块本身也挂在 aaos-apps-gradle-project 这个多模块 Gradle 工程下,依赖了自定义的 aaosApps.buildLogic 插件和 protobuf 插件,不能把它的源码单独拎出来当一个独立 Gradle 项目编译,得连着整个工程一起打开。

Host 怎么把它跑起来

Host 侧不需要自己写解析协议,官方给了一个 AppCardHost 类把这一层包掉了。用法大致是这样:

kotlin 复制代码
val appCardHost = AppCardHost(applicationContext, UPDATE_RATE_MS, FAST_UPDATE_RATE_MS, mainExecutor)
appCardHost.registerListener(viewModel)   // viewModel 实现 AppCardListener

appCardHost.refreshCompatibleApplication()          // 扫描系统里符合条件的 Provider
appCardHost.getAllAppCards(appCardContext)          // 把所有卡片数据拉回来

拉数据这一步,AppCardHost 内部对每个 Provider 发起 content://<authority>/... 的 query,拿到的 Cursor 里只有一列 appCardTransport,是一段序列化好的字节流;反序列化之后变成 AppCardContainer 对象,再回调到 AppCardListener.onAppCardReceived()。写 Host 的人从头到尾不需要碰 Cursor 或字节数组,AppCardHost 已经把这层脏活干完了。

数据是怎么变成界面的

这部分是我自己最想搞清楚的,因为光看接口容易以为"Host 拿到数据就直接画",实际中间还有一层。以官方 sample-host 为例:

HostViewModel 实现了 AppCardListener,收到卡片后做的事很直接------按 appId + appCardId 当 key,去一个 Compose 的 mutableStateMapOf 里找有没有已经存在的状态对象。有就调它的 update(),没有就用 AppCardContainerStateFactory 按卡片类型(比如 ImageAppCard)新建一个对应的状态包装类,再塞进 map。

kotlin 复制代码
val curAppCard = allAppCards[getKey(appCard)]
curAppCard?.let {
    if (it.update(appCard)) { allAppCards[key] = it }
} ?: stateFactory.getState(appCard)?.let { allAppCards[key] = it }

关键在这个状态包装类------ImageAppCardContainerState。它把 ImageAppCard 里的每个字段各自拆成一个 Compose 的 mutableStateOf:

kotlin 复制代码
private var primaryText = mutableStateOf(imageAppCard.primaryText)
private var image = mutableStateOf(imageAppCard.image)
private var buttons = imageAppCard.buttons.map { mutableStateOf(it) }.toMutableStateList()

之后每次 Provider 推送更新,update() 方法做的事只是把新值赋回这些 State 的 .value,不重新创建对象。这是 Compose 里最常见的写法------数据变了只改 State,画面自动重组,不需要手写"找到某个 View 再 setText"这种命令式代码。

真正画出来的地方是一个 @Composable fun AppCard(),结构很朴素:一个 ElevatedCard 包一个 Column,从上到下依次是标题(Header,没有 logo 就退化成用 PackageManager 拿这个 App 的图标垫背)、一张图(image.imageData 本来就是解码好的 Bitmap,直接 .asImageBitmap() 传给 Compose 的 Image())、主副文字,最后是进度条或者一排按钮,二选一。卡片多大、字号多粗、按钮什么配色,全是 Host 这份 Compose 代码里定好的,Provider 传过去的只是"内容"和几个枚举提示(比如按钮是主按钮还是次按钮),不掺和具体样式。

点击按钮之后:

kotlin 复制代码
viewModel.sendInteraction(identifier, appCardId, button.componentId, MSG_INTERACTION_ON_CLICK)

这条消息会转发回 Provider 进程,真正触发 component.onClickListener?.onClick() 的是 Provider 自己,Host 只是个信使。如果这个按钮还带了 RoutingActivityIntent(比如天气卡片上"打开设置"这种按钮),Host 会额外主动构造一个显式 Intent,把 Provider 包名和它指定的 Activity 类名拼起来直接拉起------这也是为什么天气 App 那边会有一个几乎是空壳的 SampleRoutingActivity,它不负责展示什么内容,只是被 Host 点名启动、跳转到系统设置、然后自己退出。

小结

整套东西拆开看其实没那么玄:Provider 通过 ContentProvider 把数据(文字、已经解码的 Bitmap、按钮定义)和交互逻辑推出去,自己不管任何布局;Host 把收到的数据包成 Compose 的可观察状态,交给声明式 UI 自动渲染,点击事件原路转发回 Provider 处理。整条链路里唯一算得上硬骨头的地方,不是协议或者渲染逻辑,而是那道签名级权限------它决定了这套机制默认只对系统集成商开放,普通三方 App 想自己搭一个能跑起来的 Host 环境去联调,绕不开签名这一关,只能老老实实走 AOSP 编译签名这条路。

相关推荐
卸任16 小时前
Vibe coding工具使用心得
前端·ai编程
brien917 小时前
为什么模型标称 1M,Google Antigravity 聊到 256K 却突然失忆?我们给它做了个原生行内监控圆环
前端
修炼的dance17 小时前
录屏关键帧间隔多少合适?六档实测,2秒到10秒小了75.6%
前端
Lank_M17 小时前
关键帧从1秒拉到10秒,录屏小87.3%,摄像头小10.8%
前端
补码缩写17 小时前
用 ffprobe 拆录屏,八成字节在 29 个关键帧上
前端
百思不得小李17 小时前
一个复杂的动态表单是如何实现的
前端·设计模式
陈随易18 小时前
Cmder 停更 17 个月,2.0 支持 Windows Terminal
前端·后端·程序员
风骏时光牛马18 小时前
前沿视界
前端
kyriewen18 小时前
我扫了 814 个依赖,97 个还敢要 Node 10
前端·javascript·程序员
陈随易18 小时前
3000 万人在用的翻译插件,重写一遍就是一门生意
前端·后端·程序员