普通视图

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

来聊聊 Codex 高效编程的正确姿势

HFJYHg2bYAAZYIf.jpeg

前言

继 Cursor 拉跨,Claude Code 渠道匮乏之后,Codex 成为了我的新宠。最近纯 AI 编程了一个前端工程项目后,对他的“驯服”,深有领悟,今日与众道友分享一番。

项目简介

先说下我这次项目的基本情况哈,以便后续让大家了解 Codex 帮我完成了哪些工作。

面向 C 端用户的一个 H5 短剧播放网站,内容涵盖搜索,充值,视频播放等功能。技术栈为 Nuxt4、Ts、Tailwindcss、Vant-ui、Scss 等。

规范约束

对于一个前端项目而言,Codex 做起来还是相对简单的,我们只需要约束好工程规范,剩下的就是需求喂给他就行。

Codex 在读取项目目录的时候,首先都会先去找 AGENTS.md 文件,这是项目里的入口约定,告诉 Codex 进入这个仓库要遵守什么、先读哪个 skill。例如去约定哪些文件不能动,创建文件时的命名规则等。

适合放在 AGENTS.md 里的内容:

  • 这个仓库做代码任务时必须先读哪个 skill
  • 项目通用原则,比如“不要过度抽象”“不要擅自改翻译文件”
  • 特殊目录说明,比如 app/、api/、stores/ 怎么组织
  • 提交、测试、构建的默认要求
  • 哪些文件不能随便动
  • 多人协作规则,比如不要回滚用户改动

至于 skill,就是具体执行细则,也就是真正的工作流和编码规范。

适合放在 SKILL.md 里的内容:

  • 具体编码风格,比如优先简单直接、逻辑直白、少做兜底处理
  • 注释规则,比如超过五行的业务逻辑方法加中文注释
  • API 使用规则,比如按接口字段类型直接用,不兼容旧结构
  • 页面初始化规则,比如首屏逻辑放后面,用显式 initXxxPage()
  • UI 规则,比如 Tailwind 优先用 mt-[16px] 这种像素写法,不要写 mt-6 这种 spacing scale 写法
  • i18n 规则,比如不直接使用文案,采用 t('xxx') 的写法,旁边备注好中文
  • 验证规则,比如普通改动跑 pnpm lint,路由/构建相关再跑 pnpm build,校验时走 playwright 打开页面自测功能
  • 提交规则,比如 commit 前缀、提交后是否推送

可以说 AGENTS.md 和 SKILL.md 文件就是我们对 AI 的完全约束,不让他天马行空的去帮我们完成新需求。我碰到过多次 AI,做接口联调时,不信任接口数据,每一次都要做判断兜底,生成了很多无用的代码,然后让他完全信任接口数据和返回格式,并生成对应 skill 后,代码清爽了很多。

包括很多 UI 规范,如果搞一些自己的项目,没有 UI 设计,可以直接用现成的 design.md 做规范喂给 AI,做出来的界面也会更舒服一些。

所以,对 AI 的驯服,主要就是在于 skill 文档的描述,写的越详细,他的代码就越目标化。

MCP 和技能的使用

现在很多第三方的服务平台,都有自己的 MCP 服务 或 Plugin 暴漏给 AI 提供服务,使它能更好的为我们完成工作。对前端而言,有几个很棒的工具。

Apifox MCP

前后端联调很重要的一点就是接口文档了,我们项目文档集成在 Apifox 上,只要接口文档上对字段描述清晰,传参的类型变量定义完整,就可以直接复制文档的 id,让 AI 根据项目文档完成功能联调。

尤其在做一些管理后台的增删改查需求时,把接口 id 和原型图发给他,再加上一些需求描述,就可以直接帮你完成项目功能。

使用限制:需开通编辑者权限,每个项目都要配一个 MCP 服务。

详见文档

Figma MCP

Figma MCP 可以直接帮我们绘制好界面上的 UI 图,但是一些切图处理的不好,有些静态图是 UI 做了多层图层后生成的,需要我们自己切下来,告诉他引用位置,之后他会对照这个图把其他元素渲染好。使用过程中,我发现 AI 很爱做 reactive 定位处理,这个也要在 skill 里对他做限制,尽量使用 flex 布局。

使用限制:需开通编辑者权限,企业版的账号开一个编辑者,需要每个月大几百的支出,建议大家可以自己搞个教育版账号,将图复制过来一份开发调用。

详见文档

Playwright

可以调用浏览器的服务,非常适合做一些回归测试。可以搭配 Figma MCP 对 UI稿完成校验,也适合做项目报错时的走查。

我目前只用到这三个 MCP,当把他们组合起来之后,你就会发现真的是解放双手啊。

例如:对于列表页,你可以说

根据 UI 地址:xxx。完成页面绘制,注意顶部标题栏要做吸顶处理。列表数据调接口取值,参考接口 id:xxx。空数据时,引用空图文件 xxx.png。整体页面完成后,调用 playwright 打开浏览器的 H5 模式,对照一遍 UI 完成校验

结尾

以上就是我近期的一些使用感悟,大家一定要多拥抱 AI 去做开发,可能很多朋友用起来会觉得:

「嗯,是很不错,但感觉他也就只能帮我做个小模块而已,做多了就瞎改起来了」

但,请相信 AI 的强大,丰富好你的 skill 文件,每一次对他的”调教“,都会让它下一次的编程,离你想象中更进一步。

20210324105052601.gif

VSCode 插件推荐 Copy Filename Pro,快速复制文件、目录和路径的首选

2026年4月11日 12:30

大家好,我是笨笨狗吞噬者,uni-app、varlet、nrm 等众多知名仓库的核心开发,专注于分享 前端技术 和 AI 实践知识,欢迎关注我的微信公众号 前端笨笨狗,或者加我的微信 wxid_olsjlzuh4ivf22 沟通交流!

问题背景

大家平时写代码时,经常会遇到一些很碎的小动作,比如:

  • 想快速拿到组件名、页面名、模块名
  • 想复制路径写 import、写文档或者发消息
  • 想批量整理多个文件名或路径

这些操作不难,但做得多了就很烦,尤其是在目录结构比较复杂的工程里。我也被这类问题折腾了很久,试了插件市场里的很多插件,却总不如意,于是,我就自己写了插件 Copy Filename Pro

插件功能

Copy Filename Pro 主要提供下面几个功能:

复制带文件后缀的文件名

比如我想复制某个 vue 文件的完整文件名

with-filename.gif

复制不带扩展名的文件名

比如我想复制某个 vue 文件的不包含文件后缀的文件名

no-filename.gif

复制目录名

比如我想复制某个文件夹名称

dictory.gif

复制不带拓展名的绝对路径或者相对路径

由于 VSCode 本身有复制路径和相对路径的功能了,所以这里演示如何得到不包含文件后缀的路径

path.gif

一次复制多个文件或目录的信息

比如我想一次复制多个文件名

mul.gif

下载安装

大家可以参考下面的图片安装此插件

zhinan.png

另外,此插件的源码是完全免费公开的,访问 https://github.com/chouchouji/copy-filename-pro 即可获取,如果你有更好的想法和建议,也可以留言给我。

SBTI 测试挤崩服务器:一个程序员视角的技术复盘

2026年4月10日 14:01

昨晚你的朋友圈,是不是也被"尤物""吗喽""愤世者"刷屏了?

4月9日晚,一个叫 SBTI 的人格测试突然引爆社交网络。用户蜂拥而至,网站直接崩了——页面打不开、链接失效,大家只能靠截图"云测试"。作者深夜紧急发布新链接,称"做了略微修改,应该不会再崩了"。

作为一个技术人,看到"网站崩了"和"略微修改就不崩了"这两句话,我的职业病就犯了。今天我们不聊测试准不准,只聊:这背后到底发生了什么?如果是你来做,怎么才能扛住这波流量?

一、崩溃现场还原:一个经典的"雷群效应"

SBTI 测试的崩溃,是教科书级别的 Thundering Herd(雷群效应)案例。

简单说就是:一个原本只为"劝朋友戒酒"而做的小项目,突然被几百万人同时访问。这就好比你开了一家只有两张桌子的面馆,突然被美食博主推荐,门口排了两公里的队。

根据公开信息推测,SBTI 最初大概率是这样的架构:

用户浏览器 → 单台服务器(前端 + 后端 + 数据库 all-in-one

这种架构在日常几百、几千 PV 的场景下完全够用。但当朋友圈裂变式传播启动后,并发量可能在几分钟内从个位数飙升到数万甚至数十万级别。单机扛不住,结果就是:

  • 连接池耗尽:服务器能同时处理的请求数是有限的,超出后新请求直接被拒绝
  • 带宽打满:测试页面包含图片、样式、脚本,每个用户加载一次就消耗几百 KB 到几 MB 的带宽
  • 如果有后端逻辑:数据库连接数爆满,CPU 被打满,响应时间从毫秒级飙升到超时

二、"略微修改"背后的技术真相

作者说"做了略微修改,应该不会再崩了"。这句话信息量很大。

对于一个测试类 H5 应用,最高效的"略微修改"大概率是以下几种操作之一(或组合):

方案 A:纯静态化 + CDN 分发

测试类应用的核心逻辑其实很简单:展示题目 → 用户选择 → 前端计算结果 → 展示结果页。整个过程完全可以在浏览器端完成,不需要后端服务器参与。

之前:用户 → 源站服务器(动态渲染)
之后:用户 → CDN 边缘节点(静态资源)→ 前端 JS 本地计算结果

把所有页面打包成纯静态文件(HTML + CSS + JS + 图片),扔到 CDN 上。CDN 在全国有几百个边缘节点,用户访问时会自动路由到最近的节点。这样源站压力几乎为零,理论上可以承载千万级并发。

方案 B:更换托管平台

从自建服务器迁移到 Vercel、Cloudflare Pages、Netlify 等现代静态托管平台。这些平台天然具备全球 CDN 分发能力,部署一个静态站点只需要几分钟。

方案 C:Serverless 化

如果测试逻辑中确实有需要后端参与的部分(比如 AI 生成结果文案),可以将后端逻辑迁移到 Serverless 函数(如 AWS Lambda、阿里云函数计算)。Serverless 的核心优势是自动弹性伸缩——流量来了自动扩容,流量走了自动缩容,按实际调用次数计费。

三、如果让你从零设计,架构应该长什么样?

假设你现在要做一个类似 SBTI 的病毒式传播测试应用,并且预期它可能会爆火,推荐的架构如下:

┌─────────────────────────────────────────────────┐
│                    用户浏览器                      │
│  ┌───────────┐  ┌──────────┐  ┌───────────────┐  │
│  │ 答题引擎   │  │ 计分逻辑  │  │ 结果图片生成   │  │
│  │ (纯前端)   │  │ (纯前端)  │  │ (Canvas/SVG)  │  │
│  └───────────┘  └──────────┘  └───────────────┘  │
└──────────────────────┬──────────────────────────┘
                       │ 静态资源请求
                       ▼
              ┌─────────────────┐
              │   CDN 边缘节点    │
              │  (全国 300+ 节点) │
              └────────┬────────┘
                       │ 回源(极少触发)
                       ▼
              ┌─────────────────┐
              │  对象存储 (OSS)   │
              │  HTML/CSS/JS/图片 │
              └─────────────────┘

核心设计原则:

  1. 计算下沉到客户端:题目数据、计分逻辑、结果映射全部内嵌在前端代码中,浏览器本地完成所有计算,服务端零压力
  2. 资源全量 CDN 化:所有静态资源通过 CDN 分发,用户就近访问,首屏加载时间控制在 1-2 秒内
  3. 结果图片客户端生成:使用 Canvas API 或 html2canvas 在用户浏览器中直接生成分享图片,避免服务端图片渲染的性能瓶颈
  4. 零后端依赖:整个应用不需要数据库、不需要后端 API,运维成本趋近于零

四、病毒传播的技术引擎:分享链路优化

SBTI 能刷屏朋友圈,除了内容本身的娱乐性,分享链路的技术设计也至关重要。

微信分享卡片优化

// 微信 JS-SDK 分享配置
wx.updateAppMessageShareData({
  title: '我的SBTI人格是【尤物】,你是什么?',  // 动态标题,包含测试结果
  desc: 'MBTI已经过时了,来测测你的SBTI人格吧',
  link: 'https://example.com/sbti?from=share',   // 带来源追踪参数
  imgUrl: 'https://cdn.example.com/sbti-share.jpg' // 高辨识度的分享缩略图
})

关键技术点:

  • 动态分享标题:将用户的测试结果嵌入分享标题,制造好奇心驱动的点击欲望
  • 结果图片生成:用 Canvas 将测试结果渲染为一张精美的图片,方便用户保存并发到朋友圈
  • 短链接 + UTM 追踪:通过 URL 参数追踪传播路径,了解哪个渠道带来的流量最大

分享图片的客户端生成方案

import html2canvas from 'html2canvas';

async function generateShareImage(resultElement) {
  const canvas = await html2canvas(resultElement, {
    scale: 2,              // 2倍分辨率,保证清晰度
    useCORS: true,         // 允许跨域图片
    backgroundColor: null  // 透明背景
  });
  
  // 转为图片供用户长按保存
  const imgUrl = canvas.toDataURL('image/png');
  return imgUrl;
}

这个方案的好处是:图片在用户手机上生成,不需要服务端渲染,即使同时有 100 万人生成分享图,服务器也毫无压力。

五、AI 在其中扮演的角色

根据公开信息,SBTI 的人格描述内容使用了 AI 生成技术。这带来了一个有趣的架构选择:

方案一:预生成(推荐)

在开发阶段就用 AI 生成好所有人格类型的描述文案,作为静态数据打包到前端代码中。运行时不需要调用 AI 接口,零延迟、零成本。

// 预生成的结果数据,直接内嵌在前端代码中
const SBTI_RESULTS = {
  'ABCD': {
    title: '尤物',
    description: 'AI生成的人格描述文案...',
    image: '/assets/results/abcd.png'
  },
  'EFGH': {
    title: '吗喽',
    description: 'AI生成的人格描述文案...',
    image: '/assets/results/efgh.png'
  }
  // ... 其他类型
}

方案二:实时生成(不推荐用于病毒传播场景)

每次用户完成测试后实时调用 AI API 生成个性化描述。这种方案在流量暴增时会面临:API 调用成本飙升、响应延迟增大、API 限流等问题。

从 SBTI 的实际表现来看(崩溃后"略微修改"就恢复了),大概率采用的是方案一——AI 只在开发阶段参与内容生产,运行时是纯静态应用。

六、成本算一笔账

假设 SBTI 在爆火期间有 500 万次访问,每次访问加载约 2MB 资源:

方案 预估成本 能否扛住
单台云服务器(2核4G) ¥100/月,但会崩
CDN + 对象存储 流量费约 ¥500-1000
Vercel/Cloudflare Pages 免费版 ¥0(有带宽限制) ⚠️
Vercel Pro + CDN ¥150/月

一个纯静态的测试应用,即使面对百万级流量,CDN 方案的成本也就是一顿火锅钱。而如果用单机硬扛,服务器费用可能不高,但用户体验的损失是无法估量的——多少潜在的传播链路因为"页面打不开"而断裂了。

七、给开发者的 Takeaway

SBTI 事件给我们的启示:

  1. 永远为最好的情况做准备:如果你的产品有社交传播属性,请在架构设计时就考虑流量暴增的场景。CDN + 静态化的成本几乎为零,但收益是巨大的。

  2. 能在前端做的事,别放到后端:测试类应用的计算逻辑完全可以在浏览器端完成。每减少一次服务端请求,就多了一份稳定性。

  3. 分享体验就是增长引擎:结果图片的生成质量、分享卡片的文案设计,直接决定了传播系数。技术上要保证分享链路的流畅性。

  4. AI 是内容生产工具,不是运行时依赖:对于这类应用,AI 最适合在开发阶段批量生成内容,而不是在运行时实时调用。

  5. 小项目也值得好架构:SBTI 的作者最初只是想劝朋友戒酒,没想到会火。但如果一开始就用静态托管方案,根本不会有崩溃这回事。好的架构不一定复杂,但一定要匹配场景。


一个为劝朋友戒酒而生的测试,意外成为了一堂生动的高并发架构课。技术世界的浪漫,大概就是这样吧。


八、最后

文中技术分析基于公开信息推测,不代表 SBTI 实际技术实现。
如果你也在职场摸索成长路线,想了解更多内部跳槽、团队优化、技术实践和职场认知升级的经验,可以关注我的公众号:   [码农职场]
后续我会分享更多干货,帮助你在职场和技术上持续突破。

停更5年后,我为什么重新开始写技术内容了

2026年4月8日 10:18

5年前,我还在持续更新技术、更新公众号。
那时候写的基本都是 Android 技术、踩坑总结,还有一些零散的学习笔记。

后来,我停更了。

不是因为忙,而是因为——
我开始不知道写什么了。


这5年,一个很明显的变化

如果你这几年还在一线开发,应该会有类似的感受:

技术越来越多,但“确定性”越来越少。

我这5年,大致经历了几个阶段:

  • 从 Android 转向大前端

  • 开始接触 Web、跨端、工程化

  • 再到现在,开始用 AI 写代码

表面上看是“技术栈变多了”,
但本质上,其实是:

端的边界在消失,技术开始融合。


一个让我改变认知的点

以前我一直觉得:

技术越深,越有价值。

但这几年慢慢发现一个问题:

  • 很多工作,本质是在“重复实现”
  • 多端开发,很多逻辑是类似的
  • 团队里其实存在大量“重复人效”

也就是说:

很多时候不是技术难,而是组织方式低效。


为什么我开始接触“大前端”

我这里说的大前端,不是“只写前端”,而是:

  • Android / iOS
  • H5 / Web
  • 小程序
  • Flutter / React Native

本质是:
👉 一套能力,覆盖多端


一个更现实的原因:团队人效

我所在的团队,之前是这样配置的:

  • Android:4人
  • iOS:4人
  • 前端:2人

典型的问题是:

  • 同一个业务,要做3套实现
  • 迭代周期长
  • 维护成本高

后来开始往跨端和大前端能力调整,逐步变成:

  • Android:1人
  • iOS:1人
  • 前端:5~6人(具备跨端能力)

带来的变化很直接:

  • 多端开发统一,重复工作减少
  • 业务迭代速度明显提升
  • 团队整体人效提高

这件事对我冲击挺大的:

技术本身没变,但“使用技术的方式”变了。


另一个更现实的问题:AI

这两年如果你用过 AI 写代码,大概率会有这种感受:

  • 一些重复代码,基本不用自己写了
  • 一些基础逻辑,AI能快速补全
  • 一些简单页面,生成效率很高

我不觉得程序员会被替代,但我越来越确定一件事:

“纯写代码”的价值,在下降。

那问题就变成了:

👉 如果代码越来越不值钱,我们的价值在哪?


为什么我又开始写了

停更这几年,其实我不是没写,而是没发。

原因很简单:

  • 写得不够系统
  • 没有输出的动力
  • 也觉得“没人看”

但这两年一个变化让我重新思考:

会写代码的人很多,但能总结和表达的人很少。

而表达,本身就是能力的一部分。

所以我决定重新开始写。

不是为了做内容,而是:

👉 把这些变化、选择和踩坑,整理出来。


后面会写什么?

主要会集中在几块:

  • Android → 大前端的转型过程(包括踩坑和决策)
  • 实际用 AI 写代码的一些经验(不是概念,是具体怎么用)
  • 一些团队人效、技术选型的真实思考
  • 10年开发的一些职场经验

这些内容会尽量写得更具体一点,而不是泛泛而谈


最后

这篇其实只是一个开始。

后面我会把几个比较完整的主题慢慢写出来,比如:

  • 一个人如何逐步具备跨端能力
  • AI在实际项目里的边界在哪里
  • 技术人如何避免“只会写代码”

这些内容我会优先整理在公众号里(会写得更系统一点)。

如果你对这些话题有兴趣,可以关注一下我的公众号:码农职场

基于 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,避免只依赖线程退出才释放。

参考文献

❌
❌