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

车机系统里经常能看到这种场景:天气信息出现在桌面的一张小卡片上,导航的下一步提示嵌在主界面的一角,用户没有打开这些 App,但它们的内容已经显示在别的地方了。这套机制在 AAOS 里有官方名字------AppCard ,代码在 packages/apps/Car/libs 下的 car-app-card-libcar-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 自己写的,通过 ButtononClickListener 挂上去,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-libconfig.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 编译签名这条路。

相关推荐
梦曦i1 小时前
@meng-xi/uni-router 未来展望:夯实基础、深化体验、探索前沿
前端·uni-app
IT_陈寒2 小时前
Vite静态资源路径这个大坑害我调了一下午
前端·人工智能·后端
狗哥哥2 小时前
分享下我的读书清单
前端
合天网安实验室2 小时前
Log4J2 FilteredObjectInputStream RCE 漏洞分析
前端·黑客
两点王爷2 小时前
使用 GeoServer 发布 SHP 数据并在前端页面加载显示
前端
lhldsg2 小时前
全民健身解决方案软件开发实战:从架构设计到部署指南
java·前端·数据库·小程序
四六的六2 小时前
让 AI 自己去点后台页面,它把我们的库存点没了
前端·人工智能·agent·个人开发·ai编程·ai产品·ai前端
两点王爷2 小时前
Java 与前端加载 MVT 数据:从服务端切片到浏览器渲染
java·前端·状态模式
小码过河.3 小时前
oceanbase中zone和observer、分区表之间关系
前端·数据库·oceanbase