mcpField
A
axiom-memory-debugging

axiom-memory-debugging

github.com/charleswiltgen开发1.2k192 次安装今天更新
代码仓库

关于 axiom-memory-debugging

axiom-memory-debugging 是用于诊断和修复应用内存泄漏及内存压力问题的实用技能,指导开发者使用 Instruments、Memory Graph Debugger、常见保留模式和命令行检查。

主要功能

  1. 01逐步排查内存泄漏的流程
  2. 02Memory Graph Debugger 与 Instruments 指南
  3. 03定时器、观察者和闭包循环的修复方法
  4. 04Jetsam 与后台内存指南
  5. 05命令行内存诊断检查

适用场景

  1. 01调查应用内存持续增长
  2. 02查找视图控制器关闭后仍被保留的原因
  3. 03检查定时器和通知的清理
  4. 04诊断内存压力导致的后台终止

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/charleswiltgen/axiom --skill axiom-memory-debugging

安装后在 Claude Code 中输入/axiom-memory-debugging确认已加载。

详细介绍

内存调试

概述

内存问题通常表现为长时间使用后发生崩溃。核心原则: 90% 的内存泄漏都符合 3 种模式(循环引用、计时器/观察者泄漏、集合增长)。使用 Instruments 系统地诊断,切勿猜测。

示例提示词

  • “我的应用在运行 10-15 分钟后崩溃,且没有错误消息”
  • “执行特定操作后,内存从 50MB 跳到 200MB 以上——是泄漏还是缓存?”
  • “关闭视图控制器后,它们没有释放”
  • “计时器/观察者导致内存泄漏——如何验证?”
  • “应用占用 200MB,但我不知道这是否正常”

警示信号——很可能存在内存泄漏

  • 内存持续增长:50MB → 100MB → 200MB(没有趋于稳定)
  • 应用在运行 10-15 分钟后崩溃,Xcode 控制台中没有错误
  • 设备日志中反复出现内存警告
  • 关闭视图控制器后,它们没有释放(可在 Memory Graph Debugger 中看到)
  • 多次执行同一操作会导致内存线性增长

泄漏与正常情况的区别:正常情况 = 保持在 100MB。泄漏 = 50MB → 100MB → 150MB → 200MB → 崩溃。

必须执行的第一步

始终先诊断(再阅读代码):

  1. 检查设备日志中的 "Memory pressure critical"、"Jetsam killed"、"Low Memory"
  2. 使用 Memory Graph Debugger(见下文)——查看对象数量的增长
  3. Xcode → Product → Profile → Memory。执行操作 5 次,记录内存是否持续增长

诊断结果说明:平稳 = 不是泄漏。线性增长 = 典型泄漏。先飙升再平稳 = 正常缓存。多次飙升叠加 = 复合泄漏。

为何先诊断:使用 Instruments 找到泄漏:5-15 分钟。凭猜测排查:45 分钟以上。

检测泄漏——逐步操作

第 1 步:Memory Graph Debugger(最快)
  1. 在模拟器中打开应用
  2. Debug → Memory Graph Debugger(或点击工具栏图标)
  3. 查找带有 "⚠" 标记的紫色/红色圆圈
  4. 点击它们 → Xcode 会显示循环引用链
第 2 步:Instruments(详细分析)
  1. Product → Profile (Cmd+I) → "Memory" 模板
  2. 执行操作 5-10 次
  3. 每次操作后,内存曲线都上升?= 确认存在泄漏

关键工具:Heap Allocations(对象数量)、Leaked Objects(直接检测)、VM Tracker(按类型)。

第 3 步:检查释放情况
// Add deinit logging to suspect classes
class MyViewController: UIViewController {
    deinit { print("✅ MyViewController deallocated") }
}

@MainActor
class ViewModel: ObservableObject {
    deinit { print("✅ ViewModel deallocated") }
}

进入视图,再离开。看到 "✅ deallocated" 了吗?是 = 没有泄漏。否 = 在某处仍被保留。

Jetsam(内存压力终止)

Jetsam 不是 Bug——iOS 会终止后台应用以释放内存。这不是崩溃(没有崩溃日志),但频繁被终止会损害用户体验。

终止原因成因解决方案
内存超出限制应用使用了过多内存减少峰值内存占用
Jetsam系统需要为其他应用腾出内存将后台内存占用降至 <50MB
降低 Jetsam 频率

进入后台时清除缓存:

// SwiftUI
.onChange(of: scenePhase) { _, newPhase in
    if newPhase == .background {
        imageCache.clearAll()
        URLCache.shared.removeAllCachedResponses()
    }
}
状态恢复

用户不应察觉到 Jetsam。使用 @SceneStorage(SwiftUI)或 stateRestorationActivity(UIKit)恢复导航位置、草稿和滚动位置。

使用 MetricKit 进行监控
class JetsamMonitor: NSObject, MXMetricManagerSubscriber {
    func didReceive(_ payloads: [MXMetricPayload]) {
        for payload in payloads {
            guard let exitData = payload.applicationExitMetrics else { continue }
            let bgData = exitData.backgroundExitData
            if bgData.cumulativeMemoryPressureExitCount > 0 {
                // Send to analytics
            }
        }
    }
}
App memory grows while in USE? → Memory leak (fix retention)
App killed in BACKGROUND? → Jetsam (reduce bg memory)

常见内存泄漏模式(及修复方法)

模式 1:计时器泄漏(最常见——占泄漏的 50%)

仅使用 [weak self] 无法修复计时器泄漏的原因:RunLoop 会保留已调度的计时器。[weak self] 只能防止闭包保留 self——Timer 对象本身仍会继续存在并触发。必须显式调用 invalidate(),才能解除 RunLoop 对它的保留。

❌ 泄漏——计时器从未失效
progressTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
    self?.updateProgress()
}
// Timer never stopped → RunLoop keeps it alive and firing forever
✅ 最佳修复:Combine(自动清理)
cancellable = Timer.publish(every: 1.0, tolerance: 0.1, on: .main, in: .default)
    .autoconnect()
    .sink { [weak self] _ in self?.updateProgress() }
// No deinit needed — cancellable auto-cleans when released

替代方法:在适当的清理方法(viewWillDisappear、停止方法等)和 deinit 中都调用 timer?.invalidate(); timer = nil。

有关计时器崩溃模式(EXC_BAD_INSTRUCTION)和 RunLoop 模式问题,请参阅 axiom-timer-patterns。

模式 2:观察者/通知泄漏(占泄漏的 25%)
❌ 泄漏——没有调用 removeObserver
NotificationCenter.default.addObserver(self, selector: #selector(handle),
    name: AVAudioSession.routeChangeNotification, object: nil)
// No matching removeObserver → accumulates listeners
✅ 最佳修复:Combine 发布者
NotificationCenter.default.publisher(for: AVAudioSession.routeChangeNotification)
    .sink { [weak self] _ in self?.handleChange() }
    .store(in: &cancellables)  // Auto-cleanup with viewModel

替代方法:在 deinit 中调用 NotificationCenter.default.removeObserver(self)。

模式 3:闭包捕获泄漏(占泄漏的 15%)
❌ 泄漏 — 数组中的闭包捕获了 self
updateCallbacks.append { [self] track in
    self.refreshUI(with: track)  // Strong capture → cycle
}
✅ 修复:使用 [weak self]
updateCallbacks.append { [weak self] track in
    self?.refreshUI(with: track)
}

在 deinit 中清空回调数组。只有确定 self 的生命周期长于闭包时,才使用 [unowned self]。

模式 4:强引用循环
❌ 泄漏 — 相互强引用
player?.onPlaybackEnd = { [self] in self.playNextTrack() }
// self → player → closure → self (cycle)
✅ 修复:在闭包中使用 [weak self]
player?.onPlaybackEnd = { [weak self] in self?.playNextTrack() }
模式 5:视图/布局回调泄漏

使用带有 AnyObject 协议(支持弱引用)的委托模式,而不是会捕获视图控制器的闭包。

模式 6:PhotoKit 图像请求泄漏

PHImageManager.requestImage() 会返回必须取消的 PHImageRequestID。如果不取消,待处理请求会不断排队,并在滚动时占用内存。

class PhotoCell: UICollectionViewCell {
    private var imageRequestID: PHImageRequestID = PHInvalidImageRequestID

    func configure(with asset: PHAsset, imageManager: PHImageManager) {
        if imageRequestID != PHInvalidImageRequestID {
            imageManager.cancelImageRequest(imageRequestID)
        }
        imageRequestID = imageManager.requestImage(for: asset, targetSize: PHImageManagerMaximumSize,
            contentMode: .aspectFill, options: nil) { [weak self] image, _ in
            self?.imageView.image = image
        }
    }

    override func prepareForReuse() {
        super.prepareForReuse()
        if imageRequestID != PHInvalidImageRequestID {
            PHImageManager.default().cancelImageRequest(imageRequestID)
            imageRequestID = PHInvalidImageRequestID
        }
        imageView.image = nil
    }
}

类似模式:AVAssetImageGenerator → cancelAllCGImageGeneration(),URLSession.dataTask() → cancel()。

系统化调试工作流

阶段 1:确认泄漏(5 分钟)

使用 Memory 模板进行分析,将操作重复 10 次。内存曲线平稳 = 没有泄漏(停止)。持续上升 = 存在泄漏(继续)。

阶段 2:定位泄漏(10-15 分钟)

Memory Graph Debugger → 紫色/红色圆圈 → 点击 → 查看保留循环链。

常见位置:计时器(50%)、通知/KVO(25%)、集合中的闭包(15%)、委托循环(10%)。

阶段 3:修复并验证(5 分钟)

应用上述模式中的修复方法。添加 deinit { print("✅ deallocated") }。再次运行 Instruments — 内存应保持平稳。

复合泄漏

实际应用通常会叠加 2-3 个泄漏。先修复最大的一个,再次运行 Instruments,然后重复此过程,直到内存曲线平稳。

无法复现/间歇性泄漏

当 Instruments 导致问题无法复现(Heisenbug),或泄漏只在特定用户数据下发生时:

轻量级诊断(无法附加 Instruments 时):

  1. 将 deinit 日志作为主要诊断手段 — 在所有可疑类中添加 deinit { print("✅ ClassName deallocated") }。运行 20 次以上会话。发生泄漏时(例如 5 次中出现 1 次),缺失的 deinit 消息可揭示哪些对象仍被保留。
  2. 隔离触发条件 — 分别测试每条导航路径。如果问题与时序有关,则快速切换前台/后台状态。逐步缩小范围,找出发生泄漏的具体路径。
  3. 使用 MetricKit 进行线上诊断 — 通过 MXMetricPayload.memoryMetrics.peakMemoryUsage 监控生产环境中的峰值内存。超过阈值时发出警报(例如 400MB)。这可以捕捉到只有在真实用户数据量下才会显现的泄漏。

间歇性泄漏的常见原因:在生命周期事件(viewWillAppear、applicationDidBecomeActive)中添加通知观察者,却没有先移除重复项。每次重新注册都会累积一个监听器 — 是否触发重复监听取决于时序。

TestFlight 验证:向受影响的用户发布诊断版本。添加 os_log 内存里程碑。修复部署后,监控 MetricKit 24-48 小时。

常见错误

  • 使用 [weak self] 却不调用 invalidate() — 计时器会继续运行并消耗 CPU。务必调用 invalidate() 或 cancel()
  • 调用 invalidate 却不设为 nil — timer?.invalidate() 会停止触发,但引用仍然存在。之后务必执行 timer = nil
  • 局部 AnyCancellable — 会立即超出作用域,订阅随即失效。将其存储在 Set<AnyCancellable> 属性中
  • deinit 中只有日志 — 应添加实际清理操作(使计时器失效、移除观察者),而不只是打印日志
  • 使用了错误的 Instruments 模板 — Memory 显示内存用量。Leaks 会检测实际泄漏。两者都要使用

Instruments 快速参考

场景工具观察内容
内存持续增长Memory曲线持续上升 = 泄漏
特定对象泄漏Memory Graph紫色/红色圆圈 = 泄漏对象
直接检测泄漏Leaks红色 "! Leak" 标记 = 已确认泄漏
按类型查看内存VM Tracker占用内存最多的对象
缓存行为Allocations已分配但未释放的对象

CLI 快速检查(无需 Instruments)

Xcode 提供了 CLI 工具,无需打开 Instruments 即可快速诊断内存问题。开发过程中可使用这些工具进行快速检查。

leaks — 检测运行中进程的泄漏
# Check running app by name (positional argument, not --process)
xcrun leaks MyApp

# Check by PID
xcrun leaks 12345

# Show full stack traces for each leak
xcrun leaks --fullStacks MyApp

# Analyze a memgraph file (from Xcode's Debug Memory Graph)
xcrun leaks MyApp.memgraph

使用时机:无需录制 Instruments 跟踪即可快速检查泄漏。在执行可疑代码路径后运行。

heap — 检查实时堆分配
# Show heap summary by class (process name is positional)
xcrun heap MyApp

# Show all instances of a specific class
xcrun heap --addresses=MyViewController MyApp

# Sort by size (find biggest consumers)
xcrun heap -s MyApp

# Analyze a memgraph
xcrun heap MyApp.memgraph

使用时机:查找当前占用内存的内容。无需使用 Instruments,就能回答“存在多少个 MyViewController 实例?”。

vmmap — 虚拟内存映射
# Summary view (dirty, clean, swapped)
xcrun vmmap --summary MyApp.memgraph

# Full memory regions
xcrun vmmap MyApp.memgraph

使用时机:了解内存构成。显示脏页(你的数据)、干净页(映射文件)和压缩内存。

stringdups — 查找重复字符串
# Find duplicate strings in running process (positional argument)
xcrun stringdups MyApp

# Analyze a memgraph
xcrun stringdups MyApp.memgraph

使用时机:减少重复字符串分配造成的内存占用。没有对应的 GUI 工具。

malloc_history — 跟踪分配来源
# Enable malloc logging first: set MallocStackLogging=1 in scheme env vars
# Then query a specific address
xcrun malloc_history <pid> <address>

# Show all allocations sorted by size
xcrun malloc_history <pid> -allBySize

使用时机:追踪泄漏对象的分配位置。需要在 scheme 中设置 MallocStackLogging=1 环境变量。

快速诊断工作流
# 1. Is there a leak? (30 seconds)
xcrun leaks MyApp

# 2. What's on the heap? (30 seconds)
xcrun heap -s MyApp

# 3. Any duplicate strings wasting memory? (30 seconds)
xcrun stringdups MyApp

# 4. Where is memory allocated? (requires memgraph)
xcrun vmmap --summary MyApp.memgraph

耗时:完整的 CLI 内存检查需 2 分钟,而启动 Instruments 需 10 分钟以上。

xctrace(无头 Instruments)
# Record memory trace without GUI
xcrun xctrace record --instrument 'Allocations' --attach 'MyApp' --time-limit 30s --output memory.trace

# Record leak detection
xcrun xctrace record --instrument 'Leaks' --attach 'MyApp' --time-limit 30s --output leaks.trace

实际影响

之前:50 多个 PlayerViewModel 实例带有未清理的计时器 → 50MB → 200MB → 崩溃(13 分钟) 之后:正确使计时器失效 → 50MB,数小时内保持稳定

关键见解:90% 的泄漏都是因为忘记停止计时器、观察者或订阅。务必在 deinit 中清理,或使用会自动清理的响应式模式。


资源

WWDC:2021-10180、2020-10078、2018-416

文档:/xcode/gathering-information-about-memory-use、/metrickit/mxbackgroundexitdata

技能:axiom-performance-profiling、axiom-objc-block-retain-cycles、axiom-metrickit-ref、axiom-lldb(交互式检查保留环)

评论

支持 Markdown