稳定 > 新功能 - 肘子的 Swift 周报 #138
传闻苹果在今年即将发布的全新操作系统(包括 iOS 27 和 macOS 27)中,将采取类似于当年 Mac OS X Snow Leopard 时代的调整策略——将重心放在系统稳定性、性能优化、清理老旧代码和修复 Bug 上,而不是引入颠覆性的视觉设计或繁多的底层新功能。如果真能如此,那实在是令人欣慰。
传闻苹果在今年即将发布的全新操作系统(包括 iOS 27 和 macOS 27)中,将采取类似于当年 Mac OS X Snow Leopard 时代的调整策略——将重心放在系统稳定性、性能优化、清理老旧代码和修复 Bug 上,而不是引入颠覆性的视觉设计或繁多的底层新功能。如果真能如此,那实在是令人欣慰。
做过研发的人都知道,需求评审会结束后,研发真正动手写代码,往往还要经历一轮”追着产品问”的过程。这些问题有时候很小(一个按钮文案),有时候很大(整个业务逻辑是否成立)。问题问得晚,代价是已经写了一半的代码要返工;问题没问到,代价是上线后出 bug 或功能偏差。
我们在 Codelix 中尝试用 AI 来解决这个问题。本文记录了从立项到多轮迭代的全过程,包括遇到的坑、做的取舍,以及最终效果。
最简单的思路:拿到 TAPD 需求单 → 调 AI 分析 → 输出澄清点列表 → 人工确认。
我们接入了内部的 Knot 智能体,它有联网和 RAG 能力,能结合代码库上下文分析需求。技术选型:
| 层次 | 方案 |
|---|---|
| 后端 | Go,集成进 agent-server |
| Knot 通信 | Go SSE 客户端 |
| 存储 | 本地 JSON 文件,按 storyId 分目录 |
| 前端 | React SPA,新增路由 /clarification
|
第一个版本跑通了,但踩了几个坑。
Knot 的响应协议是 SSE,但格式和标准 SSE 不完全一样:
data: {...}(有空格),Knot 是 data:{...}(无空格)bufio.Scanner 默认的 64 KB,直接截断导致 JSON 解析失败```json ``` 代码块包裹解决方案:
[...] 候选,取首个可解析的非空数组content、delta、delta.content、delta.text 四种字段格式还有一个问题:LLM 生成的 JSON 字符串里有时含未转义的英文双引号,导致反序列化失败。解决方法是先做一次轻量的 sanitizeJSONQuotes 修复再重试。
TAPD 需求单有两种 URL 格式:
https://tapd.woa.com/{workspaceId}/prong/stories/view/{storyId}
https://tapd.woa.com/tapd_fe/{workspaceId}/story/detail/{storyId}
返回结构也有四种变体:带 Story 嵌套、直接字段 map、data 数组等。评论拉取失败时不阻断主流程,降级为”无评论”继续处理。
澄清点会被多人编辑,需要保留完整历史。我们设计了 patch-based 版本链:
versions[0] 是 Knot 分析的全量 baseline,只读Resolve(N) 从最近 checkpoint 起重放 patch 链还原到版本 N并发控制:前端提交 baseVersion,后端做 CAS 校验。不同 item 的修改自动 merge,同一 item 冲突返回 409,提示用户刷新重试。
用户首次使用时需要输入 Knot API Token,但不能把 token 直接存到 localStorage——在公用机上会造成泄露。
最终设计是服务端会话:
clarification_session,写入 HttpOnly cookie(SameSite=Lax, Secure, Max-Age=15552000)401/403 时立即失效token_invalid 时服务端主动清空 session 并清 cookie,前端自动弹凭证重新输入弹窗这里有个细节:平台化后产物生成阶段使用 claude-internal CLI 时,需要把 knotApiToken 透传给远程机作为 CLI 鉴权凭证(映射为 CODEBUDDY_API_KEY)。所以 token 必须保留在服务端 session 里直到会话过期,不能在完成分析后就清掉。
核心链路跑通后,几个问题开始暴露:
于是启动平台化改造:所有澄清数据迁入 platform-server 的数据库,本地不再是真源。
核心表:
clarification_runs:每次分析一条记录,含状态机和 is_primary 标志clarification_items:当前最新澄清点状态clarification_item_versions:完整编辑历史,每次变更记 before_snapshot + after_snapshot(field_diffs 读时计算,避免三者不一致)clarification_item_comments:每条澄清项的讨论回复clarification_artifacts:生成产物(summary、tech_doc、各端技术文档、流程图、协议、tapd_update 等)clarification_knowledge_records:完成澄清后的知识沉淀1 |
editing → ready_to_complete → completing → completed |
所有澄清点非 pending 时自动推进到 ready_to_complete,允许点击”完成澄清”。完成后允许 reopen 重新进入编辑。
多人协作时,”完成澄清”只能有一个人成功触发一次。用 DB CAS 实现:
1 |
UPDATE clarification_runs |
RowsAffected = 0 说明已有其他人先点,前端提示”已有其他人正在完成澄清”。
这里有一个关键设计决策:两类 LLM 调用必须严格分开。
| 场景 | 调用来源 | 原因 |
|---|---|---|
| 首次分析 TAPD 需求 | Knot | 需要联网、RAG、内部知识召回 |
| 完成澄清后的产物生成 | 内部 LLM(claude-internal) | 只基于已确认澄清结论,避免引入未确认内容 |
协议是唯一例外——它需要结合真实协议仓上下文,允许单独调用 Knot 生成(见后文)。
完成澄清后异步生成:澄清总结 + 整体技术文档 + 各端技术文档(iOS/Android/Kuikly/Web/后台,只在该端有已决策条目时生成)+ 业务流程图(Mermaid)+ 后台协议 + TAPD 正文更新内容(tapd_update)。
前端 4s 轮询直到所有产物就绪。单个产物失败不阻断其他产物。
完成澄清后,支持将澄清结论评论到需求单,并把 tapd_update 产物写回需求正文(展示左右 diff,人工确认后写回)。
这里有个重要设计:回写状态必须持久化,不能只存前端 UI 状态。
1 |
tapd_comment_sync_status: pending | in_progress | synced | skipped |
用户中途关页面后重新进入,应能恢复到正确状态:
pending)synced 或 pending(失败可重试)有一个严格约束:diff 右侧只允许来自 tapd_update artifact,禁止用 tech_doc 或 summary 等替代写回。这些产物的生成目标不同,混用会导致需求正文变成技术文档风格。
平台化之后,真正使用时发现 AI 输出的澄清点质量有根本性问题。典型的旧格式:
阈值确定责任方和灰度策略未明确。
这种描述能指出风险,但产品拿到这句话之后还是不知道怎么决策——它指出了不确定性,但没有给决策路径。
新设计的澄清点包含:
回复并澄清(自动写入评论)和 AI 更新澄清点
1 |
{ |
rewriteDescription 是 AI 给出的”可以直接替换澄清点原文”的结论态文案。点击 AI 更新澄清点 时不是一键直替,而是弹出确认框,提供三个选项:
rewriteDescription 替换原文 + 自动标记为已澄清 + 写系统评论留下来由(🤖 采用 AI 推荐方案【A】:...)rewriteDescription 预填进编辑框,用户确认后再提交,不自动设为已澄清这个交互设计的考量是:把”一键直替”变成”可控的二次确认”,避免用户因为误点进入无法回退的状态。
原始归属只有”后台开发”,实际上算法团队(推荐排序、模型策略、置信度评估)与后台开发差异很大。本轮新增:
算法:独立于后台开发,用于 AI 能力、推荐系统相关澄清点数产:数据产品团队,负责上报方案相关确认同时 Knot prompt 新增了上报专项检查,6 类触发条件:
命中任一条件,自动生成 module=数据上报, attribution=["产品","数产"] 的澄清项。
原来协议产物是用当前上下文推测的,容易出现编造接口形态、字段结构不符合真实协议仓、忽略历史约束等问题。
本轮把 api_contract 从普通产物链路拆出,单独调用 Knot 生成,传入:需求原文 + 已确认澄清结论 + 相关后台代码 + 协议仓上下文 + 历史修改记录。协议内容中明确标注”待确认项”,不允许把不确定内容写成确定结论。
一个容易被忽略的问题:发起分析时选的 Knot 模型,在重新分析和协议生成时应该复用。否则用户会看到同一个 run 里不同阶段的产物风格迥异。
实现:clarification_runs 增加 knot_model 字段,分析时写入,重新分析和协议生成时从 run 读取。老数据没有该字段时,fallback 到系统默认 Knot 模型。
前端详情页显示”本轮分析模型:deepseek-v4-flash”,便于追溯。
让 Knot 基于代码提交历史输出推荐开发人员,一开始踩了个明显的坑:
Knot 把需求单处理人(产品经理 morsonxie)归到了 Kuikly 客户端开发。
根因:Knot 在代码线索不足时会回退到需求单的处理人/关注人,而这些经常是产品经理。
修复:prompt 明确约束——只推荐开发角色(后台/算法/iOS/Android/Kuikly/Web)、推荐依据必须是代码提交历史和模块归属、证据不足时输出”暂无可靠推荐”而非编造。
每条澄清点下方有独立的讨论回复区(clarification_item_comments):
summary artifact)时,会读取每条澄清点的回复评论,将用户在回复里补充的最终决策、原因或约束吸收进结论,避免”只复述原始问题”每条澄清点可以填写:
effort_delta_d:工作量变化(天数,可为负)effort_reason:原因说明roi_review_required:是否需要产品评估 ROI当本次澄清累计开发工作量变化 > 0.5D 时,页面顶部显示显著警告横幅:
本次澄清带来超过 0.5D 的开发工作量变化,请提醒产品评估 ROI
这个信息同时出现在”完成澄清”按钮附近、TAPD 评论摘要里、以及数据看板的 ROI 风险报表中。
每条澄清项下方支持展示 AI 分析时参考的来源(sourceRefs):
1 |
{ |
支持 iwiki / gongfeng / tapd / doc / code 五种类型。Knot prompt 要求输出 sourceRefs 可选字段,解析后落库,前端在澄清点卡片中展示”参考来源”折叠区块。
产物生成失败后,不需要重新完成整轮澄清,支持:
status,保留 artifactId 不变,避免前端轮询引用失效retry_count / last_retry_at 记录历史并发保护:同一产物 pending 时不允许重复触发重试。
完成澄清后,原来整体技术文档和各端技术文档(iOS/Android/Kuikly/Web/后台)会自动批量生成,token 成本较高,且模型质量口径和协议不一致(协议走 Knot,技术文档走内部 LLM)。
本轮调整:
tech_doc 及各端 tech_doc_* 从自动批量生成中剔除,改为 idle 状态预插入产物列表这个决策的核心是:技术文档的质量需要代码库上下文,Knot 更合适;但每次完成澄清后全自动生成 5-6 份文档成本太高,改为按需手动触发可以控制成本。
早期采纳率直接按条目算,同一个需求单重新分析几次、每次都上报,会重复计数。
修正口径:
story_id 去重,只取最近一次上报的结果采纳率 = (采纳 + 搁置) / (采纳 + 拒绝 + 搁置),pending 不进分母技术实现遇到了 MySQL 优化器 bug:用 IN (SELECT COALESCE(correlated_sub1, correlated_sub2)) 嵌套相关子查询时,MySQL 会静默返回空结果集,不报错不警告。改为非相关派生表 INNER JOIN 写法后解决。
“漏报”定义为用户在 AI 初始分析之外手动新增的澄清点。
clarification_items 新增 origin 字段(ai / manual),漏报率 = 人工新增澄清点数 / 所有澄清点数,可以持续评估 AI 分析质量的变化趋势。
典型用户行为问题:全部澄清点都确认完了,但忘记点”完成澄清”,导致产物没有触发生成。
解决:当最后一个 pending 澄清点被确认时,立即弹窗引导。触发条件严格限定为:本次更新前 pending > 0,更新后 pending = 0,且 run 未完成。刷新页面、在已完成的 run 上改判某项都不会误弹。
主按钮点击记 complete_now 埋点,进入 AI 效果漏斗统计。
clarification_runs 表新增时间戳字段:
analysis_ms:Knot 分析耗时(毫秒)first_confirmed_at:第一个澄清点被确认的时间artifacts_generated_at:自动批量产物全部生成完成的时间tapd_comment_synced_at / tapd_content_synced_at:TAPD 回写完成时间这些字段在各状态变更点写入,支持看板展示:平均分析耗时、分析完成→首个确认间隔、完成→自动产物、完成→TAPD 评论等。
最终包含三个维度(以纵向 Section 排列,不是 Tab 切换):
质量:采纳率(按需求去重,口径 tooltip 说明)、按归属拆分采纳率、漏报率趋势、工作量 ROI 风险(触发次数 + 累计工作量变化)
效率:各环节耗时均值(分析时长、人工确认时长、产物生成时长、TAPD 同步时长)、完成趋势、流程漏斗(发起→完成→评论→写回)
AI 效果:曝光 → 操作 → 完成漏斗,推荐项 vs 非推荐项点击率,rewriteDescription 直接使用率(区分”直接使用”和”编辑再替换”)
埋点事件:suggestion_shown(曝光,按 session+澄清点 sessionStorage 去重)、reply_and_confirm、rewrite_and_confirm(直接使用)、rewrite_fallback(编辑再替换/无 rewriteDescription 的兜底)、complete_now(完成引导弹窗点击)。取消等未提交动作不记。
多次重新分析后,用户可能有多个 run,但之前确认过的那轮不应该因为”重新分析了一下”就从默认视图消失。
引入 is_primary 字段(clarification_runs.is_primary):
is_primary=0,不抢占主版本顶部”重新分析”按钮增加确认弹窗,明确说明:若当前正在查看主版本,分析完成后不会自动切走当前页面,新 run 只出现在下拉选择器里。
随着使用增多,首页全量列表变得难以查找。新增双选切换:
clarification_runs.created_by = 当前用户,默认选中接口扩展:GET /api/story-clarifications?scope=mine&username=xxx
原来详情页是一个功能完整但视觉朴素的”工具页”。本轮参照设计稿做了改版,核心变化:
改版策略是”套设计稿的页面叙事和视觉层级,保留我们现有更强的真实能力”——run/version 双层浏览、item 历史记录、sourceRefs 引用来源、完成澄清后的产物区都保留,不为了贴近设计稿而删掉。
完成澄清后,异步将已决策条目写入 clarification_knowledge_records,沉淀:
后续可基于这些沉淀做相似需求召回、高频模糊点归类、采纳模式分析。
JSON 是唯一真源,MD 是展示格式:MD 导入导出存在格式损失,不能成为系统真源。版本链、并发控制、历史重建都依赖 JSON 结构。
不允许物理删除澄清项:所有”不处理”的澄清点通过 confirmResult=rejected 表达,在列表中显示删除线。多人协作时如果有人删了一条其他人正在讨论的澄清点,会造成上下文丢失,很难恢复。所有确认状态都应可撤销。
Knot 只做分析,协议是例外:产物生成只用内部 LLM,避免引入未确认内容。唯一例外是接口协议,它需要结合真实协议仓上下文,允许单独调用 Knot 生成。技术文档后来也改走 Knot,但改为手动触发。
完成澄清不是终点,是第二阶段入口:所有澄清点非 pending → 可以完成澄清 → 触发产物生成 → 评论回写 TAPD → 更新需求正文。这条链路里每个节点都需要持久化状态和可恢复设计,不能只靠前端 UI 状态。
这个方向从最初的”AI 找问题列表”演进到”AI 辅助决策工具”,核心变化:
最大的教训:功能上线不等于体验完成。第一版打通了主链路,但产品真正拿到 AI 输出后,”这句话告诉我了什么,但我还是不知道怎么决策”的反馈,才是后续所有体验升级的起点。
Codelix 最初是为后台服务设计的 AI 编码平台:给一个 TAPD 需求单,AI 自动完成需求分析、方案设计、代码生成、编译校验、Code Review 全流水线。后台场景相对规整,踩坑踩了几个月后,我们开始把这套流水线推广到 iOS、Android、Kuikly 三个客户端平台。
这篇文章记录了整个过程的技术细节,包括 iOS pipeline 从零到可用,Kuikly 接入时踩的低级 bug,Android 的广撒网问题,以及跨三端做统一优化的五轮演进。
iOS 编码流水线在一个已有 AI 工具(iOSBugAutoFix)上迁移过来,核心思路是:复用 Codelix 的编排基建,只迁移 Agent 提示词。不重新实现 pipeline 引擎,不重新写 ACP 协议,只把 iOSBugAutoFix 的 agent 逻辑搬进 Codelix 的框架里。
最终链路是 7 个 Agent:
| Agent | 阶段 | 职责 |
|---|---|---|
ios-requirement-analyst |
planning | 需求分析 + 代码搜索 + 影响范围定位 |
ios-design-interpret |
planning | 解释设计稿 / D2C 信息 |
ios-feature-impact |
planning | 评估影响范围和风险 |
ios-task-planner |
planning → coding | 方案设计 + 任务拆分 + 调度 coder |
ios-coder |
coding | 编码实现 |
ios-validate |
test | 编译验证 + 语义评估 |
ios-code-reviewer |
git | 代码审查 |
阶段之间靠结构化 artifact 传递上下文,而不是把所有历史对话塞给下一个 agent。ios-task-planner 完成后调用 add_coder_dispatch MCP 工具,前端监听后自动拉起 ios-coder。
第一个版本跑起来后,效果非常糟糕。以「歌手主页视觉优化」需求为例:
对比来源系统 iOSBugAutoFix:20 分钟 / $10 / 全部完成。
优化前 ios-coder 的实际执行顺序:
get_artifact(tech_design) 读取技术方案(4000+ token)Bash(grep) / Bash(find) 搜索确认路径Read 文件内容Edit
两次相邻 Edit 之间间隔 10 分钟(17:31 → 17:41)。Session 重启 4 次,每次都重读技术方案,累计浪费约 16,000 token。
ios-coder 的 AllowedTools 原来是 Bash(*),模型可以自由使用 find/grep/ls 等所有搜索命令。提示词里写了”Read 已知路径直读,不要用 find/grep 搜索”,但这是软约束,在高噪声场景下模型根本不遵守。
虽然有 MaxTurns=40,但没有剩余轮次催促,没有 Edit 失败预校验,model 只有硬上限,不知道”快用完了”。
P0-1:路径硬注入
ios-task-planner 在给 coder 派发任务时,coder_prompt 里必须包含已确认文件路径表格(绝对路径 + 方法名/selector + 行号辅助)以及每个文件的具体变更意图。coder 收到后路径已知,第一步直接 Read 目标文件,无需推断。
P0-2:路径守卫
ios-coder/system-prompt.md 中增加路径守卫:
若 prompt 中包含”已确认文件路径”表格,严禁对表格中的文件使用
Bash(find)/Bash(grep)/Bash(ls)进行路径搜索。直接使用表格中的绝对路径调用 Read。
与 planner 侧形成双侧约束。
P1-1:工具白名单收紧
ios-coder:Bash(*) → Bash(git *),从能力层物理封口搜索类命令ios-requirement-analyst:Bash(grep/find) → 原生 Grep + Glob(无 fork 开销)P1-2:轮次收敛提示注入
在 ws_channel.go 里,coder 启动时注入催促:剩 5 轮停止搜索,剩 3 轮强制收敛。
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 两次 Edit 间隔 | ~10 分钟 | 首轮直接 Edit |
| Session 重建 token 浪费 | ~16,000 token | 路径已注入,无需重推断 |
| 需求分析耗时 | ~25 分钟 | ~5 分钟 |
ios-validate agent 在大型 iOS 工程(QQMusic)上反复出现漏报编译错误:本地 Xcode 能看到的错误,agent 报告里显示”目标文件 0 errors”。
整个排查经历了 5 个假设:
| 假设 | 结论 |
|---|---|
tail -80 截断了关键错误 |
部分成立,但改掉后仍漏报 |
changePlan 漏列改动文件 |
部分成立,但改掉后仍漏报 |
git diff HEAD~1 HEAD 看不到未提交改动 |
部分成立,三路 git 后目标文件正确了,但仍漏报 |
| DerivedData 增量缓存跳过改动文件 | 部分成立,touch 目标文件后仍漏报 |
| scheme manual target order 串行阻断 | 加 -parallelizeTargets 后仍有 database is locked
|
最终真正的根因(第 6 轮):
用户的 Xcode GUI 在运行,DerivedData 的 build.db 被 GUI 占用。
具体机制:
IDECustomDerivedDataLocation = DerivedData(工程内相对路径)DerivedData/build.db
database is locked 退出
实测对比:
| 场景 | 结果 |
|---|---|
| Xcode GUI 在跑 + 命令行 xcodebuild | 43 秒后 database is locked,0 个 CompileC,主 target 未编译 |
| Xcode GUI 关闭 + 命令行 xcodebuild | 11 分钟完整编译,9000+ CompileC,捕获到真实错误 ✅ |
当 agent 报告的错误”很奇怪”(总是同一组无关错误)时,第一步应该是直接看 build log 里有什么(grep -c CompileC、grep "error:"),而不是改 prompt。
最终可靠的编译命令关键参数:
-quiet(会吞掉关键错误信息)-parallelizeTargets(防止 manual order 串行阻断)-destination 'generic/platform=iOS'(真机架构,避免模拟器差异漏报)CODE_SIGNING_ALLOWED=NO(跳过签名)database is locked 立即报错,而不是继续给出误导性结论编译验证之后,需要把”发现编译错误 → 修复 → 再验证”这条链路做顺。原来用户需要:看 validate 报告 → 复制错误信息 → 切回 coder tab → 自己拼修复 prompt → 修完再回 validate 重跑。5 个手动步骤。
ios-coder-fix 从 ios-coder 完全拆分出来,独立 system-prompt + 独立工具白名单:
1 |
AllowedTools: []string{ |
物理移除 Bash,模型无法调用 find/ls/git log/git show。
| 时刻 | 工具调用 | 累计耗时 |
|---|---|---|
| 22:30:09 | prompt 启动 | 0s |
| 22:31:01 |
find 探文件 |
52s |
| 22:32:44 | 又一次 find 同样文件 |
+103s |
| 22:33:03 | ls -la /Users/xxx/worktrees/ |
+19s |
| 22:33:25 | get_workspace_context |
+22s |
| 22:34:08 |
git checkout -- 撤销改动 |
+43s |
| 22:38:22 | 4 次连续 Edit 同一文件 | +234s |
| 22:39:02 | end_turn | 8m53s |
三个根因:①白名单仍有 Bash(git *),模型把 find/ls 当宽松 git 子命令提交,被 auto-approve;②prompt 体积 15KB,模型忽略注入的文件内容,主动跑 git show 读历史;③同一文件多 error 连发 4 次 Edit。
| 指标 | 优化前 | 优化后 | 改善 |
|---|---|---|---|
| 总耗时 | 8m53s | 50.1s | ↓ 90.6% |
| 首次工具调用 |
find 探文件 |
Edit 改代码 |
✅ |
| Bash 调用次数 | 11 次 | 0 次 | 物理切断 |
| 同文件 Edit 次数 | 4 次 | 1 次 | 合并 |
后来把 Fix Mode 扩展成三类,统一放在验证 tab 底部:
| fixType | 触发条件 | 问题来源 | 用户控制 |
|---|---|---|---|
compile |
有 targetFileErrors
|
test_report |
自动注入,无需输入 |
semantic |
编译通过但有 medium/high semanticRisks
|
test_report |
自动注入,无需输入 |
cr |
CR 完成,有 criticalIssues
|
commit_reviews.critical_issues |
必须经用户输入框确认 |
CR fix 必须经用户确认的原因:编译错误和语义缺失影响功能,必须解决。CR 问题不一定——可能是误报,可能推后处理。
Unified Fix Mode 上线后,第一次真实使用 CR 快修就踩坑了:
agent 把 insertOrReplaceObjects 改为了 WCDB 属性级 UPDATE,但用错了调用对象:
[self.database updateTable:onProperties:withObject:where:](不存在于此版本 WCTDatabase)[self.database getTableOfName:withClass:] 拿到 WCTTable *t,再 [t updateRowsOnProperties:withObject:where:]
根本原因:ios-coder-fix 的 AllowedTools 没有 Grep,在 CR/semantic fix 模式下,agent 无法查询头文件验证 API 是否存在。
修复:AllowedTools 加 Grep + Glob,同时 prompt 加规则”使用第三方框架 API 之前,先 Grep 对应头文件确认 selector 存在”。
Kuikly 接入后,出现了一个诡异的现象:
kuikly-task-planner 多次启动,每次很快结束,没有产出 tech_design artifact,没有拆 TODO,也没有触发 coder查看 agent 日志,发现大量:
1 |
BLOCKED tool="mcp__codelix__mcp__codelix__get_workspace_context" kind="other" agentRole=kuikly-task-planner (not in AllowedTools) |
双重前缀。
根因:codelix-mcp 的 handleToolsList 在 manifest 里把工具名注册为 mcp__codelix__<tool>(已带前缀)。Regular claude 和 tme-claude 收到这个 manifest 后,按 MCP 协议惯例再加一次 mcp__codelix__ 前缀,最终工具名变成 mcp__codelix__mcp__codelix__<tool>。
ACP 服务端的 IsToolAllowedByACPPermission 拿双重前缀名去匹配 AllowedTools(单前缀),完全匹配不上,所有 codelix 工具被全部拦截。
影响范围:所有平台(backend / kuikly / iOS / Android)所有使用 codelix MCP 工具的 agent。
修复:
acp/manager.go — IsToolAllowedByACPPermission:进入白名单检查前先归一化,检测 mcp__X__mcp__X__foo 模式并折叠为 mcp__X__foo
mcp-server/main.go — handleToolsCall:循环剥前缀,兼容双重前缀调用另外还有一个配套 bug:startCodingFromPlanning 切换到 coding tab 时,没有清除 activeRun(仍指向 task-planner 的旧 run)。coder 启动后,WS 事件被旧的 planning 视图抢走,coding tab 收不到任何输出,UI 上表现为”进不去”。修复:切 tab 之前先执行 setActiveRun(null); setActiveRunTab(null); clearSingleAgentMemory()。
Android 的「韶音二期体验优化」需求(12 条子需求):
时间线:
| 时间 | 事件 |
|---|---|
| 13:38:28 | 开始 |
| 13:39:44 | 第 1 次 Compacting(开始后仅 1.5 分钟) |
| 13:43:19 | 第 2 次 Compacting |
| 13:50:00 | 第 3 次 Compacting |
| 13:54:45 | 第 4 次 Compacting |
| 13:59:34 | 输出完成 |
4 次 Compacting 合计消耗 10–14 分钟,占总耗时 50–67%。第 1 次 Compacting 在开始后 仅 1.5 分钟就触发,说明文件读入速度极快地撑满了上下文。
最严重的一个批次:单轮读取 18 个文件 + 12 次搜索。
方案设计阶段更夸张:22 分钟,两轮探索合计读取 129 个文件、107 次搜索、614 次 Tool 调用——task-planner 把”方案核验”做成了全仓重新分析。
以上这些问题的解法,最终在 platform/05-multi-platform-analysis-design-optimization.md 做了三端统一规范,经历了五轮演进:
核心原则:把”全面覆盖”和”全面阅读”解耦。覆盖率靠”逐条需求都处理过”保证,阅读量靠”证据分级”收敛。
硬约束:先 Grep 后 Read,单轮 Read ≤ 5 个文件,大文件只精确匹配符号,同一条需求连续 3 轮无命中则停止扩散。
收益:iOS 需求分析从 ~20 分钟降到 ~5 分钟。
副作用:约束过强时,部分需求定位不准或漏掉边缘影响面。
有人为了提高定位准确性,重新强化了”每个候选文件都要读内容验证”、”发现路径立即读取”、”无法判断时扩大搜索范围”。iOS 需求分析从 ~5 分钟退回到 ~20 分钟。
教训:提示词优化有回归风险。没有工程层面的约束,只靠 prompt 软规则,容易被后来的”优化”盖过。
把第一轮 iOS 经验统一推广到 Android / Kuikly,同时限制方案设计阶段:
时间收住了,但规则没有区分”普通不确定点”和”主链路断点”。Kuikly 的 ASR 搜索需求:commonMain → native bridge → plugin/service → callback → UI 展示 这条链有没有接通才是关键,搜不到就写 risk 的规则导致主链路断点被当成普通风险,coder 继续局部实现,代码看似覆盖不少文件,但关键能力没有真正连上。
主链路必须 blocker 化,而不是 risk 化。
规则:涉及跨层能力时(bridge/native/plugin/callback/route/jump/状态链路),必须输出 contracts[],记录 entry / transport / consumer / result / ui 五个关键节点。任一节点缺失或未知 → 进入 blockingGaps[] + 阻塞 TODO,不得降级为普通 risk。
各端触发词(命中任一词即启动 Contract Gate 检查):
副作用:缺口落在跨仓时(宿主 App / 独立仓 / 预编译库,当前工作区无源码),被判 blockingGaps 会连带阻塞本仓本可编码的部分;Contract Gate 也只管到”规划”,编码阶段仍有大撒网。
问题一:编码阶段大撒网
实测某次 Kuikly 代码实现,27 分钟里前 ~14 分钟,coder 并行读了 60+ 文件、Grep 64 次。coder 提示词写了”禁止搜索”,但不起作用。
根因:ACP 模式下工具是通过运行时 request_permission + AutoApprovePermission 放行的,AllowedTools 白名单只在 CLI --print 生效,不是真正的物理门禁。
修复:HardBlockByAllowedTools(acp/manager.go)对 claude 形态 agent 拦下白名单外的工具。但原来的匹配器只认 Bash(git:*) 冒号写法,不认三端 coder 用的 Bash(git *) 空格写法,导致硬门会误杀 coder 的 git 命令。修掉这个 bug 后,硬门才真正对三端 coder 可用。
问题二:跨仓缺口过度阻塞
宿主 App / 预编译 xcframework / AAR / SDK 等,当前工作区无源码,修不了。改为 human_action TODO(coder 自动跳过、留人工),**不进 blockingGaps[]**。
问题三:校验→修复闭环断开
覆盖缺口只写进 designCoverage.missing[],但”语义修复”按钮只认 semanticRisks[]。编译通过、只差覆盖时,用户没有可点的修复入口,只能盲目”重做”,coder 拿不到缺口信息,必然复现同样结果。
实测 Kuikly ASR 需求重做两次仍缺同样 2 处 [语音] 前缀 Span。
修法:校验 prompt 统一要求,designCoverage.missing[] 中本仓可改的缺口,同时在 semanticRisks[] 镜像一条(fixable=true, severity=medium),让现成的”语义修复”按钮能识别并驱动 coder 修复。仅在本轮编译通过时镜像(有编译错误优先走”编译修复”),逻辑天然隔离。
最重要的教训:提示词软约束在 ACP 模式下不稳定。
iOS 需求分析速度从 5 分钟回归到 20 分钟,就是因为有人在提示词里加了覆盖率相关约束,把广撒网逻辑写了回去。解决方案:
ws_channel.go 在 requirement-analyst(MaxTurns 20)、task-planner(MaxTurns 15)剩余轮次不足时,注入”剩 5 轮停止搜索 / 剩 3 轮强制收敛”前两条治分析/规划阶段,第三条治编码/修复阶段。改 prompt 治”标”,工程约束治”本”。
首次在 Kuikly / iOS / Android 三端完整跑通 Codelix 流水线,4 个真实需求:
| 需求 | 端 | 估算工作量 | 总耗时 | 输入 Token | 总费用 | 完成度 |
|---|---|---|---|---|---|---|
| Kuikly A | Kuikly | 1D | 69 min | 21,451,917 | $11.69 | 95% |
| Kuikly B | Kuikly | 7D | 97 min | 16,532,406 | $15.77 | 92% |
| iOS | iOS | 2D | ~85 min | 16,801,822 | $11.00 | 100% |
| Android | Android | 2.5D | ~99 min | 23,118,214 | $17.10 | 89% |
数据为首轮试点、样本量小,仅作量纲参考,不代表稳定均值。
| 阶段 | 耗时 | 费用 |
|---|---|---|
| 需求分析 | 21 min | $3.47 |
| 方案设计 | 22 min | $4.54 |
| 代码实现 | 17 min | $3.40 |
| 校验 + CR + 多轮修复 | ~38 min | $4.69 |
这里有个典型问题:CR 快修顺手新增了 2 个 override,但没先验证编译,导致引入了 4 个编译错误,额外多出两轮”编译错误→快修”循环。后来通过在 coder prompt 中明确禁止”未经验证的顺手新增改动”来规避。
Fix Mode 必须和首次实现分开:修复轮次是机械修补,不是重新分析。两者混在同一个 agent 里会浪费 token、混淆模型决策,且无法针对 fix 场景单独收紧工具白名单。
上下文传递要主动,不要靠 Agent 重新推断:ios-task-planner → ios-coder 的调度路径上,路径信息从技术方案(自然语言)变成硬注入(结构化表格),消除了”推断 → 搜索确认”这一环。这个原则后来推广到三端。
不引入 orchestrator:iOS validate/fix 闭环不使用后端 orchestrator / 状态机。当前 codelix 是”前端 tab + artifact 串联”模式,引入 orchestrator 会出现两套并行编排模型,维护成本不成比例。编译修复闭环完全可以靠结构化 artifact + 前端按钮 + prompt 模板实现。
编译结果提取交给确定性程序:LLM agent 自由执行 shell + 解析文本本质不可靠。长期方案是新增 mcp__codelix__ios_build_verify MCP tool,由 Go 后端确定性实现——LLM 只做语义判断,不做 xcodebuild 输出解析。
客户端三端流水线从无到有,踩了大量坑:
目前的方案在 4 个真实需求样本上跑通了端到端闭环,成本在 $11–$17 / 需求、耗时在 70–100 分钟 / 需求的量级。对于一个 1–7D 的真实客户端需求,这个量级在可接受范围内。
后续主要优化方向:首轮 coder 的 token 消耗(目前占 30%–65%)、需求分析输入 token(iOS/Android 单次 600–700 万输入)、以及把编译验证从”LLM 解析 build log”升级为 MCP tool 确定性实现。
从最早思考《为什么要做一个 iOS bug 自动修复的 agent 程序》,到 V3 把单文件原型重构成 AgentEngine 引擎、V4 把领域知识结构化、V5 把「完成需求开发」提成唯一 P0 并引入 Pipeline 编排基座,这个项目断断续续做了两个月。之前我写过一篇《从 Bug 修复到需求开发:iOS AutoFix Agent 的 V3-V5 演进之路》,把版本演进的脉络讲清楚了。
接下来我的重心会转到和其他同学共建另一个 Agent 项目上。所以趁记忆还热乎,给 iOS AutoFix 做一份阶段性收尾——这篇不再复述版本演进,而是把这一年踩过坑、验证过的与领域无关、可以直接搬到下一个 Agent 项目的工程经验沉淀下来。
一句话定位整个项目的终态:
V4 解决「能不能定位/修复一个 Bug」,V5 把「完成一个需求开发」提升为唯一 P0,并引入 Pipeline Orchestrator(流水线编排基座),让
分析 → 设计 → 闸门 → 实现 → 验证 → 评审以声明式、可配置、支持闸门与智能回退的流水线串起来。
V5 围绕三条主线展开:
| 主线 | 名称 | 角色 |
|---|---|---|
| 主线 0 | Pipeline Orchestrator | 架构基座:声明式流水线引擎 + 闸门(Gate)+ 智能回退(Rollback)+ 状态持久化 + 可观测性 |
| 主线 A | 定位与修复质量 | 继续打磨 Bug 定位/修复/经验复用,在 V5 中承担「基础设施 + 壁垒」角色 |
| 主线 B | 需求开发能力 | 从需求分析、改动点识别、多文件写入到限定场景的完整需求实现 —— V5 唯一 P0 |
落地形态是一条六阶段需求开发流水线,以一个真实 TAPD 需求为例,全程约 65 分钟、中间两次人工确认:
1 |
① 需求分析 → ② 方案设计 [HumanGate] → ③ 影响评估 |
到 V6 雏形,需求开发已经从「勾哪端就各自独立跑 Pipeline」演进到「一个需求、一次整体分析、按涉及端分别落地」——iOS / Android / Kuikly 三端共享一次跨端分析,再并发分端实现,保证跨端协议字段/事件名一致。
收尾时整个系统稳定在 5 层。理解这张图,就理解了这个 Agent 的全部骨架:
1 |
入口层(CLI / GUI / 企微机器人) |
最关键的一组抽象,也是这一年最值钱的设计决策:
下面这些是我打算带去下一个 Agent 项目的「行李」。它们大多与「修 iOS Bug」这件具体的事无关,是做任何 LLM Agent 都会遇到的问题。
最容易犯的错是:先把一个个能力(分析、设计、实现)写成独立脚本,最后再用胶水代码串起来。结果就是回退、重试、断点续跑这些横切逻辑散落在各处。
V5 的做法是反过来——先建 Pipeline 编排基座,每个能力作为一个 Stage 插进去。回退、状态持久化、崩溃恢复、可观测性都由编排层统一提供。Pipeline First 是 V5 九条核心原则里的第一条,事后看这个顺序定对了。
关键阶段转换处一定要设闸门(Gate),不通过则携带反馈智能回退,而不是直接失败。闸门分三类:
回退用的是循环模型而非递归:每个 Gate 维护独立的回退计数,避免「设计→实现→验证失败→回设计→又失败」无限套娃。状态用原子写入持久化,进程崩溃能恢复到中断处。
这是 Agent 工程里水最深的部分,几条经验都和直觉相反:
note_finding 工具写入关键发现,标记 _isScratchpad,压缩时跳过,每轮作为 system message 重新注入。keyFiles / codeSnippets / callChains / hypotheses / coverage 后,主代理可以直接引用具体发现。最初的轮次控制是「第 8 轮提醒、第 12 轮强制提交」这种机械计时器——Agent 可能第 3 轮就找到答案还在空转烧 token,也可能第 12 轮没找到被强行提交。
改成置信度驱动早停:每轮自评置信度,但触发早停要三个条件同时满足——置信度 ≥ 0.8、总发现 ≥ 3、剩余方向全为低优先级。任一不满足就继续,宁可多跑也不「差点找到」漏掉根因。强制提交时打 low_confidence 标记,让下游知道定位可能不准。
让 Agent 别乱用工具,单靠 prompt 不够。有效的是双层约束协同:
两层叠加后,ripgrep 调用从 7 次降到 4 次(**-43%**),平均每次工具调用数从 7 降到 5。
只要 Agent 会改用户的代码,写入安全就不能妥协。V5 的红线是四件套:**--confirm 确认 + 事务写入 + 编译验证 + 自动回滚**。代码实现阶段所有写入打包成事务,编译不过就整体回滚,绝不留下半成品。这条在「修 Bug」时还能商量,到「需求开发」要改多文件时就是底线。
修 Bug 最大的风险不是没修好,而是改出新 Bug 或改错位置。让 LLM 生成 diff 时,强制 search 块带上前后 1-2 行上下文确保唯一匹配,而不是只给变更行(LLM 从记忆重建代码会有细微偏差,导致静默匹配失败)。
就这一个约束 + 「最小修改原则」,让修复阶段 Token 从 60K 降到 26K(**-57%**),LLM 调用从 6 次降到 3 次。
earlyTerminationSnapshot 记录早停时跳过了哪些方向,配合 evidenceHitRate / searchEfficiencyRatio / evidenceConsistencyScore 等场景级指标,做基线保存和回归检测。一组综合优化前后的实测对比(质量指标全部持平的前提下):
| 指标 | 优化前 | 优化后 | 改善 |
|---|---|---|---|
| 总 Token | 107,951 | 67,098 | -37.9% |
| 总耗时 | 272.9s | 191.0s | -30.0% |
| LLM 调用 | 18 | 13 | -27.8% |
| 主循环收敛轮次 | 3 | 2 | -33.3% |
不要押注单一模型。V5 建了一套 10 分制的多模型评测框架(评估维度:文件定位 25% / 分析深度 25% / 实现计划 20% / 风险识别 15% / 执行效率 15%),跑同一个需求对比:
| 配置 | 综合得分 | 特点 |
|---|---|---|
| claude-opus + 内置引擎 | 8.8 | 覆盖最广,风险识别最深 |
| deepseek-4 + ACP | 8.5 | 行号级精度,零幻觉 |
| claude-4.6 + claude-internal | 8.2 | 流程最稳,但会跑偏去搜 Android 代码(项目类型上下文注入不足) |
| glm-5 + CodeBuddy SDK | 6.7 | 最快(105s),质量尚可 |
线上则用降级链(CodeBuddy Claude → GLM → DeepSeek)保可用性,并对不支持 function calling 的模型做 JSON 解析降级。评测决定选型,降级链保底。
知识库不是把文档堆一起。V5 把知识分四类——案例 / 经验 / 记忆 / 索引,共享知识库走独立 git 仓、个人记忆落本地,每条知识带 confidence 和晋升路径(被多次验证的经验才升级为共享知识),并设入库准入条件和触发器。这样知识库才不会越长越脏。
核心闭环已经成形,剩下的是打磨,所以这是个合理的暂停点。但有几处确实没做完,留个记录:
additionalContext 注入各端,各端仍会冗余跑一遍自己的 analyze,时间有浪费。彻底的做法是用 preloadedAnalysis 直接跳过各端 analyze stage,但那会侵入 PipelineEngine 内部,风险大,所以先用了轻耦合方案。回头看,这一年真正沉淀下来的,不是「怎么修 iOS Bug」,而是「怎么造一个能干活的 Agent」。这两件事可迁移的程度完全不同:
下一个项目是和其他同学一起共建,正好可以把上面这套「与领域无关的 Agent 骨架」当成共识的起点,少走一遍弯路。iOS AutoFix 这边先告一段落,等下一个 Agent 跑起来,应该还有新的东西可以反哺回来。
如果只能带走三句话:
StoreKit 配置文件(.storekit)是 Apple 提供的本地测试环境配置文件,用于在 Xcode 中模拟 App Store 内购行为,无需连接真实的 App Store 服务器。
| 类型 | 说明 |
|---|---|
| Consumable | 消耗型,如游戏币 |
| Non-Consumable | 非消耗型,如解锁功能 |
| Auto-Renewable Subscription | 自动续期订阅 |
| Non-Renewing Subscription | 非自动续期订阅 |
Xcode → File → New → File → StoreKit Configuration File
Edit Scheme → Run → Options → StoreKit Configuration(指定文件)
| 对比项 | StoreKit 配置(本地) | 沙盒测试(Sandbox) |
|---|---|---|
| 需要网络 | 否 | 是 |
| 需要 App Store Connect 配置 | 否 | 是 |
| 速度 | 更快 | 较慢 |
| 适合阶段 | 开发早期 | 上线前验证 |
Debug → StoreKit → Clear Purchased Products
清除后重新运行 App 即可,无需重启模拟器。
Xcode → Window → Devices and Simulators
→ 选中模拟器 → Erase All Content and Settings
#if DEBUG
try await AppStore.sync()
#endif
.storekit 配置文件只在开发阶段生效,打包上线后自动忽略。
用户点击购买
↓
StoreKit 框架(代码不变)
↓
请求发往 Apple 生产服务器
↓
Apple 处理支付,返回 Transaction
↓
App 验证收据 → 解锁内容
| 环境 | 数据来源 | 购买记录存储 |
|---|---|---|
| 本地开发 | StoreKit 配置文件(.storekit) | 本地模拟器沙盒 |
| 沙盒测试 | App Store Connect(测试环境) | Apple 服务器(沙盒) |
| 正式上线 | App Store Connect(生产环境) | Apple 服务器(生产) |
.storekit 文件是"假数据源",上线后真实数据全部走 Apple 服务器restore purchases 从 Apple 服务器拉取动画的声明式表达是 SwiftUI 的核心优势之一。但在某些场景里,结果并不总像我们期待的那样平滑。一个典型例子是:当 `List` 行内的内容高度发生动态变化——副标题从空变为非空、文本因更新而导致行数变化——系统自带的布局引擎往往无法给出连续的过渡动画。本文从这个现象出发,逐层拆解原因,给出一种完全基于 SwiftUI 原生能力的解决方案;也借这条路径回看 SwiftUI 在布局机制层面的几个关键约束。
Dave Verwer 在 iOS Dev Weekly 第 751 期宣布,这份已经持续近 15 年的周报将交由新的团队继续运营,而他自己接下来会全职投入 Swift Package Index。我的博客在早期获得关注,也曾得益于 iOS Dev Weekly 的推荐;而我在周报中坚持撰写每期周评,同样在很大程度上受到 Dave Verwer 的启发。对于很多 Apple 平台开发者来说,iOS Dev Weekly 早已不只是一份链接合集。它既是社区路标,也是长期陪伴。
在上一篇文章里,我解释了为什么选择自建 Agent 架构,而不是做 IDE 插件——核心在于要拥有”流程控制权”,而不是”平台适配权”。
那篇文章写完后,系统先后经历了 V3、V4、V5 三个大版本的迭代。我打算在这篇里把这段演进过程讲清楚:每一版做了什么、为什么这么做、踩了什么坑、优化思路是什么。
V2 的核心问题是:代码全部堆在一个 1100+ 行的 PreciseLocator.ts 文件里。prompt 编排、工具分发、Agent 循环、评估逻辑全部混在一起,新增任何功能都要触碰这个巨型文件。更重要的是,V2 在复杂 Bug 上能力不足——8 轮搜索子代理、每轮只有 2-3 个工具调用,对于需要追踪多层调用链的 Bug,搜索深度明显不够。
V3 的首要任务是拆文件,但不是机械地拆,而是找到正确的分层方式:
1 |
AgentEngine(通用执行核心) |
Engine 负责”怎么跑”,Profile 负责”跑什么”。新增任务类型只需实现新 Profile,不用改 Engine。这个模式后来证明极其有价值——V4、V5 的十几种 Profile 都复用了同一个 Engine。
架构拆完之后,重点放在增强子代理的搜索能力:
| 维度 | V2 | V3 | 变化 |
|---|---|---|---|
| 搜索轮次 | 8 轮 | 12 轮 | +50% |
| 每轮工具调用 | 2-3 个 | 5-9 个并行 | +200% |
| 工具执行方式 | 串行 for | 限流并发(limit=4) | token -37% |
| 搜索工具 | ripgrep + read_file | + find_files | 增加文件名维度 |
其中最有意思的是强制提交轮的上下文压缩。V2 时,子代理在强制提交轮(第 14-15 轮)拿到的是完整的历史消息,约 100K tokens。LLM 面对这么大的上下文,实际有效处理率只有 33%。V3 把强制提交轮的 context 压缩为约 15K 的结构化摘要,有效率直接从 33% 拉到 100%。
V2 只有两段式控制:第 8 轮”给你个激励”,第 14-15 轮”强制提交”。V3 换成了 4 级渐进催促:
1 |
Round 1-7: 自由搜索 |
从用户体验角度看,这很像给实习生分配任务时的跟进节奏——不是一上来就催,而是在关键节点给出提示,让他有机会自我调整。
| 指标 | V2 | V3 |
|---|---|---|
| 总耗时 | ~283s | ~163s(**-42%**) |
| LLM 调用次数 | ~22 次 | ~17 次(**-23%**) |
| Token 消耗 | ~125K | ~95K(**-24%**) |
V4 是个大版本,做的事情很多,但有一条清晰的主线:**从”能定位”到”知道自己在做什么”**。
基于对 V3 的深度审查(是的,我用 LLM 跑了一次完整的 Code Review),发现了四个核心问题,V4.3 全部解决:
1. 分阶段 Prompt 注入
V3 的主 Orchestrator 有 350+ 行系统 prompt,角色定义、工具说明、bug 类型分类、退出条件全部揉在一起。LLM 在超长 prompt 中有著名的”Lost in the Middle”问题——中间部分指令遵循率明显下降。
解决方案是把 prompt 按任务阶段动态注入:
2. Scratchpad 结构化草稿板
V3 每 5 轮压缩一次对话,问题是关键的文件路径、行号、调用链会在压缩中丢失,Agent 可能在后续轮次重复搜索已经找过的文件。
V4 引入了 Scratchpad:Agent 可以通过 note_finding 工具主动记录关键发现,这些记录被标记为 _isScratchpad: true,压缩时跳过,每轮注入为 system message。等于给 Agent 配了一个不会被压缩的小本本。
3. 置信度驱动退出
把”第 12 轮强制停”改为”置信度 ≥ 0.85 时可提前停”。不只是节省 token,更重要的是让 Agent 的退出决策与任务完成质量真正绑定,而不是靠”计时器”。
4. 结构化搜索结果传递
V3 的子代理提交一份文本报告,主 Agent 从文本里提取发现——有信息损失,且可能误解。V4 改为结构化数据传递:
1 |
interface StructuredSearchResult { |
V3 有 11 种 bug 类型的支持,但相关的搜索策略、分析维度、退出条件都硬编码在一个巨大的 switch-case 里。V4 把它重构为 BugTypePolicy 注册表——每种 bug 类型有独立的策略文件,PolicyRegistry 负责查找和组合。
这解决了一个本质问题:领域知识和执行逻辑分离。现在增加一种新的 bug 类型,只需要写一个策略文件,不用改 Agent 循环代码。
V4 引入了两层知识系统:
RAG 检索有 7 条并行路径(全文检索 / 路径匹配 / 符号检索 / 依赖关系 / 案例模块 / 历史修复记录 / Knot 知识库),每条路径独立计算命中率,可以在 Metrics 里看到哪条路径最有价值。
V4 构建了完整的可观测性体系:
npm run metrics,按命令/日期汇总,sparklines 趋势可视化--save-baseline 保存基线,--check-baseline 回归检测这套体系解决了一个关键问题:不再靠”感觉”来判断系统有没有在变好。每次改动都能用数据说话。
iOS 项目有 MVC、MVVM、VIPER 等多种架构,每种架构的数据流层次不同,定位路径也不同。V4 把追踪模型做成了可配置的 ArchitectureProfile:
1 |
MVC_CGI: UI → 数据装配 → Model → CGI 层 |
在 autofix.config.json 里指定 projectArchitecture,系统自动选用对应的追踪策略。
V5 是一次战略转向。V4 解决了”能不能修 Bug”的问题;V5 的目标是:能不能让系统完成一个完整的 iOS 需求开发任务。
V5 引入了 Pipeline Orchestrator 作为核心架构基座。这是把”一组命令串起来执行”升级为”声明式流水线编排”的关键一步。
一条 Pipeline 长这样:
1 |
[需求分析] → Gate → [方案设计] → Gate → [影响评估] → [代码实现] → [编译验证] → [代码审查] |
每个 Gate 可以是三种类型之一:
Gate 失败时,系统不是直接报错,而是带着失败反馈回退到指定阶段重新执行。回退上限是双层计数(每个 Gate 独立计数 + 全局总计数),防止死循环。
一个设计细节:纯 Nullability 编译错误(Swift 的 optional 相关警告)不消耗有效 retry 配额。这是因为这类错误往往是模型生成代码时的习惯问题,让它多试几次通常能自己修好,不应该把宝贵的 retry 机会浪费在这上面。
以一个真实案例说明整个流程:【歌手主页】歌手写真切换交互+基础体验优化(TAPD 需求)。
流水线六个阶段:
1 |
① Analyze(需求分析) |
全程大约 1 小时 5 分钟,中间两次人工确认(方案设计后、代码实现前),其余步骤全部自动执行。
V5 做了一件有意思的事:用同一个需求,分别跑 5 种不同的模型 + 接入方式组合,用 Claude Sonnet 4.6 进行 10 分制精评,验证不同配置下的效果差异。
评估维度:文件定位准确性(25%)、分析深度(25%)、实现计划质量(20%)、风险识别(15%)、执行效率(15%)。
结论很有意思:
| 配置 | 综合得分 | 特点 |
|---|---|---|
| claude-opus + 内置引擎 | 8.8 | 覆盖最广、风险识别最深 |
| deepseek-4 + ACP | 8.5 | 行号级精度、零幻觉 |
| claude-4.6 + claude-internal | 8.2 | 流程最稳定,但效率问题(搜索了大量 Android/Kotlin 代码) |
| glm-5 + CodeBuddy SDK | 6.7 | 最快(105s),质量尚可 |
V2 效率垫底的根因很清楚:项目类型上下文注入不足——LLM 不知道这是 iOS/ObjC 项目,搜索方向跑偏了。这类问题很容易修,但靠人工 review 日志才能发现。有了系统化的评测框架,这类问题能够被量化追踪。
经历三个版本的迭代,我对 Agent 系统优化形成了几个比较稳定的判断:
改 LLM 行为有两个层面:工具 description 和 Prompt。只改其中一层,效果有限。
以工具使用纪律为例,V5 在工具的 description 里加了明确的使用边界(”使用前先 ripgrep 确认位置,禁止无目标读取 >100 行”),同时在 Prompt 里加了反模式禁止列表。两层协同,ripgrep 调用减少了 43%。
工具层侧重”什么时候该用”,Prompt 层侧重”什么不能做”,互相补充。
以早停机制为例,V5 的场景 B(高质量证据早停)有三个条件必须同时满足:
任何一个条件单独满足,都不触发早停。这个保守设计多次避免了因为”差点找到了”而漏掉根因的情况。
优化 Agent 有三个时机:
这个分层体系比单纯看”成功率”有价值得多,因为它告诉你为什么成功或失败。
V5 优化前,修复阶段 LLM 调用 6 次,Token 60K+,核心原因是搜索块(search block)匹配到了多个位置,修错了地方,然后重试。解决方案很简单:要求 search 块必须包含前后 1-2 行上下文,确保唯一匹配。
改动 10 行代码,修复阶段 Token 下降 57%,LLM 调用从 6 次降到 3 次。
真正的成本不是模型调用费,而是重试浪费的时间。 把匹配精准度做好,比提升模型能力更有效率。
经过 V3-V5 的演进,系统的层次结构已经比较清晰:
1 |
┌──────────────────────────────────────┐ |
入口层做薄,Pipeline 层负责编排,Engine 层通用复用,Profile 层封装差异,知识层持续沉淀。
还有几件事没做完:
1. 并行搜索方向:目前搜索方向是串行执行的,彼此独立的方向完全可以 Promise.all。预期对 3+ 方向的任务,总耗时能再降 40-60%。
2. 搜索结果缓存:同一仓库对同一 pattern 的搜索结果,绑定 Git commit 做缓存。对重复出现的 Bug 类型(比如 accessibility 问题),可以显著减少重复检索。
3. 模型分级调度:现在不同环节用的模型没有明显区分。提取结构化信息、消息摘要这类简单任务,用小模型足够了;精确定位和代码生成,才需要最强模型。分级后成本可以进一步下降。
4. 反馈闭环:CR 通过或拒绝的结果,目前没有系统性地回流到知识库。这个闭环一旦建起来,系统会越来越像”会学习的故障处理平台”。
回看这三个版本的演进,有几个观察:
架构重构的价值超出预期。 V3 把 1100 行单文件拆成 Engine + Profile 模式,当时以为只是”代码整洁”的问题。但到 V4 需要支持 11 种 bug 类型策略、10+ 种 Profile,到 V5 需要在 Pipeline 里复用这些 Profile 时,这个架构决策带来的红利远远超过了重构的成本。
可观测性不是锦上添花。 没有 Metrics 之前,优化靠直觉。有了 Metrics 之后,每个优化都能量化,失败案例能被追溯,回归能被检测。这套体系建得越早越好。
领域知识的密度决定系统价值。 通用 Agent 框架(循环、工具分发、压缩)是基础设施,可以替换;BugTypePolicy、多架构 TrackingModel、修复策略库、历史案例这些领域资产,才是真正的壁垒。前者越薄越好,后者越厚越值钱。
从”工具”到”流水线”是个重要的认知跃迁。 单个命令(locate / fix / cr)组合使用时需要大量手工协调;Pipeline 把这些步骤声明式地串起来,加上 Gate 和回退,系统才真正具备”替程序员执行一整套流程”的能力,而不只是”帮程序员在 IDE 里更快操作”。
这条路还很长,但方向是对的。
距离 WWDC 2026 只剩下 20 天了。每年到这个时候,我都会看到不少开发者分享自己的 WWDC 愿望单,写下预测与期许。但今年,至少到我汇总本期周报时,这类内容相较去年同期明显少了许多。究竟是开发者对 WWDC 的期待变淡了,还是更多人开始秉持“降低预期才能获得更多惊喜”的心理?
谷歌近期宣布,从下一个 Flutter 稳定版 3.44 开始,Swift Package Manager 将在默认路径上取代 CocoaPods,成为 iOS 和 macOS 应用的默认依赖管理器。CocoaPods 的 Trunk 仓库计划于 2026 年 12 月 2 日正式进入只读状态——这个时间点我们在 2024 年的周报中就讨论过了,但当 Flutter 真正开始在默认路径上用 SPM 替换 CocoaPods 时,还是引发了社区的广泛热议。
With a sufficient number of users of an API, it does not matterwhat you promise in the contract: all observable behaviors of yoursystem will be depended on by somebody. — Hyrum's Law
In a compiler, the most common form of Hyrum's Law is dependence onunspecified behavior — hash bucket order, the order of equalelements after std::sort, padding offsets. The same framingcovers a few cases that are technically undefined behavior (use of aninvalidated iterator) or plain incidental properties (ABI struct layout,ELF section offsets).
When the compiler itself harbors such a dependency, the symptom isusually output that varies build-to-build: an unstable sort that landsdifferently after the standard library changes, a hash map whoseiteration order shifts when the hash function does. Occasionally thevariation is run-to-run within a single build —DenseMap<void *, X> keys with an ASLR-derived seedreorder buckets each invocation. Either way, reproducible builds,bisection, and bug reports all assume same input → same output, and astealth Hyrum dependency breaks that.
This post surveys some mechanisms that perturb the contract's blindspots so dependencies cannot quietly form.
The first line of defense is the hash function itself.llvm/include/llvm/ADT/Hashing.h:
1 |
inline uint64_t get_execution_seed() { |
The seed XORed into every llvm::hash_value is theruntime address of install_fatal_error_handler — underASLR, different every process. The header comment is explicit:
the seed is non-deterministic per process (address of a functionin LLVMSupport) to prevent having users depend on the particular hashvalues.
Every hash_combine / hash_integer_valuecall picks up the seed, and every DenseMap<K, V>keyed by a hash_value-using type then reorders its bucketsper run. MD5, BLAKE3, SHA1, SHA256 stay byte-stable — those are theright tools when you actually want a digest.
My ce80c80dca45 introduced the seed in 2024.
Code can grow dependencies on the iteration order.LLVM_ENABLE_REVERSE_ITERATION walks hash containersbackwards to flag violations.llvm/include/llvm/Support/ReverseIteration.h:
1 |
template <class T = void *> constexpr bool shouldReverseIterate() { |
DenseMap flips its BucketItTy tostd::reverse_iterator<pointer>;SmallPtrSet swaps begin() andend(); StringMap bitwise-NOTs the hash beforebucket selection — the only thing that perturbs StringMap,since its hash bypasses get_execution_seed.
Unlike the hash seed, reverse iteration isn't auto-on withassertions; -DLLVM_REVERSE_ITERATION=ON opts in explicitly.In 2026 has already merged fixes triggered by it: 7f703cabf728(MLIR SSA-value completion order), 0b3afd35c41d(MLIR SROA alloca order), and f5e2c5ddcec7(a clang test).
Orthogonal to iteration order: what happens to an existing iteratorafter a mutation. llvm/include/llvm/ADT/EpochTracker.h:
1 |
class DebugEpochBase { |
DenseMap and friends inherit fromDebugEpochBase. Mutations bump the epoch; iterators captureit at construction and assert on mismatch. The destructor bumps too, sostale iterators into destroyed containers assert rather than read freedmemory.
Without it, mutate-during-iteration "happens to work" depending onbucket layout — and bucket layout is what the hash seed and reverseiteration above perturb. The epoch check turns the latent bug into aclean assert regardless of which "lucky" layout the run lands on.Collapses to a no-op under NDEBUG.
The same defensive pattern shows up twice in the monorepo, indifferent sub-projects, years apart.
llvm::sort underEXPENSIVE_CHECKS
llvm/include/llvm/ADT/STLExtras.h:
1 |
#ifdef EXPENSIVE_CHECKS |
std::sort and qsort are unstable; codeobserving the order of equal elements is depending on undocumentedbehavior. Pre-shuffling makes that observation different every run. 5a3d47fabcb6 added the wrapper in 2018, motivated by
LLVM also ships its own llvm::shuffle rather thancalling std::shuffle, "so that LLVM behaves the same whenusing different standard libraries." A reproducibility tool whosereproducibility depends on the host stdlib is worse than no tool — andthe linker section below relies on this.
llvm::stable_sort deliberately does not pre-shuffle; itis the explicit opt-in for code that legitimately needs ordering ofequal elements.
_LIBCPP_DEBUG_RANDOMIZE_UNSPECIFIED_STABILITY
libc++ has a near-perfect parallel mechanism, designed for downstreamusers rather than the project's own internals.libcxx/include/__debug_utils/randomize_range.h:
1 |
template <class _AlgPolicy, class _Iterator, class _Sentinel> |
Three callsites:
std::sort — pre-shuffles the input.std::partial_sort — pre-shuffles the input andre-shuffles the unsorted tail afterward.std::nth_element — pre-shuffles, then re-shuffles eachside of the partition.Seed handling rhymes with get_execution_seed: ASLR orstatic std::random_device for per-process variation, with_LIBCPP_RANDOMIZE_UNSPECIFIED_STABILITY_SEED=<n> as afixed-seed escape hatch. Off by default; C++11 and later only.
libcxx/docs/DesignDocs/UnspecifiedBehaviorRandomization.rstexplains the motivation:
Google has measured couple of thousands of tests to be dependenton the stability of sorting and selection algorithms. As we also plan onupdating (or least, providing under flag more) sorting algorithms, thiseffort helps doing it gradually and sustainably.
It cites PR20837 — aworst-case O(n²) std::sort — as the upgradelibc++ specifically wanted to ship. The shuffle is the gating tool: ifdownstream tests pass with it enabled, they will pass after thealgorithm change too.
Comparing the two is more interesting than either alone:
llvm::sort's wrapper is internal hygiene: LLVM is itsown primary user, so the shuffle lives in STLExtras.hbehind a build flag with no docs.DesignDocs/ page,public macro, public seed override, explicit "Patches welcome."invitation. It has to be: libc++'s users are not libc++, and thecontract being defended is the C++ standard itself.__debug_randomize_range applies at three callsites, eachdeclaring which sub-range the algorithm leaves unspecified. LLVM'swrapper only covers the simpler equal-element case.std::unordered_* iteration order —are unspecified in both, but libc++ does not randomize them.LLVM-the-library does; on this one surface LLVM is ahead of its ownstdlib.--shuffle-sections and--randomize-section-paddingTwo ELF-only lld knobs perturb layout details that no contractcovers.
--shuffle-sections=<glob>=<seed>lld/ELF/Writer.cpp:
1 |
for (const auto &patAndSeed : ctx.arg.shuffleSections) { |
Three regimes in one option:
seed = -1 — deterministic reverse, stable even as newsections appear. Glob .init_array* to -1,rebuild, run the test suite: anything that breaks is a realstatic-init-order bug. One flag, no Frankenstein link script.seed > 0 — deterministic random shuffle,reproducible across runs and hosts (because llvm::shuffleis host-independent). Useful in CI without breaking bisection.seed = 0 — std::random_device()-seeded.Fresh nondeterminism every link.History: 423cb321dfaeintroduced the =-1 reverse mode; 16c30c3c23efgeneralized to per-glob seeds, which is what makes the.init_array*=-1 recipe possible; c135a68d426ffixed a bug where the feature itself produced an invalid dynamicrelocation order — even Hyrum mitigations have correctness traps.
--randomize-section-padding=<seed>The sister option perturbs section offsets by insertingpadding between input sections and at segment starts(lld/ELF/Writer.cpp):
1 |
static void randomizeSectionPadding(Ctx &ctx) { |
Callers grow dependencies on padding-induced offsets the linker neverpromised — profile-guided pipelines, side-channel research, exploittoolchains pinning to specific addresses. A seeded perturbation makesthose dependencies visible.
Both options are ELF-only; MachO and COFF ports have nothingequivalent.
llvm/include/llvm/Config/abi-breaking.h.cmake:
1 |
#if LLVM_ENABLE_ABI_BREAKING_CHECKS |
Every TU including the header takes a weak reference toEnableABIBreakingChecks orDisableABIBreakingChecks depending on its own build flag.Mixing the two against the same libLLVM produces anunresolved symbol at link time. MSVC gets the same guarantee via#pragma detect_mismatch.
Out-of-tree users routinely compile against headers from one tree andlink against a different libLLVM. Without this gate,whichever struct layout the link happens to pick silently miscompiles;with it, the link fails.
The mechanisms above all target surfaces no stable consumer shouldcare about: bucket order, equal-element sort order, init-array order.Debuggers, profilers, sanitizers, and reproducible-build infrastructureconsume those outputs and need them stable.
In some cases, stronger guarantee is only provided with explicitoptions. For example, Bitcode and textual IR preserve use-list orderonly under -preserve-bc-uselistorder /-preserve-ll-uselistorder.
A near-cousin: clang's -frandomize-layout-seed /__attribute__((randomize_layout)). Mechanically the same —seeded std::shuffle on struct fields — and it doescoincidentally invalidate offsetof dependencies. But theintent is exploit mitigation, cribbed from GrSecurity's Randstruct GCCplugin: per-build kernel hardening, not a developer tool.
从开始深度使用 AI 工具至今已有三年。三年间,我亲历了 AI 能力的飞跃,也越来越清晰地触摸到它的边界。截至目前,AI 早已是非常出色的效率工具,但如何让它写出真正“称心”——符合我个人风格、想法与设计哲学——的代码,仍是一个不小的挑战。
先说一个看起来有点反常识的事:LLM 本身是无状态的。
每次调用模型,本质上就是一次”文本补全”——你扔一段 prompt 进去,它根据这段 prompt 续写一段输出,然后整个过程结束。下一次再调用,模型对上一次的事一无所知。从机制上讲,它和 2020 年的 GPT-3 没有本质区别,都是一次性的补全器。
但 2024 年之后,我们看到的 Claude Code、Cursor Agent、各种 deep research 工具,明明可以连续工作几十分钟、调用几十个工具、修改几百个文件,看起来”自主”得不得了。
这两件事怎么对得上?
答案藏在外面那个 while 循环里。
Agent ≠ 模型
Agent = 模型 + Loop + Tools + Context 管理
模型本身没有变,变的是包在它外面的那层东西。这层东西现在常被称作 harness(脚手架),而 harness 里最核心的部件,就是 Agent Loop。
这篇文章想回答三个问题:
把所有花哨的东西都剥掉,一个 Agent Loop 的本质大概是这样:
1 |
messages = [{"role": "user", "content": user_input}] |
就这么二十行。这就是 Claude Code、Cursor、几乎所有 coding agent 的核心。
拆解一下,里面只有四个动作:
到这里,请允许我强调一个关键认知:
所谓”Agent 的自主性”,本质就是模型在每一轮看到更新后的 context,自己决定下一步。没有任何魔法。
不是模型变得”会规划”了,是循环让它有机会根据上一步的结果,再做一次补全。它的”思考”只发生在每一次模型调用的那一瞬间,loop 只是一遍遍把它叫醒,告诉它”环境又变了,你再看看”。
理解了这一点,后面所有的工程设计,都只是这个最小循环的变体。
最小循环能跑,但不够用。一旦把它放到真实场景里,会立刻撞到一堆问题:循环什么时候停?context 越涨越长怎么办?工具调用错了怎么办?要不要并行?
围绕这些问题做的工程取舍,决定了一个 Agent 框架的性格。下面是五个最关键的决策维度。
最朴素的写法是”模型不再要求调用工具就停”,但这在生产里远远不够。常见的多重终止条件:
每个出口背后都是一次工程权衡:上限太小,复杂任务做不完;上限太大,一旦模型卡住就烧钱。
工具结果一律塞回 context,会带来一个朴素但致命的问题——context 是线性增长的。
一个改 50 个文件的任务,可能要读 100 次文件,每次读取的内容都进 context。跑到一半,context 已经塞了几十万 token,模型注意力开始稀释,关键信息被淹没。
工程上有几种常见思路:
/compact 机制就属于这一类——保留最近上下文 + 历史摘要 + 关键信息(如已修改的文件列表)这一项是目前差异最大的设计点。后面会看到 learn-claude-code 项目专门有一节叫 s06_context_compact,就是为了解决这件事。
两种主流做法:
<tool>...</tool> 这样的 XML”,外层用正则解析。早期 ReAct 论文就是这么做的,胜在通用,任何模型都能用。现在新项目基本都默认用 function calling,但提示词约定法在一些场景仍有价值——比如要让一个本地小模型当 agent,或者要做更细粒度的格式控制。
工具会出错。文件不存在、API 超时、参数类型不对、权限不足……
两种处理思路,本质是信任谁:
实践里通常是混合策略:致命错误外层拦截,业务错误丢给模型。这件事的判断需要工程经验。
单 Agent 的极致是一轮内并行调用多个工具。Claude 现在原生支持一次返回多个 tool_use 块,harness 并行执行后一次性把所有结果回灌。这能显著降低延迟。
更复杂的是多 Agent 协作:主 agent 派发子任务给子 agent,子 agent 独立 loop,结束后回报。这里立刻冒出一个新问题——路由:主 agent 怎么知道哪个子 agent 适合这个任务?是基于 metadata 标签匹配,还是让主 agent 读子 agent 的描述自己判断?这两种思路的优劣,是另一篇文章的话题了。
讲完抽象的设计维度,看一个具体的项目——开源项目 learn-claude-code。
这个项目的好处是:它把一个 nano 版 Claude Code 的演化拆成了 12 个递进的 Python 脚本,每一步只引入一个新机制。从 s01 到 s12,代码从大约 50 行长到 1000+ 行。
读它,相当于把上一节的设计决策亲眼看一遍怎么落到代码里。
下面挑几个最关键的节点:
1 |
while True: |
这就是上一章伪代码的真实版本。50 行,能跑,能调用 bash 完成简单任务。这是一切的起点。
引入 read_file、write_file、bash、grep 等多个工具。重点不在工具本身,而在 wire——怎么把工具 schema 注册给模型,怎么 dispatch 到真实函数。
到这里,agent 已经能完成”读文件、改文件、跑测试”这种基础编程任务了。
一个有意思的设计:让 agent 自己维护一个 todo list。
为什么?因为长任务里,模型很容易跑偏。任务一大,model 在第 30 轮已经忘了第 1 轮的目标是什么。TodoWrite 工具强制 agent 在开工前把任务拆成清单,每完成一项划掉一项。
这本质上是用工具调用替代记忆——不指望模型记住,而是把目标固化成 context 里随时可见的状态。Claude Code 现在就是这么做的,效果非常显著。
主 agent 不是万能的。当一个子任务的 context 会污染主 agent 的判断(比如要读一大堆代码才能定位 bug),就该把它丢给子 agent。
子 agent 有自己独立的 loop、独立的 context,跑完只把结论返回给主 agent。这是用 context 隔离换取主 loop 的清晰。
直接对应第三章讲的”context 增长策略”。当 messages 长度超过阈值,触发 compact:让模型把前面的对话总结成一段摘要,用摘要替换原始消息,保留最近几轮原文。
这是目前所有长任务 agent 的共同方案。没有 compact,agent 就走不远。
任务系统、后台任务、多 agent 团队、worktree 隔离……每一层都是在同一个 loop 上叠加工程能力。但本质都没变:还是那个 while 循环。
读完这个项目,最值得记住的是它的核心宣言:
The model is the agent. The code is the harness.
模型才是 Agent,代码只是脚手架。
这句话听起来像废话,其实暗藏一个反直觉的判断——你写的那一千行 harness 代码,不是在让 Agent “更聪明”,只是在帮模型别搞砸。模型本身已经具备 Agent 能力,harness 的工作是给它工具、管好上下文、防止失控。
Harness 越薄,说明模型越强。
Agent Loop 不是银弹。在生产里,它会以几种典型方式翻车:
1. 上下文窗口爆炸
最常见。长任务跑到一半,context 涨到几十万 token,模型注意力被稀释,开始重复读同一个文件、忽略关键约束。Compact 是缓解,但不是根治——压缩本身也会丢信息。
2. 工具调用幻觉
模型有时会编造不存在的工具,或者给真实工具传错误参数(比如发明了一个本不存在的 flag)。这件事在小模型上尤其严重。缓解办法是收紧 tool schema 的描述、用 function calling 而不是提示词约定,以及在 harness 里做参数校验。
3. 死循环
模型反复调用同一个工具拿同样的结果,不收敛。常见于”修一个 bug 但根本没想清楚”的场景:跑测试 → 失败 → 改一行 → 跑测试 → 失败 → 改回来。需要在 harness 里检测这种模式并强制中断。
4. 目标漂移
多轮之后忘了原始任务。前面提到的 TodoWrite 是一种缓解,更激进的做法是定期”自检”:让 agent 每隔 N 轮 reflect 一次,对照原始目标审视当前进展。
工程上常见的缓解组合是:context 压缩 + 工具白名单 + step budget + 显式 reflection 节点。每一项都不彻底,但叠在一起能撑很久。
最后留一个开放问题。
回看现在所有的 Agent 框架——Claude Code、Cursor、LangGraph、OpenClaw、learn-claude-code——本质都在围绕同一个 while 循环做工程优化。终止条件、context 压缩、子 agent、todo 管理……每一项都是因为模型本身做不到,所以 harness 替它做。
但模型还在变强。
Claude 已经支持 extended thinking——模型在一次调用里能做更长的内部推理。原生的 tool use 在每一代都更稳。multi-step 的 planning 能力肉眼可见地在涨。
那么一个不那么好回答的问题是:
当模型本身具备足够长的推理链和原生工具使用能力时,外部那个 while 循环还需要存在吗?
也许某一天,你只需要一次 API 调用,模型在内部就完成了全部规划、工具调用、上下文管理。harness 被吸收进了模型本身。我们今天精心设计的这些 loop 控制机制,会变成一段历史。
也可能不会。也许 harness 永远存在——因为外部环境永远是 harness 的边界,模型再强,也需要一个东西替它和真实世界对接。
不知道。但这就是现在做 Agent 最有意思的地方:你不知道自己写的这一千行 harness 代码,到底是产品的核心资产,还是即将过时的过渡方案。
唯一确定的是,所有故事的开头,都还是那个最朴素的 while 循环。
参考项目:shareAI-lab/learn-claude-code,一个把 Claude Code 拆成 12 个递进版本的开源教学项目。建议从 s01_agent_loop.py 开始读。
从 Swift 5.5 引入符合现代编程思想的新并发模型算起,一转眼快 5 年了。从 5.5 到目前的 6.3,Swift 社区一直在采用小步迭代的方式,积极推进并发 API 的演进。但在应对过多的新关键字、复杂的隔离概念以及一些容易引发困扰的“反模式”时,这个过程对开发者来说并不算顺利。
在 iOS 26 上,视频播放器使用的 libass 字幕渲染器遭遇了严重的兼容性问题。当字幕指定的字体在系统中找不到时,libass 的 CoreText 后端会尝试 fallback 到系统字体路径:
/System/Library/PrivateFrameworks/FontServices.framework/CorePrivate/PingFangUI.ttc
然而,这个路径在 iOS 26 的沙盒机制下被系统拦截,导致 fallback 失败,内嵌 ASS/SSA 字幕中的中文字符完全无法渲染。用户看到的只是一片空白或乱码。
| 字幕类型 | 问题描述 |
|---|---|
| 内嵌 ASS/SSA | 中文字符完全不显示,字体 fallback 失败 |
| 外挂 ASS/SSA | 某些字体无法渲染,fallback 到被拦截的路径 |
| SRT(内嵌提取后) | 带有 <font face="xxx"> 标签的 SRT,freetype 尝试加载指定字体失败 |
iOS 26 引入了一个沙盒安全限制,阻止了 libass 对系统字体的访问。libass 的字体回退机制无法获取 PingFang 字体,导致整个字幕渲染失败。
视频播放
↓
禁用内嵌字幕(避免 libass 走系统字体路径)
↓
提取内嵌字幕流为 SRT(通过 FFmpeg)
↓
SRT 无字体定义,通过 freetype 渲染器 + 指定中文字体显示
↓
同时对外挂 ASS 字幕做字体名替换(指向 CoreText 已注册的字体)
通过 FFmpeg 提取视频中的字幕流,转换为无字体定义的 SRT 格式:
// 使用 FFmpeg 提取字幕流
FFmpegWrapperAPI *ffAPI = [[FFmpegWrapperAPI alloc] init];
ffAPI.inputPath = videoPath;
// -map 0:s:N 选择特定字幕流
NSString *command = [NSString stringWithFormat:@"-map 0:s:%d", trackIndex];
[ffAPI runFFmpegAPI:videoPath
outputPath:srtOutputPath
prefix:nil
command:command
async:YES];
ASS 字幕中的字体名出现在两处:
[V4+ Styles] 定义行:Style: Name,Fontname,Fontsize,...
[Events] Dialogue 行内覆盖标签:{\fn字体名}
// 替换 Dialogue 行内的 {\fn任意字体名} 覆盖标签
NSRegularExpression *fnTagRegex = [NSRegularExpression
regularExpressionWithPattern:@"\\{\\\\fn[^}\\\\]+"
options:NSRegularExpressionCaseInsensitive
error:®exError];
modifiedText = [fnTagRegex stringByReplacingMatchesInString:modifiedText
options:0
range:NSMakeRange(0, modifiedText.length)
withTemplate:[NSString stringWithFormat:@"{\\fn%@", kTargetFontName]];
// 替换 Style 定义行的 Fontname 字段
NSRegularExpression *styleLineRegex = [NSRegularExpression
regularExpressionWithPattern:@"^(Style\\s*:\\s*[^,]+,)([^,]+)(,.*)$"
options:0
error:®exError];
// ...
SRT 字幕中的字体名出现在 HTML 标签中:
// 替换 <font face="任意内容">
NSRegularExpression *fontFaceRegex = [NSRegularExpression
regularExpressionWithPattern:@"<font\\s+face\\s*=\\s*([\"'])[^\"']*\\1"
options:NSRegularExpressionCaseInsensitive
error:®exError];
问题描述:用户选择中文内嵌字幕,但实际显示的是英文字幕。
根因分析:
videoSubTitlesIndexes 数组索引 0 是 "Disable"
0,第二条是 1
错误的映射:
用户选择 VLC 索引 1(第一条字幕)→ 错误地映射为 FFmpeg 索引 1 → 提取了第二条字幕
代码修复:
// 修复前(错误)
int ffmpegTrackIndex = trackIndex + 1;
// 修复后(正确)
int ffmpegTrackIndex = (int)subtitleIndex - 1;
// VLC 索引 1 → FFmpeg 索引 0
// VLC 索引 2 → FFmpeg 索引 1
日志验证:
[updateSubtitleUrl] iOS 26 拦截内嵌字幕: VLC subtitleIndex=1,
→ FFmpeg trackIndex=0 // 修复后正确映射到第一条字幕流
<font> 标签问题问题描述:内嵌字幕提取为 SRT 后,部分 SRT 仍无法显示中文。
日志分析:
[ExtractSub] SRT 前 200 字:
1
00:00:00,000 --> 00:00:03,018
<font face="方正准圆简体" size="21"><b>...
根因分析:虽然 FFmpeg 提取时没有字体定义,但某些视频的字幕流本身已包含 <font face="xxx"> 标签。这些标签导致 VLC 的 freetype 渲染器尝试加载指定字体,同样失败并 fallback 到被拦截的路径。
修复:在加载 SRT 前,批量替换所有 <font face="xxx"> 标签:
NSString *srtText = [NSString stringWithContentsOfFile:srtUrl.path encoding:NSUTF8StringEncoding error:nil];
NSString *replaced = [self replaceSrtFontNamesInText:srtText];
[replaced writeToFile:srtUrl.path atomically:YES encoding:NSUTF8StringEncoding error:nil];
问题描述:硬编码的字体名列表无法覆盖所有可能出现的字体名。
原方案:
NSArray *fontNamesToReplace = @[
@"微软雅黑", @"微软雅黑", @"SimHei", @"SimSun",
@"黑体", @"宋体", @"楷体", // ...
];
问题:总有漏网之鱼,如 方正准圆简体、Noto Sans CJK SC 等。
改进方案:正则 + 字符串匹配兜底
正则覆盖任意字体名,字符串匹配处理边缘情况:
// 正则:替换所有 {\fn任意字体名} → {\fnSource Han Sans CN}
// 兜底:字符串匹配常见字体名
modifiedText = [self fallbackReplaceFontNamesInText:modifiedText];
┌─────────────────────────────────────────────────────────────────┐
│ iOS 26 字幕兼容架构 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ handleiOS26 │ │ updateSubtitleUrl │ │convertSubtitle│ │
│ │ SubtitleOn │ │ (内嵌拦截) │ │ (外挂处理) │ │
│ │ Playing │ │ │ │ │ │
│ └──────┬──────┘ └──────┬────────┘ └──────┬────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ @available(iOS 26.0, *) 守卫 │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ 禁用内嵌字幕 │ │ FFmpeg 提取 │ │ 字体名替换 │ │
│ │ (libass) │ │ SRT │ │ (正则+兜底) │ │
│ └──────────────┘ └──────┬───────┘ └──────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ freetype 渲染 │ │
│ │ + 指定中文字体 │ │
│ └──────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
基于 Swift 5.10(含部分 Swift 6 行为)与现代 Objective-C(ARC + LLVM clang)。 文中代码片段为最小化示例,仅作为论点的佐证。
| 派发方式 | 调用开销 | 触发条件 | 可被 Hook | 典型场景 |
|---|---|---|---|---|
| Static Dispatch(直接派发) | 最低,可内联 |
struct/enum 方法、final、全局函数、@inlinable
|
否 | 值类型、性能敏感路径 |
| V-Table Dispatch(虚表派发) | 一次间接跳转 |
class 的非 final 方法(无 @objc) |
否 | 普通 Swift 类继承 |
| Witness Table Dispatch | 一次表查 + 一次间接跳转 | 通过协议变量调用协议方法 | 否 | 面向协议编程 |
Message Dispatch(OC objc_msgSend) |
SEL→IMP 查表(带缓存) |
@objc dynamic、继承自 NSObject 且未优化 |
是(Swizzle/KVO) | OC 互操作、AOP |
一句话总结:Swift 默认追求"能静态就静态",只在继承、协议、互操作三条路径上才退化为表派发或消息派发;OC 则把所有方法调用统一成 objc_msgSend 的动态消息查找,灵活但每次调用都要付出查表代价。
方法派发(method dispatch)就是编译器/运行时如何把"调用某方法"翻译成"跳转到某段机器指令"。
派发方式直接决定三件事:
OC 与 Swift 在这三个维度上的设计哲学截然相反,下面分别拆解。
objc_msgSend 的本质OC 中 [obj doSomething:arg] 在编译期不会被解析为某个具体函数地址,而是被翻译成:
((void (*)(id, SEL, id))objc_msgSend)(obj, @selector(doSomething:), arg);
objc_msgSend 是一段手写汇编,做的事情大致是:
1. 取 obj->isa 拿到 Class
2. 在 Class 的 method cache 里按 SEL hash 查 IMP
3. 命中 → 直接 jmp IMP(尾调用,不入栈)
4. 未命中 → __class_lookupMethodAndLoadCache3 走 method list / 父类链
5. 查到 → 写回 cache
6. 仍找不到 → 进入 forwarding(resolveInstanceMethod / forwardingTargetForSelector / forwardInvocation)
⚠️ 实战提示:
objc_msgSend的 cache 是 per-class 的开放寻址哈希表,命中率通常 > 95%。这意味着"OC 方法慢"的直觉并不准确——在热路径上一次调用通常只多花几个时钟周期,远低于一次 cache miss。真正的成本不在派发本身,而在无法内联导致优化器失去全局视野。
消息派发让以下能力成为零成本默认值:
Class 的 method list 即可全局劫持。NSKVONotifying_XXX 子类并替换 isa。UIApplication sendAction:to:from:forEvent: 完全建立在 SEL 之上。forwardInvocation: 让一个对象"假装"实现某协议,是 OC 多代理与 RPC 框架的基石。代价是:编译期不知道 IMP 是什么,全程无法内联,也无法做跨方法优化。
Swift 没有"统一派发"的设计,而是根据声明上下文挑选最便宜的合法方式。理解 Swift 性能模型的关键,就是搞清"什么场景用哪种"。
调用直接编译成 call <symbol>,可被内联、可被常量折叠。触发条件:
struct、enum 的所有方法(值类型不存在继承)class 中标了 final 的方法、或 final class 的全部方法private 方法(编译器能证明无覆写)static 函数@inlinable / @_transparent 修饰的方法struct Counter {
var value = 0
mutating func tick() { value += 1 }
}
var c = Counter()
c.tick()
c.tick() 在 -O 下会被完全内联,最终汇编里看不到调用指令,只剩一条 add。
Swift 的 class 与 C++ 类似,每个类有一张 V-Table(在 metadata 末尾),按声明顺序存放方法指针。调用时:
1. 从 obj 的 metadata 偏移取 V-Table
2. 按方法在表中的固定 index 取 IMP
3. call IMP
只有一次表查 + 一次间接跳转,比 objc_msgSend 少了 SEL hash 与 cache 命中判断,但因为是间接调用,仍然无法跨方法内联。
class Animal {
func speak() { print("...") }
}
final class Dog: Animal {
override func speak() { print("woof") }
}
Animal 的 speak 通过 V-Table 派发;Dog 因为 final,其调用点会被去虚化(devirtualize)回 static dispatch。
通过协议变量调用协议方法时,Swift 用一张 Protocol Witness Table(PWT):每个"类型 × 协议"对生成一张表,表里按协议方法的固定 index 存放该类型的具体实现。
protocol Drawable {
func draw()
}
struct Circle: Drawable { func draw() { /* ... */ } }
func render(_ d: Drawable) {
d.draw()
}
render 拿到的 d 是一个 existential container(值 + 类型 metadata + PWT 指针)。d.draw() 实际是:
1. 从 existential container 取 PWT
2. 按 draw 在协议中的 index 取 witness
3. call witness(witness 内部再 call 真正的实现)
⚠️ 实战坑:
some Drawable(opaque return type)和Drawable(existential)的派发完全不同。前者编译期就确定了具体类型,可以走 static dispatch + 单态化(specialization),后者必须走 PWT。把热路径里的func make() -> Drawable改成func make() -> some Drawable,在 SwiftUI / 集合操作里能拿到数量级的性能提升。
objc_msgSend)Swift 在以下两种情况会退化到 OC 的消息派发:
@objc dynamic
NSObject,且方法满足 @objc 暴露规则,且没有被去虚化优化class MyVC: UIViewController {
@objc dynamic func reload() { /* ... */ }
}
只有 @objc dynamic 的方法是保证走 objc_msgSend 的,因此也只有它能被 KVO / Method Swizzling 劫持。仅 @objc 不带 dynamic 的方法,编译器仍可能选择 V-Table 派发。
把"声明位置 × 修饰符"组合起来,就是一张完整的决策表:
| 声明上下文 | 默认派发 | 加 final
|
加 @objc
|
加 @objc dynamic
|
|---|---|---|---|---|
struct / enum 方法 |
Static | — | 不允许 | 不允许 |
class 直接定义的方法 |
V-Table | Static | V-Table(兼可 OC 调) | Message |
class extension 中的方法 |
Static | Static | V-Table | Message |
protocol 要求的方法 |
Witness | — | Message(要求 @objc protocol) |
Message |
protocol extension 默认实现 |
Static | — | 不允许 | 不允许 |
NSObject 子类的方法 |
V-Table | Static | V-Table | Message |
几条容易踩的经验法则:
extension 中定义的方法即使被子类重写也不会触发动态派发,重写会被静默忽略。要支持覆写就把方法定义放回类主体。@objc ≠ dynamic:前者只是给 OC runtime 暴露符号,后者才强制走消息派发。简化的相对开销(命中 cache、无优化干扰的情况下):
| 派发方式 | 相对开销 | 备注 |
|---|---|---|
| Inlined static | ~1× | 实质上没有调用 |
| Direct call (static) | ~1× | 一条 call
|
| V-Table | ~1.5–2× | 一次 load + 间接 call |
| Witness Table | ~2× | 与 V-Table 量级相同 |
objc_msgSend(cache 命中) |
~3–5× | 多了 SEL hash 与 cache 比对 |
objc_msgSend(cache miss) |
数十× | 走 method list 查找 |
但真实业务里这些差异通常被淹没,例如 UI 主线程一次 reloadData 的耗时可能是 10ms 量级,几百次 objc_msgSend 完全可以忽略。性能差异真正显著的场景是:
map / filter / 自定义 reduce)CADisplayLink、SwiftUI 的 body 求值)struct,需要引用语义或 OC 互操作再用 class。class 不需要继承时直接 final class,让编译器去虚化。some P 就别用 P,能用 any P 就别忘加 any 让代码意图清晰。@inlinable + @usableFromInline。@objc dynamic var ...
@objc dynamic func ...
@objc(不必加 dynamic)公平起见,OC 的消息派发模型在以下场景仍有 Swift 难以替代的优势:
| 维度 | OC 占优的原因 |
|---|---|
| 编译速度 | 没有泛型单态化、类型推断爆炸,增量编译显著快于 Swift |
| 运行时反射 |
class_copyMethodList / class_copyIvarList 等一整套 runtime API |
| 二进制体积 | 没有 witness table / metadata 膨胀,纯 OC 类的 metadata 极小 |
| AOP / Hook 生态 | Aspect、Stinger、KVO 都建立在 objc_msgSend 之上,Swift 难以等价替代 |
| C / C++ 互操作 | 与 C 二进制接口零成本互通 |
工程实践中,底层基础库(埋点、网络、监控)继续用 OC,业务层迁 Swift,往往是兼顾性能与生产力的最稳妥分工。
下面这段代码同时触发了四种派发,是理解 Swift 派发模型的一个好对照:
@objc protocol Refreshable { func refresh() }
class Base: NSObject, Refreshable {
func refresh() { print("base") }
}
final class Leaf: Base {
override func refresh() { print("leaf") }
}
let a: Refreshable = Leaf()
let b: Base = Leaf()
let c: Leaf = Leaf()
a.refresh()
b.refresh()
c.refresh()
a.refresh():Refreshable 是 @objc protocol,走 objc_msgSend。b.refresh():Base 继承 NSObject,编译器保守起见走 V-Table(若 Base 也是 final,可去虚化)。c.refresh():Leaf 是 final,编译器去虚化为 Static,可被内联。把同一个方法在三种持有方式下分别调用,得到三种不同的派发路径——这是 OC 永远不会出现的现象,也是 Swift 性能调优最容易被忽略的细节。
从 2019 年问世算起,SwiftUI 已经快七年了。它早已脱去了最初几年的稚气,逐渐成为苹果生态开发者的基础能力之一。不过,SwiftUI 的闭源属性也意味着,它的很多运行机制始终不透明。开发者在使用时固然能感受到它的表达优势,但一旦遇到问题,往往很难进一步追踪原因。这种特性也让 SwiftUI 在 AI 辅助编程时代显得有些“吃亏”——相比那些长期暴露在社区讨论、源码和文档中的技术,大模型能参考的高质量材料终究有限。
as? [String: Any]——链式 JSON 访问的正确姿势iOS三方库精读 · 第 15 期
SwiftyJSON 是一个用于 iOS/macOS 的 JSON 解析辅助库,它通过链式下标访问和安全类型转换,让原本需要大量 as? 强转和 guard let 解包的 JSON 解析代码,变成像访问字典一样直观的单行操作。
| 属性 | 信息 |
|---|---|
| ⭐ GitHub Stars | 22k+ |
| 最新稳定版 | 5.0.2 |
| License | MIT |
| 支持平台 | iOS 13+ / macOS 11+ |
| 语言 | Swift(纯 Swift,无 OC 接口) |
原生 JSONSerialization 解析复杂 JSON 的体验:
// ❌ 原生方式:每层都要 as? + guard,代码量爆炸
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let user = json["user"] as? [String: Any],
let profile = user["profile"] as? [String: Any],
let bio = profile["bio"] as? String,
let score = profile["score"] as? Double else {
return
}
5 层嵌套,1 个字段。如果某层返回 null,整个 guard 失败,无法优雅降级。
SwiftyJSON 方式:
// ✅ SwiftyJSON:一行,安全,不崩溃
let bio = json["user"]["profile"]["bio"].stringValue // 不存在则 ""
let score = json["user"]["profile"]["score"].doubleValue // 不存在则 0.0
核心优势:
.stringValue / .intValue / .boolValue 自动转换 + 默认值.string / .int / .bool 返回 Optional,可 if let
.arrayValue / .dictionaryValue
.isNull 和 .exists() 区分"不存在"和"存在但为 null"// SPM
// URL: https://github.com/SwiftyJSON/SwiftyJSON.git
// from: "5.0.2"
# CocoaPods
pod 'SwiftyJSON', '~> 5.0'
import SwiftyJSON
// 从 Data 创建(最常用)
let json = JSON(data)
// 从字典/数组创建
let json2 = JSON(["name": "Alice", "age": 25])
// 从字符串创建
let json3 = JSON(parseJSON: "{\"key\": \"value\"}")
// .stringValue → String(不存在时返回 "")
// .string → String?(不存在时返回 nil)
let name1 = json["user"]["name"].stringValue // "Alice" 或 ""
let name2 = json["user"]["name"].string // "Alice" 或 nil
// 其他类型同理
json["count"].intValue // Int,默认 0
json["count"].int // Int?
json["score"].doubleValue // Double,默认 0.0
json["score"].double // Double?
json["active"].boolValue // Bool,默认 false
json["active"].bool // Bool?
// arrayValue: 返回 [JSON],安全(不存在返回 [])
for item in json["user"]["repos"].arrayValue {
let name = item["name"].stringValue
let stars = item["stars"].intValue
print("\(name): ⭐ \(stars)")
}
// 快速 map
let repoNames = json["user"]["repos"].arrayValue
.map { $0["name"].stringValue }
.filter { !$0.isEmpty }
// dictionaryValue: 返回 [String: JSON]
for (key, value) in json["user"]["metadata"].dictionaryValue {
print("\(key): \(value)")
}
let field = json["user"]["lastLogin"]
// 区分"不存在"和"存在但为 null"
print(field.exists()) // false → 路径不存在
print(field.isNull) // true → 路径不存在或值为 null
// 带默认值的安全访问
let last = json["user"]["lastLogin"].string ?? "从未登录"
let firstTag = json["user"]["tags"][0].stringValue // "swift"
let lastRepo = json["user"]["repos"][2]["name"].stringValue // "TodoApp"
// 转回 Data(用于 Codable 混用)
let rawData = try? json["user"]["repos"].rawData()
// 转回 [String: Any]
let rawDict = json.dictionaryObject // [String: Any]?
let rawArr = json.arrayObject // [Any]?
// 用 SwiftyJSON 做"柔性"部分,Codable 做"结构化"部分
let json = JSON(data)
// 1. 取出子 JSON(SwiftyJSON 处理不确定的动态结构)
let extraInfo = json["response"]["extra"] // 动态字段,结构不定
// 2. 将确定结构的部分转为 Codable
if let reposData = try? json["user"]["repos"].rawData() {
let repos = try? JSONDecoder().decode([Repo].self, from: reposData)
}
SwiftyJSON 的核心是一个 JSON 结构体,内部用枚举表示类型:
public struct JSON {
// 内部存储联合类型
fileprivate var rawArray: [Any] = []
fileprivate var rawDictionary: [String: Any] = [:]
fileprivate var rawString: String = ""
fileprivate var rawNumber: NSNumber = 0
fileprivate var rawNull: NSNull = NSNull()
fileprivate var rawBool: Bool = false
public internal(set) var type: Type = .null
}
subscript 访问时,如果类型不匹配或 key 不存在,返回一个 JSON.null 单例而非崩溃。这是链式访问安全性的核心保障。
每次 subscript 访问都会创建新的 JSON 实例(值类型复制),深层链式访问在循环中可能造成性能开销。热路径代码建议:
// ❌ 在循环中重复深层访问
for _ in 0..<10000 {
let _ = json["a"]["b"]["c"]["d"].stringValue
}
// ✅ 缓存中间节点
let profile = json["a"]["b"] // 只创建一次
for _ in 0..<10000 {
let _ = profile["c"]["d"].stringValue
}
// 解析 https://api.github.com/search/repositories?q=swift 的响应
func parseSearchResult(data: Data) -> [String] {
let json = JSON(data)
// 总数
let total = json["total_count"].intValue
print("找到 \(total) 个仓库")
// 取前 5 个仓库名
return json["items"].arrayValue.prefix(5).map { repo in
let name = repo["full_name"].stringValue
let stars = repo["stargazers_count"].intValue
let lang = repo["language"].string ?? "Unknown"
return "\(name) ⭐\(stars) [\(lang)]"
}
}
// SwiftyJSON 的 subscript 关键实现
public subscript(key: String) -> JSON {
get {
if type == .dictionary {
if let value = rawDictionary[key] {
return JSON(value)
}
}
return JSON.null // ← 不崩溃,返回 null JSON
}
}
JSON.null 是一个静态单例,所有对它的 subscript 访问都继续返回自身,形成"null 传播链",这就是为什么 json["a"]["b"]["c"]["d"] 即便 "a" 不存在也不会 crash。
| 维度 | SwiftyJSON | Codable |
|---|---|---|
| 解析时机 | 运行时,按需访问 | 解码时一次性反序列化 |
| 类型错误 | 运行时,返回默认值 | 编译时 / 解码时抛错 |
| 内存占用 | 保留完整 JSON 树 | 只保留 struct/class 数据 |
| 适用场景 | 探索、动态结构 | 固定 API 模型 |
问题 1:.string 返回 nil 而 .stringValue 返回空字符串
null 或类型是 Number,.string 只在类型是 String 时返回非 nilstring ?? "默认值" 或 .stringValue;如果需要 Number → String 转换:
let val = json["count"].string ?? json["count"].numberValue.stringValue
问题 2:修改 SwiftyJSON 的值没有生效
JSON 是值类型(struct),赋值后修改的是副本var json = JSON(data)
json["user"]["name"] = "New Name" // ✅ 使用 subscript setter
问题 3:Swift Package Manager 找不到模块
SwiftyJSON,但有时大小写不一致import SwiftyJSON(大驼峰),检查 SPM 依赖是否成功解析问题 4:OC 项目无法使用 SwiftyJSON
NSJSONSerialization + YYModel / MJExtension,或在 Swift 桥接层封装问题 5:解析性能在大量数据时较差
JSON 实例Codable,小量动态数据 SwiftyJSON 够用| 方案 | 类型安全 | 动态 JSON | OC 支持 | 性能 | 推荐场景 |
|---|---|---|---|---|---|
| SwiftyJSON | 运行时 | ✅ 最好 | ❌ | 中等 | 探索/动态结构 |
| Codable | 编译时 | ⚠️ 需 AnyCodable | ❌ | 高 | 固定 API 模型 |
| YYModel (OC) | 运行时 | ✅ | ✅ | 高 | OC 项目 |
| ObjectMapper | 运行时 | ✅ | ❌ | 中等 | Swift,已有项目 |
| NSJSONSerialization | 无 | ✅ | ✅ | 高 | 简单/OC 场景 |
新项目 Swift:优先
Codable,复杂动态 JSON 用 SwiftyJSON 辅助。 老项目 OC:NSJSONSerialization+ YYModel / MJExtension。 混合项目:在 Swift 层用 Codable 建模,可选 SwiftyJSON 处理边界情况。
github.com/yourname/ios-lib-demos
用 SwiftyJSON 解析一个真实 API(如 GitHub / 豆瓣 / OpenWeather),要求:处理嵌套 3 层以上的 JSON,包含数组遍历和 null 字段处理,最终展示在 UITableView 中。评论区分享你选的 API 和最复杂的解析路径。
SwiftyJSON 用"null 传播"(路径不存在时返回 JSON.null 而非 crash)来保证安全性,而 Swift Codable 用 Optional 和 throws 来保证类型安全。这两种设计哲学各有什么权衡?在什么情况下"静默返回默认值"比"抛出错误"更合适?
下一期我们将深入 R.swift(编译时安全的资源访问)。你在项目中遇到过资源文件名拼写错误导致运行时崩溃吗?你目前是如何管理图片/字体/颜色等资源的?欢迎评论区分享你的资源管理方案。
📅 本系列每周五晚更新 ✅ 第11期:DGCharts · ✅ 第12期:Hero · ✅ 第13期:Realm · ✅ 第14期:Moya · ➡️ 第15期:SwiftyJSON · ○ 第16期:R.swift
iOS三方库精读 · 第 14 期
Moya 是一个建立在 Alamofire 之上的网络抽象层库,它用 TargetType 协议将所有 API 接口声明为 Swift 枚举 case,让网络请求从"散落在各处的字符串 URL"变成"编译器可检查的类型化接口",同时内置单元测试 Stubbing 和 Plugin 拦截机制。
| 属性 | 信息 |
|---|---|
| ⭐ GitHub Stars | 15k+ |
| 最新稳定版 | 15.0.3 |
| License | MIT |
| 支持平台 | iOS 13+ |
| 语言 | Swift(纯 Swift,无 OC 接口) |
| 依赖 | Alamofire 5.x |
直接使用 Alamofire 或 URLSession 时,常见的问题:
// ❌ 硬编码 URL,散落在各处
AF.request("https://api.example.com/users/\(userId)/profile",
method: .get,
parameters: ["include": "avatar"],
headers: ["Authorization": "Bearer \(token)"])
parameters: [String: Any],传错类型编译器不报错Moya 的解决方案:
sampleData + StubbingProvider,无需 mock URLSession// SPM
// URL: https://github.com/Moya/Moya.git
// from: "15.0.3"
// Products: Moya(基础)/ RxMoya(RxSwift)/ CombineMoya(Combine)
# CocoaPods
pod 'Moya', '~> 15.0'
pod 'Moya/RxSwift' # 可选
pod 'Moya/Combine' # 可选
import Moya
enum UserAPI {
case login(email: String, password: String)
case profile(userId: Int)
case updateAvatar(data: Data)
case logout
}
extension UserAPI: TargetType {
var baseURL: URL { URL(string: "https://api.example.com/v2")! }
var path: String {
switch self {
case .login: return "/auth/login"
case .profile(let id): return "/users/\(id)"
case .updateAvatar: return "/users/avatar"
case .logout: return "/auth/logout"
}
}
var method: Moya.Method {
switch self {
case .login, .updateAvatar: return .post
case .logout: return .delete
case .profile: return .get
}
}
var task: Task {
switch self {
case .login(let email, let pw):
return .requestParameters(
parameters: ["email": email, "password": pw],
encoding: JSONEncoding.default
)
case .updateAvatar(let data):
let formData = MultipartFormData(provider: .data(data),
name: "file",
fileName: "avatar.jpg",
mimeType: "image/jpeg")
return .uploadMultipart([formData])
default:
return .requestPlain
}
}
var headers: [String: String]? { ["Content-Type": "application/json"] }
// 单元测试用的 Stub 数据
var sampleData: Data {
switch self {
case .login:
return """{"token":"test_token","userId":1}""".data(using: .utf8)!
default:
return Data()
}
}
}
let provider = MoyaProvider<UserAPI>()
// 回调方式
provider.request(.login(email: "test@example.com", password: "123456")) { result in
switch result {
case .success(let response):
let json = try? response.mapJSON()
print(json ?? "")
case .failure(let error):
print(error)
}
}
// 直接 map 到 Codable 模型
provider.request(.profile(userId: 42)) { result in
if case .success(let response) = result {
let user = try? response.map(User.self)
}
}
// 统一注入认证 Token
struct TokenPlugin: PluginType {
func prepare(_ request: URLRequest, target: TargetType) -> URLRequest {
var req = request
if let token = AuthManager.shared.token {
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
return req
}
// 401 自动触发 token 刷新
func didReceive(_ result: Result<Response, MoyaError>, target: TargetType) {
if case .success(let response) = result, response.statusCode == 401 {
AuthManager.shared.refreshToken()
}
}
}
// 统计 API 耗时
struct MetricsPlugin: PluginType {
func willSend(_ request: RequestType, target: TargetType) {
Analytics.trackStart(api: target.path)
}
func didReceive(_ result: Result<Response, MoyaError>, target: TargetType) {
Analytics.trackEnd(api: target.path)
}
}
// 组合多个 Plugin
let provider = MoyaProvider<UserAPI>(plugins: [
TokenPlugin(),
MetricsPlugin(),
NetworkLoggerPlugin() // Moya 内置日志插件
])
// 无需真实网络,立即返回 sampleData
let testProvider = MoyaProvider<UserAPI>(stubClosure: MoyaProvider.immediatelyStub)
// 延迟返回(模拟网络延迟)
let testProvider2 = MoyaProvider<UserAPI>(
stubClosure: MoyaProvider.delayedStub(0.5) // 延迟 0.5s
)
// 测试代码
func testLogin() {
testProvider.request(.login(email: "test@example.com", password: "123")) { result in
switch result {
case .success(let response):
XCTAssertEqual(response.statusCode, 200)
let model = try? response.map(LoginResponse.self)
XCTAssertNotNil(model?.token)
case .failure:
XCTFail()
}
}
}
import Combine
class UserViewModel: ObservableObject {
@Published var user: User?
@Published var error: String?
private var cancellables = Set<AnyCancellable>()
private let provider = MoyaProvider<UserAPI>()
func loadProfile(userId: Int) {
provider.requestPublisher(.profile(userId: userId))
.tryMap { try $0.map(User.self) }
.receive(on: DispatchQueue.main)
.sink(
receiveCompletion: { [weak self] completion in
if case .failure(let error) = completion {
self?.error = error.localizedDescription
}
},
receiveValue: { [weak self] user in
self?.user = user
}
)
.store(in: &cancellables)
}
}
import RxSwift
provider.rx.request(.searchRepos(query: "swift"))
.map(RepoSearchResult.self)
.observe(on: MainScheduler.instance)
.subscribe(
onSuccess: { result in print(result.items) },
onFailure: { error in print(error) }
)
.disposed(by: disposeBag)
provider.request(.login(...))
↓
MoyaProvider.requestNormal
↓
调用所有 Plugin.prepare(修改 URLRequest)
↓
调用 Plugin.willSend(发送前通知)
↓
Alamofire.request(真正发网络请求)
↓
收到响应
↓
调用 Plugin.didReceive(响应后通知)
↓
回调 completion handler
Moya 本质是 Alamofire 的装饰器(Decorator),所有的实际网络操作都委托给 Alamofire,Moya 只负责协议声明、Plugin 拦截、Stub 切换。
Task 枚举涵盖了所有常见请求类型:
public enum Task {
case requestPlain // 无 body
case requestData(_ data: Data) // 原始 Data
case requestParameters(parameters:encoding:) // URL 参数 or JSON body
case uploadMultipart(_ data: [MultipartFormData]) // 文件上传
case downloadDestination(_ destination: DownloadDestination) // 文件下载
case uploadCompositeMultipart(_, urlParameters:) // 混合上传
// ...
}
这种穷举枚举设计确保了所有请求形式都有类型安全的表达方式。
// 1. 定义 API Target
enum NewsAPI {
case topHeadlines(country: String, page: Int)
case article(id: String)
}
extension NewsAPI: TargetType {
var baseURL: URL { URL(string: "https://newsapi.org/v2")! }
var path: String {
switch self {
case .topHeadlines: return "/top-headlines"
case .article(let id): return "/articles/\(id)"
}
}
var method: Moya.Method { .get }
var task: Task {
switch self {
case .topHeadlines(let country, let page):
return .requestParameters(
parameters: ["country": country, "page": page, "pageSize": 20],
encoding: URLEncoding.queryString
)
case .article: return .requestPlain
}
}
var headers: [String: String]? { nil }
var sampleData: Data { Data() }
}
// 2. Service 层封装(屏蔽 Moya 细节)
final class NewsService {
private let provider = MoyaProvider<NewsAPI>(plugins: [
TokenPlugin(),
NetworkLoggerPlugin()
])
func fetchHeadlines(country: String, page: Int) async throws -> [Article] {
return try await withCheckedThrowingContinuation { cont in
provider.request(.topHeadlines(country: country, page: page)) { result in
switch result {
case .success(let response):
do {
let articles = try response.map([Article].self, atKeyPath: "articles")
cont.resume(returning: articles)
} catch {
cont.resume(throwing: error)
}
case .failure(let error):
cont.resume(throwing: error)
}
}
}
}
}
// 3. 业务层调用
let service = NewsService()
Task {
let articles = try await service.fetchHeadlines(country: "cn", page: 1)
}
Moya 的 MoyaProvider<Target: TargetType> 是泛型类型,每种 API 有独立的 Provider 实例。这意味着:
Moya 的设计完美体现了 SOLID 原则:
| 原则 | 体现 |
|---|---|
| Single Responsibility | TargetType 只描述接口声明,Provider 只负责执行 |
| Open/Closed | 新增 API 只需新增枚举 case,不修改 Provider |
| Liskov Substitution | StubbingProvider 可无缝替换真实 Provider |
| Interface Segregation | Plugin 协议的每个方法都是可选实现 |
| Dependency Inversion | 业务代码依赖 TargetType 协议,而非具体 URL 字符串 |
问题 1:sampleData 返回空 Data 导致测试解析失败
StubbingProvider 但忘记实现 sampleData
sampleData
问题 2:Plugin.prepare 中修改 headers 无效
URLRequest 是值类型,必须先 copy 再修改func prepare(_ request: URLRequest, target: TargetType) -> URLRequest {
var req = request // ← copy 一份
req.setValue("Bearer xxx", forHTTPHeaderField: "Authorization")
return req // ← 返回修改后的副本
}
问题 3:Moya 请求不在主线程回调
callbackQueue 是 .main,但某些版本或配置下可能改变DispatchQueue.main.async { ... } 或使用 .receive(on: MainScheduler.instance)
问题 4:多个 API 服务需要不同 baseURL
UserAPI, ProductAPI),各自独立 Provider问题 5:文件上传进度无法监听
provider.request(.uploadAvatar(data: imageData)) { result in ... }
// 上面不支持进度,改用:
provider.requestWithProgress(.uploadAvatar(data: imageData)) { progress in
print("上传进度:", progress.progress)
}
| 方案 | 类型安全 | 测试友好 | 学习成本 | OC 支持 | 推荐场景 |
|---|---|---|---|---|---|
| URLSession | ❌ 字符串 | 需 mock | 低 | ✅ | 超简单场景 |
| Alamofire | ⚠️ 需封装 | 需封装 | 低 | ❌ | 中型 App |
| Moya | ✅ 枚举 | ✅ 内置 | 中 | ❌ | 中大型 Swift App |
| Apollo(GraphQL) | ✅ 代码生成 | ✅ | 高 | ⚠️ | GraphQL API |
github.com/yourname/ios-lib-demos
用 Moya 封装一个天气 API 服务层:定义 WeatherAPI enum,包含"当前天气"和"5天预报"两个 case,实现 TargetType,并编写一个 TokenPlugin 注入 API Key,最后用 StubbingProvider 为两个接口各写一个单元测试。评论区分享你的 TargetType 实现。
Moya 的 TargetType 强制将一个服务的所有 API 放在同一个 enum 里。当 API 接口很多时(50+),这个大 enum 会变得难以维护。你会如何设计拆分方案?能否在不改变使用方代码的前提下实现"API 分模块管理"?
下一期我们将深入 SwiftyJSON(JSON 解析利器)。你在处理复杂嵌套 JSON 时用过哪些方案(SwiftyJSON / Codable / ObjectMapper / 手动解析)?在 OC 项目中你是如何处理 JSON 的?欢迎评论区分享。
📅 本系列每周五晚更新 ✅ 第11期:DGCharts · ✅ 第12期:Hero · ✅ 第13期:Realm · ➡️ 第14期:Moya · ○ 第15期:SwiftyJSON