ZorvAI 可视化小卡片:七层自研 Canvas 渲染引擎架构全解析

2 阅读10分钟

一、它是什么,以及它不是什么

可视化小卡片是 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 次(CardModuleCardSpecCardRegistryCardHostChatScreen 各一处以上)。

二、七层自研架构总览

提示词注释里写的是「7 层自研渲染:spec / registry / render / host / stream / widgets」,实际目录结构对应六层 + 装配入口:

┌────────────────────────────────────────────────────────────────────┐
│  ① 装配层   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

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 一张卡的自描述

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
ChartchartchartType + series[].{name,color,points,labels} + axismetric / line_chart
MediamediamediaType + headers/rows/code/images/itemstable
FormformformType + buttons[]/fields[]/slider/selectorbutton_group
StatusstatusstatusType + text/progress/retryable/reasonstatus
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 —— 暗色模式、字体缩放自动生效:

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 树)

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 / ...
)

propsMap<String, Any?> 而非强类型 —— 这是刻意的:让 AI 能自由扩展属性而不改协议,新属性旧客户端忽略即可(渲染器用 num(props,"x",def) 安全取值)。

3.5 版本迁移器

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 契约

/** 把 AI 卡片 JSON 解析成完整 [CardSpec];失败返回 null。 */
fun parseCardSpec(json: String): CardSpec?

失败即 null,调用方降级,绝不抛异常、绝不崩对话框。

4.2 完整 JSON Schema

{
  "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 三级容错取值

// ① 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 兜底

return try {
    /* 逐字段解析 */
} catch (_: Exception) {
    null    // 任何异常 → null → 调用方降级
}

与 AIP 的对比:AIP 有四级降级(字段修复 / 块级 Fallback / 通道降级 / 纯文本兜底),小卡片只有"解析失败 → null → 降级"。粒度更粗,但换来的是实现极简(273 行 vs AIP 的 576 行)。取舍合理:单块卡片错了就整块不渲染,不需要保留半个卡

五、渲染层:三档可插拔底座

5.1 RenderBackend 接口

/**
 * 关键约束(来自需求):**绘制指令端上自己下**,不依赖任何内置/三方成品卡片控件。
 * 上层(编排层/布局层)只调用这里的 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 组合仅契约为需要原生控件交互的场景预留
GLOpenGL 渲染仅契约为高性能 / 复杂动效预留

渲染器通过 renderHint 字段声明期望的档位,宿主层按可用性回退:CANVAS 始终可用,VIEW / GL 未实现时自动降级到 CANVAS。这样既保留了未来扩展空间,又保证当前版本零风险。

5.2 渲染管线:从 CardSpec 到像素

一次完整渲染走四步,全部由自研代码完成:

  1. measure:按 LayoutNode 树递归计算每个节点的尺寸与位置,得到布局结果。
  2. layout:把布局结果落到具体坐标,处理 weight 占比、flex 伸缩与对齐。
  3. render:遍历布局树,把每个节点翻译成 RenderBackend.drawXxx 调用序列。
  4. hitTest:把点击坐标映射回布局树,命中 Action 后通过 ActionBus 回调。

这套流程与 Android View 的 measure / layout / draw 三阶段神似,但完全跑在自研的数据结构上,不依赖系统控件树,因此可以做到全宽内联、流式增量更新和 Bitmap 缓存。

5.3 宿主层:CardHost 的职责

CardHost 是卡片与外界交互的挂载点,承担四类职责:

  • 挂载:提供 CardSurface 作为绘制画布,接收渲染器输出。
  • 令牌解析:把 ColorToken / StyleToken 解析成当前主题下的具体 Color 与尺寸,暗色模式、字体缩放在此生效。
  • 缓存HeightCache 缓存测量高度避免重复计算,BitmapCache 缓存静态卡片位图,滚动时直接贴图。
  • 动画:驱动入场动画(淡入 / 上移),并处理流式更新时的平滑过渡。

5.4 流式层:StreamAssembler 的状态机

对话流里卡片是逐 token 到达的,StreamAssembler 用状态机管理生命周期:

Empty → Skeleton → Streaming → Complete / Error
  • Empty:围栏刚出现,尚无内容。
  • Skeleton:检测到 ```quro-card 开头,先渲染骨架占位。
  • Streaming:JSON 逐段到达,按 id 增量 patch,只重绘变化区域。
  • Complete:围栏闭合,解析成功,渲染最终卡片。
  • Error:解析失败,降级为纯文本展示围栏原文。

每个 patch 携带 generation 号,过期 patch 直接丢弃,避免乱序导致卡片闪烁或错乱。

5.5 实现层:内置渲染器一览

白名单里已注册的渲染器覆盖常见卡片形态:

渲染器对应 type说明
MetricRenderermetric指标大数字卡,支持渐变背景与计数动画
LineChartRendererline_chart折线图,支持多序列与坐标轴
ButtonGroupRendererbutton_group按钮组,点击触发 ActionBus 回调
SkeletonRendererskeleton骨架占位卡,流式加载时展示
TableRenderertable表格卡,来自 Media 数据形态
StatusRendererstatus状态卡,展示进度 / 结果 / 可重试提示
CustomCardRenderercustomAI 自写 LayoutNode 布局树,最灵活

其中 CustomCardRenderer 是「AI 现场设计」能力的核心:它不关心具体业务,只按 LayoutNode 树递归渲染,因此 AI 可以自由组合 column / row / text / ring / bar 等节点,拼出任意布局。

六、总结

ZorvAI 的可视化小卡片是一条完整的自研链路:协议层用 CardSpec 自描述数据与形态,反序列化层用三级容错 + try-catch 保证绝不崩溃,渲染层用三档可插拔底座 + 四步管线把布局树画成像素,宿主层负责主题解析与缓存,流式层用状态机管理增量更新,实现层提供内置渲染器并开放 custom 让 AI 自由创作。

整套设计的关键取舍是:用「数据 + 形态声明」换「渲染完全自研」。不依赖任何内置或三方卡片控件,换来的是全宽内联、流式增量、主题自适应和极低的崩溃风险;代价是渲染层需要自己实现测量、排版、绘制与命中测试。对于单块紧凑结果这一场景,这个取舍是划算的。