ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SwiftUI与Agent融合:如何将界面抽象为智能体可调用的Skill

SwiftUI与Agent融合:如何将界面抽象为智能体可调用的Skill

1. 这个标题到底在说什么

第一次看到 "SwiftUI-Agent-Skill" 这个组合词,我脑子里蹦出来的第一反应是:这大概率不是一个官方框架,也不是某个大厂放出来的 SDK,而是某个开发者把三件当下最热的事情捏在了一起——SwiftUI 做界面、Agent 做决策、Skill 做能力封装。说白了,就是想让一个智能体(Agent)具备操作 SwiftUI 界面的"技能",或者说,让 SwiftUI 应用本身变成一个可以被智能体调用的能力集合。

我先把这三个词拆开讲清楚,不然后面没法聊。

SwiftUI是苹果生态里声明式的 UI 框架,写界面像写配置,状态驱动视图,@State、@Binding、@Observable这些属性包装器是它的灵魂。它的核心特点是"数据变了,界面自己重绘",开发者不用手动去reloadData或者setNeedsLayout。

Agent这个词这两年被用烂了,但落到工程上,它其实就是一个"能感知环境、能做决策、能执行动作"的循环体。感知靠输入,决策靠模型或者规则,执行靠工具调用。它和传统的"函数调用"最大的区别在于:Agent 有自主性,它会根据当前状态决定下一步干什么,而不是被一条固定的调用链牵着走。

Skill则是 Agent 的能力单元。一个 Agent 可以挂载很多 Skill,每个 Skill 负责一类具体任务,比如"读取当前界面状态""点击某个按钮""填写表单""截图并分析"。Skill 的设计好坏,直接决定了 Agent 能不能真正干活。

把这三个词拼在一起,SwiftUI-Agent-Skill 的核心命题就是:如何把 SwiftUI 应用里的界面元素和交互逻辑,抽象成 Agent 可以理解和调用的 Skill,从而让智能体能够自主地操作一个 SwiftUI 应用。这个方向听起来有点科幻,但落到实际场景里非常具体——自动化测试、无障碍辅助、智能 UI 助手、低代码平台里的"帮我操作"功能,都是它的用武之地。

适合读这篇内容的人有三类:一是做 iOS/macOS 开发、想把自己的 App 接入智能体能力的工程师;二是做自动化测试、想摆脱脆弱 UI 测试脚本的测试开发;三是对 Agent 架构感兴趣、想找一个具体落地场景来练手的技术爱好者。不管你是哪一类,接下来的内容都会从设计思路一路讲到实操细节和踩坑经验。

2. 整体设计思路与方案选型

2.1 为什么要把 SwiftUI 和 Agent 绑在一起

先说一个很多人会忽略的前提:SwiftUI 的声明式特性,天然适合被 Agent 操作。为什么这么说?

传统的 UIKit 或者 AppKit 是命令式的,界面是一棵视图树,你要操作某个按钮,得先拿到它的引用,然后调用sendActions(for:)。这中间涉及大量运行时状态,Agent 很难在不侵入代码的情况下理解"当前界面有哪些可操作元素"。

而 SwiftUI 的视图是状态的函数。给定一个状态,界面就是确定的。这意味着 Agent 只要能够读取当前状态,就能推断出界面上应该有哪些元素、哪些是可交互的。更重要的是,SwiftUI 的Accessibility体系(accessibilityIdentifier、accessibilityLabel、accessibilityValue)提供了一套标准化的"界面语义描述",这简直就是为 Agent 量身定做的感知接口。

我个人的判断是:SwiftUI + Accessibility + Agent 这个组合,是目前移动端智能体落地最顺滑的一条路径。原因有三点。第一,SwiftUI 的状态可观测,Agent 不需要去猜界面;第二,Accessibility 树提供了结构化的元素描述,Agent 不需要做图像识别;第三,SwiftUI 的交互都是通过绑定和动作触发的,Agent 可以通过注入状态或者触发动作来完成操作,不需要模拟触摸坐标。

2.2 Skill 的粒度怎么定

这是整个项目里最容易翻车的地方。Skill 定得太粗,Agent 没法灵活组合;定得太细,Agent 的决策空间爆炸,调用链长得离谱。

我的经验是遵循"一个 Skill 对应一个用户意图"的原则。什么叫用户意图?就是"用户想完成的一件事"。比如"登录""搜索商品""提交表单"是一个意图,而"点击用户名输入框""输入文字""点击密码框"就不是意图,是操作步骤。

但这里有个矛盾:如果 Skill 粒度太粗,Agent 就没法处理异常。比如"登录"这个 Skill 内部如果写死了"输入用户名→输入密码→点登录",那遇到验证码怎么办?遇到密码错误怎么办?

所以我的方案是两层 Skill 结构:

  • 原子 Skill:对应单个 UI 操作,比如tapElement、inputText、readValue、scrollTo。这些是 Agent 的"手和脚"。
  • 复合 Skill:对应一个完整意图,内部由原子 Skill 编排而成,但暴露给 Agent 的是一个高层接口,同时提供"中断点"让 Agent 可以在关键步骤介入。

这样设计的好处是,Agent 在正常情况下调用复合 Skill 一步到位,遇到异常时可以降级到原子 Skill 手动处理。实测下来,这种结构比纯原子或者纯复合的方案都要稳。

2.3 感知层怎么设计

Agent 要操作界面,首先得"看见"界面。SwiftUI 里最靠谱的感知方式不是截图,而是读取 Accessibility 树。

具体做法是给每个需要被 Agent 操作的元素加上accessibilityIdentifier,这个标识符是稳定的、不随界面文案变化的。然后通过UIAccessibility或者XCUIElement的接口去遍历整棵树,拿到每个元素的类型、标识、值、可交互状态。

这里有个关键决策:要不要把整个 Accessibility 树都喂给 Agent。我的答案是不要。一棵复杂的界面树可能有上百个节点,全喂给模型既浪费 token 又干扰决策。正确的做法是做一层过滤和摘要:只保留可交互元素(按钮、输入框、开关、列表项),并且把每个元素压缩成一行结构化描述。

我常用的摘要格式是这样的:

[id=login_button] type=button label="登录" enabled=true [id=username_field] type=textfield value="" focused=false [id=password_field] type=securefield value="" focused=false

这种格式 Agent 一看就懂,而且 token 消耗可控。实测一个中等复杂度的界面,摘要后大概 20 到 40 行,完全在可接受范围内。

2.4 执行层怎么落地

执行层是 Skill 真正干活的地方。SwiftUI 里触发一个按钮的点击,有几种方式:

第一种是通过 Accessibility 触发,也就是XCUIElement.tap()那一套。这种方式最接近真实用户操作,但依赖 UI 测试框架,只能在测试环境用。

第二种是通过状态注入。因为 SwiftUI 是状态驱动的,你可以直接修改驱动界面的那个状态变量,界面就会自动响应。比如登录按钮的action是调用viewModel.login(),那 Agent 直接调用这个方法就行。这种方式最快,但需要代码层面暴露接口。

第三种是通过 URL Scheme 或者 Deep Link。适合跨应用或者跨页面的跳转,但粒度太粗,不适合细粒度操作。

我的建议是混合使用:在开发调试阶段用状态注入,快且稳;在端到端测试或者真实用户场景用 Accessibility 触发,更真实。两种方式通过一个统一的SkillExecutor抽象出来,Agent 不需要关心底层用的是哪种。

3. 核心细节解析与实操要点

3.1 Accessibility 标识的命名规范

这是整个项目的地基,命名乱了后面全乱。我踩过的坑是:一开始用中文 label 当标识,结果界面文案一改,Agent 就找不到元素了。

正确的做法是标识和文案彻底分离。accessibilityIdentifier用英文小写下划线,格式是页面_元素_类型,比如login_username_field、home_search_button、cart_item_cell。这个标识一旦定下来就不要改,它是 Agent 和界面之间的契约。

还有一个细节:列表项和动态元素的标识要带索引或者唯一 ID。比如购物车里的商品,不能所有 cell 都叫cart_item_cell,得是cart_item_cell_0、cart_item_cell_1,或者用商品 ID 做后缀。否则 Agent 想操作第三个商品的时候会懵。

// 推荐写法 Button("登录") { viewModel.login() } .accessibilityIdentifier("login_submit_button") // 列表项 ForEach(items) { item in ItemRow(item: item) .accessibilityIdentifier("item_row_\(item.id)") }

注意:accessibilityIdentifier在 SwiftUI 里有时候会被容器吞掉,尤其是嵌套在List或者ScrollView里的时候。如果发现 Agent 读不到某个元素的标识,先检查是不是被父容器覆盖了,必要时用.accessibilityElement(children: .contain)显式声明。

3.2 Skill 的接口设计

Skill 的接口设计要遵循一个原则:输入输出都是纯数据,不依赖任何 UI 类型。这样 Skill 才能被独立测试,也才能被不同的 Agent 复用。

我定义的 Skill 协议大概长这样:

protocol AgentSkill { var name: String { get } var description: String { get } var parameters: [SkillParameter] { get } func execute(params: [String: Any]) async throws -> SkillResult }

name是 Skill 的唯一标识,Agent 靠它来调用。description是给模型看的自然语言说明,写得好不好直接决定 Agent 会不会用错。parameters声明参数类型和是否必填,用于校验。execute是实际执行逻辑,返回SkillResult,里面包含成功与否、返回值、以及可能的错误信息。

这里有个经验:description要写得像给新人看的操作手册,不要写"执行点击操作"这种废话,要写"点击指定标识的按钮,如果按钮不可点击会返回错误"。模型看到这种描述,才知道什么时候该用、什么时候不该用。

3.3 状态同步的时机

Agent 操作界面之后,界面状态会变,Agent 需要重新感知。这里有个时序问题:SwiftUI 的状态更新是异步的,你调用了viewModel.login(),界面不会立刻变,得等下一个渲染周期。

如果 Agent 执行完一个 Skill 立刻去读界面状态,很可能读到的是旧状态。我踩过这个坑,Agent 点完登录按钮马上检查是否跳转,结果一直判断失败,因为界面还没刷新。

解决方案是在 Skill 执行后加一个"等待稳定"的步骤。具体做法是轮询 Accessibility 树,直到连续两次读取的结果一致,或者超时。这个等待时间一般设 0.5 到 2 秒,太短容易误判,太长影响效率。

func waitForStable(timeout: TimeInterval = 2.0) async throws -> [ElementSnapshot] { var lastSnapshot: [ElementSnapshot] = [] let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { let current = try readAccessibilityTree() if current == lastSnapshot { return current } lastSnapshot = current try await Task.sleep(nanoseconds: 200_000_000) } return lastSnapshot }

3.4 错误处理与重试策略

Agent 操作界面失败是常态,不是异常。元素没找到、元素不可点击、输入被拦截,这些都会发生。关键是怎么让 Agent 知道失败了,以及失败了怎么办。

我的做法是给每个 Skill 定义清晰的错误类型:

错误类型含义Agent 应对策略
elementNotFound找不到指定标识的元素重新感知界面,确认元素是否存在
elementNotInteractable元素存在但不可交互检查是否被遮挡、是否 disabled
timeout操作超时重试一次,仍失败则上报
invalidParameter参数不合法修正参数后重试
stateMismatch操作后状态不符合预期回退或走异常分支

Agent 拿到错误类型后,可以决定是重试、换策略还是放弃。这比单纯返回"失败"要有用得多。

实操心得:重试一定要有次数上限,我一般设 2 次。无限重试的 Agent 是最危险的,它会在一个死循环里把界面点烂。另外重试之间要加退避,第一次等 0.5 秒,第二次等 1.5 秒,给界面足够的响应时间。

4. 实操过程与核心环节实现

4.1 环境搭建与依赖准备

先说环境。这套东西的核心依赖其实很少,主要是三块:SwiftUI 本身(系统自带)、一个 Accessibility 读取的通道、以及 Agent 的决策循环。

Accessibility 读取通道,在测试环境用 XCTest 的XCUIApplication最方便,它能直接拿到整棵元素树。在非测试环境,可以用UIAccessibility的相关接口,但能力有限,很多信息拿不到。所以我的建议是:开发和测试阶段用 XCTest 通道,生产环境如果需要,再考虑用私有接口或者自建桥接。

Agent 决策循环这块,可以自己写一个简单的状态机,也可以接入现成的模型接口。我倾向于自己写循环,因为可控性高,调试方便。一个最小的循环大概是这样:

func runAgentLoop(goal: String, maxSteps: Int = 20) async throws { var step = 0 var history: [AgentStep] = [] while step < maxSteps { let snapshot = try await perception.readStableTree() let decision = try await brain.decide( goal: goal, currentState: snapshot, history: history ) switch decision { case .callSkill(let name, let params): let result = try await skillRegistry.execute(name, params) history.append(.init(skill: name, params: params, result: result)) case .finish(let summary): print("任务完成: \(summary)") return case .fail(let reason): throw AgentError.taskFailed(reason) } step += 1 } throw AgentError.maxStepsExceeded }

这个循环看起来简单,但每一行都有讲究。readStableTree保证读到的是稳定状态;decide把目标、当前状态、历史一起喂给模型;history让模型知道之前做过什么,避免重复操作;maxSteps是安全阀,防止死循环。

4.2 一个完整的登录场景拆解

光讲理论没意思,我拿一个登录场景从头到尾走一遍。

假设界面上有用户名输入框、密码输入框、登录按钮,还有一个可能出现的错误提示。目标是让 Agent 完成登录。

第一步,感知。Agent 读取 Accessibility 树,得到摘要:

[id=login_username_field] type=textfield value="" enabled=true [id=login_password_field] type=securefield value="" enabled=true [id=login_submit_button] type=button label="登录" enabled=false

注意登录按钮是enabled=false,因为两个输入框都是空的。这个信息很关键,Agent 如果直接点登录会失败。

第二步,决策。Agent 看到目标"完成登录",当前状态是两个空输入框加一个禁用按钮。它应该先填用户名。模型输出:调用inputText,参数是id=login_username_field, text=testuser。

第三步,执行。inputTextSkill 找到对应元素,注入文本。这里有个细节:SwiftUI 的TextField绑定的是@State或者@Observable的属性,注入文本的方式是修改绑定的值,而不是模拟键盘输入。修改后界面会自动更新。

第四步,再感知。填完用户名后,按钮还是禁用的,因为密码没填。Agent 继续填密码。

第五步,填完密码。这次感知发现按钮变成enabled=true了。Agent 调用tapElement,参数是id=login_submit_button。

第六步,等待结果。点击后界面会发起网络请求,Agent 需要等待。用前面说的waitForStable,等到界面稳定后,检查是否出现了登录成功的标志(比如首页的某个元素),或者错误提示。

整个流程走下来,Agent 大概调用了 4 到 5 个 Skill。如果中间出现错误提示,Agent 需要读取错误文案,决定是重试还是上报。这就是为什么前面强调错误类型要清晰——Agent 得知道"密码错误"和"网络超时"是两回事,前者重试没用,后者可以重试。

4.3 参数计算与阈值选择

这套系统里有几个关键参数,选得对不对直接影响稳定性。

等待稳定的轮询间隔:我设的是 200 毫秒。太短了 CPU 占用高,太长了响应慢。200 毫秒在大多数设备上是一个渲染周期的量级,比较合适。

稳定判定次数:连续 2 次读取一致就认为稳定。为什么不是 1 次?因为单次读取可能刚好卡在两次渲染之间,读到中间态。2 次能过滤掉大部分抖动。

超时时间:默认 2 秒。对于网络请求后的界面变化,2 秒可能不够,需要根据具体场景调整。我的做法是给每个 Skill 单独配置超时,tapElement用 2 秒,waitForNetwork用 10 秒。

最大步数:20 步。一个正常的任务流程很少超过 10 步,20 步是留了余量。超过 20 步基本可以判定 Agent 卡住了。

重试次数:2 次。配合退避策略,第一次 0.5 秒,第二次 1.5 秒。

这些参数不是拍脑袋定的,是我在不同复杂度的界面上反复调出来的。你可以根据自己的场景微调,但建议先按这套跑通,再优化。

4.4 感知摘要的压缩策略

前面提到要把 Accessibility 树压缩成摘要,具体怎么压?我总结了三条规则。

规则一:只保留可交互元素和关键文本。按钮、输入框、开关、滑块、列表项这些要保留。纯展示的Text、Image除非是错误提示或者关键信息,否则过滤掉。

规则二:每个元素一行,字段固定。固定字段的好处是模型容易理解,也方便做 diff。我用的字段是id、type、label、value、enabled、focused。

规则三:层级信息用缩进表示,但不超过两层。太深的层级对 Agent 决策没帮助,反而增加理解成本。列表项的子元素可以合并到父项描述里。

压缩后的摘要,一个中等复杂度界面大概 20 到 40 行,token 消耗在 500 到 1000 之间,完全可控。

5. 常见问题与排查技巧实录

5.1 元素找不到的几种情况

这是最高频的问题。Agent 报elementNotFound,原因可能有好几种,得逐一排查。

情况一:标识没加上或者加错了。最常见。SwiftUI 里.accessibilityIdentifier有时候会被父视图覆盖,尤其是用了.accessibilityElement(children: .combine)的时候。排查方法是直接在 Xcode 的 Accessibility Inspector 里看,能不能找到这个标识。

情况二:元素还没渲染出来。界面有动画或者异步加载,Agent 读得太早。解决办法是加等待,或者用waitForElement(id:timeout:)这种带条件的等待。

情况三:元素在滚动视图外。列表很长,目标元素在屏幕外,Accessibility 树里可能读不到。需要先滚动到目标位置。我一般会加一个scrollToElement的 Skill,Agent 找不到元素时先尝试滚动。

情况四:元素被模态视图遮挡。弹窗、Sheet、Alert 盖住了底层元素。这种情况下 Accessibility 树里底层元素可能还在,但不可交互。Agent 需要先关闭弹窗。

排查顺序建议是:先看 Inspector 确认标识存在,再看时序确认渲染完成,再看位置确认在可视区,最后看遮挡。

5.2 Agent 决策跑偏怎么办

Agent 跑偏的表现是:明明该填密码,它去点了别的按钮;或者在一个步骤上反复重试。

原因一:摘要信息不够。Agent 看不到某个关键元素的状态,做了错误判断。解决办法是补充摘要字段,比如加上focused状态,让 Agent 知道当前焦点在哪。

原因二:description 写得有歧义。两个 Skill 的描述太像,模型分不清该用哪个。解决办法是把描述写得更具体,明确使用场景和前置条件。

原因三:历史太长,模型被干扰。对话历史积累太多,模型注意力分散。解决办法是只保留最近 N 步的历史,或者对历史做摘要。

原因四:目标描述太模糊。"帮我操作一下"这种目标,模型没法执行。目标要具体,比如"用 testuser 和密码 123456 登录"。

我的经验是,Agent 跑偏 90% 是感知或描述的问题,不是模型能力的问题。先把摘要和 description 打磨好,再考虑换模型。

5.3 状态不同步的排查

Agent 操作后读到的状态和实际不符,这个问题很隐蔽。

排查方法一:加日志。每次读取 Accessibility 树都打时间戳和内容,对比操作前后的变化。如果操作后立刻读到的还是旧状态,就是时序问题。

排查方法二:手动复现。在 Xcode 里手动操作同样的步骤,看界面变化需要多久。如果手动操作也要等一会儿,那 Agent 就需要等。

排查方法三:检查绑定。SwiftUI 的状态绑定如果写错了,界面可能根本不响应。比如@State用了let而不是var,或者@Binding传错了层级。

避坑技巧:在开发阶段,给每个 Skill 执行后强制加 500 毫秒延迟,先保证功能跑通,再逐步优化等待策略。不要一上来就追求零延迟,那样调试起来会很痛苦。

5.4 常见问题速查表

现象可能原因排查动作解决方向
elementNotFound标识缺失/未渲染/在屏幕外Inspector 检查、加日志补标识、加等待、先滚动
elementNotInteractable被遮挡/disabled检查弹窗、检查 enabled关弹窗、等状态变化
操作后状态不变时序问题/绑定错误加延迟测试、检查绑定加等待、修绑定
Agent 反复重试错误类型不明确看 Skill 返回的错误细化错误类型
Agent 决策跑偏摘要不足/描述歧义看摘要和 description补字段、改描述
任务超时步数过多/卡死看 history加 maxSteps、加超时
摘要 token 过多过滤不彻底看摘要行数收紧过滤规则

5.5 几个我踩过的坑

坑一:用 label 当标识。一开始图省事,直接用按钮文案当标识。结果产品改了一次文案,所有 Skill 全挂。后来老老实实用独立的accessibilityIdentifier,再也没出过这个问题。

坑二:忽略 disabled 状态。Agent 看到按钮就点,不管它是不是禁用的。结果点了没反应,Agent 以为失败了,反复重试。后来在摘要里加上enabled字段,Agent 就知道该等还是该点。

坑三:重试没有退避。早期版本重试是立即重试,结果界面还没反应过来,第二次又失败,第三次还是失败。加了退避之后,成功率明显提升。

坑四:历史无限增长。长任务跑下来,history 积累了几十步,token 爆炸,模型还容易迷失。后来改成只保留最近 5 步,效果反而更好。

坑五:没有 maxSteps。有一次 Agent 卡在一个循环里,把界面点了几十次。加了 maxSteps 之后,最多 20 步就强制退出,安全多了。

6. 扩展方向与个人体会

这套东西跑通之后,能扩展的方向其实挺多的。

一个方向是多模态感知。现在只读 Accessibility 树,信息有限。如果加上截图,让模型同时看结构和图像,对复杂界面的理解会更强。尤其是那些没有加标识的自定义控件,图像识别能补上。

另一个方向是Skill 的自动发现。现在 Skill 是手动注册的,未来可以让 Agent 自己扫描界面,发现可交互元素后自动生成临时 Skill。这样面对新界面时,Agent 不需要预先配置就能操作。

还有一个方向是跨应用编排。单个 App 内的操作已经跑通了,如果能把多个 App 的 Skill 串起来,就能完成更复杂的任务。当然这涉及权限和沙箱的问题,得看具体平台的支持情况。

我个人在实际操作中的体会是:这套系统的瓶颈从来不在模型,而在感知和描述。模型再强,如果它看不到界面状态,或者 Skill 描述得含糊不清,照样干不了活。所以如果你要上手做类似的东西,我的建议是把 80% 的精力花在 Accessibility 标识规范、摘要压缩策略和 Skill 描述打磨上,剩下的 20% 再考虑模型选型和循环优化。

最后再分享一个小技巧:给每个 Skill 写单元测试。不是测 Agent,是单独测 Skill。输入一组参数,看它能不能正确操作界面、返回正确结果。Skill 稳了,Agent 才有可能稳。我见过太多人跳过这一步,直接测端到端,结果出了问题根本不知道是 Skill 的锅还是 Agent 的锅。先把 Skill 测透,再往上搭,会省很多时间。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进