一、它是什么,以及它不是什么
可视化小卡片是 ZorvAI 在对话流里"现场设计"一张卡片的能力:AI 在回复正文里写一个 ```````quro-card```` 代码围栏,内容是一段卡片 JSON;客户端用自研渲染层(自写测量 / 排版 / 绘制 / 命中测试)把它画成一张小卡片,全宽内联在对话框消息流里。不用 WebView、不套 Material Card、不用任何三方控件。
ZorvAI 有七条可视化通道,小卡片只是其中一条。源码注释里反复强调「术语铁律」,因为模型极容易认错对象:
| 通道 | 形式 | 渲染方式 | 适用场景 |
|---|---|---|---|
| 可视化小卡片 | ```````quro-card```` 围栏 | 自研 Canvas 自绘(AI 自写 layout 树) | 单块紧凑结果:指标大数字卡 / 进度环 / 迷你组合 |
| 富卡片 | ui_widget / ui_card 工具 |
预制组件库(几十种 type) | 待办 / 看板 / 饼图 / 评分 / 表单 |
| 动态 UI | ```````quro-ui```` 围栏 | 原生组件树(真实控件) | 成体系的完整交互界面 |
| 网页预览 | ```````html```` 围栏 | WebView | 完整网页 / 游戏 / 数据看板 |
| 流程图 | ```````mermaid```` 围栏 | Mermaid.js 离线渲染 | 流程 / 时序 / 类图 / 脑图 |
| 小程序 | ```````miniapp```` 围栏 | bridge.js 运行时 | 可交互小程序页面 |
| AIP 排版 | ```````aip```` 围栏 / aip_compose |
原生排版引擎 | 整篇长文档 / PPT / 报告 |
三条铁律(提示词原文摘录):
- 小卡片不是 HTML 、不用 ```````html````
- 用户说「小卡片」就是 ```````quro-card```` 围栏,别用其他通道顶替
- 反过来,要组件库卡片 / 弹窗 / 网页时也别写 quro-card
与动态 UI 是"完全独立、不合并"的两个功能。源码里这句话出现了至少 6 次(CardModule、CardSpec、CardRegistry、CardHost、ChatScreen 各一处以上)。
二、七层自研架构总览
提示词注释里写的是「7 层自研渲染:spec / registry / render / host / stream / widgets」,实际目录结构对应六层 + 装配入口:
text
┌────────────────────────────────────────────────────────────────────┐
│ ① 装配层 CardModule.kt │
│ 幂等 init(),把 7 个渲染器注册进白名单 │
├────────────────────────────────────────────────────────────────────┤
│ ② 协议层 spec/CardSpec.kt 数据模型:Action/StyleToken/ │
│ ColorToken/LayoutNode/CardData │
│ spec/CardSpecParser.kt JSON → CardSpec,失败返回 null │
├────────────────────────────────────────────────────────────────────┤
│ ③ 编排层 registry/CardRegistry.kt │
│ type → CardRenderer 白名单 + 降级判定 │
│ CardRenderer<S> 四方法契约:measure/layout/render/hitTest│
├────────────────────────────────────────────────────────────────────┤
│ ④ 渲染层 render/RenderBackend.kt │
│ 三档底座抽象:CANVAS(已落地)/ VIEW / GL(仅契约) │
│ drawRect / drawText / drawPath / drawGradientRect / ... │
├────────────────────────────────────────────────────────────────────┤
│ ⑤ 宿主层 host/CardHost.kt │
│ CardSurface 挂载点 · StyleTokenResolver · ActionBus │
│ HeightCache · BitmapCache · 入场动画驱动 │
├────────────────────────────────────────────────────────────────────┤
│ ⑥ 流式层 stream/StreamAssembler.kt │
│ Empty → Skeleton → Streaming → Complete / Error │
│ generation 号丢弃过期 patch │
├────────────────────────────────────────────────────────────────────┤
│ ⑦ 实现层 widgets/CardWidgets.kt metric/line_chart/ │
│ button_group/skeleton │
│ widgets/CardDataRenderers.kt table/status │
│ widgets/CustomCardRenderer.kt custom(AI 自写布局树) │
└────────────────────────────────────────────────────────────────────┘
2.1 核心设计前提
CardSpec.kt 文件头的注释把意图说得很直白:服务端(或端上 AI)只吐「数据 + 形态声明」,端上决定怎么画。这是「渲染层完全自研、不依赖任何内置/三方成品卡片控件」的根基 ------ 所有形态都来自 CardSpec,没有任何 Android View / Material Card 参与。
2.2 开关:feat_self_card
kotlin
object PersonaFeatureToggles {
private const val PREFS = "quro_persona_features"
private const val KEY_SELF_CARD = "feat_self_card"
fun isSelfCardEnabled(ctx: Context): Boolean =
ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getBoolean(KEY_SELF_CARD, true) // 默认开
}
关键设计:开关是提示词级,渲染管线常开 。开关开 → 提示词主动教 AI 写 quro-card,鼓励主动使用;开关关 → 提示词只讲"被动模式",但渲染管线照常解析 ,用户提醒后 AI 写的围栏仍能渲染成卡片。源码注释原话:"开关同为提示词级(主动/被动),渲染管线常开"。
三、协议层:CardSpec
3.1 一张卡的自描述
kotlin
data class CardSpec(
val id: String, // 稳定唯一 ID(流式增量按 id patch、Bitmap 缓存按 id)
val type: String, // 必须命中 CardRegistry 白名单,否则降级
val version: Int = 1, // 不匹配走 CardMigrator
val layout: LayoutNode? = null, // 自描述布局树(custom 卡的核心)
val data: CardData = CardData.Empty, // 业务数据
val actions: List<Action> = emptyList(),
val style: StyleToken = StyleToken(),
val a11y: A11y = A11y(),
val renderHint: String = "canvas", // canvas / view / gl
)
3.2 CardData:四种数据形态(sealed interface)
| 形态 | kind |
承载内容 | 对应卡片 |
|---|---|---|---|
Empty |
--- | 空数据(骨架卡) | skeleton |
Chart |
chart |
chartType + series[].{name,color,points,labels} + axis |
metric / line_chart |
Media |
media |
mediaType + headers/rows/code/images/items |
table |
Form |
form |
formType + buttons[]/fields[]/slider/selector |
button_group |
Status |
status |
statusType + text/progress/retryable/reason |
status |
kotlin
sealed interface CardData {
object Empty : CardData
data class Chart(
val chartType: String, // line / bar / pie / metric / sparkline
val series: List<Series>,
val axis: AxisConfig = AxisConfig(),
) : CardData
data class Series(
val name: String,
val color: ColorToken = ColorToken.Primary,
val points: List<Float>,
val labels: List<String> = emptyList(),
)
// ...Media / Form / Status
}
3.3 语义色令牌:与 Material 主题解耦
所有颜色走语义令牌 ,由宿主在渲染时解析成具体 Color ------ 暗色模式、字体缩放自动生效:
kotlin
enum class ColorToken {
Primary, OnPrimary, Secondary, OnSecondary,
Surface, OnSurface, SurfaceVariant, OnSurfaceVariant,
Background, OnBackground, Outline,
Success, Warning, Danger, Info,
}
data class StyleToken(
val bg: ColorToken = ColorToken.Surface,
val fg: ColorToken = ColorToken.OnSurface,
val accent: ColorToken = ColorToken.Primary,
val cornerDp: Float = 12f,
val paddingDp: Float = 12f,
val fontSizeSp: Float = 14f,
val fontWeight: Int = 400,
)
3.4 LayoutNode:自描述布局树(不是 Android View 树)
kotlin
data class LayoutNode(
val type: String, // column / row / box / card / text / spacer / ring / bar / divider
val id: String? = null,
val weight: Float = 0f, // 在父容器中的占比(0 = 按内容)
val widthDp: Float? = null,
val heightDp: Float? = null,
val flex: Int = 0,
val style: StyleToken = StyleToken(),
val children: List<LayoutNode> = emptyList(),
val props: Map<String, Any?> = emptyMap(), // 自由属性:gradient / countTo / value / ...
)
props 是 Map<String, Any?> 而非强类型 ------ 这是刻意的:让 AI 能自由扩展属性而不改协议,新属性旧客户端忽略即可(渲染器用 num(props,"x",def) 安全取值)。
3.5 版本迁移器
kotlin
object CardMigrator {
private val handlers = mutableMapOf<Pair<String, IntRange>, (CardSpec) -> CardSpec>()
fun register(type: String, range: IntRange, fix: (CardSpec) -> CardSpec) { ... }
fun migrate(spec: CardSpec): CardSpec { /* 按 type + version 区间命中迁移函数 */ }
}
⚠️ 当前状态 :
CardMigrator已定义,但全仓库没有注册任何迁移规则 ,CardHost渲染路径里也没有调用migrate()。这是一个"预留但未接线"的架构位。老会话里的旧版本卡片目前直接按当前协议渲染。
四、反序列化:CardSpecParser
4.1 契约
kotlin
/** 把 AI 卡片 JSON 解析成完整 [CardSpec];失败返回 null。 */
fun parseCardSpec(json: String): CardSpec?
失败即 null,调用方降级,绝不抛异常、绝不崩对话框。
4.2 完整 JSON Schema
json
{
"id": "card_xxx", // 可选,缺省按内容 hash 兜底
"type": "line_chart", // 必须,命中白名单
"version": 1,
"renderHint": "canvas", // canvas / view / gl
"data": {
"kind": "chart", // chart | media | form | status
"chartType": "line",
"series": [ { "name": "", "color": "primary", "points": [0.1, 0.5, 0.9] } ],
"axis": { "showX": true, "showY": true }
},
"actions": [ { "type": "callback", "name": "确定" } ],
"style": { "bg": "surface", "fg": "onSurface", "cornerDp": 12, "fontSizeSp": 14 },
"a11y": { "role": "img", "label": "" },
"layout": { "type": "column", "children": [] }
}
4.3 三级容错取值
kotlin
// ① id 兜底:按内容 hash 生成,保证流式 patch 有稳定 key
val id = obj.optString("id").ifBlank { "card_" + json.hashCode().toString(36).replace("-", "m") }
// ② 颜色令牌:大小写 + 下划线不敏感,失败回落 Primary
private fun colorTokenOf(s: String): ColorToken =
try { ColorToken.valueOf(s.lowercase().replaceFirstChar { it.uppercase() }) }
catch (_: Exception) { ColorToken.Primary }
// ③ 可选数值:显式 has() 判断,区分"未填"与"填了 0"
yMin = if (o.has("yMin")) o.optDouble("yMin", 0.0).toFloat() else null,
4.4 整体 try-catch 兜底
kotlin
return try {
/* 逐字段解析 */
} catch (_: Exception) {
null // 任何异常 → null → 调用方降级
}
与 AIP 的对比 :AIP 有四级降级(字段修复 / 块级 Fallback / 通道降级 / 纯文本兜底),小卡片只有"解析失败 → null → 降级"。粒度更粗,但换来的是实现极简(273 行 vs AIP 的 576 行)。取舍合理:单块卡片错了就整块不渲染,不需要保留半个卡。
五、渲染层:三档可插拔底座
5.1 RenderBackend 接口
kotlin
/**
* 关键约束(来自需求):**绘制指令端上自己下**,不依赖任何内置/三方成品卡片控件。
* 上层(编排层/布局层)只调用这里的 drawXxx,完全不知道底层是 Canvas / 自定义 View / GL。
*/
interface RenderBackend {
fun drawRect(left: Float, top: Float, right: Float, bottom: Float, color: Color, radiusDp: Float = 0f)
fun drawText(text: String, x: Float, y: Float, style: TextStyle)
fun drawPath(points: List<PointF>, color: Color, strokeWidth: Float)
fun drawGradientRect(left: Float, top: Float, right: Float, bottom: Float, colors: List<Color>)
fun drawRing(cx: Float, cy: Float, radius: Float, startAngle: Float, sweepAngle: Float, color: Color, strokeWidth: Float)
fun drawBar(left: Float, top: Float, right: Float, bottom: Float, color: Color, radiusDp: Float = 0f)
fun drawDivider(x1: Float, y1: Float, x2: Float, y2: Float, color: Color, strokeWidth: Float)
fun measureText(text: String, style: TextStyle): Float
fun save()
fun restore()
fun clipRect(left: Float, top: Float, right: Float, bottom: Float)
}
三档底座对应三种底层实现,但上层契约完全一致:
| 档位 | 底层 | 状态 | 说明 |
|---|---|---|---|
CANVAS |
自研 Canvas 自绘 | 已落地 | 默认档,覆盖全部现有卡片类型 |
VIEW |
自定义 View 组合 | 仅契约 | 为需要原生控件交互的场景预留 |
GL |
OpenGL 渲染 | 仅契约 | 为高性能 / 复杂动效预留 |
渲染器通过 renderHint 字段声明期望的档位,宿主层按可用性回退:CANVAS 始终可用,VIEW / GL 未实现时自动降级到 CANVAS。这样既保留了未来扩展空间,又保证当前版本零风险。
5.2 渲染管线:从 CardSpec 到像素
一次完整渲染走四步,全部由自研代码完成:
- measure :按
LayoutNode树递归计算每个节点的尺寸与位置,得到布局结果。 - layout :把布局结果落到具体坐标,处理
weight占比、flex伸缩与对齐。 - render :遍历布局树,把每个节点翻译成
RenderBackend.drawXxx调用序列。 - hitTest :把点击坐标映射回布局树,命中
Action后通过ActionBus回调。
这套流程与 Android View 的 measure / layout / draw 三阶段神似,但完全跑在自研的数据结构上,不依赖系统控件树,因此可以做到全宽内联、流式增量更新和 Bitmap 缓存。
5.3 宿主层:CardHost 的职责
CardHost 是卡片与外界交互的挂载点,承担四类职责:
- 挂载 :提供
CardSurface作为绘制画布,接收渲染器输出。 - 令牌解析 :把
ColorToken/StyleToken解析成当前主题下的具体Color与尺寸,暗色模式、字体缩放在此生效。 - 缓存 :
HeightCache缓存测量高度避免重复计算,BitmapCache缓存静态卡片位图,滚动时直接贴图。 - 动画:驱动入场动画(淡入 / 上移),并处理流式更新时的平滑过渡。
5.4 流式层:StreamAssembler 的状态机
对话流里卡片是逐 token 到达的,StreamAssembler 用状态机管理生命周期:
text
Empty → Skeleton → Streaming → Complete / Error
- Empty:围栏刚出现,尚无内容。
- Skeleton :检测到 ```````quro-card```` 开头,先渲染骨架占位。
- Streaming :JSON 逐段到达,按
id增量 patch,只重绘变化区域。 - Complete:围栏闭合,解析成功,渲染最终卡片。
- Error:解析失败,降级为纯文本展示围栏原文。
每个 patch 携带 generation 号,过期 patch 直接丢弃,避免乱序导致卡片闪烁或错乱。
5.5 实现层:内置渲染器一览
白名单里已注册的渲染器覆盖常见卡片形态:
| 渲染器 | 对应 type |
说明 |
|---|---|---|
MetricRenderer |
metric |
指标大数字卡,支持渐变背景与计数动画 |
LineChartRenderer |
line_chart |
折线图,支持多序列与坐标轴 |
ButtonGroupRenderer |
button_group |
按钮组,点击触发 ActionBus 回调 |
SkeletonRenderer |
skeleton |
骨架占位卡,流式加载时展示 |
TableRenderer |
table |
表格卡,来自 Media 数据形态 |
StatusRenderer |
status |
状态卡,展示进度 / 结果 / 可重试提示 |
CustomCardRenderer |
custom |
AI 自写 LayoutNode 布局树,最灵活 |
其中 CustomCardRenderer 是「AI 现场设计」能力的核心:它不关心具体业务,只按 LayoutNode 树递归渲染,因此 AI 可以自由组合 column / row / text / ring / bar 等节点,拼出任意布局。
六、总结
ZorvAI 的可视化小卡片是一条完整的自研链路:协议层用 CardSpec 自描述数据与形态,反序列化层用三级容错 + try-catch 保证绝不崩溃,渲染层用三档可插拔底座 + 四步管线把布局树画成像素,宿主层负责主题解析与缓存,流式层用状态机管理增量更新,实现层提供内置渲染器并开放 custom 让 AI 自由创作。
整套设计的关键取舍是:用「数据 + 形态声明」换「渲染完全自研」。不依赖任何内置或三方卡片控件,换来的是全宽内联、流式增量、主题自适应和极低的崩溃风险;代价是渲染层需要自己实现测量、排版、绘制与命中测试。对于单块紧凑结果这一场景,这个取舍是划算的。