SwiftUI+Agent+Skill:打造iOS端可插拔AI技能调度框架 SwiftUI-Agent-Skill 是我在 iOS 端做 AI Agent 工具链时沉淀的一套技能调度框架。简单说它把 Agent 的“能力”拆成一个个独立 Skill然后用 SwiftUI 原生视图做注册、展示、参数配置和执行状态跟踪。做这个东西的起因很直接之前接到一个项目需要在手机端跑一个可插拔的智能助理服务端会动态下发各种技能比如查天气、定闹钟、生成摘要但客户端如果每个技能都写一套 if-else 页面就完全没有扩展性发版节奏根本跟不上。于是我把技能组件化用 SwiftUI 统一驱动折腾出了这套方案。这篇文章适合正在做 iOS/macOS 端 AI 应用、想把工具调用变成可复用模块的开发者如果你只是想给现有 App 加几个“小助手”功能也能从这里找到一套现成的思路。1. 为什么需要一套“SwiftUI Agent Skill”的组合框架1.1 传统 Agent 技能模块在客户端侧的尴尬先聊聊我之前遇到的场景。服务端的 Agent 会返回一个技能列表客户端拿到后要渲染成可点击的卡片用户点进去、填参数、调用、展示结果。第一次实现我用了非常直接的办法服务端下发一个skillType字段客户端 switch 分发到不同页面。当时只有五个技能开发还算顺利。但第二个迭代就出问题了。技能数量到十几个每个技能的输入参数都不一样有的需要日期有的需要位置有的只是“是/否”开关执行状态也各不相同有秒回的、有流式返回的、还有需要轮询进度的。我不得不维护一堆状态标记比如weatherFetching、reminderUpdating、summaryGenerating每个技能一个布尔值页面逻辑满天飞。最难受的是产品说“下个月要支持五个新技能”这意味着客户端要预留发版、审核、灰度一个技能从后端定义到用户可见周期至少两周。这其实是很多客户端 Agent 项目都会踩的坑服务端把 Agent 的能力抽象得很好但客户端依然用“硬编码页面”来消费能力。技能的本质是一段可执行的逻辑加一个可见的交互入口在任意客户端框架下都应该用插件体系去承载而不是用 switch-case 去堆。1.2 把技能当成可插拔插件后来我把思路彻底换掉不再根据skillType去分发页面而是给每个技能实现一个统一协议包括元信息、参数声明、执行入口、结果输出。客户端启动或者用户触发技能市场时把这些技能注册到一个 Registry 里SwiftUI 列表只需要ForEach(registry.skills)然后渲染每个技能自带的面板视图。新增技能不需要改宿主 App 的任何界面代码只需要新增一个符合协议的 Skill 实例并注册进去剩下的事情由框架完成。这个方案的类比其实很好理解就像手机桌面的小组件。每个小组件都遵守系统定义的结构系统只需要负责排布和刷新小组件自己决定展示什么内容、响应用户交互。Skill 也是一样框架负责生命周期和调度技能自己负责参数校验、执行和结果展示。后面我会展开讲协议怎么设计、类型擦除怎么处理、SwiftUI 侧如何动态渲染这些都是我反复改过的痛点。2. 技能契约设计Skill 协议与类型擦除2.1 Skill 协议的定义与版本选择协议是整个框架的地基。我一开始设计时参考了常见工具调用协议的字段但发现客户端需要的东西更多服务端只要 function name、description、parameters JSON Schema 就够了客户端还要图标、分组、输入控件类型、执行 UI、结果 UI。所以我把协议拆成两层一层是纯元数据用来支撑列表展示和参数表单生成另一层是执行接口负责实际跑逻辑并产出可展示的状态。下面是简化后的核心定义/// 技能输入参数遵循 Codable 以便从 JSON Schema 自动转换 protocol SkillParam: Codable, Hashable { var description: String? { get } } /// 技能执行结果携带可展示的文本与结构化数据 struct SkillOutput: Sendable { var summary: String var detail: Data? var contentType: SkillContentType } /// 技能协议所有扩展技能必须遵守 protocol Skill: Identifiable { var id: String { get } var displayName: String { get } var subtitle: String { get } var systemImage: String { get } var category: SkillCategory { get } var version: String { get } var author: String { get } /// 需要用户填写的参数类型默认为空参数 associatedtype Param: SkillParam EmptyParam /// 参数描述用于自动生成表单 var parameterSchema: SkillParameterSchema { get } /// 执行入口 func execute(input: Param, context: SkillContext) async throws - SkillOutput /// 技能对应的 SwiftUI 配置视图默认根据 schema 自动生成 ViewBuilder func configurationView(param: BindingParam) - AnyView }协议用了associatedtype这是第一个要慎重的地方。它保证了每个技能在编译期对参数类型安全但同时也让协议在数组和集合里没法直接使用因为Skill变成了带关联类型的协议不能写[any Skill]。我的做法是保留associatedtype但把整个协议约束在“泛型上下文”里对外暴露的集合使用类型擦除包装类型这个后面会细说。SkillParam只是个标记协议实际参数结构由各个技能自行定义。以“定时提醒”技能为例参数大概是这样struct ReminderParam: SkillParam, Sendable { var title: String var date: Date var repeats: Bool var note: String? var description: String? { 在指定时间发送提醒 } }这个设计的好处是技能可以非常自然地定义自己的输入模型而且天然支持 Codable后面接服务端下发配置或者持久化都很方便。2.2 泛型与 any Skill 的取舍项目做到第二周我遇到了一个绕不开的问题技能列表要同时展示“天气技能”“提醒技能”“摘要技能”它们的参数类型互不相同底层的[Skill]数组怎么存第一版我天真地用了[any Skill]然后 Swift 编译器只给了我一个“protocol ‘Skill’ can only be used as a generic constraint because it has Self or associated type requirements”。这个报错应该让很多做过协议泛型的开发者头疼过。解法有两个方向把associatedtype去掉所有参数统一转成Data或[String: Any]在运行时解析。保留强类型但引入一个类型擦除包装器AnySkill。我选了第二条路保证正常代码路径上的类型安全同时为需要统一调用的边缘场景准备擦除接口。类型擦除包装器的关键实现struct AnySkill: Identifiable, Hashable { let id: String let displayName: String let systemImage: String let category: SkillCategory private let _execute: Sendable (Data, SkillContext) async throws - SkillOutput private let _decodeParam: Sendable (Data) throws - any SkillParam initS: Skill(_ skill: S) { self.id skill.id self.displayName skill.displayName self.systemImage skill.systemImage self.category skill.category self._decodeParam { data in try JSONDecoder.skill.decode(S.Param.self, from: data) } self._execute { data, context in let param try JSONDecoder.skill.decode(S.Param.self, from: data) return try await skill.execute(input: param, context: context) } } func decodeParam(from data: Data) throws - any SkillParam { try _decodeParam(data) } func execute(paramData: Data, context: SkillContext) async throws - SkillOutput { try await _execute(paramData, context) } }写完这个包装器技能中心终于可以拿出一个干净的统一数组去驱动 SwiftUI 列表。同时我用Hashable让技能可以在 NavigationLink 的值路径中传递。执行时不管技能参数具体是什么类型在框架层统一变成 Data到了技能内部再解码成强类型这就是“类型安全优先统一调度兜底”的平衡。2.3 参数模型与 JSON Schema 的双向转换参数表单是 SwiftUI 自动生成 UI 的关键。我实现了一个轻量的ParameterSchema结构它描述每个字段的类型、默认值、取值范围和校验规则跟服务端下发的 JSON Schema 类似但裁剪到客户端表单够用的程度struct SkillParameterSchema: Codable, Hashable { struct Field: Codable, Hashable { var key: String var label: String var type: FieldType var isRequired: Bool var defaultValue: CodableValue? var allowedValues: [CodableValue]? var placeholder: String? } var fields: [Field] var order: [String] }FieldType我定义了 text、number、date、time、toggle、selection、location 这几种足够覆盖绝大多数 Agent 工具参数。SwiftUI 视图根据这些字段自动构建表单不需要每个技能手写输入页。当然如果某个技能有特殊交互它也可以覆盖configurationView提供完全自定义的页面。这一步解决了产品经理最关心的“新技能上线不用发版”的问题服务端更新技能元数据客户端注册新 Skill参数的 schema 驱动 SwiftUI 表单全部动态生成。这里也埋了一个后续会聊的坑动态表单生成的输入值与强类型参数之间的转换处理不当很容易在运行时崩。到时候我在常见问题里会讲。3. 注册中心与调度器实现3.1 全局注册与技能列表构建有了协议和擦除类型接下来要解决“技能从哪来”的问题。我选择了最传统的注册中心模式一个全局单例SkillRegistry提供register方法App 启动时调用各技能模块的registerAllSkills()注入。这样技能模块之间保持独立宿主 App 不需要关心具体技能类。MainActor Observable final class SkillRegistry { private(set) var skills: [AnySkill] [] private var storage: [String: AnySkill] [:] static let shared SkillRegistry() func registerS: Skill(_ skill: S) { let erased AnySkill(skill) guard storage[erased.id] nil else { assertionFailure(重复注册技能: \(erased.id)) return } storage[erased.id] erased skills.append(erased) } func skill(id: String) - AnySkill? { storage[id] } func skills(in category: SkillCategory) - [AnySkill] { skills.filter { $0.category category } } }注册中心放在MainActor里主要原因是 SwiftUI 的列表读取、卡片排序、搜索结果复用 UI 状态主线程操作最省心。但这里有个问题技能注册如果只是 App 启动时一次性完成那确实安全要是后续支持“技能市场”远程加载新技能注册动作可能发生在任意线程。我的答案是把注册中心拆成两层当前展示用的 registry 在主线程刷新底层数据源用 actor 做线程安全。后者涉及调度器我们继续往下看。3.2 深链与路由绑定我的 App 用 NavigationStack 做导航技能入口散落在列表分类区和“最近使用”区。为了让任意位置都能跳转到同一个技能详情页我把路由值定义成技能 id 加一个可选参数包struct SkillRoute: Hashable { var skillID: String var payload: Data? } struct SkillDetailView: View { let skillID: String State private var paramData: Data? var body: some View { if let skill SkillRegistry.shared.skill(id: skillID) { SkillRunnerView(skill: skill, initialParamData: paramData) } else { ContentUnavailableView( 技能不可用, systemImage: questionmark.circle, description: Text(该技能未注册或已被移除) ) } } }深链绑定我用了标准的 URL Scheme比如myapp://skill/weather。App 在onOpenURL里解析出技能 id再转换成SkillRoute压入 navigationPath。这样桌面快捷指令、Push 通知、扫码跳转都能直达指定技能不需要先到首页再层层点击。这个设计本质上是把“能力入口”从界面层级中解耦。如果你想在 SwiftUI 里做 Agent 技能市场或者复杂面板路由值建议一开始就设计成纯数据的结构不要塞视图实例否则后面加深链、加状态恢复都会痛苦。3.3 调度器的并发控制技能执行不是 UI 线程的简单同步调用。典型流程是用户点“执行”技能先初始化上下文然后调远端接口拿到数据后解析、处理最后把结果放回界面。这里涉及网络、文件读写、可能的磁盘缓存。为了保证技能之间互不卡死我实现了一个SkillDispatcher它用 actor 隔离并发任务同时给每个技能一个可控的运行超时actor SkillDispatcher { private var runningTasks: [String: TaskSkillOutput, Error] [:] private var timeouts: [String: Duration] [:] func run( skill: AnySkill, paramData: Data, context: SkillContext ) async throws - SkillOutput { let id skill.id let task Task { try await withThrowingTaskGroup(of: SkillOutput.self) { group in group.addTask { try await skill.execute(paramData: paramData, context: context) } group.addTask { try await Task.sleep(for: self.timeouts[id] ?? .seconds(30)) throw SkillError.timeout(id: id) } let output try await group.next()! group.cancelAll() return output } } runningTasks[id] task defer { runningTasks.removeValue(forKey: id) } return try await task.value } func cancel(skillID: String) { runningTasks[skillID]?.cancel() } }用 actor 的原因很直接多个技能同时执行时runningTasks这个字典不能有数据竞争。Swift actor 给这个操作提供了编译期保障省掉了我以前手动加锁、死锁排查的活儿。超时任务组里我故意用Task.sleep当作“定时炸弹”到点就取消整个组避免远端接口因为网络问题永久挂起。调度器对 UI 侧返回的是SkillOutput但实际执行过程通常有多个阶段比如“准备参数”“调用服务”“解析结果”“渲染完成”。这些阶段我用 AsyncStream 暴露给 SwiftUI让界面可以实时展示状态。这一步非常有用后面讲 SwiftUI 接入时会看到具体代码。4. SwiftUI 接入技能卡片、执行面板与状态流4.1 动态技能列表渲染框架的核心价值最终要落到 SwiftUI 的展示上。列表部分我用了最朴素的ForEach加NavigationLink(value:)所有技能卡片样式统一但内容由技能自身决定。卡片中间会显示技能的系统图标、名称和一句话描述右下角保留一个“运行/配置”按钮。struct SkillListView: View { State private var registry SkillRegistry.shared State private var selectedSkillID: String? var body: some View { List { ForEach(registry.skills) { skill in SkillRow(skill: skill) .contentShape(Rectangle()) .onTapGesture { selectedSkillID skill.id } } } .navigationDestination(item: $selectedSkillID) { skillID in SkillDetailView(skillID: skillID) } } }因为registry.skills是[AnySkill]它不需要知道具体类型直接遍历渲染。卡片里面还能根据 category 给图标加颜色按“实用工具”“效率”“生活”分组。SwiftUI 的List天然支持分组样式我把skills(in:)拿到的数组按 category 组织成 Section这样用户不用在一长串技能里翻找。这里的体验细节是任何新增技能如果只提供元数据它也会立刻出现在列表里。因为列表不关心技能怎么执行只关心元数据是否完整。这意味着“新技能上线”在客户端的最小成本几乎为零只要注册进去就完事。4.2 执行状态与日志控制台列表演示简单真正的复杂度在执行面板。每个技能有自己的配置表单点击执行后需要显示进度、日志、结果。我做了一个通用的SkillRunnerView接收AnySkill和初始参数内部维护状态机。struct SkillRunnerView: View { let skill: AnySkill State private var phase: SkillRunPhase .idle State private var logs: [SkillLogEntry] [] State private var output: SkillOutput? State private var task: TaskVoid, Never? var body: some View { ScrollView { VStack(alignment: .leading, spacing: 16) { SkillParameterForm(skill: skill) SkillRunButton { start() } SkillStatusView(phase: phase) ForEach(logs) { log in LogLine(log: log) } if let output { SkillOutputView(output: output) } } .padding() } .navigationTitle(skill.displayName) .onDisappear { task?.cancel() } } private func start() { task?.cancel() phase .preparing logs [] task Task { MainActor in let stream skill.makeExecutionStream(paramData: currentParamData) for await event in stream { switch event { case .phaseChanged(let newPhase): phase newPhase case .log(let line): logs.append(line) case .finished(let output): self.output output phase .finished } } } } }这个页面把“参数修改、执行、日志、结果”放在一屏内。日志用不可变数组追加简单可靠如果日志量大后续可以迁移到UICollectionView或者表格视图不过一般技能不会有特别密集的输出数组足够。这里有一个我踩过的坑onDisappear里取消任务只是Task的取消协作技能内部的网络请求得监听Task.isCancelled才能及时中断。如果技能网络库不支持取消用户退出页面后请求还会继续跑甚至回来时看到上一次结果。后来我把所有技能的执行入口约定为“必须接受SkillContext其中包含 cancellation 标记”强制技能实现协作取消这个问题才算解决。4.3 可配置面板的自动生成参数表单是整个框架中 SwiftUI 复用度最高的部分。我根据parameterSchema遍历字段动态生成对应的输入控件struct SkillParameterForm: View { let skill: AnySkill State var paramObject: CodableValue .object([:]) var body: some View { VStack(alignment: .leading, spacing: 14) { ForEach(skill.parameterSchema.order, id: \.self) { key in fieldView(schema: skill.parameterSchema.field(key)) } } } ViewBuilder private func fieldView(schema: SkillParameterSchema.Field) - some View { switch schema.type { case .text: TextField(schema.label, text: binding(for: schema.key)) case .toggle: Toggle(schema.label, isOn: binding(for: schema.key)) case .selection: Picker(schema.label, selection: binding(for: schema.key)) { Text(请选择).tag(CodableValue.null) ForEach(schema.allowedValues ?? []) { value in Text(value.stringValue).tag(value) } } case .date: DatePicker(schema.label, selection: binding(for: schema.key)) // 其他类型类似 } } }表单和具体技能是解耦的任何技能只需要提供 schema 就能自动获得可用的配置页。如果技能的参数特别复杂比如地图选点、文件上传就在技能内部提供自定义configurationView框架优先使用自定义视图没有自定义才用自动表单。这种“约定优先扩展兜底”的思路非常适合做 Agent 技能框架。我见过很多方案尝试用 JSON 完整描述 UI最后都变成了自己造了一个 SwiftUI 解释器维护成本极高。而我这里只是用 schema 描述“最基础的输入控件”复杂交互直接回到 SwiftUI 原生实现简单又可靠。5. 常见问题与排查技巧5.1 类型擦除后的参数丢失使用AnySkill会遇到一个很隐蔽的问题技能内部明明定义了ReminderParam但擦除后的技能在执行时拿到的参数对象跟预期类型对不上。最常见的表现是从深链或者本地持久化恢复参数时把 JSON Data 交给execute但解码失败于是整个流程静默失败或者弹一个难看的错误。我排查下来根源基本都在_decodeParam的闭包写死了具体类型。比如某次我把S.Param.self误写成了另一个相近的S.Param?可选类型与 JSONDecoder 的泛型会有细微差异。解决办法是用真值源做回归测试每个技能注册时同时注册一个空参数样本、一个完整参数样本调度器启动时跑一遍“解码-执行”把错误时间提前到编译测试阶段不要让用户在生产环境遇到。另外还有一个方向如果技能参数特别复杂可以考虑把Param的 Codable 实现从自动合成改成手写用decodeIfPresent给每个字段做兜底。Swift 的自动合成对缺字段是直接抛错的手写后你就可以用默认值容忍缺参对旧版本缓存数据更友好。5.2 SwiftUI 预览中依赖注入为空这个坑出现频率最高。我项目里很多技能在执行前要拿用户偏好、登录态、位置权限这些数据都来自一个全局服务容器。正常的运行流程没问题但当我打开 SwiftUI 预览时容器是个空壳技能列表一片空白或直接崩掉。解决办法是在预览 provider 里做显式注入#Preview(Skill List) { SkillListView() .environment(\.skillServices, SkillServices.preview) }这里我给 Skills 相关的 environment key 定义了一个预览版本里面预置了假的偏好数据、假的位置管理器所有技能在预览中只能使用这些假数据。这个技巧不仅让预览恢复可用还倒逼每个技能在写的时候不直接访问全局单例而是从 context 取依赖反而让整个框架的依赖关系更清晰。如果你也遇到预览时技能界面空白先检查是不是技能内部直接拿到了主容器再查容器在预览里有没有初始化。5.3 并发环境下注册时点崩溃Skill 执行时execute是 async但要更新注册中心缓存、写入最近使用记录这些操作可能发生在后台线程。我第一次把注册中心的register方法放在主线程技能执行却用Task.detached结果在高频调用时出现主线程卡顿和偶发崩溃。后来我把状态拆开纯函数计算后台做UI 可见状态只在主线程提交。比如“最近使用”列表技能执行完成后的统计逻辑全部放在 actor 里算最终只把[AnySkill]的 id 数组传回主线程。Swift 5.5 之后的 actor 隔离在编译期就能守住这条线项目崩溃率明显下降。如果你在用Observable和MainActor记得给类和它的公开方法显式标注MainActor不要只靠实例的默认隔离否则子模块容易在不知情的情况下把调用抛到后台线程。5.4 后台刷新限制移动端 Agent 技能经常会遇到一个场景用户锁屏或者切到后台技能还在调用远端接口、处理数据。iOS 对后台执行有明确限制普通 App 在退到后台短时间内还能继续跑但超过限制后进程会被挂起。我给出的方案是分技能类型处理耗时极短的技能几秒内直接在主进程继续跑耗时长的任务比如生成摘要、批量整理文件提前用BGTaskScheduler注册后台任务技能执行前先判断是否具备后台运行条件否则提示用户保持 App 前台。千万不要在技能框架内部自己拉一个后台线程去“无限续命”这既不优雅也会被系统误伤。实测下来大部分 Agent 技能都能压到 30 秒内真正需要后台长时间跑的交给系统调度最稳妥。5.5 排查问题速查表现象可能原因处理方式技能列表空白SkillRegistry 未注册或 register 顺序错误启动时调用registerAllSkills()并在 view 中检查skills.count执行后面板无反应参数解码失败丢弃了错误在execute闭包外层 catch并在 UI 显示错误日志表单日期控件值错乱CodableValue 对 Date 序列化格式不一致统一用 ISO8601 编码并做时区归一化深链跳转找不到技能技能 id 大小写不一致注册和路由解析统一使用小写 id预览崩溃技能访问全局容器未初始化引入 preview services 环境覆盖这几条都是我实际项目中修过的问题。整理成表是想让你在遇到类似情况时能快速定位不用再从堆栈里慢慢猜。6. 项目扩展与真实落地建议6.1 与 SwiftData 持久化结合技能框架可以稳定跑起来后我给它加了一层“最近使用”和“用户偏好”的持久化。最近使用列表非常适合用 SwiftData 存储因为它需要频繁写入、按时间排序而且数据量很小。我给 SwiftData 定义了一个RecentSkillRecord字段包含技能 id、最后使用时间、使用次数、最后一次参数快照。技能每次执行成功就写一条新记录首页会读取这批记录按使用次数倒序展示。参数快照用 Data 保存再次点击“最近使用”时直接把快照填进表单用户可以快速重跑不用重新填参数。这个扩展看起来简单但它对 Agent 应用的用户留存非常关键。工具型 AI 应用最烦的就是每次都要重新配置上下文SwiftData 能把用户的操作路径记忆下来副作用是数据库模型变更时需要做迁移。好在新版 SwiftData 的迁移还算顺滑复杂场景可以配合版本化方案处理。6.2 技能市场与远程动态下发如果你想把技能框架做成一个“平台”就需要考虑怎么远程下发新技能。我建议的方案是技能包本身还是一段 Swift 代码但编译成独立的 framework 或者动态库由服务端下发二进制包和签名客户端校验后动态加载。iOS 对动态加载的限制比较多实际上更稳妥的做法是服务端下发技能的元数据、JSON Schema 和远端执行地址客户端只新增一个轻量 shell 技能负责把参数发给远端服务执行并展示结果。这样“新增技能”的动作就变成了配置数据而不是下载二进制安全性和审核风险都低很多。这个方案的局限是远端技能的网络延迟和状态感知不如本地技能但胜在更新灵活。我实际落地时通常会把技能分成两类高频、强交互、涉及隐私的本地技能低频、可标准化、纯工具类的远端技能。这样用一套框架兼顾两套执行路径。6.3 在 Widget / Shortcut 中的延伸SwiftUI-Agent-Skill 如果停留在 App 内部价值还是有限。我后来把核心技能封装成AppIntent让它们能被快捷指令和小组件调用。接入方式不复杂为每个技能生成一个对应的AppIntent它读取标准参数然后调用SkillDispatcher执行。好处是用户可以在快捷指令里拼装“每天早晨获取天气并发送通知”这种自动化流Agent 的技能不再只存在于 App 里而是成为系统级的能力。小组件方面我用 WidgetKit 做一个“一键执行”按钮用户点击后打开 App 内部的对应技能页。Widget 不能直接运行复杂网络任务所以最好的交互是占位按钮加深链跳转。这个点其实很契合 Agent 的使用习惯用户想要的是最短路径而不是打开 App 后再翻三层菜单。7. 写在最后一些个人体会花了两周把 SwiftUI-Agent-Skill 跑通又用了一个多月在真实项目里打磨我的一个强烈体会是SwiftUI 和 Agent 的结合难点不在 SwiftUI 的动画或 List 性能而在如何把“能力边界”和“UI 边界”对齐。技能不是简单的按钮回调它自带参数、执行状态、输出结构UI 也不是死板的表单页它需要跟着执行过程动态变化。最稳定的方案就是在协议层面把这些动态性全部表达清楚然后交给 SwiftUI 去驱动。如果让我给刚起步的人一个建议先做一个最小闭环比如一个文本摘要技能、一个天气查询技能把 Skill 协议、注册中心、SwiftUI 动态列表、参数表单、执行日志跑通再逐步扩展深链和持久化。不要一开始就设计一个完整的技能市场或远程动态下发那会把你拖进无穷无尽的配置问题和兼容性问题中。最后分享一个我到现在还在用的习惯每个 Skill 在开发时我会单独为它写一个#Preview用固定参数模拟执行结果这样业务开发时可以完全不依赖真实网络状态快速调整 UI。这个习惯不复杂但对 SwiftUI Agent 这种“界面频繁跟随执行状态变化”的项目来说效率提升真的非常明显。希望这篇文章能帮你绕开我踩过的那些坑。