LLM时代的生成式UI运行时

2026, May 17    

Co-authored by Claude Opus 4.7

LLM 时代有个很诱人的玩法:让模型直接说出 UI 应该长什么样,端上拿到一份描述就把界面拼出来。比起预先把每个页面写死,这种「server 推一份意图,client 渲一份界面」的形态对 AI 类业务太顺手了 —— 模型每讲一句话,界面跟着变一次,不用再发版。

Flutter 官方有一份 genui 在做这件事,但它的运行时跟它假设的服务端协议捏在一起卖。一旦你的服务端不是它那个形状 —— 不走 JSON-RPC、消息体判别 key 不一样、事件回传走另一条通道 —— 那份运行时就用不上了。

所以我把它的”形状”抽出来,写成了一个纯端侧的 SDR(Server-Driven Rendering)运行时:只对四类消息负责,剩下的怎么传、怎么发,全甩给宿主。这篇按「核心思路 → 调用路径(三个场景)→ 数据规约 → 取舍」走一遍。

术语表

下面用通用化的名字讲思路,便于阅读。出现的类型大致是这几个:

  • SdrMessage — 协议消息基类(sealed),下分 CreateSurface / UpdateComponents / UpdateDataModel / DeleteSurface 四类
  • SdrSurfaceController — 消息派发入口;宿主拿到反序列化好的消息只往它喂
  • SdrSurfaceRegistrysurfaceId → ValueNotifier<SurfaceDefinition> 的映射
  • SdrSurfaceDefinition — 一个 surface 的聚合状态(components / catalogId / theme)
  • SdrComponent — 单个组件实例(id + type + properties)
  • SdrDataModelStore — 每个 surface 一份独立的 SdrDataModel
  • SdrDataModel — 观察型 JSON 树 + per-path notifier
  • SdrCatalog / SdrCatalogItem — 组件 type → widget builder 的映射表
  • SdrSurfaceWidget — 对外的 widget 入口,按 surfaceId 挂一棵子树
  • SdrComponentRenderer — 按 id 渲染单个组件
  • SdrBound — 把 {path: '/...'} 翻译成对 DataModel 的订阅
  • SdrItemContext — 传给 widget builder 的渲染上下文

一、核心思路

两句话:

  • 客户端把组件原子化,形成一份 Meta 描述同步给服务端。服务端清楚端上能渲什么 —— Text、Button、Card、Row、Column、Image、Carousel、Divider、TextField 这些原子件,以及每个件接受哪些 props。
  • 服务端按客户端能力下发两份东西:一份是 surfaceDefinition(组件树长什么样,layout 怎么排),另一份是 dataStore(组件树里那些动态字段的实际值,按 path 索引)。两份独立推、独立更新。

这种分法的好处是:layout 变 ≠ data 变。模型说”把这条卡片的标题改成 X”,下发的是 dataStore;说”把卡片改成图文混排”,下发的是 surfaceDefinition。端上两条通路独立,刷新粒度不一样,互不打扰。

剥到最小,对外契约只剩四类消息:createSurface / updateComponents / updateDataModel / deleteSurface。再往上有没有 WebSocket、JSON-RPC、HTTP/2 stream,运行时一概不知道也不关心。

二、初始化渲染场景

走一遍从服务端下发到 widget tree 渲染完整的链路。

2.1 服务端下发

服务端用自定义的 PB 协议封一份消息,宿主层用 WS 收下,反序列化成 Map<String, dynamic>,再喂给 SdrMessage.fromJson 走 sealed 分发,最后把得到的 SdrMessage 交给 SdrSurfaceController.handleMessage。WS / PB 这一层完全属于宿主,运行时不掺合。

2.2 Cell 维度的 surfaceId 绑定

宿主侧每一个生成式 UI 容器(典型场景:一个 list cell)对应固定绑定一个 surfaceId。Cell index 和 surfaceId 一一映射,cell 重用时 surfaceId 跟着 cell 走 —— 这样服务端就能稳定寻址:”给 cell 3 的 surface 推一条新数据”。

2.3 创建一个 surface 的三步

服务端把一个新 surface 推上来,端上要做三件事,顺序固定:

  1. 创建 surface —— SdrDataModelStore 给这个 surfaceId 起一份空的 SdrDataModelSdrSurfaceRegistry 起一个 ValueNotifier<SurfaceDefinition?> 用来观察该 surface 后续的结构变化。
  2. 填 dataModel —— updateDataModel 把初始数据写进 SdrDataModel,每一条 set 都会触发对应 path 的 notifier。
  3. 刷 components —— updateComponents 把组件树写进 SurfaceDefinition,registry 上的 notifier 推一个新版本出去。

值得一提的是这三步顺序可以乱updateDataModel 允许早于 createSurface 到达(store 内部走 getOrCreate,没就建一份)。反过来 updateComponents 必须晚于 createSurface —— 给一个不存在的 surface 写组件,运行时会直接抛 SdrProtocolException,fail loud 让上游早点发现 bug。

2.4 Widget build

容器拿到自己的 surfaceId 之后,挂一个 SdrSurfaceWidget(surfaceId: ...) 就完事了。从这里往下:

  • SdrSurfaceWidget 订阅 surfaceProvider(surfaceId),拿到 SurfaceDefinition 之后构造 SdrComponentRenderer(surfaceId, rootComponentId) —— rootComponentId 是协议常量,约定每个 surface 必须有一个 id 叫 'root' 的组件作为入口。
  • SdrComponentRenderer._buildOnesurface.components[componentId] 取出当前组件,按 component.type 在 catalog 里查到 SdrCatalogItem,把组件的 properties + dataModel + 子组件构造器一起打包成 SdrItemContext,交给 CatalogItem.widgetBuilder
  • widget builder 内部用 SdrBound.string / value 解 properties:raw 值若是 {path: '/...'} 就走 dataModel.watch(path) 订阅一个 listenable;否则直接当字面量用。

整条链路如下:

flowchart TD A["Server (PB) → WS"] --> B["SdrMessage.fromJson
(sealed)"] B -->|createSurface| C1["controller._handleCreate
store.getOrCreate + registry.update"] B -->|updateDataModel| C2["controller._handleUpdateDataModel
dataModel.set(path, value)"] B -->|updateComponents| C3["controller._handleUpdateComponents
registry.update(new SurfaceDefinition)"] C1 --> R["ValueNotifier<SurfaceDefinition?>"] C3 --> R C2 --> DM["SdrDataModel
per-path notifier"] R --> SW["SdrSurfaceWidget"] SW --> CR["SdrComponentRenderer
(surfaceId, 'root')"] CR --> BO["_buildOne → CatalogItem.widgetBuilder"] BO --> BD["SdrBound.string / value"] DM --> BD BD --> W["Flutter Widget Tree"]

三、数据更新场景

服务端对已知的 surfaceId 下发一条新的 updateDataModel,只更新数据展示,不动 layout。

sequenceDiagram participant S as Server participant H as Host (WS) participant C as SdrSurfaceController participant DM as SdrDataModel participant B as SdrBound participant W as Widget property S->>H: updateDataModel { surfaceId, path, value } H->>C: handleMessage C->>DM: set(path, value) Note over DM: 通知精确 path
+ 所有祖先
+ 所有后代 DM->>B: ValueListenable triggers B->>W: 仅 rebuild 该 property 所在的小 widget

关键是这一条更新不动 SdrComponentRenderer —— 它订阅的是 surface notifier,而 updateDataModel 不动 surface notifier。所以组件树的 element 不变、state 保留、输入框光标不丢,只有对应 path 上的小 widget 拿到新值重绘。

DataModel 这一侧的策略也值得讲:写一条 path 时不光通知精确匹配的订阅者,还通知所有祖先和所有后代。写 /items/3/title 要通知 /items/3/items 上的订阅者(如果有人 watch 整个列表);反过来写 /items 把整棵替换掉时,/items/3/title 上的订阅者也得跟着刷新。代价是 O(n) 扫一遍订阅表 —— 但只在写时发生,且大多数 surface 的 path 数量都很有限,比维护一棵 path trie 简单太多。

四、UI 更新场景

服务端对已知的 surfaceId 下发一条新的 updateComponents,要换组件树或调整 layout 排列规则。

sequenceDiagram participant S as Server participant H as Host (WS) participant C as SdrSurfaceController participant R as SdrSurfaceRegistry participant N as ValueNotifier participant Rd as SdrComponentRenderer participant T as Widget Tree S->>H: updateComponents { surfaceId, components } H->>C: handleMessage C->>R: registry.update(new SurfaceDefinition) R->>N: notify N->>Rd: rebuild Note over Rd: KeyedSubtree key 包含 type
type 变 → 丢 element
仅换 props → 复用 element Rd->>T: 新组件 diff 出来

这里有两套机制叠在一起:

  • SdrComponentRenderer 监听 surface notifier,notifier 一刷就重新跑 _buildOne
  • KeyedSubtree(key: ValueKey('$surfaceId::$componentId::$componentType')) 包在每个组件外 —— component.type 一变就强制重建 element(不同 type 对应不同 widget 实现,state 必须丢);同一个 id 上只换 properties 时 element 复用、state 保留。

所以 updateComponents 既能干”换一个新组件”(type 变了),也能干”调一下 props”(type 没变),代价区分得很清楚。

五、数据规约

关于 surfaceDefinition

  • components必须存在一个 id 为 'root' 的组件作为渲染入口 —— SdrSurfaceWidget 从 root 往下递归,没有 root 就什么都不画。
  • 每个 component 的 type 必须能在当前 surface 绑定的 Catalog 里查到对应的 CatalogItem;找不到就在 dev 模式下出一块红色的 render error 块。
  • 每个 component 各自定义自己接受哪些 props(字面量或 path ref),由 CatalogItem.widgetBuilder 内部消费 —— 运行时不规定 props schema,只规定它长什么形状:要么是 JSON 字面量,要么是 {path: '/...'}
  • component 中的动态取值统一通过 SdrBound 解析 —— props 值若是 {path: '/...'},就跟 dataStore 中对应路径的值动态绑定。

关于 dataStore

  • 一份纯 JSON 树(Map / List / primitive),存所有 surface 里 component 需要消费的动态字段。
  • JSON Pointer (RFC 6901) 寻址:/items/3/title 这种路径。
  • 写入支持 auto-vivify(中间节点自动创建 Map 或 List),但 List 索引有硬上限 10000,挡 OOM 攻击面。
  • 不规定字段语义:dataStore 里有什么字段、字段叫什么名、嵌套多深,完全由服务端和该 surface 内的组件约定 —— 运行时只负责存、读、订阅,不解释含义。

六、几个比较有意思的取舍

Riverpod family 在 scope 里不会自动跟。

sdrSurfaceProvider / sdrDataModelProvider / sdrCatalogProvider 都是 Provider.family,而 family 在 Riverpod 里不会自动跟随最近的 ProviderScope —— 它的 element 默认住在 root container。结果就是:你只 override sdrControllerProvider,scope 里的 family 还是去 root 读默认实现,撞上 UnimplementedError

解法是给 scope 一次性塞一份完整的 override 列表,四个 provider 一起重写一遍。这块在我的实现里注释写得很啰嗦,因为它是 Riverpod 文档里讲得不太清楚的一处,下一个改这块的人很容易踩。

两条通路的 rebuild 粒度刻意分开。

组件树重建(surface notifier)和属性级重建(path notifier)走两条独立通路。前者粒度大,后者粒度小到一个 Text 的 value。这两套机制叠起来,一棵几十个组件的 surface 在频繁 data 推送下才能保持丝滑 —— 不然每次都从头 rebuild,输入框光标都跟着丢。

Data path 的 auto-vivify 上限。

SdrDataModel 在写深路径时会自动创建中间的 Map / List,但对 List 设了一个硬上限(我用的是 10000)。这不是性能考虑,是挡攻击面用的:服务端推一条 updateDataModel { path: '/items/9999999', value: ... } 进来,没有这个上限,端上立刻去分配一个长度上千万的 list,OOM 大礼包就送到了。这种小开关你不加,第一次出事就是线上事故。

总结

抽完之后整个库对项目来说就一句话:端上有一份 server-driven UI 运行时,但它不绑任何具体服务端。

最关键的两条边界是 transport 边界(库不管消息怎么来)和事件回传边界(库不管事件怎么发出去)。守住这两条,它可以同时给走 WebSocket 的 IM 类业务和走 HTTP 长轮询的卡片业务复用 —— 它们之间只共享四类 inbound 消息形态和一个 SdrUiEvent 出向数据类,再没别的。

接下来想做两件事:

  • catalog 改成运行时可热替换(通过 controller 上的注册接口而不是构造时传入)
  • 把 path notifier 从「O(n) 扫表」换成 trie 实现 —— 后者要在 path 数量上百之后才有实际收益,先放 backlog 里。
TOC