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

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

可视化小卡片是 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」,实际目录结构对应六层 + 装配入口:

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

propsMap<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 到像素

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

  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 用状态机管理生命周期:

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 自由创作。

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

相关推荐
可乐ea1 小时前
一个任务一条「模型流水线」:GitHub HydraFusion 多模型运行时编排拆解
github·ai智能体·agent架构·多模型编排·大模型路由
程序员柒叔2 小时前
Dify -- 定时任务系统
人工智能·github·agent·dify·rag
阿里嘎多学长3 小时前
2026-09-07 GitHub 热点项目精选
开发语言·程序员·github·代码托管
峰向AI16 小时前
告别 API 费用! 这个开源工具让你的 AI 助手在本地运行
github
乱码三千17 小时前
使用ComfyUI+MinMax-H3音视频模型生成视频
前端·后端·github
LiaCode18 小时前
别让 AI 把仓库写成“代码平行宇宙”:从 Deslop 看生成前查重
前端·后端·github
m4Rk_19 小时前
【论文阅读】Agent 记忆机制(62):DCM-Agent——用双簇记忆化解优化问题的多范式冲突
论文阅读·人工智能·学习·开源·github
逛逛GitHub20 小时前
GPT-6 Astra 上线 24 小时,看看外网爆火的惊艳玩法。
github
golang学习记20 小时前
Cursor Origin:Cursor要造一个AI时代的Github
人工智能·github·cursor