车机系统里经常能看到这种场景:天气信息出现在桌面的一张小卡片上,导航的下一步提示嵌在主界面的一角,用户没有打开这些 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 编译签名这条路。