SwiftUI 做到第四五个项目的时候大多数人都会开始觉得不对劲视图状态散得到处都是每个页面塞了不同版本的加载转圈、空态文案、错误重试同一个交互逻辑在三个页面里各写了一遍。我那个练习项目 SwiftUI-Agent-Skill 就是为了解决这个别扭劲儿做的尝试核心思路很朴素——把“视图该展示什么”和“动作该怎么编排”拆开凡是涉及业务动作的交给 Agent 统一调度凡是能复用的交互能力包装成 Skill 按需挂载。这个项目我用了大概两周的业余时间搭起来结构上借鉴了智能体编程里“Agent Skill”的思考方式但没用任何重量级框架全部基于 Swift Observation 体系和 SwiftUI 自身能力。如果你已经有半年以上 SwiftUI 基础被 ViewModel 满天飞、Published 乱窜、复用逻辑靠粘贴复制这些问题折腾过这篇内容应该能给你提供一套新的组织代码的思路。没有 SwiftUI 经验的同学也不用绕道我会把需要的基本概念都解释清楚你照着代码敲一遍也能看明白。1. 先拆一下“Agent-Skill”这个概念到底指什么1.1 为什么传统的 ViewModel 思路总差一口气SwiftUI 流行之后很多人把 MVC 里的 Controller 位置换成了 ViewModel感觉挺好用的官方后来也给了 Observable状态管理确实比早期顺手。可项目一大就暴露问题了ViewModel 是个“什么都干”的角色——要持有网络请求状态、要处理用户点击、要负责组装展示字段、偶尔还要管一下导航。我曾经见过一个超过一千行的 “HomeViewModel”里面同时塞了首页列表、下拉刷新、用户信息拉取、深链跳转四类完全不相干的职责。测试的时候光构造这个 ViewModel 的环境就足够让人怀疑人生。问题的根源不在 ViewModel 本身而在“职责边界”不够明确。页面之间复用的能力被整个复制比如加载态和错误重试三个 ViewModel 各写了一遍。等到要统一改成下拉刷新样式时就得挨个改总会漏掉那么一两个页面。1.2 Agent 的核心职责是“编排”不是“背状态”在这个项目里Agent 被定义成一个很窄的层它不关心视图长什么样只关心某个用户意图进来之后需要依次调用哪些能力、如何合并结果、状态怎么流转。它把“动作编排”这件事和“页面展示”彻底分开了。举个例子我做一个模拟项目 X 的消息列表页视图只需要知道当前是加载中、空态、还是成功展示。至于什么时候去拉数据、拉失败了要不要自动重试、重试三次之后是否弹兜底提示这些全部写在 Agent 里。视图只负责把 Agent 暴露出来的状态渲染出来用户点击“重试”按钮时调用一个agent.send(.retry)就完事。这里有个特别重要的设计选择Agent 不直接强依赖 SwiftUI 的视图生命周期。它不拥有State也不强制要求挂在某个特定页面下它只是一个带有状态和动作能力的普通 Swift 对象。视图通过 Environment 注入拿到它既保持了 SwiftUI 声明式的爽快感又让 Agent 可以被单独测试。1.3 Skill 是 Agent 手里的“可插拔能力包”Skill 的定义更像一个协议化的能力单元。一个 Skill 只干一件事比如LoadingSkill负责管理加载态和转圈文案EmptySkill负责空态判断和空态展示StreamingSkill负责接收流式文本并增量渲染。Agent 可以按需挂载一个或多个 Skill每个 Skill 遵循同一个协议暴露“能否处理这个意图”和“执行这个意图”两个核心接口。这种设计带来的最大好处是复用从“复制代码”变成了“装配模块”。哪个页面要用到空态展示就给那个页面的 Agent 挂上EmptySkill页面上直接调用统一的AgentRenderView来渲染新增页面几乎不再需要重写一套交互逻辑。2. 整体设计这套结构在 SwiftUI 里怎么落地2.1 数据流动方向Agent 是唯一的状态源我在这套架构里定了一条硬规矩页面需要展示的数据一律由 Agent 的 Context 提供页面产生的用户意图一律通过agent.handle(intent:)发送。视图不直接持有业务数据源也不允许在视图里直接改写 Agent 内部状态。视图观察 Context ↓ 用户点击发送 Intent Agent处理 Intent调度 Skill ↓ 更新自己的 Context 状态 视图因为观察到 Context 变化自动刷新这个环非常像 Redux 的单向数据流核心价值在于可预测性。调试的时候只要看 Agent 的日志就能还原整个页面生命周期的每一次状态变化不用再在十几个绑定和闭包之间来回跳。SwiftUI 里要观察 Agent 的状态最直接的做法是让 Agent 类使用Observable宏把需要暴露给视图的属性用ObservationIgnored排除掉不必要观察的部分。我给AgentContext单独定义成一个结构体Agent 持有它的唯一实例外部通过 get-only 暴露。2.2 Environment 注入让 Agent 跟随视图树而不是全局变量我也考虑过用全局单例但很快放弃了。单例一旦被多个页面共享状态就会互相污染比如一个页面的加载状态把另一个页面的内容覆盖掉了。正确做法是利用 SwiftUI 的 Environment 特性在页面级别注入 Agent 实例视图树里的任意子视图都能访问到同一个 Agent。iOS 17 之后的 SwiftUI 对 Environment 注入自定义对象的支持已经非常成熟struct MessageListView: View { Environment(MessageAgent.self) private var agent var body: some View { Bindable var agent agent // 用于需要绑定场景 List(agent.context.messages) { message in MessageRow(message: message) } } }配合.environment(agent)在根视图注入一次所有子视图都能同步拿到同一个实例。这里有个小坑后面专题讲Environment(MessageAgent.self)只能在非可选类型的注入场景中使用如果 agent 可能不存在得用Environment(MessageAgent?.self)取值时记得手动解包。2.3 协议设计让 Skill 具备统一的执行语义Skill 约定成下面这样一个协议语义非常窄protocol AgentSkill: AnyObject { /// 当前技能能否处理这个意图 func canHandle(_ intent: AgentIntent) - Bool /// 执行技能返回值通过 Task 异步上报给 Agent func execute(_ intent: AgentIntent, context: AgentContext, next: escaping (AgentEffect) - Void) async throws }这里的关键点是AgentIntent和AgentEffect两个类型。Intent 表示用户或系统发出的“目标”比如.load、.retry、.more、.cancelEffect 表示执行后产生的“副作用效果”比如.presentLoading、.presentError(String)、.appendMessages([Message])。Skill 不需要知道页面长什么样它只负责产生效果。效果最终由 Agent 统一处理并改写到 Context。这样做让 Skill 之间天然隔离互不干扰也方便做单元测试——直接调用技能方法断言它返回了哪些效果即可。2.4 一个 Agent 挂载多个 Skill执行顺序靠优先级Agent 内部维护一个[AgentSkill]数组收到 Intent 后按照优先级遍历找到第一个能处理的 Skill 去执行。我把优先级定义成一个 Int 属性值越小越先执行protocol AgentSkill: AnyObject { var priority: Int { get } ... }比如CacheSkill的优先级比NetworkSkill高收到.load之后先查缓存缓存没有再去网络拿。这个设计让页面在引入缓存逻辑时完全不用改视图只需要给 Agent 加一个技能实例。3. 核心细节Skill 和 Agent 的配合方式3.1 ContextAgent 暴露给视图的唯一快照Context 这个概念一开始我是想直接用多个Published属性解决的但后来发现那样写会让测试和调试变得很痛苦因为状态之间可能存在组合关系。比如加载中且无错误、加载失败且已有缓存数据、空态且正在请求中三合一的情况很难用布尔字段描述清楚。最后我决定用一个快照结构体聚合所有展示所需状态struct AgentContext: Equatable { var phase: LoadingPhase .idle var messages: [Message] [] var error: AgentError? enum LoadingPhase: Equatable { case idle case loading(String) case loaded case empty(String) case failure(String) } }视图只需要 switchcontext.phase就能决定渲染加载、空态还是错误页。context.messages始终是当前已展示的消息列表包括加载更多的场景里追加后的完整列表。一次只观察一个结构体SwiftUI 的 diff 效率也比观察五个零散字段高。3.2 异步任务的生命周期管理Task 归谁管SwiftUI 视图里的.task修饰器和 Agent 结合时需要格外小心这里我踩过不少坑。如果直接在视图中创建 Task 然后去调 Agent 方法Agent 自身无法感知这个任务是否被取消。页面一退请求还在跑等回来时状态已经变了极其容易出现“页面已销毁但回调仍然在更新”的旧状态竞争问题。我的方案是 Agent 自己持有当前主任务收到新的 Intent 时先取消旧任务再创建新任务同时暴露一个cancelAll()方法在视图的.onDisappear里调用。Observable final class MessageAgent { private var currentTask: TaskVoid, Never? func handle(_ intent: AgentIntent) { currentTask?.cancel() currentTask Task { [weak self] in await self?.perform(intent) } } func cancelAll() { currentTask?.cancel() currentTask nil } }这里用[weak self]是防止 Agent 被任务持有形成循环引用。Swift 的 Task 内部闭包会强持有捕获对象如果不加 weak页面都销毁了 Agent 还会被任务保留着整个状态树就泄漏了。3.3 Skill 之间怎么协作先编排后执行有人会问多个 Skill 都挂在 Agent 上如果某个 Skill 执行中需要另一个 Skill 的结果怎么办我一开始也想搞一个跨 Skill 的消息总线实际做下来太重了。后来我和项目里另一位开发者讨论了很久最终形成现在这套模式Skill 不直接调用另一个 Skill而是通过返回多个 Effect 让 Agent 决定是否触发下一个 Intent。也就是说“编排”集中在 AgentSkill 只做能力单元。举个例子NetworkSkill请求成功后返回.appendMessages(list)和.finishLoadingAgent 收到这两个 Effect 后写 Context同时如果发现还有下一页就额外给自身发送一个.prefetchMoreIntent让PrefetchSkill提前拉后面几页。编排逻辑全部集中在 Agent 的reduce(effect:)方法里段落清晰可测。4. 实操记录搭一个聊天消息流页面4.1 定义类型和基础 Agent写一个实际能跑的示例最说明问题。模拟项目 X 是一个简单的消息流页面支持首屏加载、下拉刷新、流式追加三条消息、失败重试。先定义类型struct Message: Identifiable, Equatable, Codable { let id: UUID let text: String let createdAt: Date } enum AgentIntent: Equatable { case load case refresh case sendMessage(String) case retry } enum AgentEffect: Equatable { case setLoading(String) case setLoaded case setEmpty(String) case setFailure(String) case appendMessages([Message]) case prependMessages([Message]) }然后是 Agent 核心类Observable final class MessageAgent { private(set) var context AgentContext() private var skills: [AgentSkill] [] private var currentTask: TaskVoid, Never? init(skills: [AgentSkill]) { self.skills skills.sorted(by: { $0.priority $1.priority }) } func handle(_ intent: AgentIntent) { currentTask?.cancel() currentTask Task { [weak self] in await self?.route(intent) } } private func route(_ intent: AgentIntent) async { for skill in skills { guard skill.canHandle(intent) else { continue } do { try await skill.execute(intent, context: context) { [weak self] effect in self?.reduce(effect) } return } catch { reduce(.setFailure(error.localizedDescription)) return } } } private func reduce(_ effect: AgentEffect) { switch effect { case .setLoading(let hint): context.phase .loading(hint) case .setLoaded: context.phase .loaded case .setEmpty(let message): context.phase .empty(message) context.messages [] case .setFailure(let message): context.phase .failure(message) case .appendMessages(let items): context.messages.append(contentsOf: items) context.phase .loaded case .prependMessages(let items): context.messages.insert(contentsOf: items, at: 0) context.phase .loaded } } }注意route里的执行顺序找到第一个能处理的 Skill 后就 return。如果某个 Skill 想继续快照后续技能可以在自己内部返回一个.fallthroughEffectAgent 在 reduce 阶段判断到它后继续查找下一个技能但这属于高级用法第一版先别搞容易把自己绕晕。4.2 实现一个网络加载技能我封装了一个通用的LoadMessagesSkill它负责调用伪造的远程接口获取消息final class LoadMessagesSkill: AgentSkill { var priority: Int { 100 } func canHandle(_ intent: AgentIntent) - Bool { switch intent { case .load, .refresh, .retry: return true default: return false } } func execute(_ intent: AgentIntent, context: AgentContext, next: escaping (AgentEffect) - Void) async throws { if case .refresh intent { next(.setLoading(正在刷新...)) } else { next(.setLoading(正在加载...)) } let service MessageService() let result: [Message] do { // 用一个小的随机延迟模拟真实网络 try await Task.sleep(nanoseconds: UInt64.random(in: 300_000_000...800_000_000)) result try await service.fetchMessages() } catch { next(.setFailure(加载失败请检查网络后重试)) throw error } if result.isEmpty { next(.setEmpty(这里还没有任何消息)) } else { next(.appendMessages(result)) } } }我把网络请求抛出的错误同时以 Effect 和 throw 方式传递。原因是Effect 让视图立刻展示错误throw 让 route 方法终止后续流程。这里有个心得不要在 Skill 里做弹窗或 Alert那属于视图表达把它降级为 Effect 后由视图决定展示形式Skill 保持纯逻辑。4.3 组装页面页面这部分终于回归 SwiftUI 的舒适区struct MessageListView: View { Environment(MessageAgent.self) private var agent State private var inputText: String var body: some View { Group { switch agent.context.phase { case .idle, .loading: ProgressView(加载中...) case .empty(let hint): ContentUnavailableView { Label(暂无消息, systemImage: tray) } description: { Text(hint) } case .failure(let message): VStack(spacing: 12) { Text(message) Button(重试) { agent.handle(.retry) } } case .loaded: messageList } } .navigationTitle(消息) .task { agent.handle(.load) } .onDisappear { agent.cancelAll() } } private var messageList: some View { ScrollViewReader { proxy in List { ForEach(agent.context.messages) { message in MessageRow(message: message) } } .listStyle(.plain) } } }4.4 预览和调试技巧SwiftUI 预览要注入 Agent我写了一个辅助工厂方法#Preview(默认) { NavigationStack { MessageListView() .environment(MessageAgent(skills: [LoadMessagesSkill()])) } }如果要对某个特定状态做预览可以给AgentContext加一个静态构造方法直接构造带数据的 context 传入预览专用 Agent。这个技巧非常实用页面样式回归测试就靠它了。5. 接入流式接口时Agent-Skill 怎么扩展5.1 把流式增量渲染封装成 StreamingSkill很多人的项目会碰到 AI 对话或流式返回场景这也是 Agent 模式最能发力的地方。我把流式文本的增量渲染封装成一个StreamingSkill视图完全不感知文本是分几次到达的只负责展示context.streamedText的当前值。final class StreamingSkill: AgentSkill, unchecked Sendable { var priority: Int { 50 } private var streamContinuation: AsyncStreamString.Continuation? func canHandle(_ intent: AgentIntent) - Bool { intent .sendMessage(__internal_marker__) } }实际实现里我通过一个AgentEffect.appendDelta(String)不断把增量内容追加到 context这样 SwiftUI 会按 diff 自动更新文本显示区域。注意设置文本更新频率过快的增量更新会触发大量视图刷新实测每隔 80~120 毫秒聚合一批增量比较流畅低于 50 毫秒时性能会在低端机型上明显下降。5.2 取消流的生命周期控制流式任务最怕用户退出页面后回调还在跑。我的做法是在execute内部创建AsyncStream并且检查Task.checkCancellation()一旦任务取消立刻结束流func execute(...) async throws { let stream AsyncStreamString { continuation in streamContinuation continuation } for await delta in stream { try Task.checkCancellation() next(.appendDelta(delta)) } }Agent 的cancelAll()会取消 currentTask这个任务取消信号能自动级联到技能内部创建的异步流迭代中。我在测试时验证过页面 pop 后后台请求会在大约 300 毫秒内被终止不会再产生额外写盘或网络流量。5.3 把发送消息也变成 Skill发送消息这个动作也适合做技能。SendMessageSkill负责把用户输入追加到本地消息列表乐观更新、调用接口、然后根据接口结果决定是保留还是撤回那条消息。视图只需要调用agent.handle(.sendMessage(text))完全不用关心底层是乐观更新还是等待结果后再插入这在聊天类应用里非常省心。6. 常见问题与排查心得6.1 技能不执行页面一直停在空闲状态最常遇到的问题是canHandle里的意图匹配写错了。比如发送消息的意图是.sendMessage(String)技能里却匹配了.sendMessage不带关联值Swift 会把这种模式匹配当成“忽略关联值”的通配造成逻辑混乱却编译通过。排查方法是在route方法开头加一行日志打印当前意图再打技能列表一眼就能看到问题。另一个容易忽略的点是技能数组的排序。如果两个技能都canHandle同一个意图只有排序靠前的会生效。我在init里做sorted(by: { $0.priority $1.priority })之后没有再重新排序过如果技能是后期通过类方法动态添加的记得统一由 Agent 内置的addSkill方法管理不要在外部直接改数组。6.2 视图刷新异常加载状态一直转圈这种情况基本上都是 Context 在非主线程被改写导致的。Skill 里的execute是异步方法如果在网络回调的 GCD 队列里直接调next(.setLoading(...))SwiftUI 不一定在主线程刷新表现就是卡在转圈不消失。我的经验是所有 Effect 在next进入时用MainActor.assumeIsolated保护或者直接把AgentContext的reduce方法标记为MainActor这样编译器会在非主线程调用时给出警告从源头避免问题。更隐蔽的问题是reduce方法里修改了 context 的数组却忘了把 phase 切回.loaded。比如追加消息后如果继续保持.loading状态视图会一直在 ProgressView 分支内容区不会渲染。我在reduce(.appendMessages)里显式设置了context.phase .loaded这个动作一定不能漏。6.3 页面被提前释放回调里访问不到 agent如果你在handle方法里用Task { ... }而没加[weak self]那么即使页面销毁了Agent 也一直活着导致不必要的网络请求继续跑。但如果加了[weak self]后回调里访问self又会遇到可选绑定。我的写法是Task { [weak self] in guard let self else { return } await self.perform(intent) }不过要注意guard let self只是把 self 强引用一次不是持续强引用。perform 内部如果出现新的续体闭包一样要注意是否二次捕获 self。最保险的做法是内部只使用局部常量不直接持有 selfawait self.perform(intent)这种写法整体可控目前没有在页面销毁后出现过崩溃。6.4 单测 Agent 的注意事项把这套结构跑通后一个让我安心的点是 Agent 可以完全脱离 SwiftUI 做单元测试。我建了一个AgentTests测试类直接创建带 Mock Skill 的 Agent然后发送 Intent 并等待一段时间断言 context 是否符合预期。Mock Skill 里我会用XCTestExpectation标记技能调用次数确保流程只执行了一次再配合一个会抛出错误的 Skill 测试失败分支。这里有个细节如果期望在真实任务里覆盖不确定延迟直接sleep(0.5)会让测试很慢且不稳定我选择在测试里用一个默认返回结果的 Mock 服务让异步任务在下一帧就完成整个测试套件跑下来不到两秒。7. 这套思路后续还能往哪走我在模拟项目 X 里继续扩展了三件有意思的事一是把 Agent 的完整状态历史记录成 JSON页面出问题时可以直接导出一份时间线给同事对照排查二是给 Skill 增加了可配置的开关比如空态文案可以按页面主题覆盖不用为每个页面重新建技能三是尝试把多个页面的 Agent 用一颗 Agent 树管理起来父级只负责权限和导航子级管理具体数据这个方向还在实验中。最近我在琢磨的是把 Skill 的定义再抽象一层做成类似插件系统的东西让技能可以在 App 启动时从本地配置动态加载。这样发布新交互能力时可以不走整包更新。虽然 SwiftUI 动态化能力有边界限制但技能模块本身是纯 Swift 对象动态下发完全可行只是需要额外解决签名校验和版本兼容问题。把这套东西沉淀下来之后最大的收益倒不是代码变少了而是排查问题的范围一下子变得非常明确。视图渲染出了问题去看 view 层交互状态出了问题去看 Agent 的日志某个能力不好用了单独测对应的 Skill。脑子不用装整个页面的多份副本一个页面对应一张清晰的流程图。对我这种做了几年 iOS 开发的人来说这种“能迅速定位问题在哪”的爽快感是最值钱的。