
做SwiftUI开发也有三四年了一开始我总觉得它不过是一套比UIKit“叛逆”一点的声明式UI框架写界面的方式确实清爽但真正扎进复杂业务之后才发现难的地方根本不在界面而在“状态管理”和“异步任务”这两件事上。尤其是当你想把一个智能体Agent塞进App里让它可以调用各种技能完成任务SwiftUI那套“状态驱动视图”的线程模型跟Agent天然异步、流式返回的行为方式经常打架。这个项目我命名为SwiftUI-Agent-Skill中心思想就一句话让Agent技能像插线板一样可插拔同时让技能执行过程中的每个状态变化都能被SwiftUI视图稳定、流畅地感知。文章会从为什么需要这个抽象讲起拆解技能协议的设计、状态管理的取舍、流式消息的UI刷新链路最后聊几个我在实际调试中踩到、翻车之后才想明白的坑。不管你是想在App里接大模型做对话还是想把已有的工具能力以“技能”的形式开放给智能体这篇内容应该都能给你一些可以直接落地的思路。1. 为什么我想把Agent技能塞进SwiftUI应用1.1 问题的起点App内嵌对话功能的失控事情是这样的。某次我开始做一个实验性质的小项目需求很简单在App里加一个智能助手入口用户可以用自然语言提需求比如“帮我把右上角的设置项打开”“给当前列表加个筛选”应用内的Agent去理解意图然后调用相应的内部能力。最初的做法没有任何抽象直接在ViewModel里写了一个巨大的switch-case。Agent把用户指令翻译成一个动作名然后我在视图层或者业务层挨个判断动作名是“openSettings”就跳设置页是“applyFilter”就调筛选逻辑。这个方案前几个技能还勉强能跑到了第五个第六个技能时已经非常痛苦每次加新能力都要改动多个文件还要小心翼翼处理视图绑定的状态技能执行中按钮要不要禁用进度提示放哪失败后的错误弹窗谁负责更让人头大的是SwiftUI对状态更新非常敏感。Agent技能的执行是异步的可能一个技能跑好几秒期间还要不断往界面上吐中间日志、更新进度。如果直接把这些异步回调散落在各个视图里很容易出现“技能执行完了界面却没刷新”“回调在主线程外调用导致SwiftUI更新警告”之类的诡异问题。这促使我去想一个更系统的方案。1.2 为什么偏偏用SwiftUI来搭这套框架有人可能会问这种Agent调度逻辑跟UI框架有什么关系用UIKit不也一样能实现区别在于SwiftUI的“状态驱动视图”模型天然和Agent技能执行过程形成了张力。SwiftUI希望你的界面是状态的纯函数给定一份状态界面就该长成那个样子。而Agent技能执行偏偏是一个长时间、多阶段、有中途反馈的过程。如果不在中间加一层“可观测的状态机”SwiftUI视图根本不知道该听谁的指令来更新。另一方面SwiftUI的Observation框架iOS 17之后的Observable提供了很细粒度的依赖追踪。传统UIKit里你可能要手动刷新某个控件在SwiftUI里只要技能状态对象是一个可观察对象视图会自己决定哪些部分需要重新渲染。这正是Agent场景需要的技能A执行时希望展示进度条技能B执行时希望展示一条确认弹窗技能C也许只需要静默执行。你不需要在视图层写满if-else只需让技能本身去更新自己的状态字段视图天然响应这些变化。所以我把“技能抽象”和“SwiftUI状态驱动”放在一起设计目的不是炫技而是想找到一种能让异步Agent流程和声明式UI和谐共处的架构模型。2. 让Agent技能可插拔从一团乱麻到协议驱动2.1 技能协议的第一版能跑但不好扩展在设计技能协议之前我写了一个粗糙的版本。每个技能都是一个类提供一个run()方法返回一个结果对象protocol OldSkill { func run(input: String) async throws - String }这看起来很简单但实际使用时很快遇到了几个问题。第一技能没有“身份描述”。Agent拿到用户的自然语言以后怎么知道该调度哪个技能只能靠if-else去试探或者人工维护一张映射表。很不灵活。第二技能没有“能力声明”。有的技能需要用户确认权限有的技能需要传入几个参数有的技能在执行过程中需要展示不同的UI。一个统一的run(input:)签名把这些差异全都抹掉了导致技能内部充满了字符串解析和类型判断的脏代码。第三技能执行过程中想更新UI非常尴尬。有的技能执行时要显示进度有的技能执行时会触发一个中途交互。旧协议里run(input:)只能返回一个最终结果中途状态没有表达渠道。最后我只能靠回调闭包传递事件代码变得非常绕。2.2 协议怎么设计才能让技能“可插拔”后来参考了智能体和插件系统的常见做法我把技能协议拆分成了几个清晰的职责部分描述、匹配、执行、事件输出。一个技能对外暴露出来的信息足够多Agent才能优雅地路由技能自己主动产出状态事件SwiftUI才能响应式地更新界面。我最终用的协议大概长这样public struct AgentSkillDescriptor: Sendable { public let identifier: String public let displayName: String public let description: String public let parameterSchema: [String: AgentSkillParameter] } public struct AgentSkillParameter: Sendable { public let key: String public let title: String public let required: Bool public let type: ParameterType } MainActor public protocol AgentSkill: AnyObject { var descriptor: AgentSkillDescriptor { get } func canHandle(instruction: String, context: AgentContext) async - Bool func perform(arguments: [String: Any], context: AgentContext) async throws - AgentSkillResult func onEvent(_ event: AgentSkillEvent) }这里有几个关键设计点值得展开说一下。descriptor是一个纯数据对象包含了技能的标识、名称、描述和参数Schema。它对应生活里的“说明书”概念。Agent需要一套说明书才能理解这个技能是干什么的、需要什么输入。这和传统函数调用的区别在于技能的能力描述不再是隐藏的、靠硬编码的而是可以被读取、被路由算法理解的。canHandle负责意图匹配。每个技能可以自己判断我能不能处理这条指令这种判断可以基于关键词、规则也可以调用一个本地的小模型做语义匹配。实际做下来让每个技能自己声明“我擅长什么”比统一训练一个意图分类器要灵活得多。新增一个技能的时候不需要动老技能也不用重新训练任何模型只要新技能自己的canHandle写得靠谱就行。perform是技能真正干活的入口。它接收Agent解析出的参数表执行自己的业务逻辑返回一个结构化的AgentSkillResult。协议本身不限制你干什么调网络接口、修改App设置、读写本地文件都可以。onEvent是技能向外界广播事件的口子也是我认为“SwiftUI友好”最关键的一点。技能执行过程中如果想要更新UI就通过这个方法发出事件比如AgentSkillEvent.progressUpdated(正在下载, 0.4)、.askForConfirmation(确认要删除吗?)。外加有一个context参数可以拿到当前App环境、用户Session之类的共享信息。2.3 注册与调度让Agent知道现在该叫谁有了协议之后下一步就是管理这些技能。我写了一个技能注册表本质上是一个数组加一层索引MainActor final class SkillRegistry { private(set) var skills: [any AgentSkill] [] func register(_ skill: any AgentSkill) { skills.append(skill) } func resolveSkill(for instruction: String, context: AgentContext) async - (any AgentSkill)? { for skill in skills where await skill.canHandle(instruction: instruction, context: context) { return skill } return nil } }这个resolveSkill在真实项目里会做排序、打分而不是简单的第一个命中。不过核心思路一样Agent拿到用户指令后先让所有技能毛遂自荐然后由调度器拍板。这很像一个客服总台用户提出需求总台根据每个客服的擅长领域分配工单。如果所有人都摇头就走兜底逻辑告诉用户“这个请求我暂时还处理不了”。在这个阶段我逐渐意识到之前的设计误区把技能当作“被调用的函数”是错误的。技能应该被当作“拥有主观能动性的插件”它自己知道自己擅长什么自己决定如何用最佳方式执行还主动把自己的执行状态分享给外界。把这一层想清楚之后后面的状态管理设计和视图绑定才变得顺理成章。3. 状态管理打通从Agent到视图的“反应链”3.1 从ObservableObject到Observable的迁移聊SwiftUI的项目状态管理是绕不开的话题。一开始我习惯性地用ObservableObject配合Published来承载Agent技能的状态这也是iOS 17以前最常见的做法。后面在实际调试中发现ObservableObject有一个非常不利的细节它要求所有变化都通过Published属性触发一旦某个字段变化它会把所有观察这个对象的视图都通知一遍。Agent技能执行过程中的状态变化非常频繁某一句思考日志、某个步骤进度、某个中间结果这些都在变化。如果全用Published广播出去视图层的刷新范围往往比实际需要大得多App在技能执行期间会明显掉帧。后来我迁移到了iOS 17的Observable宏。这套Observation框架的最大卖点是“细粒度依赖追踪”只有真正读取了某个属性的视图才会在该属性变化时刷新。Agent技能的新状态对象本身也会在状态变化时自动追踪。迁移之后刷新的范围一下子收敛了进度条只监听进度字段状态标签只监听阶段字段彼此互不干扰界面流畅度提升非常明显。3.2 技能执行的三个状态维度pending、streaming、done对技能执行过程的建模我最终抽象出了三个状态维度分别对应“技能是否被认领”“当前有没有流式输出”“执行结果是什么”。三个维度独立演化组合起来就能描述绝大多数界面场景。enum AgentSessionPhase { case idle case resolvingSkill case executing case failed case finished } Observable MainActor final class AgentSessionStore { var messages: [AgentMessage] [] var phase: AgentSessionPhase .idle var activeSkillIdentifier: String? var streamingContent: String var lastError: String? }我在实际项目里的做法是给Agent的每一轮交互建立一个“Session”会话内记录所有消息滚动式地存储Agent回复。phase字段承担“阶段指示器”的角色用户发送指令后进入resolvingSkill找到技能并开始执行后进入executing技能中途有流式输出时streamingContent持续变化最终落入finished或failed。这三个维度听起来简单但帮我把大量杂乱的UI逻辑理顺了。比如视图层只需要检查activeSkillIdentifier就能知道“现在是不是有技能正在工作”检查lastError就能决定是否展示错误弹窗。不需要在我原先那个巨大的switch-case里到处塞状态。3.3 视图怎么绑定技能状态一个实时案例我举一个实际界面的例子当Agent正在执行“查询天气并汇报”的技能时界面需要显示一条会话气泡里面有一个动态的“打字式”文本。这个文本流就是streamingContent的实时映射。struct AgentChatView: View { Bindable var store: AgentSessionStore var body: some View { ScrollView { LazyVStack(spacing: 12) { ForEach(store.messages) { message in MessageBubble(message: message) } if store.phase .executing { StreamingTypingView(content: store.streamingContent) } } } } }这里的核心是视图不需要知道技能内部怎么执行、怎么生成文本它只需要观察store.streamingContent这个字段。技能在perform里不断调用onEvent(.responseChunk(今天多云))事件总线把字符串追加到streamingContentSwiftUI自动把新内容渲染到气泡里。整个过程一气呵成。我在这个阶段最大的体会是Agent技能系统在设计时一定要把“状态存储”和“业务逻辑”分开。技能负责“做什么”AgentSessionStore负责“状态长什么样”。如果二者耦合在一起界面逻辑和技能逻辑就会纠缠不清最后代码越写越乱。这中间需要一个事件出口来明确分割线。4. 流式消息的处理AsyncStream与UI刷新的协同4.1 Agent的回复为什么不能等“全部完成”再显示如果你只是让Agent一次性返回完整结果那UI刷新其实没有任何难度和普通网络请求一样。但现实中的Agent尤其是对接本地推理或云端大模型接口的Agent往往需要几秒甚至更久才能生成完整回复。如果让用户对着一个空白的对话框等上整整几秒体验非常糟糕。流式返回的核心意义在于把“正在思考的中间过程”展现给用户让用户感受到Agent是“活”的。这就像看人打字字幕一句一句蹦出来比等一整段文字突然出现要自然得多。但流式返回对SwiftUI来说并不友好。SwiftUI的视图更新机制要求状态变化要么发生在主线程要么通过合理的方式调度到主线程。而Agent的流式回调很可能来自后台队列、网络回调、甚至一个独立的推理线程。直接在这些线程里修改streamingContent轻则触发“Publishing changes from background threads is not allowed”的警告重则造成数据竞争和崩溃。4.2 AsyncStream与Task的配套写法为了把流式回调安全地转接到SwiftUI的更新链路我采用了AsyncStream加Task的写法。技能执行时Agent把输出事件写入一个异步流UI侧用Task消费这个异步流并在迭代时切换到主线程更新状态。func executeSkill( _ skill: any AgentSkill, instruction: String, context: AgentContext ) async { let stream AsyncStreamAgentSkillEvent { continuation in Task { await skill.perform(arguments: args, context: context, onEvent: { event in continuation.yield(event) }) continuation.finish() } } let task Task { MainActor in for await event in stream { switch event { case .responseChunk(let text): store.streamingContent text case .progressUpdated(let progress): store.progress progress case .askForConfirmation(let prompt): store.pendingConfirmation prompt case .completed(let result): store.messages.append(.agent(result.summary)) store.phase .finished } } } activeTask task }这里有一个小细节容易踩坑Task { MainActor in }内部是一个MainActor隔离的异步上下文for await迭代事件流的每次循环默认都会在MainActor上执行因此直接更新store就是安全的。这里不需要再嵌套MainActor.run写起来干净很多。AsyncStream还有一个很大的好处便于在技能被取消时关闭。只要父Task被取消continuation的底层就会被正确清理消费端也能正常退出循环。相比老式的回调闭包这算是真正能配合结构化并发的做法。4.3 MainActor隔离在Agent回调里碰UI的第一道红线很多初学者在接Agent回调时喜欢在回调闭包里直接操作UI状态然后很快撞上线程警告。我想强调一个原则AgentSessionStore这类与视图直接绑定的状态对象应该在类声明处就标注MainActor让编译器从源头挡住非主线程更新。MainActor Observable final class AgentSessionStore { ... }这么做之后所有试图在没有MainActor隔离的上下文里修改store的行为都会在编译阶段直接报错。你会被迫思考“我这条回调线程到底怎么把数据传回主线程”而不是等运行时崩溃才意识到问题。技能本身执行复杂计算时perform方法内部完全没必要约束在主线程。比较合理的分工是perform在后台队列上执行重活在产出UI事件时才通过事件总线回传AgentSessionStore作为UI状态容器只负责接收、存储和暴露数据。两边用AsyncStream这个“管道”对接各司其职。5. 我在实测里踩过的几个坑5.1 列表卡顿流式消息高频率刷新与节流第一次跑通整个流式链路的时候界面看起来非常“兴奋”——文字刷新频率极高但实测在低端设备上明显掉帧。原因是Agent的流式输出可能以非常小的粒度源源不断地产生事件比如每10毫秒就有一个几个字的chunk回传。如果每个chunk都触发一次streamingContent 再驱动视图重新计算一次文本布局很容易让主线程忙不过来。我的解决方案是“节流合并”。在消费流事件时不立即把每个chunk都写入状态而是先攒进一个缓冲区每隔80毫秒左右把缓冲区里的内容批量添加到streamingContent一次。这样既保留了打字机式的视觉效果又把主线程的刷新频率降到一个合理范围。var buffer: [String] [] for await event in stream { buffer.append(text) guard Date().timeIntervalSince(lastFlush) 0.08 else { continue } store.streamingContent buffer.joined() buffer.removeAll() lastFlush Date() }在真机上测试80毫秒的节流间隔在普通文本场景下几乎感知不到延迟流畅度却好了很多。5.2 ScrollViewReader自动滚动的失效与修正流式输出时聊天列表需要自动滚到底部。大部分人会想到ScrollViewReader加scrollTo。实测中发现一个奇怪现象在连续流式输出期间调用scrollTo有时会失效有时能滚到指定位置但画面显得非常顿挫。排查之后发现问题出在滚动时机。流式输出每刷新一次界面新文本的行高其实还没被布局系统计算完。此时强制scrollTo目标位置会被估算得不准。更合理的做法是在刷新之后短暂延一拍再执行滚动。我采用的方式是在Task里稍微让一帧Task { try? await Task.sleep(for: .milliseconds(60)) withAnimation(.easeOut(duration: 0.15)) { proxy.scrollTo(lastMessageID, anchor: .bottom) } }这里有个细节withAnimation会让滚动不那么生硬但动画时间别设太长否则新消息又出来时滚动可能跟不上。0.15秒左右的轻量动画在实际体验里比较合适。5.3 Task生命周期管理页面消失了回调还在更新流式事件消费是通过Task挂在视图或ViewModel上的如果这个Task没有妥善取消页面已经销毁了回调还在更新原来的store轻则产生无意义计算重则持有已经释放的视图导致隐式泄漏。SwiftUI提供了.task修饰符来绑定生命周期.task { await executeSkill(...) }.task会在视图从层级中消失时自动取消绑定的Task。但如果你把Task手动保存在属性里像我在前面的例子里写的activeTask就要记得在视图消失时主动取消。我最后采用的做法是统一使用.task管理会话执行同时让AsyncStream的消费循环检查Task.isCancelled一旦取消就退出并清理状态。for await event in stream { guard !Task.isCancelled else { break } ... }这一步看似不起眼实际上救了我好几次——技能还在执行时用户退出页面回来之后发现界面状态没被上一轮残留回调污染这是一个很关键的用户体验细节。5.4 技能结果与状态不一致的脏读问题最后一个坑比较隐蔽。在一次“技能执行中用户又发来新指令”的并发场景下我发现activeSkillIdentifier和streamingContent偶尔会对不上界面上显示技能A正在执行流式文本却是技能B的内容。排查后发现AgentSessionStore里多个字段是独立存储的当新技能开始执行时旧技能的流式输出可能还在路上。如果先更新了activeSkillIdentifier旧技能的chunk才被消费到视图就会看到“技能被换掉了但流式文本还是旧技能”的中间状态。解决办法是引入“会话标识”机制。每次开始新技能执行时生成一个sessionID流式事件里带上这个ID消费端只处理当前sessionID匹配的事件旧技能残留的chunk直接丢弃。store.phase .executing store.activeSkillIdentifier skill.descriptor.identifier let currentSessionID UUID() store.currentSessionID currentSessionID for await event in stream { guard event.sessionID store.currentSessionID else { continue } ... }这个改法看起来多了一点样板但它消除了整个系统里最让人头疼的一类数据竞争问题。以后不管技能怎么并发执行界面状态始终只跟“当前会话”绑定。6. 这个项目后续还能往哪走6.1 从单技能到技能编排目前的SkillRegistry是“找一个人来干一件事”的模式。但实际用户需求往往不是单个技能能搞定的比如“帮我把这个文件转成PDF发给会议里的所有人”就涉及文件转换、联系人查找、发送消息三个技能。我接下来的方向是给AgentSessionStore增加一个“工作计划”的概念让Agent不只选择一个技能而是拆解成一个技能序列按顺序或按依赖关系执行。这一步在协议层面并不难扩展因为技能本身已经具备了描述能力Agent可以读取多个技能的能力集然后编排任务链。真正复杂的是状态管理编排过程中每个子技能都有独立的进度和事件界面需要展示一个总体的执行管线。我目前的想法是引入“任务组”状态把技能各自的AgentSkillEvent汇总成子任务列表视图用List展示。6.2 让技能描述更结构化接入自然语言意图路由后的方向现在canHandle主要靠关键词和规则还挺简陋。如果想让技能匹配更智能可以考虑接入自然语言理解模型来做意图路由所有技能的descriptor作为候选集用户的自然语言指令作为查询把匹配得分最高的技能选出来。这也正是descriptor里description字段的用武之地——语义路由模型需要一个文本形式的“技能能力说明”。我在测试中尝试过用embedding向量做相似度匹配效果比关键词规则好不少。技能的description写得好匹配准确率显著提升。比如“打开设置”和“切换到偏好设置页面”可以匹配到同一个技能前提是技能的描述里能覆盖这两种表达方式。这一步做好之后整个体质就可以从容面对各种模糊表达的用户而不必在canHandle里堆砌大量规则。6.3 一个我准备继续做的小实验去年开始我逐渐意识到SwiftUI的Observable和Agent技能执行过程是天然契合的组合。你越往后做越会觉得“技能系统”本质上就是一个状态机输入是一个自然语言请求输出是一连串UI状态事件技能内部不过是状态转移的载体。如果你在做一个SwiftUI的智能交互应用不管当前是只有一个技能还是已经有一堆技能我都建议你尽早把技能抽象成协议、状态统一收口到MainActor的可观察对象里。这个投入的回报在项目规模变大后会非常明显。我目前还在继续做的一个小实验是把技能的权限弹窗也纳入事件体系。技能执行到“需要读取通讯录”时通过事件主动向会话请求授权由界面层生成确认弹窗用户点了“允许”之后再把授权结果作为参数回传给技能。这样技能能保持纯业务逻辑不需要直接管理权限弹窗的展示层级视图层也能根据自己的设计风格灵活处理提示交互。等我跑通这个流程应该可以再写一篇复盘。