LLM时代的生成式UI运行时
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 一份独立的SdrDataModelSdrDataModel— 观察型 JSON 树 + per-path notifierSdrCatalog/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 推上来,端上要做三件事,顺序固定:
- 创建 surface ——
SdrDataModelStore给这个surfaceId起一份空的SdrDataModel;SdrSurfaceRegistry起一个ValueNotifier<SurfaceDefinition?>用来观察该 surface 后续的结构变化。 - 填 dataModel ——
updateDataModel把初始数据写进SdrDataModel,每一条 set 都会触发对应 path 的 notifier。 - 刷 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;否则直接当字面量用。
整条链路如下:
(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。
+ 所有祖先
+ 所有后代 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 排列规则。
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 里。