首页 / 资讯中心 / 文章详情

SwiftUI Skill:纯本地可组合式异步能力封装体系

SwiftUI Skill:纯本地可组合式异步能力封装体系 ★ FEATURED ARTICLE
1. 项目概述这不是一个“AI Agent”而是一套 SwiftUI 原生技能封装体系“SwiftUI-Agent-Skill”这个标题乍看容易让人联想到当前泛滥的“AI Agent”概念——大模型调度、工具调用、自主规划……但实际拆解下来它完全不涉及任何大语言模型、远程API调用或后台服务编排。它是一个纯前端、纯本地、纯 Swift 实现的可组合式交互能力抽象层核心目标非常务实解决 SwiftUI 开发中反复出现的三类高频痛点——状态驱动的异步操作封装难、多步骤用户流程如表单提交、配置向导、权限引导难以复用、以及 UI 与业务逻辑耦合导致的测试与维护成本高。我第一次在某跨平台系统重构中遇到这个问题当时要为 7 个不同页面实现“带加载态错误重试成功跳转”的网络请求流程。每个页面都 copy-paste 一套StateTask.task { } 手动管理 loading/error/success 状态光是状态枚举就写了 4 种变体。后来团队里某开发者提出“能不能把‘发起请求’这件事本身变成一个可声明、可组合、可测试的‘技能’”——这正是 “SwiftUI-Agent-Skill” 的起点。它不追求智能只追求确定性、可预测性与可组合性。关键词里的 “Agent” 并非指代 AI 主体而是取其“代理行为执行者”本义它代理你完成一段有始有终、有状态、有副作用的 UI 相关任务“Skill” 则强调其可插拔、可复用、可声明式调用的特性。它适合所有正在用 SwiftUI 构建中大型应用的开发者尤其是那些已踩过“状态管理泥潭”、正被“重复逻辑拷贝”拖慢迭代速度的团队。如果你还在用StateObject包裹一堆async/await调用、手动切换 loading 提示、写大量 guard-let 解包逻辑那么这套设计思路能直接帮你砍掉 60% 以上的胶水代码。2. 整体设计与思路拆解为什么放弃“ViewModel StateObject”老路2.1 核心矛盾SwiftUI 的声明式哲学 vs. 异步操作的过程性本质SwiftUI 的核心范式是“状态驱动视图”即View是State的函数View f(State)。但真实业务中用户触发的绝大多数关键动作——比如点击“提交订单”、长按“删除文件”、滑动“刷新列表”——本质上都是有生命周期的过程Process它有明确的起始trigger、中间态loading, validating、可能的分支success/failure/retry和终结navigation, toast, state update。传统方案强行将这个过程“扁平化”塞进State或StateObject结果就是状态爆炸一个简单登录流程需维护isLoggingIn: Bool,loginError: Error?,isLoggedIn: Bool,showSuccessToast: Bool,navigateToHome: Bool至少 5 个独立状态变量副作用失控Task { }块内执行网络请求后更新多个状态变量但这些更新可能因 View 重建而丢失如导航过程中 View 被销毁逻辑不可复用A 页面的登录逻辑无法直接用于 B 页面的注册流程哪怕它们共用同一套 API 客户端。“SwiftUI-Agent-Skill” 的破局点是承认并拥抱“过程”的存在将其从 View 层剥离封装为独立、自洽、可声明式调用的Skill类型。它不是 ViewModel 的替代品而是 ViewModel 的“能力增强器”。2.2 设计选型为何选择MainActorAsyncSequenceObservable组合我们对比了三种主流方案方案技术栈优势关键缺陷是否采用Combine AnyPublisherPublishedflatMapLatest响应式链路清晰错误处理成熟Combine 在 Swift 5.9 已被官方标记为“不推荐新项目使用”且AnyPublisher泛型擦除导致类型安全弱调试困难❌ 淘汰纯 TaskGroup 封装Task { }withTaskGroup无额外依赖轻量无法自然表达“等待过程结束”语义状态同步需手动加锁易出竞态❌ 淘汰ObservableAsyncStreamMainActorObservable class SkillTAsyncStreamResultT, Error✅ 类型安全泛型 T 明确输入输出✅ 生命周期可控AsyncStream可取消✅ 主线程安全MainActor保证状态更新不越界✅ 声明式调用skill.perform()返回Task需手动管理AsyncStream的创建与取消对初学者有学习成本✅ 采用最终选定第三种根本原因在于它完美对齐 SwiftUI 的运行时约束。MainActor不是可选项而是强制要求——所有影响 UI 的状态更新必须在主线程。AsyncStream则提供了最自然的“事件流”抽象一个 Skill 的执行过程本质上就是一连串状态事件.starting,.succeeding(value),.failing(error),.cancelling的流式输出。而Observable让 Skill 自身成为一个可被 View 观察的“活对象”无需额外的StateObject包裹。实测下来这套组合在 iOS 17 设备上内存占用比同等功能的 ViewModel 低 35%且Task.cancel()后资源释放更彻底避免了常见的“僵尸 Task”问题。2.3 架构分层Skill 不是黑盒而是可穿透的“能力管道”整个体系分为三层每层职责清晰绝不越界View 层声明式调用仅负责“告诉 Skill 我想做什么”例如Button(提交) { loginSkill.perform() }。View 不关心 Skill 内部如何执行只订阅其公开状态如loginSkill.isRunning,loginSkill.errorMessage。Skill 层能力抽象LoginSkill: SkillLoginResponse。它持有业务逻辑如调用AuthClient.login()定义执行流程验证 → 请求 → 解析 → 存储 token并通过AsyncStream向外广播状态变更。它不持有任何 UI 相关属性如State、Binding纯粹是数据与流程的容器。Infrastructure 层能力支撑提供Skill基类、SkillResult枚举、SkillExecutor协议等基础设施。其中SkillExecutor是关键——它定义了“如何执行一个 Skill”默认实现是DefaultSkillExecutor在主线程启动Task但允许替换为TestSkillExecutor用于单元测试立即返回 mock 结果或BackgroundSkillExecutor用于耗时操作自动切到后台队列。这种分层让 Skill 具备极强的可测试性。写单元测试时只需注入TestSkillExecutor调用perform()后直接断言AsyncStream输出的事件序列无需启动模拟器、无需等待真实网络延迟。我们为一个包含 4 步校验的支付 Skill 编写了 12 个边界测试用例全部在 0.8 秒内跑完。3. 核心细节解析与实操要点Skill 的四大生命阶段与状态管理3.1 Skill 的基础协议与泛型设计为什么必须是SkillTSkill并非一个具体类而是一个协议其核心定义如下已做脱敏处理MainActor protocol Skill: Observable, Sendable { associatedtype Input associatedtype Output /// 技能执行的唯一标识符用于日志追踪与调试 var id: UUID { get } /// 当前执行状态idle / running / succeeded / failed / cancelled var status: SkillStatus { get } /// 最近一次执行的错误信息仅当 status .failed 时有效 var lastError: Error? { get } /// 执行入口返回一个可取消的 Task func perform(_ input: Input) - TaskVoid, Never /// 取消当前执行如果正在进行 func cancel() }关键设计点解析MainActor强制标注这是硬性安全红线。SwiftUI 的Observable类若未标注MainActor其属性变更可能在后台线程触发导致 UI 更新异常甚至崩溃。我们曾在线上版本中漏标一个 Skill结果在快速连续点击按钮时status状态错乱出现“按钮显示 loading但网络请求早已完成”的诡异现象。补上MainActor后问题消失。associatedtype Input/Output强制 Skill 具备明确的输入输出契约。Input可以是Void无参技能如“刷新列表”也可以是LoginRequest含用户名密码Output则是成功时返回的数据如LoginResponse。这杜绝了“万能 Skill”反模式——那种接受Any输入、返回Any?输出的 Skill后期维护成本极高。id: UUID看似冗余实则关键。当多个相同类型的 Skill 实例同时存在如页面上有 3 个“删除”按钮各绑定一个DeleteSkillFile日志中仅靠类型名无法区分。加入id后Xcode 控制台输出形如[LoginSkill-8a2b] starting...排查问题时效率提升数倍。3.2 状态机实现SkillStatus枚举的精妙设计SkillStatus并非简单的enum Status { case idle, running, success, failure }而是采用更精细的、反映真实用户感知的状态enum SkillStatus: Equatable, CustomStringConvertible { case idle case running(progress: Double?) // progress 可选支持 0.0~1.0 进度条 case succeeded(output: AnyHashable) // AnyHashable 便于 View 层统一处理 case failed(error: Error) case cancelled var description: String { switch self { case .idle: return idle case .running(let p): return running(\(p?.description ?? nil)) case .succeeded: return succeeded case .failed: return failed case .cancelled: return cancelled } } }这个设计解决了两个实际痛点进度反馈的灵活性progress: Double?允许 Skill 在执行中动态报告进度。例如文件上传 Skill 可基于URLSessionUploadTask的countOfBytesSent/countOfBytesExpectedToSend计算实时进度而纯网络请求 Skill 则设为nil表示“无进度只有开始和结束”。View 层通过if skill.status.isRunning判断是否显示 loading 指示器通过skill.status.progress绑定进度条值逻辑清晰无歧义。成功状态的数据携带.succeeded(output:)携带了AnyHashable类型的输出。这里没有用泛型Output是因为 View 层通常不需要强类型访问成功数据它只负责跳转或刷新 UI而AnyHashable既满足Equatable用于Observable状态变更检测又避免了泛型擦除带来的复杂性。实测发现若此处坚持用OutputView 中需为每个 Skill 类型写单独的switch分支来提取数据代码量陡增且易出错。提示SkillStatus的Equatable遵循非常重要。Observable类的状态变更检测依赖于此。我们曾因忘记为progress的Double?添加Equatable支持nil与nil应相等导致进度条在 0.99 到 1.0 之间卡住因为0.99 ! 1.0被视为新状态但 UI 更新被优化掉了。修复方法是在SkillStatus的实现中对Double?做特殊比较abs(lhs.progress ?? 0 - rhs.progress ?? 0) 0.001。3.3 执行流程控制perform()方法背后的AsyncStream机制perform(_:)方法的实现是 Skill 的心脏。以LoginSkill为例其核心逻辑如下func perform(_ input: LoginRequest) - TaskVoid, Never { // 1. 创建 AsyncStream用于广播状态事件 let stream AsyncStreamSkillEvent { continuation in // 2. 立即发送 .starting 事件触发 View 更新 continuation.yield(.starting) // 3. 启动主任务 Task { do { // 3.1 验证输入同步 try input.validate() // 3.2 执行网络请求异步 let response try await authClient.login(input) // 3.3 保存 Token异步 try await tokenStore.save(response.token) // 3.4 发送成功事件 continuation.yield(.succeeding(output: response)) } catch let error as ValidationError { // 4. 特殊错误处理表单验证失败不视为技能失败而是用户输入问题 continuation.yield(.validatingFailed(error: error)) } catch { // 5. 通用错误网络失败、解析错误等 continuation.yield(.failed(error: error)) } // 6. 无论成功失败都发送 .completed 事件关闭流 continuation.finish() } } // 7. 将 stream 与 Skill 状态绑定并返回可取消的 Task return executor.execute(stream: stream) { [weak self] event in guard let self else { return } switch event { case .starting: self._status.wrappedValue .running(progress: nil) case .succeeding(let output): self._status.wrappedValue .succeeded(output: output) case .failed(let error): self._status.wrappedValue .failed(error: error) case .validatingFailed(let error): // 此处可设置特定 UI 状态如高亮错误字段 self._status.wrappedValue .failed(error: error) case .completed: // 流结束状态保持不变 break } } }这个流程的关键在于事件驱动而非状态轮询。View 层不主动“检查”Skill 是否完成而是被动响应status变更。AsyncStream的continuation.yield()是单向、不可逆的事件发射确保了状态流转的确定性。我们曾尝试用PublishedPassthroughSubject替代结果在并发场景下出现事件丢失——因为PassthroughSubject的send()不是线程安全的而AsyncStream.continuation是MainActor保护的天然安全。4. 实操过程与核心环节实现从零构建一个可复用的ImageUploadSkill4.1 需求分析一个真实场景的技能拆解假设我们要为某图片编辑 App 实现“上传选中图片到云端”功能。需求细节包括用户从相册选择 1~5 张图片[UIImage]上传需显示实时进度每张图片上传完成百分比任意一张失败整体视为失败但需记录哪些成功、哪些失败成功后返回云端 URL 数组供后续分享使用失败时需提供重试单张图片的选项。这个需求若用传统方式需在 View 中管理selectedImages,uploadProgress,uploadedUrls,failedIndices,isUploading,retryIndex等至少 6 个状态逻辑交织。而用 Skill 思路我们将其拆解为一个ImageUploadSkill其Input是[UIImage]Output是[String]成功 URL 数组。4.2 Step-by-Step完整代码实现与参数说明第一步定义 Skill 协议符合类MainActor final class ImageUploadSkill: Skill { typealias Input [UIImage] typealias Output [String] ObservationIgnored private let uploader: ImageUploader ObservationIgnored private let progressTracker: ProgressTracker // MARK: - Public Properties (Observable) ObservationIgnored private(set) var id UUID() ObservationIgnored private(set) var status: SkillStatus .idle ObservationIgnored private(set) var lastError: Error? // MARK: - Internal State (Not Observable) private var currentUploadIndex 0 private var uploadedUrls: [String] [] private var failedUploads: [(index: Int, error: Error)] [] init(uploader: ImageUploader DefaultImageUploader(), progressTracker: ProgressTracker DefaultProgressTracker()) { self.uploader uploader self.progressTracker progressTracker } }ObservationIgnored是关键注解它告诉Observable编译器“这个属性我不希望被观察它只是内部实现细节”。uploader和progressTracker是依赖项不应触发 View 重建currentUploadIndex等是瞬时状态也不该暴露给 View。只有id,status,lastError是 View 需要响应的“可观测状态”。第二步实现perform(_:)—— 核心上传流程func perform(_ input: [UIImage]) - TaskVoid, Never { // 输入校验不能为空不能超限 guard !input.isEmpty else { lastError ValidationError(至少选择一张图片) status .failed(error: lastError!) return Task {} } guard input.count 5 else { lastError ValidationError(最多选择 5 张图片) status .failed(error: lastError!) return Task {} } // 创建 AsyncStream let stream AsyncStreamSkillEvent { continuation in continuation.yield(.starting) Task { do { // 逐张上传 for (index, image) in input.enumerated() { // 更新进度已完成 index 张共 input.count 张 let progress Double(index) / Double(input.count) continuation.yield(.running(progress: progress)) // 执行单张上传 let url try await uploader.upload(image) uploadedUrls.append(url) // 记录进度用于 UI 显示单张详情 progressTracker.recordSuccess(for: index, url: url) } // 全部成功 continuation.yield(.succeeding(output: uploadedUrls)) } catch let error as UploadError { // 记录失败 failedUploads.append((index: currentUploadIndex, error: error)) continuation.yield(.failed(error: error)) } catch { // 未知错误 failedUploads.append((index: currentUploadIndex, error: error)) continuation.yield(.failed(error: error)) } continuation.finish() } } // 执行流并绑定状态 return executor.execute(stream: stream) { [weak self] event in guard let self else { return } switch event { case .starting: self.status .running(progress: 0.0) self.lastError nil self.uploadedUrls [] self.failedUploads [] case .running(let progress): self.status .running(progress: progress) case .succeeding(let output): self.status .succeeded(output: output) case .failed(let error): self.lastError error self.status .failed(error: error) case .completed: break } } }第三步View 层集成——声明式调用与状态响应struct UploadView: View { StateObject private var uploadSkill ImageUploadSkill() State private var selectedImages: [UIImage] [] var body: some View { VStack(spacing: 16) { // 图片选择区域略 Button(上传图片) { // 声明式调用只传入数据不关心过程 uploadSkill.perform(selectedImages) } .disabled(uploadSkill.status.isRunning || selectedImages.isEmpty) // 状态反馈 UI if uploadSkill.status.isRunning { ProgressView(上传中..., value: uploadSkill.status.progress) .progressViewStyle(.linear) } if let error uploadSkill.lastError { Text(错误\(error.localizedDescription)) .foregroundColor(.red) } // 成功后显示 URL可选 if case .succeeded(let urls) uploadSkill.status { List(urls, id: \.self) { url in Text(url) .lineLimit(1) } } } .onChange(of: uploadSkill.status) { newStatus in // 全局状态变更钩子可用于埋点或日志 Logger.shared.log(UploadSkill status changed to: \(newStatus)) } } }uploadSkill.perform(selectedImages)是最干净的调用——没有await没有try没有状态管理。View 只负责“触发”和“响应”。disabled(uploadSkill.status.isRunning || selectedImages.isEmpty)利用SkillStatus的计算属性一行代码实现按钮禁用逻辑比手动维护isUploading状态简洁得多。onChange(of:)钩子是 SwiftUI 6.0 新增的高效状态监听方式比旧版onReceive更轻量且能精准捕获status变更。4.3 参数调优与性能实测为什么progress用Double?而非Int在ImageUploadSkill中progress被设计为Double?而非Int如 0~100 的整数。原因有三精度需求上传 5 张图片时Int进度只能是 0, 20, 40, 60, 80, 100共 6 个离散点。但用户感知是连续的——当第 3 张图片上传到 50% 时Double可精确表示为0.4 0.5 * 0.2 0.5而Int只能四舍五入为 50丢失了中间态。UI 渲染友好ProgressView的value参数类型就是Double?直接绑定无需转换。若用Int每次都要Double(intProgress) / 100.0增加无谓计算。扩展性未来若支持“单张图片内部分块上传”Double天然支持亚像素级进度如0.45表示第 3 张图片的 45%而Int需要重新设计范围。实测数据在 iPhone 13 上使用Double?进度的ProgressView渲染帧率稳定在 59.8 FPS与Int版本无差异但用户主观感受更“顺滑”尤其在图片较多时。5. 常见问题与排查技巧实录来自真实项目的 7 个高频坑5.1 问题速查表症状、根因与解决方案症状根因解决方案实操心得Skill 执行后View 状态不更新Skill类未标注MainActor导致status更新在后台线程在Skill类声明前添加MainActor这是最常犯的错误。Xcode 15 会在编译期警告Observable类未标注MainActor务必开启-warn-concurrency编译选项。连续快速点击按钮出现多个并发执行perform()方法未做防重入检查在perform()开头添加guard status ! .running else { return Task {} }不要依赖 UI 层的disabled它只是体验优化。Skill 层必须有原子性保障。AsyncStream未取消导致内存泄漏Task被取消后AsyncStream的continuation未被清理在perform()的Task中监听Task.isCancelled并在取消时调用continuation.finish()我们曾因此导致一个图片浏览页内存占用飙升至 200MB。修复后单次上传后内存回落正常。SkillStatus的Equatable实现不完善导致 UI 卡顿Double?的nil比较未处理或progress精度比较过于严格在实现中对Double?使用abs(lhs - rhs) 0.001判断相等进度条卡顿往往源于此。建议所有涉及浮点数的状态比较都采用容差判断。Output数据在 View 中无法正确提取SkillStatus.succeeded(output:)的output是AnyHashableView 中未做类型转换使用if case .succeeded(let rawOutput) skill.status, let urls rawOutput as? [String]安全解包永远不要用as!强转as?加if case是最安全的模式。单元测试中AsyncStream事件接收不到测试代码未awaitAsyncStream的for await循环或未正确awaitperform()返回的Task在测试中必须await skill.perform(input)然后for await event in stream我们写了一个TestHelper.wait(for: skill, timeout: 5.0)封装内部自动处理Task等待与事件收集大幅提升测试编写效率。Skill 执行时App 进入后台Task 被系统挂起Task默认不支持后台保活挂起后continuation.yield()失效对于必须完成的操作如上传在perform()中调用beginBackgroundTask(withName:)并在continuation.finish()后调用endBackgroundTask()这是 iOS 系统限制无法绕过。但需注意后台时间有限通常 30 秒超时会被系统终止。5.2 独家避坑技巧三个被文档忽略的实战细节技巧一ObservationIgnored的误用陷阱很多开发者认为ObservationIgnored是“性能优化”可以随意加在所有非 UI 属性上。这是危险的。ObservationIgnored的真正作用是标记“此属性变更不会触发 View 重建”。但如果一个被忽略的属性其值被另一个可观测属性的get计算所依赖就会出问题。例如// ❌ 错误示范uploader 被忽略但 status 的 get 依赖它 ObservationIgnored private let uploader: ImageUploader var status: SkillStatus { get { // 这里用了 uploader.someProperty但 uploader 变更不会通知 View return uploader.isBusy ? .running : .idle } }正确做法是要么将uploader设为可观测加Observable要么将status的计算逻辑移到perform()内部由AsyncStream事件驱动。技巧二Task.cancel()的“假成功”现象Task.cancel()并不保证 Task 立即停止。它只是设置Task.isCancelled true真正的退出需由 Task 内部代码主动检查。在ImageUploadSkill的perform()中我们这样处理for (index, image) in input.enumerated() { // 在循环内频繁检查取消状态 if Task.isCancelled { continuation.yield(.cancelled) continuation.finish() return } // ... 上传逻辑 }否则即使用户点了取消Task 仍会继续上传完所有图片只是最后才响应取消——这违背了用户预期。技巧三AsyncStream的continuation.finish()必须调用这是最隐蔽的坑。AsyncStream的continuation必须显式调用finish()否则流永远不会结束for await循环会永远挂起。我们曾在一个网络请求 Skill 中因catch块遗漏了continuation.finish()导致 View 层的onChange永远收不到.completed事件状态卡在.running。修复后在所有yield分支包括catch后都加上continuation.finish()并用 Xcode 的静态分析工具clang开启-Wunreachable-code警告确保finish()不会被遗漏。6. 扩展与演进从 Skill 到 Skill Pipeline 的能力升级6.1 技能组合PipelineSkill实现多步骤串联单一 Skill 解决单点问题但真实业务常需多步串联。例如“用户注册”流程ValidateEmailSkill→SendVerificationCodeSkill→VerifyCodeSkill→CreateAccountSkill。手动在 View 中写if success { nextSkill.perform() }会退化为回调地狱。PipelineSkill的设计思想是将多个 Skill 按序排列形成一条“能力流水线”。其核心是perform()方法的递归实现func perform(_ input: Input) - TaskVoid, Never { guard !skills.isEmpty else { return Task {} } return Task { do { // 执行第一个 Skill let firstOutput try await skills[0].perform(input).value // 递归执行剩余 Skill以上一个的输出为下一个的输入 if skills.count 1 { let remainingSkills Array(skills.dropFirst()) let pipeline PipelineSkill(skills: remainingSkills) _ try await pipeline.perform(firstOutput).value } } catch { // 任一环节失败整个 Pipeline 失败 lastError error status .failed(error: error) } } }View 层调用依然简洁pipelineSkill.perform(registrationData)。Pipeline 的优势在于错误传播透明VerifyCodeSkill失败status直接变为.failedlastError就是验证码错误无需在 View 中层层判断。6.2 技能路由RouterSkill实现条件分支有些流程需要根据 Skill 执行结果动态选择下一步。例如“支付”成功 → 跳转成功页失败 → 弹出重试弹窗余额不足 → 跳转充值页。RouterSkill封装了这种决策逻辑struct RouterSkillInput: Skill { let onSucceed: (Input) - SkillVoid let onFail: (Error) - SkillVoid let onTimeout: () - SkillVoid func perform(_ input: Input) - TaskVoid, Never { // 执行一个基础 Skill如 CheckBalanceSkill return baseSkill.perform(input).map { result in switch result { case .succeeded(let balance): if balance requiredAmount { return self.onSucceed(input).perform(()) } else { return self.onFail(InsufficientBalanceError()).perform(()) } case .failed(let error): return self.onFail(error).perform(()) } } } }这实现了“业务逻辑与 UI 导航的分离”View 只需关注RouterSkill.status而具体的跳转动作由onSucceed/onFail中的 Skill 完成。6.3 个人经验体会为什么这套设计值得长期投入我在某高校实验室主导的一个跨平台教育 App 项目中全程采用SwiftUI-Agent-Skill体系。项目历时 14 个月从 0 到上线覆盖 iOS/macOS/watchOS。回顾整个过程这套设计带来的最大收益不是“写得快”而是“改得稳”。重构成本降低 70%当后台 API 接口变更时我们只需修改AuthClient和对应的LoginSkill所有 12 个使用登录功能的页面无需任何改动自动适配。新人上手极快新加入的 A 同学第一天就能独立开发一个ExportReportSkill因为他只需要理解“输入是什么、输出是什么、中间要做什么”而不用研究整个 App 的状态管理架构。线上问题定位提速所有 Skill 的id和status变更都会打点到日志系统。当用户反馈“点击提交没反应”我们直接搜索SubmitOrderSkill-[id]就能看到完整的状态流转日志平均定位时间从 2 小时缩短到 8 分钟。它不是一个炫技的框架而是一套经过真实项目千锤百炼的、面向 SwiftUI 本质的工程实践。它的价值不在于创造了什么新概念而在于用最 Swift 的方式解决了最 SwiftUI 的问题。
阅读完成 · 觉得有帮助?
咨询建站