产品9/14/2026·约 4 分钟

Widget 是文档,不是一棵 View 树

WidgetCraft 不再维护第二份 UI 实现:一份文档包现在同时喂给创作预览、扩展与渲染器,并且走同一棵经过测试的树。

会说谎的预览

WidgetCraft 最初采用的是 widget 构建器最常见的形状:创作间组装一张 widget,预览显示创作间刚组装的东西,而真正交给系统安装的扩展有自己的一套生成代码。三个界面看起来有关联。它们并不是同一个程序。

只有在它们意见不一致时,这个区别才会显形,而那正是 widget 构建器最贵的调试时刻。预览可以显示创作间内存里的对象,扩展却加载了旧归档;数据提供者在 app 进程里有值,扩展却拿到另一张注册表;布局改动在编辑器里出现,主屏上却没有。每一边单独看都说得通,用户看到的却只是 widget 不对。

所以第一个设计决定不是一个页面,而是一条边界:

Widget 是一份由两个运行时解释的文档。

创作间不把 View 对象交给扩展,而是写出一个包。包里有描述容器的 manifest,也有描述元素树的 document.json。扩展不需要知道创作间怎么产生它,只需按同一份运行时契约加载并解释这份文档。

这听起来像序列化,但它比序列化更具体。文档是产品的持久产物,因此它的形状是一个面向用户的兼容边界。解释器是机制:它知道文本节点、图片节点、栈、timeline、数据提供者和动作怎样变成设备上真正的 widget View。产品拥有提供哪些节点、怎么命名;机制拥有合法节点在设备上的行为。

包就是接缝

包系统让两边各自有清楚的责任。

创作间可以编辑文档,不必导入扩展的生命周期。它可以预览草稿,不必假装用户已经安装。安装器可以验证 manifest,把包复制进容器的存储边界,并发布安装事件。扩展可以请求选中的文档并渲染它,不必知道这份文档来自创作间、出厂卡片还是将来的包来源。

也正因为如此,选择机制必须与产品配置分开。扩展拥有面向系统的 intent,运行时拥有文档。WidgetCraft 提供可用选择;Foundation 的 widget 运行时解析用户选中的包。如果渲染器直接 import 某个产品的 WidgetConfigurationIntent,机制就已经认识了某一个产品,边界也已经坏了。

包还让生命周期变得可见。创建 widget 不等于安装 widget。创作间写出一个候选包,用户点 安装,安装器才让它对扩展可见。安装或移除之后,app 还要告诉 WidgetKit 可用的 timeline 集合已经变化。没有这个信号,磁盘上的文件可以是正确的,主屏却继续画旧快照。用户动作和系统刷新是同一个状态迁移的两半。

把机制搬出产品

文档是看得见的接缝,更大的变化是把它背后的代码提升到 Foundation。

旧的 WidgetCraft 目录里有数据提供者、设备信息、存储、JavaScript API、动作、timeline 和容器管理。它们之所以在产品里,主要因为第一个消费者是这里。到 9 月,这个目录已经长出约 2.85 万行并不需要知道 WidgetCraft 存在的代码。继续把它放在那里,要么下一个 widget 产品复制运行时,要么依赖一个产品 target;两种选择都会把机制变成产品依赖。

迁移严格按 owner 分工:

  • Foundation 拥有文档模型、包存储、解释器、提供者、动作与生命周期。
  • WidgetCraft 拥有内置 widget 的目录、创作间 UI 与它提供的选择。
  • 扩展拥有系统入口,并请求运行时渲染选中的文档。

这不是一次目录整理,而是改变了能表达什么。一种新的数据提供者可以在不启动 WidgetCraft 的情况下针对运行时测试;一张新的创作卡片可以增加而不改扩展渲染器;第二个产品也可以消费这套机制,而不必把第一个产品变成库。

渲染真正会出厂的东西

预览测试是架构必须兑现的地方。只实例化一个 View controller 的测试可以在文档为空、某个节点被忽略、或者扩展走了另一条路径时照样通过。所以测试会构造真实元素树,把它喂给真正的 SwiftUI 渲染器,并把 PNG 写到测试输出。

这样断言的对象是内容,而不是存在性。测试可以检查生成的图像,也让文档和渲染器走扩展将要走的同一条路径。预览只有在回答用户安装之后会问的问题时才有用:主屏上究竟会出现什么?

文档测试位于渲染测试之下,覆盖解码、未知字段、容器元数据与存储行为。渲染测试覆盖可见的树。集成路径覆盖包安装和 WidgetKit 刷新。每个测试拥有一条边界;没有哪一张快照被要求同时证明存储、选择、渲染和系统刷新。

文档带来的东西

它当然有代价。文档格式需要版本、校验和迁移。直接用 Swift 写 View 可以使用任何私有实现细节;文档必须说清楚它表达什么。我们接受这笔成本,是因为它在包边界一次性支付:解码失败可以点名字段并拒绝安装。坏掉的 widget 不应该在用户主屏上变成一半能画、一半不能画的东西。

回报是创作间、预览和扩展不再靠约定保持一致,而是读取同一个产物。运行时可以在这份产物之后演进,产品可以扩充目录,却不用教运行时自己属于哪个产品。

当你在 WidgetCraft 里做一张 widget,用户看见的是编辑器。让这个编辑器值得信任的工程决定更安静:你创建的是一份文档,而系统会把这份文档一路渲染到主屏。

关键结论

把生成出来的 UI 当成带契约的产物。如果创作端和运行时各自重建结果,它们迟早会分歧。manifest 与文档让边界可以被查看、验证和测试。

让解释器与产品无关。把机制放进 Foundation 后,产品可以拥有自己的目录,另一个消费者也能复用渲染器,而不必导入第一个产品的组合根。

让预览执行出厂路径。View 存在性断言可以在 widget 为空时通过;真正的元素树经过真正的 SwiftUI 路径,才是对用户可见结果的断言。

把安装建模成生命周期迁移。写包、选择、安装和刷新 WidgetKit 是不同操作,每个状态变化都应有自己的 owner。把它们当成一次文件复制,正确的 widget 就会在主屏上保持陈旧。

文中提到的产品

继续阅读