一个iOS APP内存泄露排查案例
昨晚睡觉前,我打开了自己那个打鼾监测 App,早上其实发现它消失了,打开记录停在了 02:06,而我七点半才醒。我意识到,不妙,来活儿了~

这种”整夜监测只录到一半”的问题最难受的地方在于:它不报错、不崩溃、不留任何痕迹。App 就是在某个时刻悄无声息地没了,第二天你只看到一条被截断的记录。
这篇写的就是把这件事查清楚的全过程——从设备里的 JetsamEvent 报告,到 Instruments 的 Generation 快照,最后落到一行 SwiftUI 代码上。中间还有一段完整的、被自己推翻的错误推断,作为教训一并写了进来。
第一步:先确认它是”被杀的”还是”自己停的”
这两种情况的修法完全不同,不能靠猜。
我的 App 在监测过程中每 5 分钟往 UserDefaults 写一次检查点,进程要是被强杀,下次启动时会拿检查点的时间去补 endTime(不然历史里就是一条 endTime 为空的孤儿记录)。所以判据现成的:
|
1 2 3 |
开始 00:01:36 结束 02:06:36 差值 7500 秒 = 25 × 300 |
正好是检查点间隔的整数倍。如果是正常停止,endTime 会是任意时刻;只有走孤儿恢复才会精确落在检查点节拍上。 结论:进程在 02:06:36 到 02:11:36 之间没走正常的停止流程就没了。
顺带说个同一晚的另一个 bug,跟主线无关但挺典型的:

|
1 2 |
>>> import struct; struct.pack('>I', 2003329396) b'what' |
CoreAudio 的错误码经常是四字符码,2003329396 就是 'what'。触发路径是:点开始 → 5 秒预备倒计时 → 我顺手按了锁屏键 → 倒计时在后台结束 → 这时才去开麦。而 UIBackgroundModes: audio 这个豁免只保”已经在录的继续录”,不保”后台新开麦”,系统直接拒了。改法很简单:把开麦挪到倒计时之前,会话在麦克风激活之后,锁屏就管不着了。
JetsamEvent 报告怎么读
iOS 上的查看路径是 设置 → 隐私与安全性 → 分析与改进 → 分析数据,往下翻找 JetsamEvent-年-月-日-时分秒.ips。这个文件是两段 JSON 拼起来的:第一行是头,剩下是正文。
|
1 2 3 4 5 6 |
import json raw = open("JetsamEvent-2026-08-16-020855.ips").read() d = json.loads(raw.split("\n", 1)[1]) PS = d["memoryStatus"]["pageSize"] # 16384 for p in sorted(d["processes"], key=lambda x: -x["rpages"])[:5]: print(f'{p["name"]:24} {p["rpages"] * PS / 1048576:8.1f} MB prio={p["priority"]}') |
几个关键字段:
1. rpages × pageSize 是各进程占的内存,注意 pageSize 现在是 16 KB 不是 4 KB。
2. physicalPages.internal 是 [clean, dirty],脏页才是真正赖着不走的那部分。
3. age 单位是纳秒,用报告时间减一下就能反推每个进程什么时候启动的。
4. 带 reason 字段的那几条才是这次事件里被杀的,其余是快照。
5. largestProcess 直接告诉你当时谁最胖。
我这份 02:08:55 的报告里,NightSnore 压根不在进程列表里——说明它在这次事件之前就已经被杀了。当时整机也确实在雪崩:可用内存 162 MB、compressor 涨到 1.87 GB、最后三分钟有 172 个进程被杀了重启,连 SpringBoard 自己都撞了 per-process 上限。
真正的实锤在另一份 ips 文件里,因为我发现还有一份六天前的:
|
1 2 3 |
NightSnore rss = 1814.3 MB dirty = 1734 MB purgeable = 0 prio = 100 states = [active] age = 29.5 分钟 largestProcess: NightSnore |
监测跑了 29.5 分钟,进程涨到 1.8 GB,其中 1.73 GB 是脏内存。 全机最大。那次系统没杀它,而是把 180 多个闲置进程 idle-exit 掉给它让路。
到这儿性质就清楚了:不是什么玄学的后台调度问题,就是漏内存漏到撑爆。
证据不够的时候,别急着改代码
这一节是我这次最大的教训。
拿到 1.8 GB 这个数之后,我去读音频管线的代码,很快”找到”了三处无上界结构:波形显示每来一个音频 buffer 就起一个 Task { @MainActor } 把整块样本捕获进去;两级 DispatchQueue 都是无条件 async,处理端一慢就会无限堆积。三处加起来的量级估算下来跟观测值挺接近。
于是我给队列加了背压、给波形加了节流。改完确实好了一些——但这是推断,不是证据,这里其实浪费了很多时间。
Instruments 的 Generation 快照发现根因
Xcode 的 Memory Report 只能告诉你”在涨”:

(可以发现,除了内存一直涨,CPU也很高这个后面也一起修复了)
要知道”谁在涨”,得用 Instruments 的 Allocations,核心功能是 Mark Generation:
1. Product → Profile(⌘I),选 Allocations
2. Recorder Settings 里面的 Recording mode 要选 Immediate
3. 跑一分钟,点一下 Mark Generation
4. 再跑三分钟,再点一次
5. 展开第二个 Generation,看 Growth 列
Growth 只列”两次快照之间新增、且到现在仍然存活”的对象——这就是内存泄漏的定义。别的视图都会被大量正常的临时分配淹没,只有这个视图干净。

结果一眼就能看出问题:
|
1 2 3 4 5 6 7 |
UnknownObjectType 5.71 MiB 25,584 _ContiguousArrayStorage<AnyViewTrait> 2.16 MiB 5,904 ImageProviderBox<NamedImageProvider> 553.50 KiB 5,904 LocalizedTextStorage 461.25 KiB 5,904 AnyViewStorag<Label<Text, Image>> 369.00 KiB 5,904 TagIndexProjection<Int> 184.50 KiB 1,968 _TtCC5UIKit12_UITabButton… 96.00 KiB 6 |
跟音频、跟 CoreML、跟我改了半天的那些队列,一点关系都没有。全是 SwiftUI,而且全是 TabView 标签栏的零件:Label<Text, Image> 是三个 tab 项,NamedImageProvider 是 tab 图标,LocalizedTextStorage 是 tab 标题,TagIndexProjection<Int> 是 .tag(),最后还有 UITabButton 本体。
UnknownObjectType:

AnyViewTrait:

真正把结论钉死的是数字本身:
|
1 2 |
5,904 = 3 × 1,968 (三个 tab) 1,968 ÷ 180 秒 = 10.9 次/秒 |
10.9 次每秒——正好是音频 buffer 的到达频率。
根因:根视图订阅了高频 @Published
顺着这个频率回去看代码,一行就找到了:
|
1 2 3 4 5 6 7 8 9 |
struct ContentView: View { @ObservedObject private var monitoringService = MonitoringService.shared // ... TabView(selection: $selectedTab) { MonitoringView().tabItem { Label("监测", systemImage: "waveform.circle.fill") }.tag(0) SessionListView().tabItem { Label("历史", systemImage: "clock.fill") }.tag(1) SettingsView().tabItem { Label("设置", systemImage: "gearshape.fill") }.tag(2) } } |
MonitoringService 里有个 @Published var audioSamples: [Float],监测时每秒更新约 11 次给波形图用。链条是这样的:
|
1 2 3 4 |
audioSamples 每秒发布 11 次 → ContentView.body 每秒重算 11 次 → 整个 TabView 连同三个 .tabItem { Label(...) } 重建 → SwiftUI 把标签项的内部存储泄漏掉 |
一小时约 200 MB,整夜下来就是 GB 级。而且这跟屏幕亮不亮无关——锁屏后 SwiftUI 不渲染了,但 body 该重算还是重算。
根视图的 body 其实根本不依赖任何监测状态,它只是需要在两个字段变化时做点事而已。那就别观察整个对象,改成订阅具体的 publisher:
|
1 2 3 4 5 |
private let monitoringService = MonitoringService.shared // 不再是 @ObservedObject // ... .onReceive(monitoringService.$finishedSessionForSummary) { _ in presentSummaryAfterPublish() } .onReceive(monitoringService.$audioError) { _ in presentSummaryAfterPublish() } |
同样口径复测:

|
1 2 3 |
修之前 修之后 Growth 10.81 MiB 581 KiB 存活对象 61,631 609 |
字节数降了 19 倍,对象数降了 101 倍。后者才是关键信号——”每帧泄一批”的模式没有了。剩下那 581 KiB 里 80% 是 4 个 CALayer 后备存储和 1 次 SQLite 页缓存增长,都是一次性的。
支持,内存泄露的问题算是解决了,再去看CPU使用率,也从 75% 降到了 5% 不到。
几个坑
1. @Published 是在 willSet 时发布的。 换成 .onReceive 之后,闭包跑在属性真正写入之前,这时候回读那个属性拿到的还是旧值——比如 audioError 明明马上要变成 nil,读出来仍然非 nil,判断就全反了。要么用闭包参数带过来的新值,要么推到下一个主队列 turn 再处理。
2. 本进程不在 JetsamEvent 列表里,不代表这份报告没用。 它恰恰说明进程在这次事件之前就已经被杀了,应该去找更早的那份。iOS 不会为每次 kill 都写报告,也会轮转,所以未必找得到。
3. Xcode Organizer 的 Terminations / Memory 面板需要足够用户量。 我的 App 用户少,那儿一直显示 Insufficient usage data。真要在生产环境拿这个数据,得自己接 MetricKit——MXAppExitMetric.backgroundExitData 会把后台退出按原因分桶(memoryResourceLimit / memoryPressure / cpuResourceLimit / appWatchdog / normal),MXMemoryMetric.peakMemoryUsage 给峰值内存,而且不依赖样本量。注意 payload 是按天生成、日终才交付的,今天出的事明天才读得到。
4. 有界即可控,无论多大。 我中途一度去调小音频缓冲的上限、给队列加背压丢帧——那是让泄漏变慢,不是修泄漏,而且丢帧会漏掉真实的鼾声事件。判据应该是”有没有上界”,不是”占了多少”:有上限封顶的缓冲填到 80 MB 是可控的,无上界的队列涨到 10 MB 才是 bug。根因定位之后,那些基于错误推断做的改动我全部还原了。
5. 别给泄漏加兜底。 我还写过”剩余内存不足就自动结束监测”的保护,听起来很负责,实际上根因修掉之后它只剩下误停正常会话的风险。也删了。
回头看
整件事的证据链是这样一条:endTime 落在检查点整倍数上(判定被杀)→ JetsamEvent 报告(拿到 1.8 GB 这个量级)→ git log(作废错误推断)→ Instruments Generation(点名到具体对象)→ 对象个数 = 3 × body 重算次数(锁定根因)。
前面几步都是旁证,只有 Generation 快照那一步是真正指名道姓的。我在拿到它之前写的所有”修复”,事后看全是在给一个不存在的病开药。读代码能产生假设,但假设需要被证据杀死或者证实,中间那一段自己觉得”应该就是这个了”的笃定,最不可信。
不说了,我赶紧发新版本去了,这APP之前是下载就收费,刚改成免费试用+一次性买断的模式,结果免费用户就遇到了内存泄露,异常退出的大问题,实在是很不应该。。。对我的打击也实在是有点大!😭
发表回复