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 — 消息派发入口;宿主拿到反序列化好的消息只往它喂
  • SdrSurfaceRegistry — surfaceId → 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 起一份空的 SdrDataModel;SdrSurfaceRegistry 起一个 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._buildOne 拿 surface.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 没变),代价区分得很清楚。

五、数据规约

5.1 关于 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 中对应路径的值动态绑定。

5.2 关于 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