普通视图

发现新文章,点击刷新页面。
昨天以前首页

Tauri 应用首次上架 App Store 被驳回了 3 次(iOS)和 12 轮(macOS)的经历

作者 ssshooter
2026年5月26日 17:14

Mind Elixir 是一款基于 Tauri 框架的跨平台思维导图应用,支持 iOS、macOS、Linux、Windows、Android。免费用户最多创建 24 个思维导图,付费用户(订阅/终身许可)可解锁无限数量。付费功能通过官网购买,应用内登录账号解锁。

第一次把 Tauri 应用提交到 App Store,iOS 被驳回了 3 次,macOS 跟 Apple 来回沟通了 12 轮才通过。这篇文章记录整个过程中踩过的坑,希望能帮到同样在做跨平台付费应用的开发者。


iOS:3 次驳回,10 天通过

Round 1:登录体验 + 商业模式审查

4月15日收到驳回,涉及两个条款:

Guideline 4 — Design:应用把用户跳转到外部浏览器登录注册,Apple 认为体验不好。

当时我的登录逻辑是直接调起系统默认浏览器走 OAuth 流程。Apple 的要求是要么在应用内实现登录,要么用 ASWebAuthenticationSession 在应用内嵌浏览器完成。

Guideline 2.1(b) — Information Needed:Apple 看到应用有付费功能,但不确定商业模式,要求回答四个问题:

  1. 用户在哪里购买订阅?
  2. 用户能访问哪些已购内容?
  3. 有哪些付费内容不走 IAP?
  4. 用户如何注册账号?

我回复解释了跨平台架构——订阅通过官网购买,登录后解锁"无限思维导图"。

Round 2:IAP 产品没提交

4月17日再次驳回,条款 Guideline 2.1(b) — App Completeness。

这是个低级错误。我的应用里引用了 Annual Pass、Lifetime Pass 这些订阅产品,但我在 App Store Connect 里没有把这些 IAP 产品一起提交审核。Apple 直接说"我们没法继续审核,因为找不到这些 IAP"。

教训:提交应用审核的同时,必须把关联的 IAP 产品也一起提交,并且要提供审核截图。

Round 3:订阅信息不完整

4月20日第三次驳回,涉及两个条款:

Guideline 3.1.2(c) — Subscriptions:应用内缺少自动续期订阅的必要信息展示。Apple 要求必须展示:

  • 订阅服务标题
  • 订阅时长
  • 价格
  • Terms of Use 链接
  • Privacy Policy 链接

Apple 还建议使用 SubscriptionStoreView,可以一站式搞定这些信息展示。

Guideline 2.1(b) — Information Needed:还是找不到 IAP 产品。这次 Apple 明确说了,要在 sandbox 环境测试通过,并且确保 Paid Apps Agreement 已签署。

最终通过

4月21日第四次提交,4月23日通过。


macOS:12 轮拉锯战

macOS 的审核比 iOS 复杂得多。一方面是 macOS 的审核规则和 iOS 有差异,另一方面我用了 Tauri 框架打包,引入了一些 iOS 不存在的问题。

第一轮:三个条款同时命中

4月15日 / 16日收到驳回,一次性命中三个条款:

Guideline 2.4.5(vii) — Performance:应用包含了可能用于在 Mac App Store 之外更新应用的框架或 API。

这个是我用 Tauri 打包时带进来的。Tauri 默认会集成一些自动更新的依赖,即使我没有在代码里使用,打包后的二进制文件里仍然包含了这些框架。Mac App Store 要求所有更新必须通过 App Store 进行,不能有额外的更新检查机制。

Guideline 3.1.1 — In-App Purchase:Premium 会员可以通过官网购买,但应用内没有提供 IAP 购买选项。

Apple 的规则很明确:跨平台应用可以让用户访问在其他平台购买的内容,但前提是该内容也必须可以通过 IAP 购买(参考 Guideline 3.1.3(b))。不能只允许通过网站购买。

Guideline 4 — Design:和 iOS 一样的登录体验问题。

这个条款后来拉扯了好几轮,说起来挺搞笑的。我在 macOS 版已经用了 ASWebAuthenticationSession,但问题是 macOS 上的 ASWebAuthenticationSession 表现出来就是打开默认浏览器——这是 Apple 自己文档里写的行为,不是我的实现问题。但审核员看到"打开了浏览器",就直接判我没用 ASWebAuthenticationSession,反复驳回。

我后来把 Apple 官方文档里关于 macOS ASWebAuthenticationSession 行为的说明贴了回去,审核员才承认我确实用了,然后换了个理由继续驳回。笑死。

后续拉锯

这之后经历了多轮驳回和重新提交:

日期 动作 主要问题
4/21 Apple 驳回 继续追问细节
4/24 Apple 驳回 新问题出现
4/24 我回复 x2 提供更多信息
4/27 Apple 驳回 IAP 功能 bug
4/27 我回复 说明修复进展
4/29 Apple 驳回 订阅链接问题
4/30 我回复 补充元数据
5/2 Apple 驳回 最后几个问题
5/2 我最终回复 一次性解决所有问题

最终回复中解决的问题

  1. Guideline 2.1(b):修复了一个 bug——"升级"按钮在某些情况下会变灰无法点击
  2. Guideline 3.1.2(c):在 App Store 元数据的 app description 里添加了 Terms of Use 链接(https://desktop.mind-elixir.com/eula
  3. Guideline 4:详细解释了 Tauri 框架下使用 ASWebAuthenticationSession 的技术方案,说明这是 Tauri 应用做 OAuth 的标准做法
  4. Guideline 5.1.1(v):Apple 其实不推荐让用户必须登录后才能购买 IAP,他们认为这会增加购买摩擦。但我解释了跨平台场景下的实际需求——用户登录后购买,可以关联到账号,这样在其他平台(Linux、Windows、Web)也能直接使用,避免重复购买。Apple 最终接受了这个解释。

5月2日最终通过。


踩坑总结

IAP 相关

  • IAP 产品必须和应用一起提交。不要想着先提交应用再补 IAP,这会导致 Round 2 那种情况
  • 订阅页面必须展示完整信息。用 SubscriptionStoreView 可以省很多事
  • 跨平台应用必须同时提供 IAP 购买选项。即使你的主要销售渠道是官网,App Store 里也必须有 IAP

登录相关

  • 不要跳转外部浏览器登录。用 ASWebAuthenticationSession 在应用内完成
  • 支持注册的应用必须支持账号删除(Guideline 5.1.1(v))
  • Apple 不推荐"先登录再购买"。默认情况下,用户应该能直接购买 IAP,不需要先注册或登录。但如果你有合理的跨平台需求(比如购买需要关联账号以同步到其他平台),可以在回复中详细说明理由,Apple 会酌情考虑

macOS 专项

  • 检查打包依赖中是否包含更新框架。Tauri、Electron 等框架可能默认带入自动更新模块,需要在打包时排除
  • Mac App Store 禁止任何应用外更新机制

审核策略

  • 首次提交会收到更详细的反馈。Apple 会恭喜你加入开发者计划,同时可能一次性指出多个问题
  • 回复 Apple 消息时要详细、有条理。我最后一轮回复是一次性解决了四个条款的问题,之后就通过了
  • 准备好被驳回的心态。尤其是跨平台 + 付费模式的组合,审核会更严格
  • 每次回复都要把之前的问题重新说一遍。这是我踩的一个大坑——macOS 审核过程中,Apple 多次提出相同的问题,比如登录体验、IAP 覆盖等,明明我在之前的回复里已经解释过了,下一轮又会重新问。我不确定是每次 review 的审核员不同、上下文没延续,还是他们就希望你每次都确认。总之,不要假设审核员看过你之前的回复,每次提交补充信息时,把之前已回答的问题也一并带上,避免来回拉锯

审核时间线

iOS:
04/13  提交
04/15  ❌ 驳回(登录体验 + 商业模式)
04/17  ❌ 驳回(IAP 未提交)
04/20  ❌ 驳回(订阅信息不完整)
04/21  第四次提交
04/23  ✅ 通过

macOS:
04/15  提交
04/16  ❌ 驳回(更新机制 + IAP + 登录)
04/17 ~ 05/02  12 轮消息往来
05/02  ✅ 通过

整个过程最大的体会:跨平台应用上架 App Store,技术上要处理框架带来的隐式依赖(比如 Tauri 的更新模块),产品上要适配 Apple 的 IAP 生态(即使你的主力销售在官网),体验上要符合 Apple 的设计规范(登录流程不能跳外部浏览器)。三个维度缺一不可。

通用 AI Agent 驱动网关路由安全审计实践

作者 得物技术
2026年4月30日 09:57

本项目构建了一个网关路由 AI 安全审计系统,采用"通用 Agent + 业务 Skill"分层设计,增量日检/存量月检。落地 Open 网关路由越权漏洞检测流程,通过 AI 批量筛查 + 人工深度验证的人机协同模式,为大规模 API 安全审计提供了可复用的智能化解决方案。充分发挥通用 Agent 能力,业务逻辑在 Skill 中快速迭代。

一、背景与技术方案

安全审计的核心挑战

随着平台 API 规模持续扩张,安全审计面临新的规模化挑战。

image.png

主要挑战:

  • 覆盖面不足:抽样审计约覆盖 ~20%
  • 时效性压力:新接口需要更快的安全评估
  • 规则一致性:标准化检测规则难以沉淀

技术选型与建设契机

当前,以大型语言模型为基座的 AI Agent 在代码语义理解、逻辑推理与自动化执行等维度的能力已超预期成熟,工程落地准确率与稳定性得到大规模验证。这一技术跃迁使全量自动化安全审计从概念验证走向可靠实践。

传统人工抽样模式在数万条 API、数百个微服务的规模下已难以为继,而基于 AI Agent 的方案可实现 100% 路由覆盖与分钟级检测响应。本文将围绕这一契机,阐述如何构建贯穿全链路调用链的智能审计体系,解决覆盖面、时效性与规则一致性三大核心挑战。

二、技术架构

整体架构设计

架构说明:常规代码负责任务调度与结果存储,所有 AI 分析工作由超级 Agent 完成。具体项目分析告警时采用 AI 批量筛查 + 人工深度验证的人机协同模式。

image.png

具体项目分析告警时采用 AI 批量筛查 + 人工深度验证的人际协同模式:

image.png

场景化应用:

image.png

架构设计原则:通用 Agent + 业务 Skill 分离

设计优势:

image.png

  • 通用 Agent 能力最大化:充分利用 Claude Code/OpenCode 的代码理解、推理分析、上下文管理、会话恢复等标准能力,不重复造轮子
  • 业务逻辑快速迭代:检测规则、分析流程、报告格式等业务逻辑全部沉淀在 Skill 中,可随时调整优化
  • 任务可追溯可复现:通过 --resume 恢复会话现场,任何分析过程都可回溯、可验证

Skill 层核心组成

gateway-route-vuln-analyzer/
├── SKILL.md # 核心:漏洞分析主流程 
│ ├── 检测决策树(Step 1-4)
│ ├── 危害评估规则
│ └── 报告输出模板 
├── references/ 
│ ├── unauthorized_patterns.md # 越权漏洞模式库
│ ├── logic_flaws.md # 逻辑漏洞检测指南
│ ├── data_classification.md # 数据敏感性分级
│ └── report_template.md # 标准化报告模板
└── scripts/ 
└── mcpcli-gateway # CLI入口(Token优化)

MCP 工具集设计

image.png

三、漏洞检测方法论(以越权为例)

越权漏洞精细化分类

基于公开漏洞案例库分析,细分越权漏洞类型:

image.png

检测决策流程

检测分四步,前两步设有短路退出以降低成本:

路由配置检查:检查 auth_config.publicrequired_scopes,配置无异常则跳过后续审计。登录态识别:遍历调用链查找认证节点,标准认证路由直接信任,跳过代码审计 代码审计(三维检测)。:检查权限注解(@PreAuthorize)、用户 ID 来源(登录态 vs 请求参数)、所有权校验(DB 过滤 vs 代码显式校验)。精细化危害评估:区分越权读取(数据敏感性)和越权操作(利益流向),输出风险等级与修复建议。

精细化危害评估机制

越权读取 - 数据敏感性评估

依据《数据安全法》确立的数据分类分级保护制度及《网络安全法》关于网络运营者数据安全管理义务的相关要求,通过 AI Agent 对源代码文本分析(基于变量名、字段类型、接口定义等代码特征进行技术推断),对路由功能返回涉及的数据资产进行分级评估。

image.png

越权操作 - 利益流向评估

image.png

四、技术优化:Token 成本降低 95%+

问题诊断

通过对多个会话日志的深入分析,识别出 Token 消耗的关键问题:

image.png

Token 消耗热点:

image.png

优化策略与效果

MCP → CLI 转换(mcp2cli)⭐核心优化

原理:将 MCP 工具暴露为 CLI 命令,避免每次会话加载 MCP 上下文。

# 优化前:MCP调用(需加载完整MCP上下文)
Claude → MCP Server → 工具执行
# 优化后:CLI调用(直接执行,无额外上下文)
Claude → Bash → mcp2cli → 工具执行

效果:节省 61% Token 消耗。

工具返回值优化 ⭐关键优化

问题:无参数调用返回完整文件(最大 1.47MB ≈ 500K tokens)。解决方案:为 gitlab_file 添加精准参数,实现按需提取。

image.png

效果:v2 vs v1 再节省 88%。

Early-Exit 模式

原理:标准认证路由直接信任,跳过冗余代码审计。

image.png

效果:标准认证场景节省 50-70%。

AI 友好返回格式 YAML

原理:YAML 天然比 JSON 的 Token 量更少,对 AI 阅读更友好,降低模型解析成本。

五、模型选型原则与决策框架

在路由安全审计场景下,模型选型需围绕准确率与召回率两大核心指标建立决策矩阵:

image.png

最终选型:满足 P0/P1/P2 三重约束的最优模型。选型理由:在召回率 100% 的候选模型中,qwen3.5-plus 以更低的单位成本实现可接受的准确率,是批量场景下的最优解。

局限性说明:

测试集局限性:上述结论基于独立手工标注测试集(100+ 样本),与生产告警数据相互隔离,仅可作为基线参考依据。模型迭代风险:大模型迭代更新速度极快,市场中或存在性能更优、成本更低的全新模型,暂未纳入本次评测范围

AI 不是替代人工,而是放大安全工程师的能力:AI 处理重复性筛查,人工聚焦深度分析和复杂判断。

六、方法论沉淀

定制化漏洞分析能力沉淀:针对 API 越权漏洞场景,构建专属漏洞分析技能体系,持续沉淀检测规则与标准化分析流程,保障规则具备落地实战有效性。精细化危害评估体系:严格区分越权读取与越权操作两类风险行为,引入利益流向分析维度,规避一刀切式风险评级,评估更贴合业务实际风险。Token 成本系统化优化:通过 MCP→CLI 格式转换 + 代码精准按需提取 + Early-Exit 提前终止三层优化方案,整体实现 Token 成本降幅 95%+。场景化分层模型选型:批量例行场景采用高性价比模型,核心关键场景启用高精度模型,在检测效果与使用成本之间实现最优平衡

七、误报分析与改进方向

误报根因分析

基于已完成的复核告警深度分析,识别出以下主要误报原因及改进方案:

image.png

针对性改进方案

强化信任边界追踪(解决 35% 误报)

问题:AI 发现中间层没校验就停止,直接认为存在漏洞,未追踪到最终数据操作层。方案:在 Skill 中增加指导规则:发现校验就停止,发现缺失就继续,必须追踪到信任边界,中间层不能下结论。

上下游参数一致性校验(解决 25% 误报)

问题:下游方法参数有越权可能,但上游调用时未传入资源 ID。方案:分析 Controller 层 Request 对象,确认是否包含资源 ID 字段。如果上游 Request 中无资源 ID 字段,判定为"仅操作当前用户数据"。

Dubbo 配置信息补充(解决 10% 误报)

问题:内部网关转 Dubbo 传参信息 AI 无法获取,导致认证判断错误。方案:引入 Open 网关 Dubbo 配置信息(通过内部管理平台获取),新增 MCP 工具 get_dubbo_config,获取路由的 Dubbo 调用配置。

# 新增工具
"get_dubbo_config": {
    "description": "获取路由的Dubbo调用配置和参数映射",
    "inputSchema": {"route_path": "string", "service_name": "string"}
}

响应体价值预判优化(解决 10% 误报)

问题:AI 将无敏感信息的读操作误判为越权。方案:细化响应体敏感性判断规则。不敏感(可忽略):

纯状态码返回
通用配置信息(活动名称、时间、状态)
流程节点信息

后续规划

image.png

八、小结

网关路由 AI 驱动审计是一个面向 API 安全场景的智能化交付范式,将规模化安全审计升级为 AI 自动化检测 + 精细化危害评估 + Token 成本优化的标准化机制。它回答的问题:如何在网关路由规模快速扩张的背景下,实现安全漏洞的全量自动化检测,同时将成本控制在可接受范围。它证明的事:多个高危外网漏洞的实际发现,验证了 AI 在代码审计场景的有效性。单条成本 ¥0.23,某大型业务集群全量扫描一轮不到 1 万元,成本完全可控。人机协同模式有效:AI 批量筛查 + 人工深度验证,既保证效率又确保准确性。它沉淀的方法:MCP→CLI 转换、精准代码提取、Early-Exit 优化,可复用于其他 AI 安全审计场景。

九、附录

标准化告警报告模板

本项目输出标准化的漏洞分析报告,以下是模板结构:

## 漏洞分析报告

### 1. 路由信息
- 路由ID: [必填:从 analyze_route 返回的 route_id]
- 路径: [必填:分析的路由路径]
- 目标服务: [必填:目标服务名称]
- 描述: [必填:路由描述,如无则填"无"]

### 2. 接口功能分析
- 接口用途: [必填:简要说明接口用途]
- 业务场景: [必填:业务场景描述]
- 调用方: [必填:H5前端/APP客户端/小程序/其他服务]
- 数据敏感性评估:
  - 涉及数据类型: [必填:PII/金融数据/订单信息/配置信息等]
  - 敏感等级: [必填:critical/high/medium/low]
  - 影响用户范围: [必填:全平台用户/特定用户群/内部用户等]

### 3. 调用链分析
[必填:调用链树形展示,使用缩进表示层级关系,标记漏洞点]

示例格式:
[网关入口] (总耗时)
  └─ [认证服务]: [认证接口] (耗时) [认证节点]
  └─ [业务服务A]: [业务接口] (耗时) [漏洞点]
     └─ [业务服务B]: [数据库查询] (耗时)

关键中间件操作(只写与漏洞分析直接相关的部分):
- SQL: [可选:列出执行的 SQL 操作类型]
- Redis: [可选:如有 Redis 操作则填写]
- MQ: [可选:如有 MQ 操作则填写]

### 4. 漏洞详情

⚠️ 说明:
- 如果发现漏洞,必须填写此章节
- 如果未发现漏洞,填写"未发现漏洞"并跳过后续小节

#### 漏洞 1: [必填:漏洞标题]
- 严重等级: [必填:critical/high/medium/low]
- 类型: [必填:越权读取/越权操作/逻辑漏洞/竞争条件]
- 漏洞位置:
  - 服务: [必填:漏洞所在服务名称]
  - 方法: [必填:漏洞所在方法名]
  - 文件: [必填:文件路径:行号]
  - 仓库链接: [必填:GitLab 代码链接]
  - Trace ID: [必填:对应的 trace_id]
- 问题描述: [必填:详细描述漏洞原因和利用机制]
- 调用链位置: [必填:标注漏洞在调用链中的位置]
- 问题代码: [必填:漏洞代码片段,包含行号]
- 漏洞危害:
  1. [必填:直接危害]
  2. [必填:间接危害]
  3. 潜在连锁攻击: [必填:可能的连锁攻击场景]
  4. 合规风险: [必填:法律合规风险]
- 修复建议:
  1. [必填:具体修复方案]
  2. [必填:具体修复方案]
  3. [可选:额外加固建议]

### 5. 分析置信度
- 置信度: [必填:高/中/低]
- 依据: [必填:说明置信度的判断依据]

### 6. 局限性
⚠️ 数据获取不全说明:

| 限制类型 | 说明 | 影响范围 | 建议操作 |
|----------|------|----------|----------|
| [限制类型] | [说明] | [影响范围] | [建议操作] |

### 7. 二方包依赖
- 涉及的二方包: [必填:group_id:artifact_id:version - 用途说明,如无则填"无"]
- 源码获取: [必填:已获取/未获取/不需要]

### 8. 涉及的微服务
⚠️ 重要:必须包含 commit_id 和 project_path

- [service_name1] (commit: [commit_id], project: [project_path])
- [service_name2] (commit: [commit_id], project: [project_path])

往期回顾

  1. AI 驱动:从运营行为到自动化用例的智能化实践|得物技术
  2. 生成式召回在得物的落地技术分享与思考
  3. 立正请站好:一个组件复用 Skill 的工程化实践|得物技术
  4. 财务数仓 Claude AI Coding 应用实战|得物技术
  5. 日志诊断 Skill:用 AI + MCP 一键解决 BUG|得物技术

文 / 炁源

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

生成式召回在得物的落地技术分享与思考

作者 得物技术
2026年4月16日 10:17

一、背景

推荐系统在提升用户体验的同时,也面临着信息茧房、兴趣收敛和内容同质化的挑战。随着用户与系统交互的深入,"推荐→用户反馈→再推荐"的闭环会逐渐强化用户的少数主兴趣,导致推荐结果趋同,降低用户的新鲜感与满意度。

生成式AI技术的快速发展为推荐系统带来了新的机遇。与传统的判别式匹配范式不同,生成式召回通过预测用户下一个可能点击的内容,实现从"匹配已知"到"预测潜在"的范式转变。在得物社区这一潮流生活方式平台上,用户对内容多样性和新颖性的需求尤为突出,这为生成式召回的探索提供了天然的场景。

基于此背景,得物启动了生成式召回方向的一期探索,旨在为下一代智能推荐系统的构建积累经验,探索推荐系统的 scaling-law 规律。

传统召回方法的局限性与生成式召回的动机

传统判别式ANN召回的局限性

  • 时序信息建模不足:难以有效捕捉用户行为序列中的长期兴趣、短期偏好及其动态演变过程。
  • 兴趣多样性受限: 基于历史行为的匹配范式容易收敛到少数高频兴趣点,难以拓展用户的兴趣广度。
  • 匹配范式天花板:判别式兴趣建模受限于已有历史数据,难以预测用户未来的、潜在的兴趣方向。
  • 兴趣融合能力弱:各兴趣点通常独立建模,缺乏对用户多兴趣间协同关系的端到端建模能力。

生成式召回的核心优势

  • Next-Token Prediction 范式:通过预测用户下一个可能点击的内容,实现端到端的用户兴趣融合建模。
  • 引导式召回机制:为生成式模型提供可控的、结构化的召回条件,确保召回内容的相关性与业务目标一致性。
  • 时序依赖建模:基于 Transformer 架构,自然捕捉用户行为序列中的时序依赖关系。
  • 兴趣预测能力:不仅能匹配已知兴趣,还能基于历史行为模式,预测用户的潜在兴趣方向。
  • 端到端优化:从用户行为序列直接生成召回结果,减少中间环节的信息损失。
  • 具有 scaling-law 规律:随着样本与模型规模的提升,能大幅提高模型的表达能力,提高线上的推荐效果。

二、技术方案

得物生成式召回系统采用 Generative Model 与 Rerank Model 联合训练的端到端设计,实现了生成与排序的协同优化。

image.png

Generative Model设计细节

生成式模型基于 Transformer 架构实现 Next-Token 生成任务,主要特征包括:

  • 主序列特征:使用用户图文和视频的有效点击序列,以及对应的一 / 二 / 三级类目序列,截断最近 100 个行为;
  • 首位 User Token 生成策略:联合训练辅助双塔模型产出首位 user_token,通过梯度隔离机制,确保生成任务与双塔任务的独立优化;
  • 模型参数配置:采用当前 DeepRec 框架可承受的最大参数规模,配置为 n_layers=3,n_heads=4,dim=64,并加入 position embedding,增强时序建模能力。

Rerank Model设计细节

重排模型与生成式模型联合训练,通过多任务学习提升召回精度:

  • 联合训练机制:通过召回目标同时训练 rerank 模型的 item 塔与 user 塔,与 Generative Model 共享底层特征表示;
  • 多任务梯度平衡:设计合理的损失权重分配策略,确保生成任务与重排任务的梯度协同优化。

推理过程:从一级类目生成到精准召回

生成式召回在线上推理时遵循"生成→向量化→检索→重排"的四步流程,兼顾了生成式模型的预测能力与向量检索的效率。

一级类目生成

推理过程首先通过生成式模块的 Decoder 生成 Top-K 一级类目。经过离线 recall@100 参数搜索对比,确定 K=4 为最优配置,在召回效果与计算成本间取得平衡。生成的一级类目作为后续步骤的 “硬条件” 向量,为多兴趣建模提供结构化引导。

多兴趣向量构造

基于生成的 K 个一级类目,通过条件双塔的 user_tower 分别得到图文和视频的 K 个用户兴趣向量。这一设计实现了兴趣解耦,每个兴趣向量专注于特定类目下的用户偏好,避免了传统单向量表示中的兴趣混淆问题。

ANN召回与Rerank排序

各兴趣向量分别进行 ANN 向量检索,从候选池中召回相关 item。召回结果再由 Rerank 模型进行精细化打分排序,最终通过蛇形 Merge 策略将多个兴趣通道的结果融合,作为最终召回列表输出。

三、实验效果

为验证生成式召回的实际效果,我们在得物社区进行了严格的AB测试。结果也带来了社区线上指标的显著提升。验证了生成式算法的在得物落地的可行性,并预示着更大的探索潜力。

核心消费指标显著提升

生成式召回在多个核心消费指标上取得显著正向效果:

指标名称 相对提升(%) 显著性
人均推荐有效VV +0.41% 显著
社区DAU均时长(秒) +0.37% 显著
人均推荐总时长(秒) +0.45% 显著
推荐曝光UV人均内容VV +0.39% 显著

多样性指标改善

除了消费深度,生成式召回在兴趣广度拓展上也表现突出:

多样性指标 相对提升(%) 显著性
人均点击一级类目数 +0.18% 显著
人均点击三级类目数 +0.23% 显著
人均曝光三级类目数 +0.19% 显著

未来工程优化方向

基于一期实践经验,后续工程优化将聚焦于:

  • 框架迁移:从 DeepRec 迁移至 DeepSea-Torch 框架,支持更大参数量与稀疏特征;
  • 架构升级:探索 One-Rec 框架落地,统一生成式与判别式召回范式;
  • 推理加速:研究模型压缩、量化等推理优化技术,进一步降低服务延迟;
  • 成本优化:通过训练策略改进和资源调度优化,降低单位效果的成本。

四、总结与展望

得物生成式召回一期实践表明,通过 “生成预测 + 引导召回” 的技术路径,可以在可控成本下,同时实现用户消费深度与兴趣广度的双重提升,为下一代智能推荐系统的构建提供了重要参考。本次实践成功验证了生成式召回在工业级推荐场景的可行性与有效性。通过 Generative Model 与 Rerank Model 的联合训练架构,实现了从判别式匹配到生成式预测的成功范式迁移。技术方案在保持推荐相关性的同时,显著提升了兴趣探索能力,为打破信息茧房提供了新的技术路径。

当前方案以一级类目作为生成目标,这是考虑到类目体系的稳定性和可解释性。下一步将基于社区样本训练 Item Embedding,并将 Item Token 离散化与用户 Next-Token 生成任务联合训练。这一演进将实现从粗粒度到细粒度的兴趣预测,提升召回的精准度。

模型能力升级

通过框架迁移大幅提升模型参数量,支持大规模稀疏特征,探索更强大的生成式模型架构。具体方向包括:

  • 扩展上下文窗口:从当前的 100 行为扩展到更长序列,更好建模用户长期兴趣;
  • 改进注意力机制:研究稀疏注意力、线性注意力等高效注意力变体,平衡效果与效率。

与LLM结合的可能性

借鉴得物在基于大语言模型的新颖性推荐上的经验,生成式召回可与 LLM 知识增强结合。LLM 的世界知识可以帮助识别用户潜在但未明确表达的兴趣,而生成式模型则负责将这些兴趣转化为可执行的召回策略,形成知识增强的生成式召回新范式。

多模态与跨域生成

探索利用多模态信息生成更丰富的用户兴趣表示,并尝试跨业务域的生成式兴趣迁移。在得物的业务生态中,社区内容与电商商品之间存在天然关联,通过跨域生成式召回,可以实现从内容兴趣到商品需求的自然过渡,提升业务协同价值。

往期回顾

1.立正请站好:一个组件复用 Skill 的工程化实践|得物技术

2.财务数仓 Claude AI Coding 应用实战|得物技术 

3.日志诊断 Skill:用 AI + MCP 一键解决BUG|得物技术

4.Redis 自动化运维最佳实践|得物技术

5.Claude在得物App数仓的深度集成与效能演进

文 /流煜曦

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

基于 Cursor Agent 的流水线 AI CR 实践|得物技术

作者 得物技术
2026年4月7日 11:13

一、背景

在实际迭代开发中,不同需求的代码规模差异很大,有些需求涉及上千行代码,有些则只有一两行。且对于前端的代码验收,主要侧重在界面功能,通过功能验收,没法确保每一行代码都测试到的,以及功能的代码逻辑是否合理,是否健壮、是否规范等问题,都需要通过人工代码 CR 来进一步兜底验收代码的质量,尽量降低业务线上出错的可能。但当面对上千行的代码变更时,人工 CR 也是心有余而力不足。

传统的代码审查依赖人工,面对大规模代码变更时效率有限,而 AI 代码审查能够实现自动化、标准化的质量检查,有效补充人工审查的不足。

二、前端研发 CR 现状与可优化点

CR 现状

目前前端研发同学主要使用的代码质量保障工具有前端 Apex 插件智能体、Uraya 质量分检测。其中 Apex 插件智能体是通过前端研发自助点击或 git hook 自动触发 CR 智能体执行,智能体内定制了 CR 规则以及与 MCP 的结合,利用 Cursor IDE 的 Agent 能力进行本地 AI CR ,找出代码问题、本地解决问题。Uraya 质量分检测是在创建 MR 后,通过流水线自动触发,Uraya 质量分检测代码变更的质量分浮动,产出具体问题的记录,引导研发优化代码。

可优化点

  1. 本地触发 CR 需要研发同学主动点击触发或者通过 Apex git hook 执行 CR 智能体,当开发的需求多、分支多、提交次数多的时候,时长容易漏触发、忘记点。
  2. 对于 MR 评审人员,如果希望通过 Cursor CR 时,需要在本地通过调用 CR 智能体再执行一遍,获取 CR 结果,在目前 Cursor 按量计费的背景下,重复执行 CR 智能体的成本需要及时关注。
  3. 当前流水线 Diff + 大模型 API 的 AI CR 方式,误报率较高,研发使用意愿较低。

三、AI CR 方案对比分析

基于以上现状分析,我们对不同 AI CR 方案进行了深入对比。

Cursor Agent CR 主要优势

流水线集成 CR 与本地 AI CR 差异

四、技术方案设计

结合目前现状与可优化点,我们期望能像 Uraya 质量分检测一样,在 MR 过程中通过流水线自动触发,中途每次代码提交也能自动触发,对于流水线中的 CR 不满意时,可以结合 Apex CR 智能体进行本地 CR 调整代码。

为此我们考虑结合 Cursor Agent CLI 在流水线中增加一个 AI CR 的任务,自动触发 Cursor Agent 代码 CR,并记录 CR 结论,及时展示给研发或者代码评审的同学,辅助代码质量优化。

整体链路设计如下:

  1. 当研发创建 MR 后,流水线配置了 AI CR 检测流水线后,将会自动触发 Cursor Agent CR 任务。
  2. 接收到检测任务后,将会前置将该仓库准备好,并将 MR 的信息以及制定的 CR 规则,一并交给 Cursor Agent CLI 执行,待执行完成,会得到一份 CR 报告。
  3. 接收到检测任务完成后,目前会通过 MR 评论的方式添加到对应的 MR 中,引导用户查看。
  4. 对于开发者视角,打开审查报告,可以根据审查出的问题,进行修改。
  5. 对于 CR 人员视角,打开审查报告,可以根据审查出的问题,一键添加到评论,引导开发者修改。

五、MR 流水线接入与 AI CR 报告

自动触发

以下图 MR 为例,在 MR 流水线中,添加了仓库流水线 AI 检测的检测任务,当创建 MR 时,会自动触发执行一次,在 MR 未合入的过程中,每次代码变更也会自动触发。

添加审查报告评论

检测完成后会自动添加一条 MR 评论,通知研发已完成检测,可以点击查看 CR 报告。评论概览中有审查摘要,显示聚类问题的数量;还有审查总结,即对所有反馈的总结,概览问题。

AI CR 报告

以下为实际 MR 生成的 CR 报告,可以看到,报告主要包括:MR 的基础信息、问题的分类 Tab、问题的具体描述、问题的操作。

具体问题列表

首先报告列表会对问题进行聚类,分为严重问题、警告、建议三类,切换对应 Tab 可以看到问题列表。具体的问题信息,主要有类型、问题代码、修复后代码、描述、文件路径、行号、操作等列。

添加到评论

点击操作列的添加到评论,将会一键将相关问题的信息,生成格式化描述,添加到 MR 的评论中,提醒开发者关注问题、解决问题。

AI 智能解决

点击操作列的 Cursor 解决,将会一键将相关问题的信息,生成解决问题 Prompt,一键打开本地 Cursor ,创建 Agent 对话去解决问题。打开链接后,Cursor 会先接收 Prompt ,你可以简单浏览下,点击 Create Chat ,即可一键创建 Chat,回车执行修复。

Cursor Prompt 预览

Cursor Prompt 预览 确认填入 Chat 执行

复制 Prompt

点击复制 Prompt,支持一键复制修复问题 Prompt,可以放到期望的 IDE 里使用。如下图,就是复制的 Prompt 示例。

六、推荐研发流程实践

尽早创建 MR

当需求分支第一次提交后,就可以创建到 release 或 test 目标分支的 MR 了,后续每次提交代码都将会自动触发检测,产出 AI CR 报告。

研发自主查看与解决

研发收到 AI CR 报告的通知后,可以及时打开 CR 报告查看,确认反馈的疑问点是否需要调整,如果需要调整可以通过 Cursor 一键解决,将问题解决前置到提测以前,这样所有的改动可以尽可能的被测试同学验证到。

人工 CR

发布前最后的人工 CR 可以通过前置的 AI CR 发现与问题前置解决,大幅提升靠最后人工 CR 的反馈、修改等环节效率。特别是当业务需求代码量较大时,人工 CR 浏览的效率和质量也是无法保证的。

七、内置提示词工程

AI CR 其实就像给 AI 一个详细的检查清单。这个清单分两部分:一部分是基本规则,比如"你要扮演什么样的角色"、"按什么流程检查";另一部分是具体的技术要点,比如"注意空指针问题"、"检查React用法是否正确"等。有了这个清单,AI 就能像有经验的程序员一样,系统地检查代码,发现各种潜在问题,让代码质量得到保障。

具体这个规则体系的结构如下:

.cursor/rules
├── 00-role-and-constraints.mdc          # 角色与约束 - 定义AI代码审查助手的角色和基本约束条件
├── 01-workflow-steps.mdc                # 工作流程步骤 - 描述代码审查的工作流程和步骤
├── 02-detection-standards.mdc           # 检测标准 - 定义代码问题的检测标准和准则
├── 03-output-format.mdc                 # 输出格式 - 规定代码审查结果的输出格式和规范
├── 04-best-practices.mdc                # 最佳实践 - 提供代码审查中的最佳实践建议
├── common                               # 通用规则目录 - 包含各种常见的代码问题检测规则
│   ├── 01-null-pointer-defense.md       # 空指针防御 - 防止空指针异常的最佳实践
│   ├── 02-react-hooks-usage.md          # React Hooks 使用 - React Hooks 的正确使用方式
│   ├── 03-data-merge-state.md           # 数据合并状态 - 处理数据合并时的状态管理问题
│   ├── 04-async-programming.md          # 异步编程 - 异步编程模式和常见陷阱
│   ├── 05-memory-leak-performance.md    # 内存泄漏性能 - 检测和防止内存泄漏问题
│   ├── 06-security-coding.md            # 安全编码 - 安全编程实践和漏洞防范
│   ├── 07-compatibility.md              # 兼容性 - 确保代码兼容性的检查点
│   ├── 08-git-conflict-detection.md     # Git 冲突检测 - 检测并解决 Git 合并冲突
│   ├── 09-code-quality.md               # 代码质量 - 代码质量评估和改进规则
│   ├── 10-resource-handling.md          # 资源处理 - 正确处理系统资源的规则
│   ├── 11-url-params.md                 # URL 参数 - URL 参数处理的安全和有效性检查
│   ├── 12-business-logic-consistency.md # 业务逻辑一致性 - 确保业务逻辑一致性的规则
│   └── 13-monorepo-dependency.md        # 大仓依赖 - Monorepo 架构中的依赖管理规则
└── README.md                            # 说明文档 - 规则系统的介绍和使用说明

八、模型选择

在 AI CR 环节,模型的选择需要考虑模型对于代码理解的复杂性、上下文长度需求以及推理准确性、模型的速度、模型的使用成本等考量。在 Cursor 的模型列表中,我们优先使用 Compose 1.5,当额度不足时,我们也会降级使用 Auto 模型。

以下为 Cursor auto 模型与 Composer 1.5 模型对比,可以看出,两个模型都找出了 4 个问题,但在时间上,Composer 1.5 进行需 44 秒即可完成,而 auto 模型需要 91 秒。

九、总结与规划

通过多个迭代实践与数据统计,Cursor Agent CR 挖掘的有效问题数可以达到 50% 左右,研发使用的意愿也相比原来有不少提升。当前我们也在将 AI CR 报告融合到 Cursor IDE 插件中,进一步融合到研发流程里。

随着 AI 生成代码在开发流程中越来越普遍,AI CR 的重要性将进一步凸显。相比传统的人工审查,AI 审查能够自动发现 AI 生成代码中可能存在的逻辑错误、安全性问题和规范性缺陷,提前在开发过程中消除隐患。同时,AI CR 还能确保 AI 生成的代码符合团队的技术规范和最佳实践,保持代码风格的一致性。为 AI 时代的开发流程提供了可靠的质保机制,让开发流程更加顺畅,是现代软件开发的重要保障。

往期回顾

1.从IDE到Terminal:适合后端宝宝体质的Claude Code工作流|得物技术

2.AI编程能力边界探索:基于 Claude Code 的 Spec Coding 项目实战|得物技术

3.搜索 C++ 引擎回归能力建设:从自测到工程化准出|得物技术

4.得物社区搜推公式融合调参框架-加乘树3.0实战

5.深入剖析Spark UI界面:参数与界面详解|得物技术

文 /大圣

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

日志诊断 Skill:用 AI + MCP 一键解决BUG|得物技术

作者 得物技术
2026年4月2日 11:17

一、概述

做后端开发,调 BUG 有一个让人头疼的固定流程:打开日志平台,输入 traceId 或关键词,搜日志;从几十上百条日志里,找到关键的那几条;把日志里的类名、方法名复制出来,去 IDE 里找对应代码;结合代码逻辑,判断哪里出了问题;如果一次找不准,回去再搜日志,再翻代码……

这个过程相对固定,但非常耗时间。每次 BUG 定位,光在日志平台和 IDE 之间来回切换,就能消耗掉大半的时间。

最开始在去年 Q3 想到这个问题的时候,脑子里浮现的第一个方案是:用 Cursor + MCP,把日志平台接进来,再挂一个代码知识库,让 AI 帮我查日志。但这个方案有缺陷 —— 日志查询是「动态的」,它依赖环境、应用、时间范围,没办法静态预置。此外,这样处理没有办法做到比较丝滑地读代码、改代码。

后来开始用 Claude Code,接触到了 Skill 的概念:可以在项目里定义一套自定义命令,描述 AI 应该怎么执行这个命令的每个步骤,于是整个思路变得清晰了。

日志平台有 MCP,Claude Code 有 Skill,两者结合,就能让 AI 自动完成「查日志 → 找关键信息 → 扫描代码 → 定位问题」这整个闭环。然后在 PM 的帮助下,才有了 /log-diagnosis 这个 Skill。

二、日志平台 MCP 是什么

MCP 原理

日志平台推出了基于 MCP(Model Context Protocol)协议的日志查询服务,让 Claude 可以直接调用日志平台的能力,无需人工在日志平台上手动查询。

MCP 本质上是一种标准化的「工具调用协议」,Claude Code 通过 SSE(Server-Sent Events)长连接与 MCP Server 通信,实时获取日志数据。

MCP 环境对照

核心 MCP 工具

鉴权流程

secretKey(日志平台后管申请)
    ↓ acquireTokenTool
accessToken(1小时有效,最多同时存在5个)
    ↓ 携带 accessToken
logsQuery / logSqlQuery / countLogTool ...

secretKey 申请地址:进入日志管理后台 → 日志权限 → 我的应用 → 生成密钥。

三、/log-diagnosis Skill 是什么

Skill 工作原理

log-diagnosis 是一个运行在 Claude Code 里的自定义诊断命令。Claude Code 支持通过 .claude/skills/ 目录定义自定义技能(Skill),以 Markdown 文件描述行为规范,Claude 在收到对应命令时会自动加载并执行。你只需要把 traceId 或告警信息告诉它,剩下的全部交给 AI。完整执行链路如下:

用户输入 /log-diagnosis {环境} {代码分支} {诉求}
    ↓
Claude 加载 .claude/skills/log-diagnosis/SKILL.md
    ↓
读取 .diagnosis/config.json 获取当前环境配置
    ↓
检查 accessToken 是否过期,过期则自动刷新
    ↓
从 traceId 计算日志时间范围(取第9-16位16进制时间戳)
    ↓
调用日志平台 MCP 分页拉取全量日志(最多20页,不遗漏)
    ↓
切换到指定代码分支,结合日志关键词检索代码
    ↓
综合分析:上游日志 + 当前服务日志 + 代码逻辑 → 根因
    ↓
生成诊断报告(飞书文档 or 本地 Markdown)
    ↓
恢复原始代码分支

两种诊断入口

核心能力

  • Token 自动管理:accessToken 过期自动刷新,无需手动维护;
  • 分页全量拉取:自动分页拉完所有日志,禁止只查第一页就下结论(最多 20 页);
  • 跨服务分析:自动识别上下游服务,拉取关联服务日志交叉验证;
  • 代码联动:日志里出现的类名/方法名,直接在代码里精确定位。

queryString 语法规则

# 格式
{field} {操作符} "{值}" {连接符} {field} {操作符} "{值}"
# 操作符
=  : 精确匹配
≈  : 模糊匹配(like)
# 连接符
AND / OR / NOT
# 示例
trace_id"a1b2c3d4e5f6789012345678abcdef01"
trace_id"xxx" AND log_level = "ERROR"
endpoint ≈ "/api/your-endpoint" AND log_level"ERROR"
message ≈ "timeout"

注意:时间范围只通过 start/end 参数控制,不要写在 queryString 中。

四、安装与配置

安装日志平台 MCP

Claude Code

在 Claude Code 命令行中执行,按需安装对应环境:

# 测试环境
claude mcp add --transport sse dw-log-mcp-t1 https://{your-t1-aigw-domain}/api/v1/mcp/log-mcp/sse
# 预发环境
claude mcp add --transport sse dw-log-mcp-pre https://{your-pre-aigw-domain}/api/v1/mcp/log-mcp/sse
# 生产环境
claude mcp add --transport sse dw-log-mcp-prd https://{your-prd-aigw-domain}/api/v1/mcp/log-mcp/sse

安装后重启 Claude Code,执行 /mcp 确认连接状态正常。

Cursor

  1. 打开 Cursor Setting;

  2. 点击 Tools & MCP,添加 MCP Server;

  3. 添加 URL,MCP Server 名称任意。

建议按需安装 MCP Server,避免额外消耗 token,示例配置:

{
  "mcpServers": {
    "dw-log-mcp-t1": {
      "url": "https://{your-t1-aigw-domain}/api/v1/mcp/log-mcp/sse"
    },
    "dw-log-mcp-pre": {
      "url": "https://{your-pre-aigw-domain}/api/v1/mcp/log-mcp/sse"
    },
    "dw-log-mcp-prd": {
      "url": "https://{your-prd-aigw-domain}/api/v1/mcp/log-mcp/sse"
    },
    "dw-log-mcp-oversea-prd": {
      "url": "https://{your-oversea-aigw-domain}/api/v1/mcp/log-mcp/sse"
    }
  }
}

4. 返回设置,就可以看到已经连接上。

安装 /log-diagnosis Skill

将 log-diagnosis 目录放到项目的对应目录下:

Claude Code

your-project/
└── .claude/
    └── skills/
        └── log-diagnosis/
            ├── SKILL.md        # 技能行为规范(核心)
            ├── README.md       # 使用说明
            └── reference.md   # 附录:时间脚本、queryString 示例等

Cursor

your-project/
└── .cursor/
    └── skills/
        └── log-diagnosis/
            ├── SKILL.md        # 技能行为规范(核心)
            ├── README.md       # 使用说明
            └── reference.md   # 附录:时间脚本、queryString 示例等

配置 .diagnosis/config.json

首次运行会自动引导创建 (直接调用 /log-diagnosis,Skill 会一步步指示你给出 secret key),也可手动在项目根目录创建 .diagnosis/config.json:

your-project/
└── .cursor/
    └── skills/
        └── log-diagnosis/
            ├── SKILL.md        # 技能行为规范(核心)
            ├── README.md       # 使用说明
            └── reference.md   # 附录:时间脚本、queryString 示例等

字段说明:

secretKey:唯一需要人工填写的字段,在日志平台后管申请;

accessToken:首次使用时由 AI 自动调用 acquireTokenTool 获取,过期自动刷新;

accessTokenExpireAt:从 acquireTokenTool 返回值自动填充;

fields:调用 logFields 工具自动获取。

五、使用方式

命令格式:

/log-diagnosis {环境} {代码分支(可选)} {诉求描述}

参数说明:

  • {环境}:T1 / PRE / PRD(按实际环境标识填写);
  • {代码分支}:可选,留空则使用当前分支;
  • {诉求描述}:包含 traceId 或告警信息的问题描述,用自然语言书写即可。

示例:

# 用 traceId 定位接口异常
/log-diagnosis T1 feature/your-branch trace_id: "your-trace" 为什么最终没有返回数据
# 用告警信息分析错误原因
/log-diagnosis PRD master 告警详情:【接口:YourService/yourMethod】【业务码:10002000】【业务码消息:系统异常,请稍后重试】帮我分析问题可能性

一行命令,AI 全程接管,几分钟内给出根因分析。

六、实战案例:一个隐蔽的 SQL BUG

背景

某搜索接口在测试环境反馈没有返回数据。拿到 traceId,直接执行:

/log-diagnosis T1 feature/your-branch trace_id: "your-trace" 为什么最终没有返回数据

← 就这一句话,接下来全部交给 AI。

AI 自动拉取日志

Skill 触发后,AI 自动完成:

  • 从 traceId 推算出日志时间范围(2026-02-27 全天);
  • 检查 accessToken 已过期,自动刷新;
  • 调用日志平台 MCP,分 2 页拉取完整日志,共 73 条。

请求入参(从日志自动提取):

{
  "assembleByOrg": true,
  "channelType": "MANUAL",
  "orderNo": "your-order-no",
  "status": 1,
  "ticketNo": "your-ticket-no"
}

AI 还原完整调用链路

AI 自动识别出关键节点:resultList is empty,SQL 查询返回了空结果。问题在 DB 层,而不在业务逻辑层。

AI 提取组装后的查询 DTO

从日志中提取到 toSearchDTO 组装结果:

{
  "channelType": "MANUAL",
  "customerTag": 1,
  "deliveryMode": "某配送方式",
  "orderStatus": "8010",
  "orderType": "0",
  "productCategoryIds": [29],
  "status": 1,
  "ticketSource": 67,
  "ticketTypeId": 5802
}

AI 从日志中提取实际执行的 SQL 发现根因

ORM 框架在日志中打印了实际执行的 SQL,AI 直接读取并分析:

SELECT a.id, a.pid, a.name, a.mode, a.status, a.org_id, a.org_ids,
       a.ticket_group_id, a.tenant_id, a.is_del, a.channel_types
FROM your_type_table a
LEFT JOIN your_relation_table b
    ON b.tenant_id = 1 AND a.id = b.type_id AND b.type = 3 AND b.is_del = 0
WHERE a.tenant_id = 1 AND a.mode = 2 AND a.is_del = 0
  AND a.status = 1
  AND (a.channel_types IS NULL OR a.channel_types = '' OR FIND_IN_SET('MANUAL', a.channel_types) > 0)
  AND (b.root_id is null or b.root_id in (29))
  AND (a.order_types IS NULL OR a.order_types = '' OR FIND_IN_SET('0', a.order_types) > 0)
  AND (a.order_statuses IS NULL OR a.order_statuses = '' OR FIND_IN_SET('8010', a.order_statuses) > 0)
  AND (a.delivery_modes IS NULL OR a.delivery_modes = '' OR FIND_IN_SET('某配送方式', a.delivery_modes) > 0)
  AND (a.ticket_sources IS NULL OR a.ticket_sources = '' OR FIND_IN_SET(67, a.ticket_sources) > 0)
  AND (a.customer_tag IS NULL OR a.customer_tag = 1)   ← BUG 在此

AI 发现:其他字段都处理了 IS NULL 和 = ''(空字符串代表 “不限制”)两种情况,唯独 customer_tag 只判断了 IS NULL,遗漏了空字符串 '' 的情况。

SQL 语义对比:

-- 其他字段(正确):IS NULL 和 '' 都处理了
AND (a.order_types IS NULL OR a.order_types'' OR FIND_IN_SET('0', a.order_types) > 0)
AND (a.delivery_modes IS NULL OR a.delivery_modes'' OR FIND_IN_SET('某配送方式', a.delivery_modes) > 0)
AND (a.ticket_sources IS NULL OR a.ticket_sources'' OR FIND_IN_SET(67, a.ticket_sources) > 0)
-- customer_tag(遗漏了 = '' 的判断)← BUG
AND (a.customer_tag IS NULL OR a.customer_tag1)

DB 中现有的数据,customer_tag 字段都存的是空字符串(未配置),按业务语义本应匹配所有请求,却因为这个遗漏被全部过滤掉了。

AI 定位代码,给出修复方案

AI 在代码中直接找到对应的 MyBatis Mapper XML:

<!-- 问题代码 -->
<if test="customerTag != null">
    and (a.customer_tag IS NULL OR a.customer_tag = #{customerTag})
</if>
<!-- 修复后 -->
<if test="customerTag != null">
    and (a.customer_tag IS NULL OR a.customer_tag = '' OR a.customer_tag = #{customerTag})
</if>

效率对比

这个 BUG 的隐蔽性在于:SQL 语法正确,逻辑上也「看起来」没问题——只有对比了其他字段的写法,才能发现 customer_tag 独自遗漏了空字符串的处理。这类细节差异,人工排查很容易忽略,AI 反而很擅长。

七、诊断效率关键点

  • 有 traceId 时优先用 traceId 拉日志,可精准获取单次请求的完整链路,比关键词搜索精确得多;
  • 关注关键日志节点:toSearchDTO finished / search begins / resultList is empty / search finished 等,快速判断数据在哪一层丢失;
  • SQL 打印日志(ORM 框架输出)是黄金线索,直接反映最终执行的查询条件,AI 能从中发现肉眼难以察觉的差异;
  • 分页必须拉完:日志平台一次只返回部分数据,AI 会严格执行分页直到取完,确保不遗漏关键日志。

八、总结

核心思路:用「协议 + 规范」让 AI 接管固定流程:

这篇文章的本质,是一次对重复性工程劳动的自动化尝试。调 BUG 的过程——查日志、提取关键信息、找代码、分析原因——逻辑固定,步骤繁琐,但并不需要太多创造性思维。这类工作恰好是 AI 最擅长接管的。

实现这个闭环,靠的是两个关键组合:

  • MCP:让 AI 能够调用外部系统(日志平台),突破了「AI 只能处理静态上下文」的限制,实现了对动态数据的实时获取。
  • Skill:给 AI 一份行为规范,告诉它每一步该怎么做、先做什么后做什么、遇到什么情况怎么处理,把「一次性对话」变成「可复用的工程化能力」。

两者缺一不可。只有 MCP,AI 能查日志但不知道怎么系统地分析;只有 Skill,AI 有流程但没有数据来源。组合起来,才形成了真正可落地的闭环。

值得借鉴的地方:

识别「固定流程」是自动化的起点:不是所有工作都适合 AI 接管,但凡是「步骤固定、信息来源明确、输出格式可预期」的工作,都值得尝试用 Skill + MCP 的方式来自动化。排查 BUG 是一个典型,类似的还有:代码审查、性能分析报告生成、告警巡检等。

Skill 的本质是「给 AI 写操作手册」:Skill 文件不是在「训练模型」,而是在给 AI 一份清晰的 SOP。写得越细、约束越明确(比如「禁止只查第一页就下结论」「必须分页拉完所有数据」),AI 的执行质量越稳定。这和写给人看的文档本质上是一回事。

AI 擅长发现「横向对比」类的 BUG:本文的案例揭示了一个有意思的规律:AI 在处理「同类字段逻辑不一致」这类问题时,表现往往比人工更好。原因在于 AI 没有「先入为主」的经验偏见,不会因为「这段代码看起来没问题」就跳过,它会对所有字段做同等的审查。

最后说一句:AI 时代,工程师的核心竞争力不只是「能写代码」,更是「能把自己的经验和流程转化成可复用的 AI 能力」。/log-diagnosis 是一次小小的尝试,但背后的思路,值得在更多场景里延伸。

往期回顾

1.Redis 自动化运维最佳实践|得物技术

2.Claude在得物App数仓的深度集成与效能演进

3.Claude Code + OpenSpec 正在加速 AICoding 落地:从模型博弈到工程化的范式转移|得物技术

4.大禹平台:流批一体离线Dump平台的设计与应用|得物技术

5.基于 Cursor Agent 的流水线 AI CR 实践|得物技术

文 /阿程

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

如何在本地跑 Core ML 模型识别呼噜声,并用 iCloud 优雅同步?

作者 Flutter笔记
2026年4月2日 15:00

大家好,我最近开发了一款App《SleepDiary(睡眠声音日记)》。

9771791b1b272012179e60c5853cedc8.jpg

作为一款睡眠监测类 App,核心业务逻辑可以用一句话概括:

录一整夜的音,把打呼噜和说梦话的片段摘出来,最后生成睡眠报告。

看似简单,但在工程实现上却困难重重:

  1. 隐私与成本问题:长达 8 小时的音频绝对不能一整段传到服务器端,这不仅会直接把你的服务器带宽跑破产,还会被用户骂死(谁敢把在卧室一整夜的录音全传到网上?)。
  2. 性能与功耗问题:放在端侧跑模型,势必要使用长时间的后台保活,如何避免手机发热和 OOM (Out Of Memory)?

经过最近这段时间的研究,我用 AVFoundation + Core ML + SwiftData 的纯血原生技术栈把这套流程跑通了。今天就和大家分享一下我的实现思路与踩坑日记。

一、端侧的 AI:硬核从零训练自己的鼾声分类模型

最初的设计方案很简单粗暴:开个录音,每秒去判断分贝,超过阈值就保存。但这完全不行,深夜翻身的声音、空调声、外面的汽车声都会被误判。

市面上现成的声音分类模型要么太大(动辄上百MB),要么对“鼾声”、“梦话”这种特定场景不够敏锐。于是我决定硬核一点——自己动手,从收集数据开始训练一个专用的轻量级神经网络(SnoreWave.mlpackage)。

1.1 数据收集与模型训练

为了让模型足够精准,我花了大量时间收集开源数据集并结合自己实录的各种“打雷级”打呼声(最终 1.2w 条数据)。 把杂乱的音频转换成模型能“看懂”的输入是第一步——将音频流转化为梅尔频谱图(Mel-spectrogram) 。这相当于将一维的声音信号,变成了二维的图像图像特征,然后再喂给我用深度学习框架搭建的 CNN(卷积神经网络)进行分类。

模型训练收敛后,我依靠 coremltools 将其转换为了 Apple 原生支持的 .mlpackage。为了控制 App 包体积并保证低功耗运转,这个模型被我极致压缩,剥离了非必要分支,达到了极高的预测效率。

1.2 AVAudioEngine 实时截流送显

有了自己的模型,下一步就是在 iOS 端跑通流式推理。 我们不使用高层的 AVAudioRecorder,而是使用 AVAudioEngine。因为它允许我们通过 installTap 在音频流经过的过程中“截胡”到 AVAudioPCMBuffer

然后在端侧把这个 Buffer 原样转化成模型需要的数组输入:

// 截胡音频流的伪代码
let inputNode = audioEngine.inputNode
let format = inputNode.inputFormat(forBus: 0)
inputNode.installTap(onBus: 0, bufferSize: 4096, format: format) { [weak self] (buffer, time) in
    // 捕获到音频帧后,交给我们自定义的分类器管线
    self?.audioCaptureService.processAudioBuffer(buffer)
}
audioEngine.prepare()
try audioEngine.start()

1.3 降维打击 OOM 崩溃:用 Actor 隔离模型生命周期

坑点来了!如果每次截取到一个 buffer,都在主线程或者随机的 Dispatch Queue 去实例化这个自定义模型进行预测,一晚上下来你的 App 必因为内存暴涨被系统强制 Kill 掉(Jetsam Event)。

解法:引入 Swift Actor 隔离与复用机制 

在《睡眠声音日记》中,我是用全局唯一的 Actor 来维持模型的单一生命周期,使用环形缓冲区去缓存几秒钟的声音片断,组合后一次性输出:

swift
actor EventDetectionPipeline {
    // 全局唯一持有我们自己训练好的模型实例
    private let model = try? SnoreWaveformCNN(configuration: MLModelConfiguration())
    
    func processAudioWindow(_ window: AudioWindow) async {
        // 将音频转化成梅尔频谱所需的 MLMultiArray
        guard let multiArray = window.toMLMultiArray() else { return }
        
        // 发起端侧离线推理
        if let prediction = try? model?.prediction(input: multiArray) {
            if prediction.classLabel == "snore" {
                // 命中目标:触发存储!
                await persistCapturedEvent(label: .snore)
            }
        }
    }
}

通过自己训练轻量级模型 + Actor 的串行数据处理,保证了模型资源的极致释放。即便后台连续疯狂推理 8 个小时,CPU 的平均占用率也能被压在极低的水平,用户即使整晚充着电,手机也完全不发烫。

二、存储的艺术:音频文件与 SwiftData 模型分离

识别完事件后,怎么持久化? 这引发了第二个大问题——千万别把音频这种大块二进制流全都写进 SwiftData 或者 Core Data!

2.1 相对路径是王道

我的存储策略是:结构化数据走 SwiftData(打点时间、标签量化数据),音频文件走沙盒原生写入。 在《睡眠声音日记》的 SleepEventRecord 模型中,我只存了一个相对路径(filePath)。

@Model
final class SleepEventRecord {
    var timestamp: Date
    var duration: TimeInterval
    var eventLabel: EventLabel // .snore, .speech, .cough
    var filePath: String? // 只存相对路径: "20240315/snore_0234.m4a"
    
    init(timestamp: Date, eventLabel: EventLabel) {
        self.timestamp = timestamp
        self.eventLabel = eventLabel
    }
}

为什么要相对路径?  因为沙盒路径(UUID)在每次应用重签或重新安装时是会变的。如果存绝对路径,第二天文件全找不到了!读取时,永远使用 FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent(filePath) 动态拼装。

2.2 防治 iCloud 把服务器挤爆

录了一晚上的高音质 M4A 文件,如果不加限制,系统的 iCloud 备份会自动把它们全传上去。用户那可怜的 5GB iCloud 很快就会爆满。因此,我在写入音频文件后,立马用原生 API 给文件打上“拒绝备份”的 Tag:

var url = documentDirectoryURL.appendingPathComponent(fileName)
var resourceValues = URLResourceValues()
resourceValues.isExcludedFromBackup = true // 保护用户的 iCloud 空间!
try url.setResourceValues(resourceValues)

三、私有 CloudKit 的优雅同步体验

音频不用同步了,但我们的 SleepSessionRecord(当晚评分,鼾声次数统计,分析数据)需要跨设备(尤其是和 Apple Watch 联动时)和防止在用户删掉 App 重装后丢失。

以往做 Core Data + CloudKit 繁琐得让人想死。但在 iOS 17 的 SwiftData 下,它变成了真正的“优雅”:我们甚至可以动态控制它的开启闭合。

我把开关值存到了 NSUbiquitousKeyValueStore(KVS),根据这个从远程同步过来的用户偏好,动态初始化 ModelContainer

// 在 SleepDiaryApp.swift 入口处动态配置容器
var sharedModelContainer: ModelContainer = {
    let schema = Schema([SleepSessionRecord.self, SleepEventRecord.self])
    
    // 读取 UserDefaults/KVS 的 iCloud 开关
    let isCloudSyncEnabled = UserDefaults.standard.bool(forKey: "iCloudSyncEnabled")
    
    let configuration = ModelConfiguration(
        schema: schema,
        isStoredInMemoryOnly: false,
        // 如果开启,赋予 private 数据库标识;如果不开启,设为 .automatic 或本地优先
        cloudKitDatabase: isCloudSyncEnabled ? .private("iCloud.xxxxx") : .none
    )
    
    do {
        return try ModelContainer(for: schema, configurations: [configuration])
    } catch {
        fatalError("Could not create ModelContainer: (error)")
    }
}()

依靠云端大容量配额下的 .private 标识,只要用户打开 iCloud 同步,他们在换了新手机后重新下载 App,所有的睡眠数据历史记录就会像魔法一样哗哗哗回到列表中。


四、写在最后

开发《睡眠声音日记》的这段时间里,我最大的感触是:苹果的原生护城河真的很香。  只依靠一套 Swift 兵器库:从 SwiftUI 的丝滑动画绘制、到 HealthKit 获取深度睡眠的联动、再到 Core ML 的底层加速,这是以往杂糅其它中间件完全得不到的性能优势和开发爽感。

感兴趣的同行们,可以在 App Store 搜  “睡眠声音日记-SleepDiary”  下载把玩一下,有任何架构或技术点上的建议,大家评论区见,或者私下找我交流。也欢迎吐槽!

厌倦了那些看着像一个模版复刻出来的抓包工具,我开发了一款iOS端HTTPS抓包调试工具

作者 吴就业
2026年3月27日 22:29

最近的一份工作,因为对业务不熟悉,产品经理出的需求又不考虑历史兼容性,问同事同事也不清楚,作为一个后端开发,我也拿不到客户端的代码,于是我就想到了抓包,通过安装app,抓取某块功能使用了哪些接口。

因为我手机是iPhone, 我因此试用了很多款在app store下载的HTTPS抓包工具,包括免费的Stream、ProxyPin、付费了一款螃蟹抓包。但这些工具感觉都是出自于同一个模版,体验雷同,因为没有别得选择,当时只好忍受。

当时没被满足的一些需求:

1、发现一些图片无法抓取到(我想知道图片用的域名和路径,知道是直接访问云存储,还是用的哪个文件系统服务,这在后端项目中看不出来,因为这个项目的后端也没提供文件上传功能)。

2、JSON无高亮、无搜索功能,也无法对比某个业务参数(比如当商品类型是电子钥匙时、以及商品类型是摄像头时,实际传的参数以及响应的Body有哪些不同的字段)。

3、除体验外,我当时还希望能满足我这个需求:我想把这些接口导入到Apifox,并且基于当前接口和新的迭代需求在此基础上去修改接口,并在团队中共享这份接口。 而当时我只能基于抓取的响应结构,自己在Apifox里面写接口,这耗费了我整整一天时间。

经过那次之后,我决定自己研究写一个,这个HTTPS抓包工具一定把用户体验做好,一定支持抓图片、支持JSON高亮和搜索(甚至是JSON Diff),以及支持自动生成API文档,可以一键导出到Apifox。

2026年1月我开发出来了,这款APP就叫ApiCatcher(因为一开始的目的就是抓API的,所以取名ApiCatcher),所有产品功能皆为原创设计。

能做出来要感谢那些开源项目的,比如ProxyPin,或许是因为开源项目没有盈利,所以体验没做好吧。我似乎也能理解为什么大多数抓包工具长得那么相似了。

我研究了他们的核心抓包功能是如何实现,用了哪些技术,然后自己花两周时间在Claude辅助下用Swift造了一份轮子(就是核心的NIO代理服务器以及SSL握手),在此基础又花两周时间做了优化性能,降低CPU和内存的占用,同时支持抓取大文件请求,避免进程被系统kill掉。我使用SwiftData和文件来存储抓包数据,将请求和响应Body存文件,其它字符串存SwiftData,然后通过边读边写文件来降低对内存的占用,而SwitData则提供更强大的搜索能力,这为产品做查询过滤功能提供了支持,所以ApiCatcher支持非常多的过滤条件。

以下是产品最初几个核心功能的产品设计:

1、极简风格的抓包页面。(我还加了个小创意:正在抓包中的背景是一张蜘蛛网,有一只蜘蛛在上面爬) ApiCatcher | HTTPS抓包工具

2、请求详情内容聚合,便于在手机这种小设备上更好的查看数据,同时减少操作步骤。请求响应的每个部分都是一个卡片,卡片可展开收起。Body可导出和一键复制。Body可展开全屏预览。Body目前支持渲染图片、svg、html、xml和json。 ApiCatcher |请求详情页

3、JSON格式化、高亮、搜索、Diff支持: ApiCatcher | JSON格式化、高亮、搜索、Diff支持

4、接口文档自动生成,以及导出接口文档到Apifox等API调试工具,因为海外用户不用Apifox,所以也支持了Postman和Bruno: ApiCatcher | api导出到Apifox、Postman、Bruno

5、可以抓文件,其实任何HTTP请求都支持,不仅仅是图片,而且没有限制图片大小,多大都能抓,这些图片还可以导出来拿来测试用(一些需要上传特定图片测试的接口):

在这里插入图片描述

经过两个月时间,加上有不少用户给我提需求,于是慢慢功能都完善了。基本app store上的https抓包工具有的功能ApiCatcher都支持了,并且体验更好,像一些正则表达式、脚本都集成AI生成功能提升效率,让用户自己填API Key 。

工具本就是为开发者提升工作效率而开发,所以我们做了支持导入企业内部使用的受信的自签私钥和证书,也可以自己开发一个接收器实时接收抓包流量,实现API扫描分析需求。

这款工具不支持iOS17以下系统,因为用了SwiftData,SwiftData需要17.0以上才支持。整个项目纯SwiftUI开发,核心功能代码用swift-nio等apple官网库。代码高亮则用了WebView+CodeMirror+Highlight.js以及一些插件。这些在app关于我们->开源组件许可都有声明。

ApiCatcherChatTCP这两款网络数据包抓包分析工具都是我自己原创设计、开发的作品,目前两款产品在海外还是不少用户喜欢的,我知道国内大家都喜欢用免费的,比如Stream、ProxyPin、Reqable,但我还是要在各个平台上分享一下的,避免后面被人借鉴反被别人说是我们抄袭,赚不赚钱是次要的,得先证明自己是原创的。

我做了一个鼾声记录App,聊聊背后的功能设计

作者 Flutter笔记
2026年3月27日 10:47

最近做了一款叫「睡眠声音日记」的App,主要用来记录睡眠时的鼾声和梦话。

今天主要聊聊这个App的功能设计思路。

为什么做这个App?

起因很简单:我自己打鼾,但完全不知道每晚打多少、什么时候最严重。 市面上的睡眠App大多侧重睡眠阶段分析,对鼾声的处理比较粗糙。我想做一个真正能听清楚每一段鼾声的工具。

核心:ML鼾声识别

App用CoreML跑了一个本地训练的声音分类模型,实时区分鼾声和人声(梦话)。每检测到一段就自动裁剪保存音频片段,第二天可以逐段回听。

不依赖网络,所有识别都在本地完成,隐私上比较放心。

灵敏度做了三档可调,适配不同噪音环境。

睡眠评分:5个维度

单纯告诉用户"你昨晚打了12次鼾"其实没什么指导意义,所以我做了一套100分制的评分系统,拆成5个维度:睡眠时长、鼾声/呼吸、深睡质量、睡眠连续性、身体恢复。每个维度单独打分,用户一眼就能看出问题出在哪。

AI个性化分析

接入了大模型做每日分析。不是泛泛的建议,而是把用户昨晚的实际数据(鼾声次数、时段分布、评分、HealthKit数据)传进去,生成针对性的建议。

历史页面还有基于多晚数据的趋势分析,能发现长期规律。如果鼾声连续多晚偏重,会主动建议用户去做专业评估。

趋势可视化

做了7晚和30晚两个维度的趋势图表:鼾声趋势、评分趋势、心率趋势、血氧趋势、睡眠时长柱状图。

还有一个昼夜节律分析,记录满5晚后自动解锁,分析用户的时型(早起型/夜猫子)。

这些图表对于观察干预效果很有用——比如换了枕头之后鼾声是不是真的少了。

Apple Watch用户体验拉满

如果你有Apple Watch,体验会更完整:

  • 手表上能看录音状态,直接停止记录
  • 昨晚的评分、鼾声、时长一目了然
  • 详情页有睡眠阶段时间线(核心/深睡/REM),鼾声事件直接叠在上面,一眼看出"你在深睡的时候鼾声最重"
  • 心率、血氧趋势图也有

小组件 + 灵动岛

桌面小组件做了3个尺寸,核心交互是一键开始/停止记录。大号组件额外展示鼾声时间分布图。录音期间支持灵动岛实时活动,锁屏上也能看到计时和事件计数。

其他细节

  • iCloud多设备同步
  • 数据备份恢复,支持导出
  • 音频自动清理(3/7/14/30天),重要片段可钉住跳过清理
  • 睡眠目标 + 睡前提醒
  • 成就系统,增加使用粘性
  • 一键生成分享图片,方便发给医生或朋友
  • iPad侧边栏适配

订阅模式

月订阅6元,年订阅38元,终身买断68元。

欢迎试用,有反馈随时评论区交流~

Claude Code + OpenSpec 正在加速 AICoding 落地:从模型博弈到工程化的范式转移|得物技术

作者 得物技术
2026年3月24日 10:22

一、破局:AI 编码的真正瓶颈不是模型,是上下文管理

在软件开发的历史进程中,每一次效率的飞跃都伴随着抽象层次的提升。从汇编语言到高级语言,从手动内存管理到垃圾回收,开发者始终在寻求降低认知负荷的方法。进入 2026 年,生成式人工智能(GenAI)已成为编程领域不可或缺的力量。 然而,行业正经历从 “模型崇拜” 向 “工程落地” 的深刻转型,单纯依靠增加大语言模型(LLM)的参数规模已无法解决复杂业务逻辑中的幻觉与失控问题。

当前的共识是,AI 编码(AICoding)的真正瓶颈不在于模型的逻辑能力,而在于上下文管理(Context Management)的失效与开发意图(Intent)的模糊。

通过对 Anthropic 推出的 Claude Code(以下简称 CC)与 Fission AI 倡导的 OpenSpec 进行深度解构可以发现,两者正在通过 “代理化执行” 与 “规格化驱动” 双轮驱动,构建一套闭环的 AI 研发体系。这种结合不仅标志着 AI 编程工具从 IDE 插件向终端原生代理(Agentic Tool)的转变,更预示着 “规格驱动开发”(Spec-Driven Development, SDD)将成为企业级 AICoding 落地的核心范式。

在 AICoding 的早期阶段,开发者普遍认为只要模型足够强大,就能解决所有编程难题。然而,随着项目复杂度的增加,这种观点遭到了现实的挑战。研究表明,虽然 AI 编码助手的使用率在提升,但软件交付的稳定性却在下降。例如,Google 的 DORA 2024 报告指出,AI 采用率每增加 25%,交付稳定性反而下降 7.2%。

生产力悖论与认知负荷

AICoding 领域存在一个显著的 “生产力悖论”:开发者在使用 AI 时主观感知速度提升了 20%,但实际完成任务的时间却增加了 19%。这一现象的根源在于 AI 在处理长上下文时的效能衰减。随着任务推移,AI 往往会陷入修正循环(Fix/Test Loops),无法触及深层的业务功能,反而需要更多的人工干预。

模型的逻辑推理能力(Reasoning)在短小上下文中表现卓越,但在大型工程环境中,模型面临的是 “上下文中毒”(Context Poisoning)和 “注意力漂移”(Attention Drift)。当对话历史过长或包含过多无关代码时,模型的性能会呈现非线性下降。例如,GPT-4o 等先进模型在 1K Token 时的准确率为 99.3%,而当上下文扩展到 32K Token 时,准确率会暴跌至 69.7%。这种 “性能断崖” 意味着,单纯依靠扩大上下文窗口(Context Window)并不能解决问题。

上下文工程的兴起

上下文工程(Context Engineering)正在取代提示词工程(Prompt Engineering),成为 AICoding 的核心技术方案。上下文工程的核心不在于 “如何写更好的指令”,而在于 “如何为模型筛选最精准的 Token 集合”。

下表对比了传统缩放路径与上下文工程路径的局限性:

在大型组织中,上下文管理面临更严峻的挑战。很多关键决策并未记录在代码中,而是散落在飞书文档评论、群消息、会议或开发者的认知中。AI 代理在缺乏这些隐性知识(Implicit Knowledge)的情况下,生成的方案虽然符合语法,但却违背了架构初衷或业务约束。

上下文作为一等系统

现代 AI 代理架构开始将上下文视为一种具有自身架构、生命周期和约束的 “一等系统”。在这种视角下,上下文管理不再是临时的字符串拼接,而是一条精密的 “编译器管道”:

  • 存储与呈现分离: 区分持久化的会话状态(Session)与单次模型调用的工作上下文(Working Context)。
  • 显式转换: 通过命名的、有序的处理器(Processors)构建上下文,而非随机堆砌。
  • 默认作用域: 每个子代理仅能看到执行任务所需的最小上下文,通过工具(Tools)按需获取更多信息。

二、Claude Code:把 AI 变成真正懂你项目的编码伙伴

Claude Code (CC) 是 Anthropic 推出的原生代理工具,它直接运行在终端中,具备读取文件、运行命令、执行重构以及自主验证的能力。与传统的 IDE 插件相比,CC 的核心优势在于其“代理循环”(Agentic Loop)和对上下文协议的深度掌控。

代理循环:收集、行动与验证

CC 的工作流程被定义为一个闭环系统,旨在模仿人类工程师的思维过程:

  • Gather Context(收集上下文): CC 不会盲目读取整个目录,而是通过文件搜索、Git 状态检查以及读取特定的 CLAUDE.md 文件来建立认知。
  • Take Action(采取行动): 基于推理,CC 可以跨多个文件执行编辑,或者利用终端工具(如 npm install、git commit)操作环境。
  • Verify Results(验证结果): 这是 CC 最具杀伤力的特性。它能自动运行测试、捕捉错误,并根据反馈调整方案。研究表明,带有验证步骤的 Coding 生成过程,其成功率远高于单次生成。

终端原生的工程哲学

CC 选择了终端而非图形界面作为主场,这体现了其 “代理优先” 的设计哲学。CC 遵循 Unix 哲学,支持管道(Pipe)、脚本化和自动化集成。这种设计使得 CC 能够与现有的 CI/CD 流程完美衔接,例如在 GitHub Actions 中自动执行代码审计。Anthropic 最新推出的 Code Review 功能,就是通过 Claude Code 基于 PR 的方式进行 bug 的追踪。

下表详细对比了 CC 与行业领先的 AI 编辑器 Cursor 的差异:

MCP 与“即时上下文”

CC 深度整合了模型上下文协议(Model Context Protocol, MCP)。MCP 是一个开放标准,允许 AI 代理安全地访问外部数据源。

为了应对大规模工具定义导致的上下文溢出,CC 引入了 “工具搜索” 和 “代码执行” 模式。代理不再一次性加载成千上万个 API 定义,而是通过编写代码按需调用 MCP 服务。例如,在分析大型数据库时,CC 不会加载全量数据,而是编写针对性的查询语句,仅将结果摘要读入上下文。这种 “按需加载” 策略极大地提升了 Token 的效用。

CLAUDE.md 与自动记忆

CC 引入了 CLAUDE.md 文件作为项目的 “操作手册”。这是一个置于根目录的 Markdown 文件,用于存储项目特定的编码标准、架构决策和测试指令。与临时提示词不同,CLAUDE.md 提供了持久的、跨会话的约束。

此外,CC 具备 “自动记忆”(Auto Memory)功能。它会自动在 MEMORY.md 中记录项目的构建命令、调试心得和用户的偏好设置。每当新会话启动时,CC 会加载这些记忆的前 200 行,从而确保 AI 在长期协作中能够 “越用越懂你”。

三、OpenSpec:给 AI 编码加上"规格书",从失控到可沉淀

虽然 Claude Code 提供了强大的执行引擎,但在复杂业务中,AI 仍然可能因为意图不明而跑偏,最终导致交付的代码不符合预期。

OpenSpec 的出现为 AI 编码提供了 “规格说明书”,将 AICoding 从 “凭感觉写代码” 提升到了 “按规格执行任务” 的高度。

规格驱动开发 (SDD) 的兴起

OpenSpec 倡导的是一种 “规格驱动开发”(Spec-Driven Development)范式。其核心理念是:在写任何一行代码之前,先由人类与 AI 共同协商并锁定一份机器可读、人可评审的规格文档。

下表展示了 SDD 的三个演进阶段:

OpenSpec 的工件体系 (Artifacts)

OpenSpec 弃用了笨重的开发文档,转而采用一套轻量级的、面向 AI 优化的 Markdown 工件体系。每个变更(Change)都被组织在独立的文件夹中:

  • proposal.md: 描述变更的初衷(Why)和范围(What)。
  • specs/: 具体的逻辑规格,通常包含 “Scenario(场景)” 描述,通过具体的输入输出消除模糊性。
  • design.md: 技术设计方案,包括本次变更涉及的数据库变更、接口调整等。
  • tasks.md: 原子化的任务清单,作为 AI 的执行路径图。

解决上下文污染:提案、应用与归档

OpenSpec 最具洞察力的设计在于其生命周期管理。AI 在处理新任务时,最忌讳被旧任务的陈旧信息干扰。OpenSpec 的 “归档(Archive)” 机制解决了这一问题:

  • Proposal 阶段: 建立一个独立的变更上下文,让 AI 只关注当前变更。
  • Apply 阶段: AI 严格按照 tasks.md 执行,避免了盲目扫描全库导致的 Token 浪费。
  • Archive 阶段: 任务完成后,临时变更文档被移入归档,核心规格更新至主规格文件。这保证了 AI 始终在一个 “卫生” 的上下文环境下工作,同时也为项目留下了可追溯的决策链路。

四 、实战:CC + OpenSpec 如何落地真实业务

在实际的企业业务场景中,如何整合这两大工具?答案在于将 OpenSpec 的标准化指令集注入到 Claude Code 的会话环境中。

案例实战:复杂业务逻辑的重构

假设一个电商项目需要重构其优惠券结算逻辑。在传统的 AI 辅助下,AI 可能会在修改 CouponService.java 时遗漏分布式锁,或者破坏原有的满减叠加规则。采用 CC + OpenSpec 模式,流程如下:

第一步:提案初始化

执行 /opsx:propose "重构优惠券结算逻辑,引入 Redis 分布式锁并支持多卷叠加"。CC 会在 openspec/changes/refactor-coupon-logic/ 下生成整套骨架。AI 会通过分析现有代码,在 spec.md 中自动列出已知的结算场景。

第二步:规格对齐与边界确认

这时不用急着让 AI 写代码,而是需要先审阅 spec.md。如果发现 AI 没考虑 “优惠券过期临界点” 的并发问题,可以直接要求 AI 修改规格:“在 spec.md 中增加过期校验场景,并要求使用 Lua 脚本保证原子性”。

第三步:受控应用(Apply)

一旦规格通过人工评审,就可以执行 /opsx:apply 了。这时,CC 就变成了完美的执行机器。它不再 “猜” 开发者的意图,而是对照 tasks.md 逐项实施。每一项修改后,它都会运行相关的测试。如果测试失败,CC 会自动分析错误并重新修复,直到该项 Task 标为 “完成”。

第四步、归档与知识固化

任务结束后,执行 /opsx:archive。原本散落在会话记录中的重构逻辑,现在变成了 openspec/specs/coupon-settlement.md 中的标准规格。当下一次另一个 AI 代理(或新入职同事)需要修改此模块时,它只需读取这份规格,即可获得完整的业务语境。

工具链对比:为何选择 OpenSpec

在 SDD 工具链中,OpenSpec 展现出了极高的工程性价比:

OpenSpec 的优势在于它不试图改变开发者的工具偏好。无论是使用 Claude Code、Cursor 还是 Aider,都可以无缝接入 OpenSpec 的规格管理层。

五、沉淀:让 AI 编码能力在团队中持续积累

AICoding 落地的终极目标不是让个体开发者写得更快,而是提升整个团队的知识资产质量。AI 编码能力不应随对话窗口的关闭而消失,而应作为 “团队记忆” 沉淀下来。

从个人技能到组织技能

团队可以通过自定义 Skill 和 MCP Server 来固化组织资产。

  • Skill: 将公司特有的代码风格、安全审计清单,或者特定中间件的使用指南封装为 .claude/skills/。当团队成员使用 CC 时,AI 会自动加载这些技能,仿佛有一位资深架构师在时刻盯着每一行代码。
  • MCP Server: 连接企业内部的向量数据库(如基于 Zilliz 的语义搜索),让 AI 代理能够从数千万行历史代码中找到最佳实践。

建立 AICoding 效能飞轮

AICoding 的成功落地需要建立一套正向循环的 “飞轮”:

  • 规格积累: 每完成一个 PR,都强制更新对应的 OpenSpec 规格文件。
  • 指令进化:发现 AI 反复犯的错,就将其转化为 CLAUDE.md 中的负向约束(Prohibited rules)。
  • 并行执行: 利用 CC 的 Agent Teams 能力,让一个代理负责写规格,另一个代理负责审计代码,第三个代理负责集成测试。

角色转变:从 “码农” 到 “规格定义者”

在 CC + OpenSpec 模式下,软件工程师的角色正在发生质变。如果 AI 能够根据完美的描述生成任何代码,那么 “代码” 本身就变成了编译后的中间产物,而 “规格” 才是核心产品。领域专家(Domain Experts)的重要性显著提升,因为他们能提供最高质量的业务意图描述。这种趋势将迫使开发者从关注 “语法实现” 转向关注 “系统设计” 和 “逻辑严密性”。

六、结语:AICoding 落地的飞轮正在转动

在 2026 年,AICoding 已不再是科幻。Claude Code 提供的强大代理能力,配合 OpenSpec 提供的精密规格框架,为企业提供了一套可复制、可量化的研发新范式。

我们必须承认,AI 编码的瓶颈从来不是模型不够聪明,而是我们与 AI 之间的 “沟通带宽” 太低且 “上下文” 太脏。通过上下文工程化管理(CC)和意图标准化表达(OpenSpec),我们正在构建一套让 AI 能够长期、稳定产出的工程环境。

随着这一模式的普及,软件开发的门槛将进一步降低,而创新的上限将被无限拉高。AICoding 落地的飞轮已经转动,那些能够率先将 AI 编码能力转化为团队组织资产的企业,将在未来的数字化竞争中占据绝对的先机。毕竟,在 AI 时代,掌握了 “意图” 与 “上下文” 的人,才掌握了软件工程的未来。

参考文档:

  1. thenewstack.io/context-is-…
  2. github.blog/ai-and-ml/g…
  3. solguruz.com/blog/spec-d…
  4. medium.com/@eran.swear…
  5. www.anthropic.com/engineering…
  6. code.claude.com/docs/en/how…
  7. www.anthropic.com/engineering…
  8. code.claude.com/docs/en/bes…
  9. dev.to/webdevelope…

往期回顾

1.大禹平台:流批一体离线Dump平台的设计与应用|得物技术

2.基于 Cursor Agent 的流水线 AI CR 实践|得物技术

3.从IDE到Terminal:适合后端宝宝体质的Claude Code工作流|得物技术

4.AI编程能力边界探索:基于 Claude Code 的 Spec Coding 项目实战|得物技术

5.搜索 C++ 引擎回归能力建设:从自测到工程化准出|得物技术

文 /后羿

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

02-主题|事件响应者链@iOS-hitTest与事件传递详解

2026年3月2日 14:13

本文专门讲解 iOS 中事件传递的「确定目标」阶段:hitTest(_:with:)point(inside:with:) 的原理、算法、子视图遍历顺序,以及可响应条件、自定义命中区域和常见图示。与「响应者链」的传递阶段配合理解,可参见 03-响应者链与 nextResponder 详解


一、为什么需要 Hit-Testing

触摸发生时,系统需要确定「触摸点落在哪个视图上」,以便将事件交给该视图并进入响应者链。Hit-testing 即在这一阶段,从窗口根视图开始,沿视图层级向下查找最底层且包含该点的视图,该视图将作为该触摸事件的第一响应者(hit-test view)[1]


二、核心 API

方法 所属 作用
hitTest(_:with:) UIView 在视图树中查找包含指定点的最底层子视图;返回 nil 表示当前视图及其子视图均不接收该点
point(inside:with:) UIView 判断给定点是否在当前视图的 bounds 内(可被重写以扩展或缩小命中区域)

系统从 UIWindow 开始,对根视图调用 hitTest(_:with:),传入触摸点(已转换为该视图坐标系)。视图内部会先调用 point(inside:with:) 判断点是否在自己范围内,再递归对子视图调用 hitTest(_:with:)


三、hitTest 算法与伪代码

3.1 可响应前提

视图要参与 hit-test,通常需同时满足(否则当前分支会被剪掉,返回 nil)[[2]][[3]]:

  • isUserInteractionEnabled == true
  • isHidden == false
  • alpha > 0.01

不满足时,hitTest(_:with:) 直接返回 nil,该视图及其子视图都不会成为命中目标。

3.2 系统 hitTest 逻辑(伪代码)

以下为对系统行为的等价描述,便于理解顺序与剪枝逻辑;实际实现以 Apple 源码为准。

函数 hitTest(point, event) -> UIView?:
    若 当前视图 不满足可响应条件(userInteractionEnabled / hidden / alpha):
        返回 nil

    若 pointInside(point, event) 为 false:
        返回 nil   // 点不在当前视图内,整棵子树不再查找

    // 按子视图「从后往前」顺序遍历(逆序:最后加入的、Z 轴更靠前的先测)
    对 每个 subview 从 subviews.last 到 subviews.first:
        candidate = subview.hitTest( 将 point 转换到 subview 坐标系, event )
        若 candidate != nil:
            返回 candidate   // 找到第一个有返回值的子视图即停止

    若没有子视图命中:
        返回 self   // 点在自己范围内且没有更底层子视图命中,则自己就是 hit-test view

要点:

  1. 先判 pointInside:点不在当前视图内则直接返回 nil,整棵子树被剪枝。
  2. 子视图逆序:按 subviews 从后往前遍历,即** Z 轴靠前的子视图优先**,与视觉上的「最上层」一致。
  3. 第一个非 nil 即返回:找到第一个返回非 nil 的子视图就停止,该子视图即为 hit-test view。

3.3 point(inside:with:) 默认行为

默认实现等价于:判断点是否落在视图的 bounds 内(通常不考虑 subview 的超出部分;且若父视图 clipsToBounds == true,超出父视图 bounds 的子视图区域不会参与父视图的 hit-test,因为点不在父视图 bounds 内会先被剪枝)[[1]]。

函数 pointInside(point, event) -> Bool:
    返回 CGRectContainsPoint(self.bounds, 将 point 转换到当前视图的 bounds 坐标系)

可重写以扩大或缩小可点击区域(如圆形按钮、不规则形状、透明区域穿透等)。


四、事件传递流程(自上而下)

4.1 流程图

flowchart TB
    A[触摸发生] --> B[UIWindow 收到事件]
    B --> C[对根 view 调用 hitTest:withEvent:]
    C --> D{pointInside 为 true?}
    D -->|否| E[返回 nil,该分支结束]
    D -->|是| F[按逆序遍历子视图]
    F --> G[对子视图递归 hitTest]
    G --> H{有子视图返回非 nil?}
    H -->|是| I[返回该子视图 作为 hit-test view]
    H -->|否| J[返回 self]
    I --> K[该 view 成为触摸的 first responder]
    J --> K

4.2 泳道图:Hit-Test 各角色协作

flowchart TB
    subgraph 用户
        U1[手指触摸屏幕]
    end
    subgraph 系统_UIApplication
        S1[事件入队]
        S2[派发至 keyWindow]
    end
    subgraph 系统_UIWindow
        W1[hitTest 根 view]
        W2[得到 hit-test view]
    end
    subgraph 视图层级
        V1[pointInside 判断]
        V2[逆序遍历子视图]
        V3[递归 hitTest]
        V4[返回最终 view]
    end
    U1 --> S1
    S1 --> S2
    S2 --> W1
    W1 --> V1
    V1 --> V2
    V2 --> V3
    V3 --> V4
    V4 --> W2

4.3 Hit-Test 知识结构(思维导图)

mindmap
  root((Hit-Test))
    入口
      UIWindow 根视图
      hitTest:withEvent:
    条件
      userInteractionEnabled
      hidden / alpha
      pointInside
    遍历
      子视图逆序
      Z 轴优先
    结果
      hit-test view
      first responder
    自定义
      扩大热区
      穿透
      不规则区域

五、子视图顺序与 Z 轴

子视图在 subviews 数组中的索引越大,在 hit-test 时越被遍历,因此后加入的、索引更大的子视图会优先被命中,与它们在屏幕上的「盖在上面」一致。若两个子视图重叠,则上面那一层会先被 hitTest 到并成为 hit-test view。

flowchart LR
    subgraph 视图层级
        V[父视图]
        V --> A[子视图 A index 0]
        V --> B[子视图 B index 1]
        V --> C[子视图 C index 2]
    end
    subgraph hitTest 顺序
        C --> B
        B --> A
    end

六、clipsToBounds 与命中

  • pointInside 只判断点是否在当前视图的 bounds 内。
  • 若父视图设置了 clipsToBounds = true,子视图超出父视图 bounds 的部分会被裁剪掉显示,但 hit-test 仍按 bounds 判断:若触摸点落在父视图 bounds 外(即使落在子视图的 frame 内),父视图的 pointInside 会返回 false,整棵子树不会参与命中 [[1]]。
  • 因此:子视图若超出父视图 bounds 且父视图 clipsToBounds,超出部分在默认实现下无法被 hit-test 命中,除非在父视图层重写 point(inside:with:)hitTest(_:with:) 做特殊处理。

七、自定义 hitTest / pointInside 的常见用法

需求 做法
扩大点击区域 重写 point(inside:with:),对中心区域做扩展(如上下左右各扩展 44pt)
透明区域不响应 重写 point(inside:with:),根据像素透明度返回 false
让触摸「穿透」到下层 重写 hitTest(_:with:),在特定条件下返回 nil,使当前视图不参与命中
指定子视图优先 重写 hitTest(_:with:),自定义遍历顺序或强制返回某子视图

示例(扩大点击区域):

override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
    let inset: CGFloat = -20
    return bounds.insetBy(dx: inset, dy: inset).contains(point)
}

商用场景示例:商品列表 Cell 内「加购」「收藏」等小图标,视觉约 24pt,为提升点击率将热区扩大到 44pt,重写该图标的容器 view 或子类的 point(inside:with:) 即可。

穿透示例(浮层不拦截、点击落到下层):

/// 用于半透明遮罩:触摸不消费,交给下层视图(如背后的列表、按钮)
class PassThroughView: UIView {
    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        let hit = super.hitTest(point, with: event)
        return hit == self ? nil : hit  // 若命中自己则返回 nil,让下层接收
    }
}

商用场景示例:活动弹窗关闭后残留半透明遮罩,希望点击遮罩空白处能穿透到下层(如关闭按钮、跳过);或直播/视频上的礼物动画层不拦截点击,让下层进度条、点赞可点。

Swift 完整示例:可复用的「扩大热区」UIView 子类(适用于任意按钮/图标):

/// 将子视图的可点击区域向外扩展,不改变视觉 frame
final class ExpandHitAreaView: UIView {
    var hitAreaInset: UIEdgeInsets = .zero  // 负值表示扩大,如 (-10,-10,-10,-10)
    override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
        bounds.inset(by: hitAreaInset).contains(point)
    }
}
// 使用:将按钮包在 ExpandHitAreaView 内,设置 hitAreaInset = UIEdgeInsets(top: -10, left: -10, bottom: -10, right: -10)

八、与响应者链的衔接

hit-test 得到的是触摸事件的第一响应者(某个 UIView)。触摸事件会先发给该视图(及其上的手势识别器);若视图未处理或未实现 touchesBegan 等,事件会沿 nextResponder 向上传递。因此:

  • 阶段一(本文):hit-test,自顶向下,确定「谁被点中」。
  • 阶段二:响应者链,自底向上,确定「谁处理」。详见 03-响应者链与 nextResponder 详解

参考文献

[1] Using responders and the responder chain to handle events - Determine which responder contained a touch event
[2] Event handling for iOS - hitTest:withEvent: and pointInside:withEvent:
[3] HitTest and UIResponder in iOS (Medium)

12-主题|内存管理@iOS-Option与内存优化技术

本文介绍与内存相关的几类优化与极限管理Option/位运算共用内存(多选项共用一个整数)、内存的极限管理(低内存策略与约束)、Copy-on-Write(写时拷贝)Tagged Pointer。与 01-内存五大分区11-深浅拷贝与内存07-实践与常见问题 配合阅读。


一、Option 与位运算共用内存

1.1 概念

  • 将多个布尔或选项压缩到一个整数的不同上,通过位运算读写,实现「多个开关/状态共占一块内存」;在 C/OC 中常用 NS_OPTIONS位域(bitfield),在 Swift 中对应 OptionSet
  • 内存:N 个独立 BOOLbool 可能占 N 个字节(甚至对齐后更多);用一个整型的若干位表示,只需 1 个整型(如 4 或 8 字节),在选项较多或实例数量巨大时显著节省内存并利于缓存。

1.2 NS_OPTIONS / 位运算示例(Objective-C)

// 多个「选项」共用一个整型,每位表示一种开关
typedef NS_OPTIONS(NSUInteger, ViewOptions) {
    ViewOptionsNone     = 0,
    ViewOptionsHidden   = 1 << 0,   // 1
    ViewOptionsDisabled = 1 << 1,   // 2
    ViewOptionsSelected = 1 << 2,   // 4
    ViewOptionsLoading  = 1 << 3,   // 8
};

// 使用:一个 NSUInteger 存下所有选项
ViewOptions opts = ViewOptionsHidden | ViewOptionsSelected;

// 判断
BOOL isHidden = (opts & ViewOptionsHidden) != 0;

// 置位 / 清位
opts |= ViewOptionsLoading;
opts &= ~ViewOptionsDisabled;
  • 上述 ViewOptions 只占 一个 NSUInteger(8 字节),即可表示 64 个独立布尔;若用 64 个 BOOL 属性,会占用更多内存且不利于缓存。

1.3 位域(bitfield)共用内存

// 结构体内用位域:多个成员共占一个或多个整型
struct PackedFlags {
    unsigned int visible  : 1;  // 1 bit
    unsigned int enabled  : 1;
    unsigned int selected : 1;
    unsigned int loading  : 1;
    unsigned int reserved : 4;  // 预留
};  // 整体可仅占 4 字节(一个 unsigned int)
  • 多个成员共享同一整型内存,适合配置、状态、权限等密集布尔/小范围枚举,在大量实例(如 Cell、配置项)时减少内存占用。

1.4 典型场景

场景 说明
UI 状态 如 hidden、enabled、selected、loading 等用 NS_OPTIONS 或 OptionSet 存为一个整数
权限/能力 读、写、执行等用位表示,一个整数表示一组权限
配置/特性开关 大量配置项用位域或 OptionSet,减少结构体/对象体积
网络/解析标志 协议中的 flags 字段,多位表示多种含义,共用内存

二、内存的极限管理

2.1 目标与场景

  • 内存紧张(低内存设备、后台、系统压力大)时,通过主动释放、限制缓存、延迟加载等手段,把占用控制在系统允许范围内,避免被系统杀掉或 OOM。
  • 07-实践与常见问题 中的「内存警告」「音视频/图层场景」配合使用。

2.2 策略要点

策略 说明
响应 didReceiveMemoryWarning 释放可重建的缓存(图片、数据)、释放非当前页大对象;主线程不阻塞,异步释放。
缓存上限与淘汰 图片/数据缓存设置 maxCount / maxCost,LRU 等淘汰;避免无界增长。
后台释放 进入后台时释放非必要资源(解码器、大缓冲、预览图),回到前台再按需加载。
按需加载 / 流式 大列表、大文件不一次性进内存;分页、流式读取、大图降采样。
@autoreleasepool 循环中大量临时对象用 @autoreleasepool {} 控制峰值,见 [05-AutoreleasePool与RunLoop](05-主题 内存管理@iOS-AutoreleasePool与RunLoop.md)。
内存映射 大文件用 mmap 等映射访问,减少常驻 RSS;注意映射大小与释放时机。

2.3 极限下的注意

  • 不保留可重建数据:能重新下载、重新计算的就不要在内存里常驻。
  • 控制单页/单模块占用:列表、相册、音视频播放等设定上限,避免单场景吃满内存。
  • Instruments:用 Allocations、VM Tracker、Leaks 做「极限场景」压测(反复进入退出、后台、低内存模拟),观察峰值与泄漏。

三、Copy-on-Write(写时拷贝)

3.1 概念

  • Copy-on-Write(COW):多个逻辑上的「副本」在未修改前共享同一份底层存储;仅在某一方发生写操作时才为该方复制出一份新存储,再修改,从而避免「一赋值就整块拷贝」的开销。
  • 与深浅拷贝的关系:浅拷贝是「多引用、共享子对象」;COW 是「多引用、共享存储,写时才真正拷贝」,在保证值语义的前提下减少内存与 CPU 消耗。详见 11-深浅拷贝与内存

3.2 Swift 中的 COW

  • Array、Dictionary、Set、String 等值类型在 Swift 标准库中实现了 COW:赋值时不立即复制底层 buffer,而是共享;首次发生写操作时,若检测到 buffer 被多处引用(非唯一引用),则先复制 buffer 再写。
  • 实现要点:内部持有一个引用类型的 buffer;写前通过 isKnownUniquelyReferenced(或等价机制)判断是否唯一引用,若不唯一则 copy buffer 再写。
  • 效果:大量「只读共享」的赋值与传参几乎零拷贝成本;只有写时才付出拷贝代价,适合读多写少的集合与字符串。

3.3 与内存的关系

  • 省内存:未修改的「副本」不占额外存储,仅多一个指向同一 buffer 的引用。
  • 写时峰值:在共享的 buffer 上首次写入会触发一次拷贝,此时有短暂的内存与 CPU 开销;若写非常频繁且共享多,需注意是否适合用 COW 结构。
  • 自定义值类型:Swift 不会自动为自定义 struct 实现 COW,若需要需自己维护「内部引用 + 写时复制」逻辑。

3.4 流程图(概念)

flowchart LR
    A[赋值/传参] --> B{写操作?}
    B -->|否| C[继续共享 buffer]
    B -->|是| D{唯一引用?}
    D -->|是| E[直接写]
    D -->|否| F[复制 buffer 再写]

四、Tagged Pointer

4.1 概念

  • Tagged Pointer 是 Apple 在 64 位 架构下的一种优化:把「小对象」的数据与类型信息直接编码进指针值本身,而不在堆上分配对象;该「指针」并不是指向堆地址,而是即是指针也是数据
  • 内存:不占用,不参与引用计数(retain/release 对 Tagged Pointer 为 no-op);仅占一个指针宽度(8 字节),无额外分配、无 isa、无引用计数块,极限节省小对象的内存与调用开销。

4.2 原理(64 位简要)

  • 64 位下对象指针通常 16 字节对齐,低 4 位恒为 0;系统用最高位或最低位(依平台而定,如 ARM64 常用最高位)作为 tag,表示「这是 Tagged Pointer」。
  • 其余位中:若干位表示类型(如 NSNumber、NSString、NSDate 等),其余位存数据(如小整数、短字符串的编码)。
  • 运行时通过「解 tag + 类型 + 数据位」还原出逻辑上的「对象」,不访问堆,不触发 retain/release。

4.3 典型类型与约束

类型 说明
NSNumber 小整数、部分浮点数可直接存进指针,不分配堆对象。
NSString 较短字符串(如 ASCII 或少量字符)在较新系统上可能用 Tagged Pointer;更长则仍为堆上分配。
NSDate 部分小对象类型在系统实现中可能使用 Tagged Pointer。
  • 约束:能编码进指针的数据量有限(几十 bit),仅适用于「小」数据;大数、长字符串、复杂对象仍走普通堆分配。

4.4 对内存管理的影响

  • 无堆分配:Tagged Pointer 不占堆,不增加 Allocations 中的对象数。
  • 无引用计数:对 Tagged Pointer 发 retain/release 会被识别并忽略,不会造成过度释放或泄漏(从引用计数角度)。
  • 不可假设地址:不能把 Tagged Pointer 当普通指针做指针运算或与 C 内存接口混用;判断是否 Tagged Pointer 可用运行时 API(如 objc_isTaggedPointer)。

4.5 小结对比

维度 普通堆对象 Tagged Pointer
存储位置 指针值本身(无堆)
引用计数 无(no-op)
内存占用 对象头 + 实例 + 指针 仅 8 字节指针
适用 任意对象 小数据(小整数、短字符串等)

五、思维导图

mindmap
  root((Option 与内存优化))
    Option 位运算
      NS_OPTIONS OptionSet
      位域 共用整型
    内存极限管理
      内存警告 缓存上限
      后台释放 按需加载
    CopyOnWrite
      写时复制 共享 buffer
      Swift 集合 isKnownUniquelyReferenced
    Tagged Pointer
      小对象编码进指针
      无堆 无引用计数

参考文献

11-主题|内存管理@iOS-深浅拷贝与内存

本文介绍 浅拷贝(Shallow Copy)深拷贝(Deep Copy) 的含义、在 Objective-C / Foundation 中的表现、与内存的关系(引用计数、新对象分配、共享与独立),以及 NSCopying、属性 copy、Swift 值类型与写时拷贝。前置知识见 03-引用计数与MRC详解04-ARC详解


一、浅拷贝与深拷贝的定义

1.1 概念

类型 含义 内存上的表现
浅拷贝 只复制「当前这一层」:得到一个新对象(新指针),但对象内部的元素/子对象仍指向原有的实例。 新对象占用新内存(新引用计数);内部元素复制,多出一份对原元素的引用(引用计数 +1)。
深拷贝 递归复制整棵对象树:当前对象及其内部所有引用到的对象都重新创建一份。 整棵对象树都占用新内存,拷贝前后完全独立,无共享引用。
  • 单层对象(如 NSString、NSData):浅拷贝与深拷贝在「是否共享内容」上的差异,取决于类型是否可变、实现是否共享底层存储(如 copy 后可能共享 buffer,仅引用计数 +1)。
  • 集合类(NSArray、NSDictionary 等):浅拷贝 = 新容器 + 元素仍指向原元素;深拷贝 = 新容器 + 对每个元素再递归 copy,需自行实现或使用 initWithArray:copyItems:YES 等 API。

1.2 与内存、引用计数的关系

  • 浅拷贝:生成一个新对象(容器或包装类),该对象对内部子对象的引用会使这些子对象的引用计数 +1;子对象本身不复制,内存上共享子对象。
  • 深拷贝:生成全新的对象图,每个被拷贝的对象都有新内存、新引用计数;原对象与拷贝无共享,释放一方不影响另一方。

二、Foundation 中的 copy 与 mutableCopy

2.1 常见类型的拷贝语义(概览)

类型 copy mutableCopy 说明
NSString 不可变副本(可能共享存储,引用计数 +1) NSMutableString 不可变 → 不可变 多为浅拷贝;不可变 → 可变 会分配新缓冲
NSMutableString 不可变 NSString(新内存) NSMutableString(浅拷贝) copy 得到不可变,防止外部修改
NSArray 浅拷贝,新数组、元素仍指向原元素 NSMutableArray(浅拷贝) 元素引用计数 +1,元素本身不复制
NSDictionary 浅拷贝 NSMutableDictionary(浅拷贝) 同上
NSData 浅拷贝(可能共享字节) NSMutableData 实现可能共享底层 buffer
自定义类 copyWithZone: 实现决定 mutableCopyWithZone: 决定 可做浅拷贝或深拷贝
  • 上述「浅拷贝」指:容器是新对象,元素仍是原对象引用;对容器增删不影响对方,对元素内容的修改可能影响对方(若元素可变)。

2.2 集合的「单层深拷贝」

  • [[NSArray alloc] initWithArray:array copyItems:YES]:会向每个元素发送 copy,得到新数组 + 一层新元素;若元素本身是集合,其内部不会递归 copy,因此是单层深拷贝,不是递归深拷贝。
  • 真正递归深拷贝需自己实现或使用序列化(如 NSKeyedArchiver)再反序列化,注意性能与内存。

三、NSCopying 与 NSMutableCopying

3.1 协议

  • NSCopying:实现 - (id)copyWithZone:(NSZone *)zone;调用 [obj copy] 时最终走 copyWithZone:
  • NSMutableCopying:实现 - (id)mutableCopyWithZone:(NSZone *)zone;调用 [obj mutableCopy] 时走 mutableCopyWithZone:

3.2 拷贝与内存管理

  • ARC:copy/mutableCopy 返回的对象由调用方持有(引用计数 +1),遵循 ARC 规则。
  • MRC:返回的对象为调用方拥有,需在适当时机 release 或 autorelease;在 copyWithZone: 里返回的对象应为 +1 所有权(alloc 或 copy 出来的)。

3.3 自定义类的浅拷贝与深拷贝示例(概念)

// 浅拷贝:新对象,但 property 仍指向原对象(retain/copy 使引用计数 +1)
- (id)copyWithZone:(NSZone *)zone {
    MyClass *copy = [[MyClass allocWithZone:zone] init];
    copy.name = self.name;           // 若 name 是 copy 属性,会 [self.name copy]
    copy.child = self.child;         // 若 child 是 strong,仅 retain,共享同一 child
    return copy;
}

// 深拷贝:递归复制子对象
- (id)copyWithZone:(NSZone *)zone {
    MyClass *copy = [[MyClass allocWithZone:zone] init];
    copy.name = [self.name copy];
    copy.child = [self.child copy];  // 子对象也 copy,完全独立
    return copy;
}
  • 选择浅拷贝还是深拷贝取决于业务:共享子对象可省内存但需注意多线程/可变性;完全独立则省心但内存与耗时更大。

四、属性的 copy 与内存

4.1 copy 属性

  • 声明为 @property (copy) NSString *name 时,setter 会对传入值调用 copy,即持有的是「传入对象的拷贝」的所有权;若传入的是 NSMutableString,拷贝后得到不可变 NSString,避免外部在别处修改导致当前实例被意外改动。
  • Block 使用 copy 属性:Block 的 copy 会把栈 Block 拷贝到堆(见 10-Block内存管理),并持有该堆 Block;与「深浅拷贝」中的「拷贝」语义不同,但都涉及「新对象 + 引用计数」。

4.2 深浅拷贝与属性

  • 若属性是 集合(如 NSArray),用 copy 只是对集合本身做浅拷贝(新容器、元素仍共享);若希望「外部传入的数组」与内部完全隔离,要么接受浅拷贝(元素共享),要么在 setter 里做一层 initWithArray:copyItems:YES 或自定义深拷贝,并注意内存与性能。

五、内存注意与选型

5.1 浅拷贝

优点 缺点
省内存、速度快 与原对象共享子对象;若子对象可变,一边修改会影响另一边;多线程需额外同步

5.2 深拷贝

优点 缺点
完全独立,无共享,线程安全更易控制 内存与 CPU 开销大,递归深拷贝需防循环引用与栈溢出

5.3 何时用哪种

  • 浅拷贝:只关心「多一份容器引用」、元素共享可接受(或元素不可变)时;Foundation 的 copy/mutableCopy 默认多为浅拷贝(容器层)。
  • 深拷贝:需要「完全独立副本」、避免外部修改或跨线程共享可变状态时;可单层深拷贝(copyItems:YES)或自定义递归深拷贝。

六、流程图:浅拷贝与深拷贝的内存关系(概念)

flowchart TB
    subgraph 浅拷贝
        A1[原容器] --> A2[新容器]
        A1 --> A3[元素a]
        A2 --> A3
    end
    subgraph 深拷贝
        B1[原容器] --> B2[新容器]
        B1 --> B3[元素a]
        B2 --> B4[元素a 的副本]
    end

七、Swift 中的「拷贝」与内存

7.1 值类型与引用类型

  • 值类型(struct、enum、基础类型):赋值与传参是拷贝语义(复制一份值);从「不共享同一块堆对象」的角度看,更像「深拷贝」。
  • 引用类型(class):赋值与传参是引用,不产生新对象,仅多一个指针;若要独立副本需显式实现拷贝(如实现 NSCopying 或自定义 copy() 方法)。

7.2 写时拷贝(Copy-on-Write)

  • Array、Dictionary、Set 等是值类型,但底层存储可能共享 buffer;修改时才复制一份(Copy-on-Write),既保证值语义又减少不必要的内存与拷贝开销。
  • 与 OC 的「浅拷贝」不同:Swift 集合的「拷贝」在未修改前可能共享存储,修改时再分配新内存,由标准库保证语义正确。COW 原理、Swift 实现要点(如 isKnownUniquelyReferenced)及与内存的关系见 12-Option与内存优化技术 中的「Copy-on-Write」一节。

八、思维导图:深浅拷贝与内存

mindmap
  root((深浅拷贝与内存))
    概念
      浅拷贝 新对象 共享元素
      深拷贝 新对象 递归复制
    引用计数
      浅拷贝 元素 rc+1
      深拷贝 全新对象图
    Foundation
      copy mutableCopy
      集合 copyItems
    NSCopying
      copyWithZone
      自定义浅/深拷贝
    属性 copy
      setter 调 copy
      Block NSString
    Swift
      值类型 拷贝语义
      CopyOnWrite

九、参考文献

10-主题|内存管理@iOS-Block内存管理

本文专门介绍 Objective-C BlockSwift 闭包内存管理:Block 的三种类型(全局/栈/堆)、捕获变量与内存、copy 语义、循环引用 与破除,以及作为属性/参数时的注意点。前置知识见 04-ARC详解06-weak与循环引用


一、Block 是什么(与内存的关系)

  • Block 是 Apple 对 C 语言扩展的闭包:可捕获外部变量、作为对象参与引用计数;在内存上既包含代码(函数指针),也包含捕获的变量(结构体形式),因此既有「存在位置」(栈/堆/全局)也有「对捕获对象的持有关系」。
  • 内存管理 需关注两点:Block 对象本身 的分配与释放(栈 block / 堆 block / 全局 block),以及 Block 对捕获变量(尤其是 OC 对象) 的强引用/弱引用,避免循环引用与泄漏。

二、Block 的三种类型与内存位置

2.1 类型与存储位置

类型(运行时 isa) 存储位置 产生条件(典型)
NSGlobalBlock 全局区(.data/.text) 未捕获任何外部变量(或仅捕获全局/静态变量)
NSStackBlock 捕获了自动变量(局部变量),且未 copy 到堆(MRC 下常见)
NSMallocBlock 对栈 block 执行 copy,或 ARC 下多数「需要逃逸」的 block 被编译器自动 copy 到堆
  • 全局 Block:不依赖栈帧,无需 copy,可当作单例使用。
  • 栈 Block:随栈帧销毁而失效,若要在作用域外使用(如存为属性、异步回调),必须先 copy 到堆;ARC 下编译器会在赋值给 strong/copy 属性、跨函数传递等场景自动插入 copy。
  • 堆 Block:参与引用计数,由 ARC/MRC 管理;copy 时引用计数 +1,release 时 -1。

2.2 简单判断示例(ARC)

// 无捕获 → 全局 Block(__NSGlobalBlock__)
void (^gBlock)(void) = ^{ NSLog(@"no capture"); };

// 捕获局部变量 → 栈 Block(__NSStackBlock__),若赋给 strong/copy 属性则会被 copy 成堆 Block
int a = 1;
void (^sBlock)(void) = ^{ NSLog(@"%d", a); };
// 赋值给 copy/strong 属性或作为参数传给需要「持有」的 API 时,会变成 __NSMallocBlock__

2.3 MRC 下 Block 的 copy 必要性

  • MRC 下,栈上的 Block 在函数返回后栈帧被回收,若此时 block 已被传给调用方或存到堆对象(如属性),再执行会野指针/未定义行为
  • 因此 MRC 下:凡是需要跨作用域保留的 block,必须对其执行一次 copy,将栈 block 拷贝到堆上,得到 NSMallocBlock,之后按普通 OC 对象做 retain/release;用完后要对堆 block 做 release(或 autorelease)。
  • ARC 下:编译器在「赋值给 strong/copy 属性、作为参数传给会保留 block 的 API」等场景自动插入 copy,一般无需手写 [block copy]

三、Block 捕获变量与内存

3.1 捕获方式概览

捕获对象/变量 默认行为(OC 对象) 对引用计数的影响
局部 OC 对象(自动变量) 强引用(strong) Block 被 copy 到堆时,会 retain 被捕获的对象;block 释放时 release
局部标量(int、结构体等) 值拷贝 不涉及引用计数
__block 修饰的变量 生成结构体,block 与外部共享 若 __block 变量指向 OC 对象,需注意 MRC/ARC 下 retain 行为;__block 可改写
__weak 修饰的对象 弱引用 Block 不持有该对象,不增加引用计数,可避免循环引用

3.2 对象捕获与循环引用

  • Block 若强引用了某个对象 A(如直接使用 self),而 A 又强引用了该 block(如 block 被 A 的 strong/copy 属性持有),则形成 self → block → self 的循环,两者都不会释放。
  • 解决:在 block 外使用 __weak typeof(self) wself = self,block 内使用 wself,这样 block 对 self 是弱引用;若在 block 执行过程中担心 self 被释放,可在 block 内再用 __strong typeof(wself) sself = wself 强引用一次(仅限 block 执行期),避免执行到一半 self 被置 nil。详见 06-weak与循环引用

3.3 __block 与内存(简述)

  • __block 使局部变量在 block 内可被修改,编译器会生成一个包装结构,block 捕获的是该结构;若 __block 变量指向 OC 对象,在 ARC 下通常不会造成 block 对对象的强引用(对象存在 __block 结构里),但若在 block 内给该变量赋新值,会涉及旧值 release、新值 retain。MRC 下 __block 不会自动 retain 对象,需自行管理。
  • 历史上用 __block 打破循环(__block self + block 内置 nil)的写法在 ARC 下不推荐,应使用 __weak 打破循环。

四、Block 作为属性、参数与返回值

4.1 属性声明

属性修饰 说明
copy 设值时对 block 执行 copy;MRC 时代推荐,ARC 下 strong 与 copy 对 block 效果类似(都会 copy 到堆),习惯上仍常用 copy 表达「这是 block」的语义。
strong ARC 下与 copy 类似,赋值时也会把栈 block copy 到堆并强引用。
  • Block 属性应避免用 assign(栈 block 离开作用域后失效,会野指针)。

4.2 作为参数与返回值

  • 作为参数:若 API 会保存 block(如延迟执行、存入数组),API 内部应对传入的 block 做 copy(或由 ARC 在传入时保证是堆 block);调用方传栈 block 时,由被调用方 copy 到堆是常见约定。
  • 作为返回值:返回 block 时,若希望调用方在函数返回后仍能使用,应返回堆上的 block(MRC 下 return 前对 block 做 copy/autorelease;ARC 下编译器会根据返回类型自动处理)。

五、ARC 与 MRC 下 Block 内存小结

场景 MRC ARC
栈 block 需跨作用域使用 必须对 block 执行 copy,用完后 release 编译器在赋值给 strong/copy、传参等场景自动 copy
Block 属性 copy,setter 里对 block copy、对旧 block release copystrong 均可,都会导致 copy 到堆并强引用
Block 内引用 self 避免 self→block→self:用 __weak 或 __block+置 nil __weak self,必要时 block 内 __strong 一次
Block 捕获的 OC 对象 copy 到堆时 block 会 retain 捕获的对象;block release 时 release 这些对象 同左,由编译器插入

六、流程图:Block 从创建到释放(概念)

flowchart LR
    A[定义 Block] --> B{是否捕获自动变量?}
    B -->|否| C[__NSGlobalBlock__ 全局]
    B -->|是| D[__NSStackBlock__ 栈]
    D --> E[赋值给 strong/copy 或 传参]
    E --> F[copy 到堆 __NSMallocBlock__]
    F --> G[Block 被 release]
    G --> H[对捕获对象 release]

七、Swift 闭包与内存

  • Swift 闭包 与 OC Block 语义对应:闭包会捕获外部变量,默认对类对象是强引用
  • 循环引用:若对象强引用闭包,闭包内又使用了 self(或捕获了 self),则形成循环;解决方式为在闭包捕获列表中写 [weak self][unowned self](后者在 self 一定不会先于闭包释放时使用,否则会野指针)。
  • @escaping:标记闭包会「逃逸」出当前函数(如异步回调),编译器会按需将闭包拷贝到堆上,与 OC 中「block 被 copy 到堆」对应。

八、思维导图:Block 内存管理知识结构

mindmap
  root((Block 内存管理))
    三种类型
      全局 Block 无捕获
      栈 Block 捕获未 copy
      堆 Block copy 后
    捕获与引用
      对象默认强引用
      __weak 破循环
      __block 可改写
    属性与生命周期
      copy/strong 属性
      MRC 需手写 copy
      ARC 自动 copy
    循环引用
      self → block → self
      weak self strong self

九、参考文献

09-主题|内存管理@iOS-Category与关联对象内存管理

本文介绍 Objective-C Category(分类) 与内存的关系,以及通过 关联对象(Associated Objects) 在 Category 中「挂载」数据时的内存管理:关联策略(policy)、释放时机、循环引用与最佳实践。前置知识见 04-ARC详解06-weak与循环引用


一、Category 与内存的关系

1.1 Category 是什么

  • Category 用于在不修改原类的前提下,为已有类添加方法(以及通过关联对象间接添加「属性」式的存储)。
  • Category 不能直接添加实例变量(ivar),因此不会改变类实例的内存布局sizeof;实例大小由原类及其子类的 ivar 决定。

1.2 对内存管理的影响

维度 说明
实例大小 Category 不增加实例占用,无需从「对象体积」角度做特殊内存管理。
方法实现 Category 中的方法若创建或持有对象,仍遵循 ARC/MRC 规则(谁持有谁释放、避免循环引用)。
「属性」存储 若在 Category 中通过 关联对象 模拟属性,则关联的 value 的持有方式association policy 决定,需正确设置以避免泄漏或野指针。

下文重点说明关联对象的内存语义与使用注意。


二、关联对象(Associated Objects)简述

2.1 作用

  • 不增加 ivar 的前提下,把键值对绑在某个对象上:主对象被释放时,运行时会自动释放其关联的 value(按 policy 做 release 等)。
  • 常用于在 Category 中为已有类添加「存储型属性」、或为任意对象挂载扩展数据。

2.2 API(Objective-C 运行时)

// 设置:object 为主对象,key 为键,value 为值,policy 为关联策略
void objc_setAssociatedObject(id object, const void *key, id value, objc_AssociationPolicy policy);

// 获取
id objc_getAssociatedObject(id object, const void *key);

// 移除(将 value 设为 nil 即可,会按 policy 释放原 value)
objc_setAssociatedObject(object, key, nil, policy);

三、关联策略(policy)与内存管理

3.1 常用策略对照表

策略常量 语义(对 value 的持有方式) 适用场景
OBJC_ASSOCIATION_RETAIN 强引用(retain),主对象释放时对 value release 普通 OC 对象属性(类似 strong)
OBJC_ASSOCIATION_COPY 拷贝后强引用(copy),主对象释放时对拷贝 release 字符串、block 等需拷贝的类型
OBJC_ASSOCIATION_ASSIGN 不持有(assign),主对象释放时不对 value 做 release 基本类型、或「弱引用」场景(注意野指针)
OBJC_ASSOCIATION_RETAIN_NONATOMIC 同 RETAIN,非原子 性能敏感、不需原子性时
OBJC_ASSOCIATION_COPY_NONATOMIC 同 COPY,非原子 同上

3.2 与 ARC 属性修饰符的对应

若属性声明为 关联时建议 policy
strong(对象) OBJC_ASSOCIATION_RETAIN
copy(block/NSString) OBJC_ASSOCIATION_COPY
assign / weak OBJC_ASSOCIATION_ASSIGN(assign 不保证置 nil,若为对象有野指针风险;true weak 需运行时支持,关联对象常用 ASSIGN 存 weak 包装或非持有)

3.3 释放时机

  • 主对象 dealloc 时,运行时会自动对所有关联的 value 按各自 policy 执行 release(或等效操作),无需在 dealloc 里手动 objc_setAssociatedObject(..., nil, ...) 或单独 release。
  • 若在业务上希望提前解除某条关联,可主动 objc_setAssociatedObject(object, key, nil, policy),原 value 会按 policy 被释放。

四、Category 中「属性」的常见写法与内存

4.1 强引用存储(RETAIN)

// Category 中为 NSObject 添加一个“强引用”属性
static const void *kMyKey = &kMyKey;

- (void)setMyProperty:(id)obj {
    objc_setAssociatedObject(self, kMyKey, obj, OBJC_ASSOCIATION_RETAIN);
}
- (id)myProperty {
    return objc_getAssociatedObject(self, kMyKey);
}
  • 内存:set 时对新 value retain、对旧 value release;主对象 dealloc 时自动 release 当前 value,无泄漏。
  • 注意:若 myProperty 内部又强引用主对象(如 block 捕获 self),会循环引用,需用 weak 打破(见下)。

4.2 拷贝存储(COPY,如 block)

- (void)setMyBlock:(void (^)(void))block {
    objc_setAssociatedObject(self, kBlockKey, block, OBJC_ASSOCIATION_COPY);
}
  • Block 常用 COPY,与属性 copy 一致;主对象释放时会对拷贝的 block release。

4.3 弱引用 / 不持有(ASSIGN)与循环引用

  • 若用 OBJC_ASSOCIATION_ASSIGN 存一个对象指针,主对象不会持有该对象;但主对象 dealloc 时不会把该指针置 nil,若外部未持有,可能产生野指针
  • 循环引用:主对象 A 通过 RETAIN 关联了对象 B,B 又强引用了 A → 双方都不释放。解决办法:让 B 对 A 使用 weak(若 B 是自定义类可改);或 A 不通过 RETAIN 关联 B,改用 ASSIGN + 弱引用包装(需注意生命周期与野指针)。
  • Category 中若「属性」是 delegate 或会反向引用 self 的对象,应避免用 RETAIN 持有该对象,可考虑 ASSIGN 存 weak 包装或不在 Category 里存该引用。

五、流程图:关联对象生命周期

flowchart LR
    A[主对象存在] --> B[setAssociatedObject value policy]
    B --> C[value 被 retain/copy 等]
    A --> D[主对象 dealloc]
    D --> E[运行时按 policy 释放所有关联 value]
    E --> F[value 引用计数减一 或 置空]

六、小结与最佳实践

场景 建议
Category 中存普通 OC 对象 使用 OBJC_ASSOCIATION_RETAIN(或 RETAIN_NONATOMIC)。
Category 中存 block / 需拷贝类型 使用 OBJC_ASSOCIATION_COPY
不持有、仅赋值指针(如 delegate) 可用 OBJC_ASSOCIATION_ASSIGN,注意主对象释放后不置 nil,避免野指针。
避免循环引用 不在 Category 中用 RETAIN 关联「会强引用主对象」的对象;或对方对主对象使用 weak。
释放 主对象 dealloc 时关联会自动清理,一般无需在 dealloc 里手动移除。

参考文献

08-主题|内存管理@iOS-内存对齐

本文介绍 内存对齐(Memory Alignment) 的概念、为何需要对齐、结构体内存对齐 的规则与示例,以及在 iOS/ARM64 下的典型约定。与「内存五大分区」中数据在栈、堆、全局区的布局密切相关,见 01-主题|内存管理@iOS-内存五大分区


一、什么是内存对齐

1.1 定义

  • 内存对齐:数据在内存中的起始地址满足一定约束,通常是「地址为自身所占字节数的整数倍」(或按平台规定的对齐值)。
  • 例如:4 字节的 int 在多数平台上需** 4 字节对齐**(地址为 4 的倍数);8 字节的 double8 字节对齐(地址为 8 的倍数)。

1.2 为什么需要对齐

原因 说明
CPU 访问效率 许多 CPU 对未对齐访问有性能惩罚或需多次总线访问;对齐后可按固定步长、单次或更少次数访问。
硬件与 ABI 要求 ARM、x86 等架构对某些类型有对齐要求;未对齐访问在部分平台可能触发异常(如 ARM 未对齐访问可配置为 fault)。
以空间换时间 通过填充(padding) 满足对齐,会多占一些字节,但换来稳定、高效的访问。

二、基本类型的对齐(典型值)

以下为 64 位 iOS/ARM64 下常见类型的典型对齐与大小(具体以 ABI 与编译器为准):

类型 大小(字节) 典型对齐(字节)
char / bool 1 1
short 2 2
int 4 4
long / 指针(64 位) 8 8
float 4 4
double 8 8
long double 8 或 16 8 或 16

平台约定:iOS 64 位(ARM64)下,编译器常采用 8 字节 作为结构体整体对齐的上限之一(即结构体大小与起始地址常为 8 的倍数);32 位下多为 4 字节。


三、结构体内存对齐规则

3.1 三条常见规则

  1. 成员对齐:结构体第一个成员的偏移为 0;后续成员的起始偏移 = 该成员自身对齐值的整数倍,不足则插入 padding
  2. 嵌套结构体:若成员是结构体,该成员的起始偏移 = 其内部最大成员对齐值的整数倍(即嵌套结构体按自身「最严格」对齐要求对齐)。
  3. 整体对齐:结构体的总大小 = 其内部最大成员对齐值的整数倍;末尾不足则补足,以便结构体数组时每个元素仍对齐。

3.2 流程图:计算结构体布局(伪流程)

flowchart TB
    A[遍历每个成员] --> B[当前偏移 是 该成员对齐的整数倍?]
    B -->|否| C[补 padding 到满足]
    B -->|是| D[放置该成员]
    C --> D
    D --> E[偏移 += 成员大小]
    E --> A
    F[所有成员放完] --> G[总大小 是 最大成员对齐的整数倍?]
    G -->|否| H[末尾补 padding]
    G -->|是| I[得到 sizeof]
    H --> I

四、示例:结构体大小与 padding

4.1 C / Objective-C 示例

// 假设 64 位:指针 8 字节、int 4 字节、char 1 字节
struct Example1 {
    double a;   // 8 字节,偏移 0,[0-7]
    char b;     // 1 字节,偏移 8,[8]
    int c;      // 4 字节,需 4 对齐,故偏移 12,[12-15]
    short d;    // 2 字节,偏移 16,[16-17]
};              // 最大成员对齐 8,总大小需 8 的倍数:18 → 24,末尾补 6 字节
// sizeof(Example1) == 24
成员 大小 对齐 起始偏移 说明
a 8 8 0 第一个成员
b 1 1 8 无 padding
c 4 4 12 偏移 9、10、11 不满足 4 对齐,补 3 字节
d 2 2 16 无 padding
(尾部) 18→24 总大小凑成 8 的倍数

4.2 成员顺序对大小的影响

同一批成员、顺序不同会导致 padding 不同,从而总大小不同

struct Compact {
    double a;   // 0-7
    int b;      // 8-11
    int c;      // 12-15
    char d;     // 16
};              // 总大小 17 → 对齐 8 → 24 字节(末尾补 7)

struct Sparse {
    char a;     // 0
    double b;   // 需 8 对齐 → 8-15,前补 7
    int c;      // 16-19
};              // 总大小 20 → 对齐 8 → 24 字节

实践建议:若需节省结构体占用,可将大类型放前、小类型集中,减少中间 padding。


五、Swift 中的内存布局与对齐

5.1 MemoryLayout

  • MemoryLayout<T>.size:类型 T 的实际占用字节数(不含尾部为数组元素对齐而留的 padding)。
  • MemoryLayout<T>.stride:在连续存储(如数组)中,相邻两个 T 的起始地址之差,即「对齐后的大小」。
  • MemoryLayout<T>.alignment:类型 T 的对齐要求(字节数)。

5.2 示例

struct SHPerson {
    var age: Int    // 8 字节
    var weight: Int // 8 字节
    var sex: Bool   // 1 字节
}
// size  = 17(实际成员占用)
// stride = 24(8 字节对齐后,用于数组等)
// alignment = 8

六、与内存五大分区的关系

  • 栈、堆、全局区中存放的局部变量、对象、全局/静态变量,其起始地址与内部成员都受对齐约束;编译器与运行时在分配时会保证对齐。
  • 理解对齐有助于:估算结构体/类实例占用、排查「sizeof 与预期不符」、与 C 互操作或做底层布局时避免未对齐访问。

七、自定义对齐与 packed(简述)

手段 说明
_attribute_((aligned(n))) 指定变量或结构体按 n 字节对齐(如缓存行 64 字节)。
_attribute_((packed)) 取消结构体内部 padding,成员紧挨排列;可减小体积但可能未对齐,访问效率或安全性下降,需谨慎使用。

八、小结(思维导图)

mindmap
  root((内存对齐))
    目的
      CPU 访问效率
      ABI 与硬件要求
    规则
      成员按自身对齐
      整体大小为最大对齐的整数倍
    iOS/ARM64
      常用 8 字节整体对齐
      size 与 stride
    实践
      成员顺序影响大小
      packed / aligned 慎用

参考文献

07-主题|内存管理@iOS-实践与常见问题

本文在 01~06 基础上,汇总 内存警告Instruments 排查与泄漏分析Timer 管理野指针音视频与图层场景 等实践要点,以及常见问题与最佳实践。建议先掌握总纲与 ARC、weak 等再阅读本文;Timer 与 NSProxy 见 06-weak与循环引用


一、内存警告(Memory Warning)

1.1 机制

  • 系统在内存紧张时向应用发送 UIApplication 内存警告(如 didReceiveMemoryWarning);若不释放非必要缓存,系统可能终止进程

1.2 响应建议

做法 说明
释放缓存 图片缓存、数据缓存等可重建的,在收到警告时清理或缩小
释放不可见资源 非当前页的大图、大模型等可延迟重新加载的,可先释放
不阻塞主线程 释放与重建尽量异步,避免卡顿

1.3 回调示例(ViewController)

- (void)didReceiveMemoryWarning {
    [super didReceiveMemoryWarning];
    // 释放可重建的缓存、大图等
}

二、内存泄漏(Leak)排查

2.1 常见原因

  • 循环引用:对象成环,引用计数永不为 0 → 用 weak 打破。
  • 定时器/观察者未移除:NSTimer、KVO、Notification 等强引用 target/observer,未在 dealloc 前移除 → 及时 invalidate/removeObserver。
  • Block/闭包强引用:block 强引用 self 且 self 强引用 block → [weak self];Block 类型与 copy 语义见 10-Block内存管理
  • Category 关联对象:用 objc_setAssociatedObject 时若用 RETAIN 关联了「会强引用主对象」的对象(如 block 捕获 self),会形成循环引用;应避免或改用 weak 打破。详见 09-Category与关联对象内存管理

2.2 Instruments(Leaks / Allocations)

  • Leaks:检测进程内已无法被引用到的「泄漏」内存块。
  • Allocations:查看各对象分配与存活情况,结合 GenerationsMark Generation 观察某操作后是否持续增长不降。
  • 结合 Call Tree 与源码,定位泄漏对象与引用链。

2.3 内存泄漏的内存分析(进阶)

  • 堆快照与对比:在 Allocations 中多次 Mark Generation(如进入页面前 Mark、返回后再 Mark),对比两次快照的「Persistent」对象数量与大小,找出本应释放却仍存活的对象。
  • VM 区域:在 Allocations 的 StatisticsVM Tracker 中查看各 VM 区域(如 CG image、Image IO、IOSurface、Audio 等),定位是哪类内存持续增长(如解码图、音视频缓冲未释放)。
  • 引用链分析:对疑似泄漏对象右键 Show in Memory Graph 或查看 Reference History,看清「谁在持有它」的引用链,从而找到应改为 weak 或应 invalidate/remove 的持有方。
  • Malloc Stack / Call Tree:开启 Malloc Stack(Allocations 模板或 Edit Scheme → Diagnostics)可看到分配时的调用栈,便于确认泄漏对象来自哪段代码;Call Tree 的「Invert Call Tree」「Hide System」可快速聚焦业务代码。
  • Leaks 与 Allocations 配合:Leaks 只报「不可达」的泄漏;很多「仍被错误持有」的对象不会报 Leak,需用 Allocations 的 Generation 对比 + 引用链分析。

2.4 Timer 管理(详细)

  • NSTimer 会强引用 target,且 RunLoop 会持有 timer;若 VC 强引用 timer 且 target 是 self,则 VC → timer → self 形成循环,VC 不会 dealloc。
  • 解决:① 在 dealloc 前 invalidate(若循环未破,dealloc 不会被调用,故须先破环);② 用 NSProxy 弱引用 self 作为 timer 的 target,使 timer 强引用的是 proxy 而非 VC,详见 06-weak与循环引用 中的「NSProxy 与 Weak、Timer 管理」;③ iOS 10+ 使用 block 版 scheduledTimerWithTimeInterval:repeats:block:,block 内用 weak self,timer 不直接强引用 self。
  • CADisplayLink 同样强引用 target,需用相同思路(proxy 或 block 若可用)并在 dealloc 里 invalidate

三、野指针与崩溃

3.1 成因

  • 对象已 release/dealloc,仍有指针访问该内存 → 野指针;再次向该对象发消息或访问成员易 EXC_BAD_ACCESS 等崩溃。

3.2 预防

手段 说明
ARC + weak 使用 weak 时,对象释放后指针自动置 nil,发送消息无效果但不会崩溃
不重复 release(MRC) MRC 下严格配对,避免对同一对象多次 release
置空指针 释放后将指针置 nil(ARC 中 weak 自动完成)

四、音视频场景内存注意

  • 解码缓冲与采样缓冲:音视频解码会产生 CVPixelBufferCMSampleBuffer 等,若不及时释放或重复堆积,会快速推高内存;播放/渲染完或不再需要时应及时释放,避免在回调或队列中积压。
  • 大文件/流:避免一次性将整段音视频读入内存;使用 AVAssetReader流式读取 等按需加载,及时释放已解码帧或已播放的缓冲。
  • 后台与生命周期:进入后台时释放非必要解码器、清空大缓冲或暂停解码,回到前台再重建,可配合 UIApplication 后台通知didReceiveMemoryWarning
  • 循环引用:在 AVFoundation 回调、block 中若使用 self,需 weak self,避免 VC 或播放器持有 block 且 block 强引用 self 导致不释放。
  • CVPixelBuffer / 图像缓冲:渲染或处理完及时 CVPixelBufferRelease(若自己 retain 过)或交给系统回收;避免在缓存中无上限保留未释放的 buffer。

内存极限管理(缓存上限、后台释放、按需加载、内存映射等)见 12-Option与内存优化技术 中的「内存的极限管理」一节。


五、图层处理场景内存注意

  • 图片解码与尺寸:UIImage 在赋值给 UIImageView 或绘制前会解码为位图,大图会占用 宽×高×4 字节 量级内存;应对大图做降采样(如用 Image I/O 或 Core Graphics 按显示尺寸解码),或使用缩略图/裁剪,避免全尺寸解码多张大图。
  • CALayer 与 backing store:图层有内容(如 contents、drawRect)时会有 backing store 占用内存;离屏渲染(圆角+裁剪、阴影、group opacity 等)会生成额外离屏缓冲,多而大时会增加内存与 GPU 压力,可适当减少离屏层或用位图缓存。
  • 离屏渲染与缓存shouldRasterize = YES 会缓存光栅化结果,图层复杂或尺寸大时缓存会占内存;在不需要时关闭或缩小 layer bounds。
  • 大图列表:列表(UITableView/UICollectionView)中大量大图时,做好 复用按需加载内存警告时释放;可配合 didReceiveMemoryWarning 清空图片缓存。
  • Core Graphics / 位图:自己创建的 CGContextCGBitmapContext 在不用时 CGContextRelease;UIGraphics 的 context 若为自己创建需对应释放。

六、最佳实践小结

场景 建议
属性默认 对象类型用 strong;delegate/dataSource 用 weak
Block 若 block 被 self 持有且 block 内用 self,用 weak self;block 内若需保证执行期 self 存活,可再 strong 一次;Block 属性用 copy/strong,详见 [10-Block内存管理](10-主题 内存管理@iOS-Block内存管理.md)
定时器/通知 在 dealloc 前 invalidate timer、removeObserver,避免强引用导致不释放
大量临时对象 循环内使用 @autoreleasepool 控制峰值
内存警告 实现 didReceiveMemoryWarning,释放可重建缓存
Category 关联对象 OBJC_ASSOCIATION_RETAIN/COPY 存对象、OBJC_ASSOCIATION_COPY 存 block;避免关联会强引用主对象的对象以防循环引用,详见 [09-Category与关联对象内存管理](09-主题 内存管理@iOS-Category与关联对象内存管理.md)
集合/对象拷贝 区分浅拷贝(新容器、元素共享)与深拷贝(完全独立);属性 copy 对集合仅浅拷贝,需完全隔离时考虑深拷贝或 copyItems,详见 [11-深浅拷贝与内存](11-主题 内存管理@iOS-深浅拷贝与内存.md)
Timer NSProxy 弱引用 self 作 target 破循环,或 iOS 10+ 用 block 版 API;dealloc 前必须 invalidate,详见 [06-weak与循环引用](06-主题 内存管理@iOS-weak与循环引用.md)
音视频 及时释放解码/采样缓冲,流式加载大文件,后台释放非必要资源,回调中用 weak self
图层/大图 大图降采样、控制离屏渲染与 rasterize 缓存、列表复用与按需加载、CGContext 及时释放

七、流程图:泄漏排查思路

flowchart LR
    A[怀疑泄漏] --> B[Instruments Leaks]
    B --> C[看引用链]
    C --> D[查循环引用/未移除的观察者等]
    D --> E[weak/移除/改设计]

参考文献

06-主题|内存管理@iOS-weak与循环引用

本文介绍 weak(弱引用) 的语义、在运行时中的实现思路(SideTable/weak_table)、循环引用 的成因与破除方式,以及 block、delegate 等场景下的注意点。ARC 基础见 04-ARC详解。Block 的三种类型、copy 与捕获变量见 10-Block内存管理


一、weak 的语义

1.1 定义

  • weak:不增加对象的引用计数,不拥有对象;当对象被释放时,所有指向它的 weak 指针会被自动置为 nil,避免野指针。
  • strong 对比:strong 持有对象(rc+1),strong 不释放则对象不 dealloc;weak 不持有,对象可被其他引用释放,释放后 weak 自动置 nil。

1.2 使用场景

场景 说明
打破循环引用 A → B → A,将其中一条边改为 weak,避免双方都无法释放
非拥有关系 delegate、dataSource 等,通常用 weak,由外部持有生命周期
block 内引用 self 使用 [weak self] 避免 self → block → self 循环

二、循环引用(Retain Cycle)

2.1 成因

  • 循环引用:对象 A 强引用 B,B 又强引用 A(或经过多条边回到 A),形成环;双方引用计数都不为 0,永远无法 dealloc,造成泄漏。

2.2 常见情形与破除

情形 破除方式
两个对象互相 strong 一方改为 weak(如 child 对 parent 用 weak)
self → block → self block 内用 [weak self],必要时内部再 strong 一次避免提前释放
delegate 双方都 strong 通常 delegate 属性声明为 weak,由外部持有
Timer 强引用 target VC 强引用 timer,timer 强引用 target(即 VC)→ 循环;用 NSProxy 弱引用 VC 作为 timer 的 target,或 iOS 10+ 用 block 版 API

2.3 Block 中 weak self 示例(Objective-C)

__weak typeof(self) wself = self;
self.block = ^{
    __strong typeof(wself) sself = wself; // 避免 block 执行过程中 self 被释放
    if (!sself) return;
    [sself doSomething];
};

三、NSProxy 与 Weak、Timer 管理

3.1 NSTimer 的循环引用问题

  • NSTimer强引用target;若 target 是 VC(或任意对象 A),且 A 又强引用了该 timer(如 self.timer),则形成 A → timer → target(A) 的循环,A 与 timer 都不会释放。
  • 仅在 A 里用 __weak self 给 timer 的 target 传参无效:timer 内部保存的是传入的 target 指针并对其强引用,不会因为调用方用 weak 而改为弱引用。

3.2 用 NSProxy 打破 Timer 循环引用

  • 思路:让 timer 的 target 不是一个强引用 self 的对象,而是一个中间对象;该中间对象对 self 只持 weak,并把 timer 的回调转发给 self。这样引用关系为:VC → timer → proxy(弱引用 VC),VC 释放时 proxy 的 weak 置 nil,proxy 可随之释放;timer 需在 VC 的 dealloc 里 invalidate,或由 proxy 在转发时发现 target 为 nil 时 invalidate(视实现而定)。
  • NSProxy 是专门做「转发」的根类,不继承自 NSObject,实现 forwardInvocation:methodSignatureForSelector:,把消息转给 weak 持有的 target 即可;内存上 proxy 只多一个 weak 指针,不增加 target 的引用计数。

3.3 WeakProxy 示例(Objective-C)

@interface WeakProxy : NSProxy
@property (nonatomic, weak) id target;
+ (instancetype)proxyWithTarget:(id)target;
@end

@implementation WeakProxy
+ (instancetype)proxyWithTarget:(id)target {
    WeakProxy *p = [WeakProxy alloc];
    p.target = target;
    return p;
}
- (NSMethodSignature *)methodSignatureForSelector:(SEL)sel {
    return [self.target methodSignatureForSelector:sel];
}
- (void)forwardInvocation:(NSInvocation *)invocation {
    [invocation invokeWithTarget:self.target];
}
@end

// 使用:timer 强引用的是 proxy,proxy 只 weak 引用 self
WeakProxy *proxy = [WeakProxy proxyWithTarget:self];
self.timer = [NSTimer scheduledTimerWithTimeInterval:1 target:proxy selector:@selector(onTick) userInfo:nil repeats:YES];
// dealloc 中仍须 [self.timer invalidate],否则 RunLoop 仍持有 timer

3.4 Timer 管理要点小结

要点 说明
invalidate VC(或持有 timer 的对象)dealloc 前必须调用 [timer invalidate],否则 RunLoop 持有 timer,timer 又强引用 target,导致泄漏或野指针。
block 版 API(iOS 10+) +[NSTimer scheduledTimerWithTimeInterval:repeats:block:] 的 block 里用 [weak self],timer 不直接强引用 self,可避免 timer→self 的强引用;仍需在 dealloc 里 invalidate。
子线程 子线程 RunLoop 默认不跑,timer 需加到 RunLoop 并 run;线程结束时记得 invalidate。

四、weak 实现思路(简述)

4.1 全局 weak 表

  • 运行时维护全局的 weak 表(与对象地址关联):记录「哪些 weak 指针正在指向该对象」。
  • 当对象 dealloc 时,查该表,把表中所有 weak 指针置为 nil,再销毁对象。

4.2 SideTable 与 weak_table(概念)

  • 为减少锁竞争,常用 SideTable 分片:根据对象地址映射到某一张 SideTable;每张表内有 weak_table,存「对象 → 指向它的 weak 指针列表」。
  • storeWeak 等函数:在注册 weak 指针、对象释放时更新对应 SideTable 中的 weak 表。

4.3 流程图:对象释放时 weak 置 nil

flowchart TB
    A[对象 dealloc] --> B[查 weak 表]
    B --> C[遍历指向该对象的 weak 指针]
    C --> D[将每个 weak 指针置为 nil]
    D --> E[销毁对象]

五、思维导图小结

mindmap
  root((weak 与循环引用))
    weak 语义
      不增加引用计数
      对象释放时置 nil
    循环引用
      成环 无法释放
      破除 一方改 weak
    Block
      weak self
      strong self 防提前释放
    NSProxy 与 Timer
      Timer 强引用 target
      WeakProxy 转发 破循环
    实现
      SideTable weak_table
      dealloc 时清空 weak

参考文献

05-主题|内存管理@iOS-AutoreleasePool与RunLoop

本文介绍 自动释放池(AutoreleasePool) 的原理、底层结构(AutoreleasePoolPage)、与 RunLoop 的协作关系,以及对象何时被批量 release。引用计数基础见 03-引用计数与MRC详解


自动释放池是什么(简要介绍)

自动释放池(AutoreleasePool) 是用于延迟释放对象的机制:当对象收到 autorelease 时,不会立即让引用计数 -1,而是被加入当前线程的自动释放池;当池被 pop/drain 时,池会对其中所有对象统一发送 release,从而在「某一时刻」批量 -1。在 MRC 下需手写 autorelease;在 ARC 下由编译器在需要时自动插入。主线程的 RunLoop 在每次循环开始会 push 一个池、在休眠或退出前 pop 该池,因此主线程上的 autorelease 对象多在「本次事件处理结束」时被释放。子线程若无 RunLoop,应显式使用 @autoreleasepool { } 控制释放时机,避免临时对象堆积。


一、AutoreleasePool 的作用

1.1 为什么需要

  • autorelease 表示「稍后再 release」:不立刻 -1,而是把对象交给当前自动释放池,由池在某一时刻统一对池内对象发送 release。
  • 作用:延迟释放,避免在密集创建临时对象的场景下频繁立刻 release,可将多次 release 合并到池 drain 时执行,有利于性能与局部性。

1.2 与 RunLoop 的关系(主线程)

  • 主线程 RunLoop 在一次循环中会:
    • 进入时:push 一个 AutoreleasePool;
    • 休眠/退出前:pop 该池,即对池内所有对象执行 release(drain)。
  • 因此,主线程上没有显式 @autoreleasepool 时,当前 RunLoop 迭代结束前创建的 autorelease 对象,会在本次迭代末尾被批量释放。

二、@autoreleasepool 语法与底层

2.1 语法

@autoreleasepool {
    // 池内创建的 autorelease 对象,在 } 时统一 release
    id obj = [SomeObject createObject]; // 若返回 autorelease 对象
}
// 池 pop,obj 收到 release

2.2 底层对应(伪代码)

  • @autoreleasepool { ... } 编译后等价于:
    • 入口:objc_autoreleasePoolPush()(入栈一个哨兵/边界);
    • 出口:objc_autoreleasePoolPop()(pop 到该边界,对之间加入的对象依次 release)。

2.3 AutoreleasePoolPage(简述)

  • 自动释放池由 AutoreleasePoolPage 组成的栈结构实现;每页约 4KB,存若干对象指针。
  • push 时可能新开一页或复用当前页;pop 时从栈顶向栈底对每个对象 release,直到遇到对应 push 的边界。

三、释放时机小结

场景 释放时机
主线程、无显式 @autoreleasepool 当前 RunLoop 迭代结束前(休眠/退出时 pop 顶层池)
显式 @autoreleasepool { } 离开 } 时 pop,池内对象立即被 release
子线程 若没有 RunLoop 或未手动加池,需在线程中显式 @autoreleasepool,否则 autorelease 对象可能堆积到线程退出

四、流程图:RunLoop 与 AutoreleasePool 协作(主线程)

flowchart LR
    subgraph RunLoop 一次迭代
        A[进入] --> B[Push Pool]
        B --> C[处理事件]
        C --> D[休眠/退出前]
        D --> E[Pop Pool]
        E --> F[池内对象 release]
    end

五、应用场景

  • 循环中大量创建临时对象:在循环内层包一层 @autoreleasepool { },每轮迭代结束即释放,避免峰值过高。
  • 子线程中创建大量 autorelease 对象:在线程入口或循环内使用 @autoreleasepool,避免只依赖线程退出才释放。

参考文献

04-主题|内存管理@iOS-ARC详解

本文介绍 ARC(Automatic Reference Counting,自动引用计数) 的机制、strong/weak/unowned 等所有权修饰符、编译器如何插入引用计数代码,以及常见应用场景与注意事项。前置知识见 03-引用计数与MRC详解


ARC 是什么(简要介绍)

ARCAutomatic Reference Counting:在编译期由编译器根据代码中的所有权修饰符(如 strong、weak)和代码结构,自动插入 retain、release、autorelease 等调用,开发者不再手写这些方法。底层仍然使用与 MRC 相同的引用计数规则,只是「谁在何时 +1/-1」由编译器决定。ARC 自 iOS 5 / WWDC 2011 引入,现为 Objective-C 与 Swift 的推荐方式;Swift 仅支持 ARC。使用 ARC 时仍需理解强引用与弱引用循环引用自动释放池 的释放时机,见 05-AutoreleasePool与RunLoop06-weak与循环引用


一、ARC 是什么

1.1 定义

  • ARC 是编译期特性:编译器根据所有权修饰符代码结构,在合适位置自动插入 retain、release、autorelease 等调用。
  • 与 MRC 使用同一套引用计数规则,对象生命周期语义一致;开发者不再手写 retain/release,减少遗漏与错误。

1.2 与 MRC 对比

维度 MRC ARC
谁写 retain/release 开发者手写 编译器自动插入
所有权表达 通过方法名约定 + 手写调用 通过变量/属性修饰符(strong/weak 等)
autorelease 手写 autorelease 编译器在需要时插入
循环引用 需手写 weak 或打破引用 同样需用 weak/unowned 打破

二、所有权修饰符(Objective-C)

2.1 常见修饰符

修饰符 含义 引用计数影响
__strong(默认) 强引用,拥有对象 赋值时 retain,离开作用域或置 nil 时 release
__weak 弱引用,不拥有对象 不增加引用计数;对象释放时自动置为 nil
__unsafe_unretained 不保留引用,不拥有 不增加引用计数;对象释放后不置 nil,可能野指针
__autoreleasing 通过引用传入并在 autorelease 池中释放 用于 out 参数等场景

2.2 属性与修饰符对应

属性声明 默认修饰符 说明
strong __strong 强引用,常用
weak __weak 弱引用,打破循环或非拥有关系
copy __strong(拷贝语义) 设值时 copy,用于 block、NSString 等;深浅拷贝与 copy 语义见 [11-深浅拷贝与内存](11-主题 内存管理@iOS-深浅拷贝与内存.md)
assign __unsafe_unretained 不持有,多用于基本类型或需避免循环时(非对象慎用)

三、ARC 下的典型场景

3.1 强引用与释放时机

// 局部变量:离开作用域时自动 release
- (void)foo {
    NSObject *obj = [[NSObject alloc] init]; // 强引用,rc=1
    // 使用 obj
} // 作用域结束,编译器插入 release,obj 可能 dealloc

3.2 弱引用与循环引用

// 两个对象互相强引用 → 循环引用,都无法释放
// 解决:一方改为 weak
@interface Child : NSObject
@property (nonatomic, weak) Parent *parent; // 弱引用父类
@end

3.3 Block 中的循环引用

// self → block → self,形成循环
__weak typeof(self) wself = self;
self.block = ^{
    __strong typeof(wself) sself = wself;
    [sself doSomething];
};

详见 06-weak与循环引用。Block 的三种类型(全局/栈/堆)、copy 语义与 MRC/ARC 差异见 10-Block内存管理


四、流程图:ARC 编译期插入示意

flowchart TB
    subgraph 源码
        A[strong 赋值]
        B[变量离开作用域]
    end
    subgraph 编译器插入
        A --> C[插入 retain]
        B --> D[插入 release]
    end

五、Swift 中的 ARC

  • Swift 仅支持 ARC,无 MRC。
  • strong(默认)、weakunowned 与 OC 语义对应;闭包捕获列表 [weak self] / [unowned self] 用于避免循环引用。
  • 详见 Swift 官方 - Automatic Reference Counting

参考文献

❌
❌