news 2026/10/30 6:03:02

SwiftUI架构实践:Agent-Skill模式让状态管理更清晰

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SwiftUI架构实践:Agent-Skill模式让状态管理更清晰

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、.cancel;Effect 表示执行后产生的“副作用效果”,比如.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 Context:Agent 暴露给视图的唯一快照

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: Task<Void, 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。也就是说“编排”集中在 Agent,Skill 只做能力单元。

举个例子,NetworkSkill请求成功后返回.appendMessages(list)和.finishLoading,Agent 收到这两个 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: Task<Void, 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 想继续快照后续技能,可以在自己内部返回一个.fallthroughEffect,Agent 在 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: AsyncStream<String>.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 = AsyncStream<String> { 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。最保险的做法是内部只使用局部常量,不直接持有 self:

await 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 开发的人来说,这种“能迅速定位问题在哪”的爽快感,是最值钱的。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/21 16:38:32

Unity UI点击检测:用EventSystem.RaycastAll精准获取被点击的UI元素

在Unity里处理UI点击&#xff0c;很多人第一反应是给Button挂一个onClick监听就完事了。但实际项目里总有那么些时候&#xff0c;你需要反过来问一句&#xff1a;我这一鼠标点下去&#xff0c;究竟点到了哪个UI元素&#xff1f;这个需求听着基础&#xff0c;真正动手做却发现网…

作者头像 李华
网站建设 2026/10/25 8:31:20

Paperclip多代理组织架构:从单体智能到组织智能的实操指南

1. 从单体智能到组织智能&#xff1a;Paperclip 到底在解决什么问题第一次看到 Paperclip 这个项目标题的时候&#xff0c;我脑子里蹦出来的不是回形针&#xff0c;而是一个很具体的画面&#xff1a;一个 AI 代理在干活&#xff0c;旁边还有另一个 AI 代理在给它派活&#xff0…

作者头像 李华
网站建设 2026/10/23 13:52:14

小学生也能懂的AI入门第一课:用生活类比讲透训练、推理与提示词

1. 为什么“小学生也能懂”才是AI入门最难的部分我做了十多年技术布道和科普内容&#xff0c;带过的大学生、职场转行者、甚至一些企业内训学员加起来少说也有几千人。这些年我最大的感受是&#xff1a;把AI讲复杂很容易&#xff0c;讲简单反而最难。你随便翻开一本深度学习教材…

作者头像 李华
网站建设 2026/10/20 17:16:24

Java核心术语解析与高效学习指南

1. Java编程语言单词汇总的必要性 作为一门面向对象的编程语言&#xff0c;Java拥有庞大而严谨的术语体系。从基础语法到高级特性&#xff0c;每个专业词汇都承载着特定的技术含义。我在实际教学中发现&#xff0c;很多初学者在阅读文档或技术文章时&#xff0c;常常因为不理解…

作者头像 李华
网站建设 2026/10/23 13:45:00

AnyPS5:PS5固件在非原生硬件上的可信执行实践

1. “AnyPS5”不是产品代号&#xff0c;而是开发者社区里一个隐秘的共识性称呼 最近在几个硬核技术论坛和跨平台开发群组里&#xff0c;“AnyPS5”这个词频繁出现在讨论帖标题和代码注释中。它既不是索尼官方发布的型号&#xff0c;也不是某家厂商注册的商标&#xff0c;更不是…

作者头像 李华
网站建设 2026/10/29 12:48:13

不止淡入淡出:keyframeAnimateTo 做一个多阶段点赞动画【鸿蒙心迹】

大家好&#xff0c;我是[晚风依旧似温柔]&#xff0c;新人一枚&#xff0c;欢迎大家关注~ 本文目录&#xff1a;前言一、为什么不用一个 animateTo 直接做完二、先把官方规则弄清楚三、搭一个最小点赞场景四、核心代码实现五、三个阶段到底是怎么连接起来的1. duration 是“这一…

作者头像 李华