SwiftUI 和 AI Agent 结合,听起来像是个很“新潮”的方向,但拆开来看,本质上是解决一个非常务实的问题:让 AI 不仅能“想”,还能“动手写界面”。这个实验框架的核心思路,是把 SwiftUI 的声明式描述能力,封装成 Agent 可以调用的“技能”。
1. 项目概述:为什么我要折腾一个 SwiftUI Agent Skill
1.1 起的什么风:AI Agent 需要一条“会写 UI”的手
如果你最近关注过 AI 编程,大概率见过各种“对话生成应用”的 Demo。不过大多数人做的都是 To-Do List 或计数器,因为这些界面足够简单,用一两个组件就能拼完。可一旦你想让 Agent 输出一个带侧边栏的仪表盘、一个多层级表单,或者一个需要滚动和状态控制的页面,事情就变得相当棘手。
为什么棘手?因为 Agent 本身并不理解 SwiftUI 的 ViewBuilder 语法,它只知道“文本生成”。你要想让 AI 生成界面,通常有两条路:一是让它直接吐 Swift 代码,再交给编译器;二是让它输出一个结构化的描述,由运行时解释成 UI。前者的不确定性太高,LLM 写 Swift 代码经常出现语法错误、类型不匹配、甚至引入根本不存在的 API。后者相对可控,但需要自己设计一套 DSL,还要做一个解释器。
我做的这个实验项目,正是走了第二条路:给 Agent 提供一套结构化的“技能”,让它可以描述界面,再由 SwiftUI 层把描述渲染成真正的视图。整个项目叫“SwiftUI-Agent-Skill”,名字有点长,但核心就三件事:定义描述语言、实现执行引擎、暴露给 Agent 调用。
1.2 这个项目到底做了什么
简单说,我把 SwiftUI 的常用控件(Text、Button、List、NavigationStack 等)抽象成一种 JSON 友好的描述格式,然后写了一个引擎,把这些描述转换成真正的 SwiftUI View。Agent 只需要输出符合这套格式的 JSON,就能在 iOS 应用里渲染出真实可交互的界面。
这样做最直接的好处是,AI 不再需要关心 Swift 语法细节,也不用担心编译错误。它只需要遵循我定义的 schema,输出结构正确的 JSON 即可。而 JSON 是 LLM 非常擅长生成的东西。
项目本身是一个 Swift Package,可以直接集成到任意 SwiftUI 项目中。我没有依赖任何第三方库,只用了 SwiftUI 和 Combine 自带的框架能力。
1.3 适用场景和面向人群
这个项目比较适合两类人:
第一类是正在做 AI 应用开发的 iOS 工程师。如果你的项目里需要让大模型动态生成界面,或者让 Agent 操作界面,这套“技能”可以直接拿来用。
第二类是对 SwiftUI 底层机制感兴趣的开发者。熟悉 ViewBuilder 和 Result Builder 的实现原理,会在你自己的组件开发中很有帮助。因为这个项目实际上重新实现了一遍 UI 构建流程,你会看到描述语言、数据绑定、布局计算是怎么串联起来的。
2. 设计思路与架构拆解
2.1 核心设计原则:让 Agent 永远不写 Swift 代码
如果让 LLM 直接生成 SwiftUI 代码,看起来很美,实际上隐患很大。我自己试过很多次,让大模型写一个复杂页面,常常能跑通,但代码质量参差不齐,而且一旦引入自定义组件,模型就会开始“瞎编”。
所以我的设计原则很明确:Agent 只负责输出数据,不负责输出代码。这个思路参考了前端领域 RSC(服务端组件)的某些理念——把 UI 当作数据来处理,界面只是数据的投影。
这样一来,有几个直接的好处:
- 输出格式可控,校验成本低
- 任何能输出 JSON 的模型都能用,不限定具体厂商
- 界面更新是纯数据流驱动,方便做状态管理和撤销重做
- 理论上,同一套 DSL 可以移植到 AppKit 或 UIKit 上(虽然本案没做)
当然,代价也很明显:描述不了太复杂的自定义视图。比如高度定制的 Canvas 绘图,或者复杂的 Gesture 逻辑,用这种 schema 描述会非常笨拙。这是方案取舍,我认了。
2.2 三个关键模块:DSL 描述、Builder 执行、Agent 接入
整个框架拆成三层,每一层只向上提供服务。
第一层是 DSL 定义层。用的是枚举和结构体,定义了什么是一个“描述好的组件”。核心的协议叫SKComponent,任何可以被描述和渲染的组件都遵循它。
第二层是执行引擎层。核心方法就是把SKComponent转换为AnyView。这一步是整个项目的“魔法”所在。SwiftUI 的ViewBuilder是编译期的语法糖,而我的实现则是运行期的分发器,把描述数据“翻译”成具体视图。
第三层是 Agent 接入层。它把一个“技能清单”暴露给外部调用者,比如智能体运行时。Agent 只需要调用executeSkill(_:parameters:),就能拿到一个 SwiftUI View。
2.3 为什么不用现成的 SwiftUI ViewBuilder
这是很多人的第一个疑问:SwiftUI 不是已经自带 ViewBuilder 了吗?直接让 Agent 生成 Swift 代码然后用 ViewBuilder 处理不就行了?
问题在于,ViewBuilder 是编译期机制,不是运行时机制。它依赖 Swift 编译器的语法转换能力,把多个视图表达式合成为一个视图。但 Agent 的输出是运行时的文本数据,你不可能让编译器去编译一个运行时的字符串。
就算你用@ViewBuilder写了个闭包,让 Agent 根据条件返回不同内容,也仍然需要 Agent 输出的是“分支逻辑”,而不是“数据”。这个界限很重要。
所以我做的本质上是一个运行时的解释器:输入是 JSON,输出是AnyView。它和 ViewBuilder 各干各的,不存在替代关系。
2.4 整体数据流:从 Agent 输出到屏幕渲染
整个链路是这样的:
Agent 收到用户意图后,决定调用某个技能。技能接收参数,返回一段 JSON 结构。这个 JSON 交给AgentRenderer,先做 schema 校验,然后转换成SKComponent树,最后调用render()方法生成AnyView,嵌入到 SwiftUI 的NavigationStack或任意容器中。
如果组件树中有按钮、输入框等交互元素,它们会绑定到特定的 action 标识符,事件回调统一走ActionDispatcher,又绕回到 Agent 侧处理。
这个设计天然支持“重新渲染”:Agent 只需要返回新的 JSON,视图就自动替换。
3. 核心实现细节
3.1 DSL 描述语言:如何定义一套可校验的组件 schema
DSL 最核心的部分是定义一个稳定且可扩展的 schema。我用了一个枚举来区分组件类型,每个枚举关联不同的参数结构体。
public enum SKComponent: Codable, Identifiable, Equatable { case text(SKTextConfig) case button(SKButtonConfig) case textField(SKInputConfig) case list(SKListConfig) case image(SKImageConfig) case spacer(SKSpacerConfig) case divider(SKDividerConfig) case stack(SKStackConfig) public var id: String { switch self { case .text(let config): return config.id case .button(let config): return config.id // ... } } }这种设计的好处是,每个组件的配置清晰独立,校验时只需要检查关联的 config。而且因为遵循 Codable,解码 JSON 非常自然。
每个组件配置里都会带一个id字段。这是我自己加上去的,非常重要。有了稳定 ID,才能在状态更新时做局部刷新,而不是整树重建。如果让 Agent 每次都生成随机 ID,React 和 SwiftUI 的 diff 优势就全没了。
3.2 SKComponent 转换为 SwiftUI 视图的运行时机制
现在到了整个项目最关键的一块:把SKComponent变成AnyView。这里我用了 switch 分发,而不是协议多态。为什么不用协议?因为每个组件的渲染逻辑差异太大,硬做成协议会让协议接口非常臃肿,不好维护。
func render(_ component: SKComponent) -> AnyView { switch component { case .text(let config): return AnyView( Text(config.content) .font(config.font.map { .system(size: $0) }) .foregroundColor(config.color.map { Color(hex: $0) } ?? .primary) ) case .button(let config): return AnyView( Button(action: { actionDispatcher?.send(config.action) }) { Text(config.title) } ) // ... } }这里有一个细节:SwiftUI 的Text是View,Button也是View,但它们的类型不同。你没法直接把Text和Button放进一个数组里,除非做类型擦除。AnyView就是官方的类型擦除手段。
但AnyView有一个性能问题:它会造成视图结构信息丢失,SwiftUI 的优化器没法做静态的类型推断。如果你在一个列表中渲染几百个AnyView,可能感觉到滚动掉帧。我的解决方案是,能组合的尽量组合成 group,减少AnyView的层级,实测在 500 个组件以内问题不大。
3.3 Skill 协议与 Agent 的接入方式
Agent 侧的接入比想象中简单。我定义了一个AgentSkill协议,任何技能都遵循它。
public protocol AgentSkill { var name: String { get } var description: String { get } var parametersSchema: [String: Any] { get } func execute(parameters: [String: Any]) async throws -> SKComponent }name和description是为了让 Agent 知道这个技能有什么用、什么时候该调用parametersSchema是参数清单,描述每个参数的类型和约束execute是核心方法,接收参数,返回一个组件树
你可以把execute想象成一个函数:输入一行 JSON,输出一棵组件树。Agent 不在乎这个函数内部怎么实现,它只需要知道调用规则。
public final class AgentExecutor { private let skills: [String: any AgentSkill] public init(skills: [any AgentSkill]) { self.skills = Dictionary(uniqueKeysWithValues: skills.map { ($0.name, $0) }) } public func callSkill(_ name: String, parameters: [String: Any]) async throws -> AnyView { guard let skill = skills[name] else { throw SkillError.skillNotFound } let component = try await skill.execute(parameters: parameters) return render(component) } }这样,Agent 运行时只需要维护一个技能注册表,收到用户请求后,先解析意图,再调用技能,最后把返回的 View 丢给宿主界面。
3.4 布局归一化:让 Agent 不用关心具体的 SwiftUI 布局语义
Agent 不擅长理解VStack和HStack的区别,也不清楚.padding(12)和.padding(24)的视觉差异。所以我没有让 Agent 直接写布局代码,而是抽象成“区块模型”。
{ "type": "section", "direction": "vertical", "spacing": 12, "components": [ { "type": "text", "content": "Hello Agent", "align": "center" } ] }方向字段direction可以是vertical或horizontal,这就对应了VStack和HStack。Agent 只需要知道“竖向排列”还是“横向排列”,至于具体是哪个 SwiftUI 容器,由渲染器决定。
这种归一化处理非常有效。实测即使是参数量较小的开源模型,也能正确输出direction字段,因为“方向”是所有人都能理解的概念,不需要学 SwiftUI 特有的 API 知识。
3.5 深色模式与主题适配
原生 SwiftUI 对深色模式的支持是通过系统环境自动完成的。但我的渲染器直接指定了一些颜色值,容易破坏深浅色适配。
我的做法是:颜色字段支持两种写法。一种是固定的 hex 色值,另一种是语义化的名称,比如label、secondaryLabel、background。
let resolvedColor: Color switch config.color { case .hex(let hex): resolvedColor = Color(hex: hex) case .semantic(let name): resolvedColor = semanticColor(name) default: resolvedColor = .primary }语义化名称会和系统调色板绑定,这样深色模式下的界面就不会一片惨白。让 Agent 尽量输出语义色,而不是具体色值,这是我在一轮轮适配中总结出来的经验。
4. 实操过程与核心环节实现
4.1 搭建项目环境
我先创建了一个 Swift Package,然后再在示例 App 里引用它。
mkdir SwiftUI-Agent-Skill cd SwiftUI-Agent-Skill swift package init然后把 Package.swift 的 platforms 改为 iOS 17 或更高,确保能用到较新的 SwiftUI API。
// swift-tools-version: 5.9 import PackageDescription let package = Package( name: "SwiftUIAgentSkill", platforms: [.iOS(.v17)], products: [ .library(name: "SwiftUIAgentSkill", targets: ["SwiftUIAgentSkill"]) ], targets: [ .target(name: "SwiftUIAgentSkill") ] )iOS 17 的理由很简单:我需要用到Observable和@Environment的一些新特性,同时ViewBuilder的某些行为在新版本中更稳定。
4.2 定义第一个 Skill:文本展示器
为了测试链路是否通畅,我写了第一个技能:TextDisplaySkill。
public struct TextDisplaySkill: AgentSkill { public var name: String { "display_text" } public var description: String { "显示一段文本内容,支持标题和正文样式" } public var parametersSchema: [String: Any] { [ "title": ["type": "string", "optional": true], "content": ["type": "string"], "style": ["type": "string", "enum": ["title", "body", "caption"]] ] } public func execute(parameters: [String: Any]) async throws -> SKComponent { let content = parameters["content"] as? String ?? "Default Text" let title = parameters["title"] as? String let leading: [SKComponent] = ... return .stack(SKStackConfig(direction: .vertical, spacing: 2, components: components)) } }实践告诉我,先跑通最简技能,再逐步添加复杂性,是调试这类框架最稳的路径。如果你一上来就试图让 Agent 生成整个仪表盘,出了问题很难定位是模型问题、json 解码问题,还是渲染问题。
4.3 从 JSON 到 UI 的完整链路
我现在模拟一次真实的调用过程。假设 Agent 返回了下面这段 JSON:
{ "type": "stack", "direction": "vertical", "spacing": 16, "components": [ { "type": "text", "content": "欢迎使用智能助手", "align": "center", "style": "title" }, { "type": "button", "title": "点击获取今日推荐", "action": "fetch_recommendation" }, { "type": "list", "items": [ {"title": "推荐一", "subtitle": "基于你的偏好"}, {"title": "推荐二", "subtitle": "社区热门"} ] } ] }这段 JSON 先经过JSONDecoder,解码成SKComponent。
let jsonData = jsonString.data(using: .utf8)! let rootComponent = try JSONDecoder().decode(SKComponent.self, from: jsonData)这里有个小技巧:SKComponent是递归类型,因为stack里包含子组件,而子组件也可能还是stack。Swift 的 Codable 天然支持递归解码,只要你的 JSON 结构是对应的嵌套结构。
解码之后,调用render()去渲染。渲染是纯函数式的:给定一个组件树,总是返回同一个AnyView。这个特性让我可以放心地在@Observable里持有SKComponent状态,UI 会自动跟随变化。
4.4 性能基准测试
很多人会担心运行时的性能。我用 Xcode 的 Instruments 做了几组试验:
- 100 个文本组件:渲染耗时约 0.01 秒,可忽略
- 300 个组件的嵌套列表:约 0.03 秒
- 复杂组合(含图片、按钮、输入框)约 0.05 秒
结论是:在常规业务界面规模内,运行时性能完全够用。真正的瓶颈在AnyView层级过多的时候,SwiftUI 的 diff 会退化。所以我在渲染器里特别处理了“连续相同容器”的合并,减少无意义的嵌套。
4.5 给 Agent 提供的技能描述模板
既然要让 Agent 能正确调用技能,技能描述里最好包含示例。这一步很容易被忽略,但效果立竿见影。我在每个技能描述后面加了一段“example”:
{ "skill": "display_text", "description": "显示文本", "example": { "title": "今日天气", "content": "晴,温度 24°C", "style": "body" } }加了 example 之后,Agent 返回的参数结构错误率显著下降。这个经验不仅适用于这个项目,任何对外暴露给 LLM 的 API 或者函数描述,都应该提供示例。
5. 常见问题与排查技巧实录
5.1 Optional 字段的解码陷阱
最常踩的坑之一,是把可选字段类型设为String?,但 JSON 里却传了null。Swift 的 JSONDecoder 默认情况下对null的处理是:如果字段类型是 Optional,它会解析为 nil,这没问题。但如果你用decodeIfPresent,行为就会变得微妙。
我实际遇到的坑是:某个组件在缺少某个字段时报错,而不是优雅降级。排查后发现,是我的自定义init(from decoder:)里用错了方法。
我的建议是:除非必须自定义解码逻辑,否则尽量让Codable自动合成实现。遇到复杂需求,优先把模型结构调整为“多一个可选字段”,而不是“重写解码方法”。
5.2 AnyView 层级过深导致的性能下降
当嵌套层级超过五层时,AnyView的 diff 成本开始上升。尤其当列表里每一个 cell 都用AnyView包装时,滑动掉帧明显。
我的优化策略有三条:
- 能合成
Group就合成,减少单一类型擦除的层次 - 把重复出现的子组件做成
@ViewBuilder函数,而不是每次都走完整 switch - 在渲染时做一层“扁平化”:如果某个
stack只有一个子组件,就把它提升到父级
这三招下来,性能立刻好看了不少。这也是为什么我建议你封装一层渲染器,不要在外面到处调用AnyView,控制在内部,方便做优化。
5.3 渲染结果与预期不符怎么排查
最难过的是:Agent 输出了正确的 JSON,解码也成功,但渲染出来的 UI 和自己预想的不一样。
我后来总结出一套排查流程:
- 先打印解码后的
SKComponent树,确认数据结构正确 - 检查布局参数(spacing、padding、frame)是否合理
- 单独渲染这个组件,在 Preview 里测试,排除宿主页面干扰
- 如果依然不对,很可能是某个布局参数在 Agent 那里被理解错了,及时更新 DSL 的字段描述
这里还要强调:DSL 字段的命名要直观,尽量用产品语言,不要用技术术语。比如direction不要叫layoutOrientation,spacing不要叫interElementSpacing。Agent 理解spacing很容易,但interElementSpacing就未必了。
5.4 状态刷新时局部重建的坑
如果 Agent 返回的组件树每次整体替换,会导致 UI 闪烁。比如输入框正在输入时,Agent 更新了某个文本组件,结果整个页面重建,输入框失焦。
我的解法是:在渲染器内部记录每个组件的 id。当新的组件树到来时,根据 id 做差异比对,只更新变化的组件。这个思路类似 React 的 reconciliation。
id的生成策略也很重要。我建议用稳定的业务 ID,而不是随机的 UUID。比如一个文本组件的 id 绑定到数据库里的字段名,这样更新时就能精准替换。如果用 UUID,每次都不一样,等于没有 diff。
5.5 调试技巧:让 Agent 在线调试
开发这套框架时,我做了个辅助调试页面。它有三个区域:
- 左边是 Agent 的原始返回 JSON
- 中间是解码后的组件树结构
- 右边是渲染出来的预览
修改左边 JSON,右边的预览会实时变化。这比一遍遍跑模拟器效率高得多,因为你能立刻看到“改哪个字段对应 UI 的哪部分变化”。
这个调试工具还有一个作用:把某些常用 JSON 模板存下来,下次直接让 Agent 效仿。相当于把以前踩过的坑沉淀成模板。
6. 经验与扩展方向
做了一个多月,踩了不少坑,也有些心得。
最大的感悟是:真正让 Agent“会写界面”的关键,不是模型有多聪明,而是你的描述层设计得有多稳定。模型输出的偏差是客观存在的,与其反复喂 Prompt 试图让模型更准确,不如把 DSL 做得更宽容、更傻瓜化。
一个比较直接的建议是,在 DSL 层内置一套默认值机制。无论 Agent 漏掉哪个可选字段,渲染器都能用合理的默认值补上,而不是崩溃。这样整个系统对不完美输入的容忍度就高了。
后续可以扩展的方向也很多。比如加入自定义组件注册机制,让开发者把自己写的 SwiftUI 视图注册进去,像插件一样扩展 DSL 的能力。再比如做一套可视化编辑器,用拖拽的方式生成 JSON,再让 Agent 去学习这些模板。这些方向本质上都在解决同一个问题:降低 Agent 生成复杂 UI 的难度。
最后再分享一个小技巧。当你在调试 Agent 生成的界面时,记得把“渲染过程的误差”和“模型输出的误差”分开看。先用固定的 JSON 模板测试渲染器,确认渲染器没问题后,再去调整 Agent 的 Prompt 和技能描述。这样定位问题会快很多。