跳到主要内容
教程目录

Swift 内存泄漏怎么查?7 种循环引用场景与 Windows 真机排查

最后更新

Swift 内存泄漏多数来自循环引用。Swift 用自动引用计数(ARC)管理内存,两个对象互相强引用、或者对象持有的闭包又强引用了它自己,页面关掉后谁也释放不了,内存越用越多。修复办法是在环上找一条引用改成 weak 或 unowned,或者在页面消失时主动断开(取消定时器、移除观察者、取消任务)。

这篇先给出 7 种最常见泄漏场景的泄漏代码和修复代码,再讲怎么在 Windows 上用 KXBuild 和 kxdevice 在 iPhone 真机上确认泄漏、找到源头、验证修好。

你需要一台 iPhone 和数据线,工程已经能用 KXBuild 编译并按 F5 装到手机上,安装见安装页,调试基础见真机调试。文中的代码在 Swift 5 和 Swift 6 语言模式下都能编译。

怎么判断对象有没有释放?

判断一个对象有没有释放,最直接的办法是看它的 deinit 有没有执行。排查前先给怀疑的页面和 ViewModel 加上一行打印。

1deinit {
2    print("OrderDetailViewController deinit")
3}

按 F5 运行,进入页面再返回,「输出 → KXBuild Console」里应该打印这一行。没有打印,说明还有别的对象强引用着它,下面的场景基本覆盖了原因。

场景 1:闭包捕获 self 怎么形成循环引用?

ViewController 持有 ViewModel,ViewModel 持有回调闭包,闭包里用了 self,三者连成一个环。

泄漏代码。

 1final class SearchViewModel {
 2    var onResults: (([String]) -> Void)?
 3
 4    func search(_ text: String) {
 5        onResults?([text])
 6    }
 7}
 8
 9final class SearchViewController: UIViewController {
10    private let viewModel = SearchViewModel()
11    private let label = UILabel()
12
13    override func viewDidLoad() {
14        super.viewDidLoad()
15        viewModel.onResults = { results in
16            self.label.text = results.joined(separator: "、")
17        }
18    }
19
20    deinit {
21        print("SearchViewController deinit")
22    }
23}

修复代码。在捕获列表里写 [weak self],闭包不再持有 ViewController。闭包开头用 guard let self 解包,页面已经释放时直接返回。

1override func viewDidLoad() {
2    super.viewDidLoad()
3    viewModel.onResults = { [weak self] results in
4        guard let self else { return }
5        self.label.text = results.joined(separator: "、")
6    }
7}

不是所有闭包都要 [weak self]。UIView.animate、map、forEach 这类执行完就丢掉的闭包,以及只执行一次就结束的网络请求回调,不会形成环,最多让对象晚一点释放。要 weak 的是被别的对象长期保存的闭包:存进属性的回调、定时器、通知、Combine 订阅、一直运行的任务。

场景 2:delegate 写成强引用

DownloadTask 被 ViewController 持有,它的 delegate 又指回 ViewController。

泄漏代码。

 1@MainActor
 2protocol DownloadTaskDelegate: AnyObject {
 3    func downloadDidFinish(_ task: DownloadTask)
 4}
 5
 6@MainActor
 7final class DownloadTask {
 8    var delegate: (any DownloadTaskDelegate)?
 9
10    func finish() {
11        delegate?.downloadDidFinish(self)
12    }
13}
14
15final class DownloadViewController: UIViewController, DownloadTaskDelegate {
16    private let task = DownloadTask()
17
18    override func viewDidLoad() {
19        super.viewDidLoad()
20        task.delegate = self
21    }
22
23    func downloadDidFinish(_ task: DownloadTask) {
24        print("下载完成")
25    }
26}

修复代码。delegate 属性加 weak。协议要声明 AnyObject,否则 weak 会报错,因为只有类的实例才能弱引用。

1@MainActor
2final class DownloadTask {
3    weak var delegate: (any DownloadTaskDelegate)?
4
5    func finish() {
6        delegate?.downloadDidFinish(self)
7    }
8}

系统控件的 delegate、dataSource 已经是 weak 的,问题都出在自己写的类上。Objective-C 的属性对应写成 @property (nonatomic, weak) id<XxxDelegate> delegate;。

场景 3:Timer 为什么让页面释放不了?

target/selector 形式的 Timer 会一直强引用 target,直到调用 invalidate(),这一点在 Apple 的 Timer 文档里写明了。RunLoop 持有定时器,定时器持有页面,页面关掉也不会释放,定时器还在后台一直触发。

泄漏代码。

 1final class CountdownViewController: UIViewController {
 2    private var timer: Timer?
 3    private var remaining = 60
 4
 5    override func viewDidLoad() {
 6        super.viewDidLoad()
 7        timer = Timer.scheduledTimer(timeInterval: 1, target: self,
 8                                     selector: #selector(tick),
 9                                     userInfo: nil, repeats: true)
10    }
11
12    @objc private func tick() {
13        remaining -= 1
14    }
15}

修复代码。改用 block 形式并弱引用 self,页面消失时 invalidate()。block 里发现 self 已经释放,也顺手停掉定时器。

 1final class CountdownViewController: UIViewController {
 2    private var timer: Timer?
 3    private var remaining = 60
 4
 5    override func viewDidAppear(_ animated: Bool) {
 6        super.viewDidAppear(animated)
 7        timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] timer in
 8            guard let self else {
 9                timer.invalidate()
10                return
11            }
12            MainActor.assumeIsolated {
13                self.tick()
14            }
15        }
16    }
17
18    override func viewWillDisappear(_ animated: Bool) {
19        super.viewWillDisappear(animated)
20        timer?.invalidate()
21        timer = nil
22    }
23
24    private func tick() {
25        remaining -= 1
26    }
27}

block 形式的 Timer 回调是 @Sendable 闭包,在主线程创建的定时器在主线程触发,所以用 MainActor.assumeIsolated 告诉编译器这里在主线程,Swift 6 模式下才能调用页面的方法。CADisplayLink 同样强引用 target,处理方法一样,页面消失时调用 invalidate()。

场景 4:NotificationCenter 的 block 观察者

addObserver(forName:object:queue:using:) 会一直强引用 block,直到你移除这个观察者。block 里用了 self,页面就跟着一直活着。

泄漏代码。返回值没有保存,以后也没法移除。

 1final class InboxViewController: UIViewController {
 2    override func viewDidLoad() {
 3        super.viewDidLoad()
 4        NotificationCenter.default.addObserver(
 5            forName: UIApplication.didBecomeActiveNotification,
 6            object: nil,
 7            queue: .main
 8        ) { _ in
 9            self.refresh()
10        }
11    }
12
13    func refresh() {}
14}

修复代码。block 里弱引用 self,返回的观察者对象交给一个小的包装类保存,页面释放时包装类跟着释放,在它的 deinit 里移除观察者。

 1final class NotificationToken {
 2    private let token: any NSObjectProtocol
 3
 4    init(_ token: any NSObjectProtocol) {
 5        self.token = token
 6    }
 7
 8    deinit {
 9        NotificationCenter.default.removeObserver(token)
10    }
11}
12
13final class InboxViewController: UIViewController {
14    private var activeObservation: NotificationToken?
15
16    override func viewDidLoad() {
17        super.viewDidLoad()
18        let token = NotificationCenter.default.addObserver(
19            forName: UIApplication.didBecomeActiveNotification,
20            object: nil,
21            queue: .main
22        ) { [weak self] _ in
23            MainActor.assumeIsolated {
24                self?.refresh()
25            }
26        }
27        activeObservation = NotificationToken(token)
28    }
29
30    func refresh() {}
31}

更省事的是用 selector 形式。Apple 文档说明,App 最低支持 iOS 9 及以上时,用 addObserver(_:selector:name:object:) 注册的观察者不需要手动移除。selector 形式没有 block,也就没有捕获 self 的问题。

 1override func viewDidLoad() {
 2    super.viewDidLoad()
 3    NotificationCenter.default.addObserver(
 4        self,
 5        selector: #selector(appDidBecomeActive),
 6        name: UIApplication.didBecomeActiveNotification,
 7        object: nil
 8    )
 9}
10
11@objc private func appDidBecomeActive() {
12    refresh()
13}

场景 5:Combine 的 sink

sink 返回的订阅存在 cancellables 里,cancellables 属于 self,sink 的闭包又捕获了 self。

泄漏代码。

 1@MainActor
 2final class SearchModel: ObservableObject {
 3    @Published var query = ""
 4    @Published private(set) var results: [String] = []
 5    private var cancellables = Set<AnyCancellable>()
 6
 7    init() {
 8        $query
 9            .debounce(for: .milliseconds(300), scheduler: RunLoop.main)
10            .sink { text in
11                self.results = self.search(text)
12            }
13            .store(in: &cancellables)
14    }
15
16    private func search(_ text: String) -> [String] {
17        text.isEmpty ? [] : [text]
18    }
19}

修复代码。闭包里弱引用 self。

1init() {
2    $query
3        .debounce(for: .milliseconds(300), scheduler: RunLoop.main)
4        .sink { [weak self] text in
5            guard let self else { return }
6            self.results = self.search(text)
7        }
8        .store(in: &cancellables)
9}

结果只是赋给另一个 @Published 属性的,用 assign(to: &$results),订阅的生命周期跟着属性走,不会形成环,连 cancellables 都不用。assign(to: \.results, on: self) 会强引用 self,同样会泄漏,不要用这个写法。

1init() {
2    $query
3        .debounce(for: .milliseconds(300), scheduler: RunLoop.main)
4        .map { text in text.isEmpty ? [] : [text] }
5        .assign(to: &$results)
6}

场景 6:Task 里捕获 self

Task 会强引用它捕获的 self,直到任务结束。普通的一次性请求很快就结束,问题不大。监听消息、轮询这种一直不结束的循环,页面就永远释放不了。

泄漏代码。

 1final class ChatViewController: UIViewController {
 2    private var messages: [String] = []
 3
 4    override func viewDidLoad() {
 5        super.viewDidLoad()
 6        Task {
 7            for await message in ChatService.messages() {
 8                self.messages.append(message)
 9            }
10        }
11    }
12}

修复代码。保存任务,页面消失时取消。闭包里同时弱引用 self,每次收到消息时再解包,等待期间不持有页面。

 1final class ChatViewController: UIViewController {
 2    private var messages: [String] = []
 3    private var listenTask: Task<Void, Never>?
 4
 5    override func viewDidAppear(_ animated: Bool) {
 6        super.viewDidAppear(animated)
 7        listenTask = Task { [weak self] in
 8            for await message in ChatService.messages() {
 9                guard let self else { return }
10                self.messages.append(message)
11            }
12        }
13    }
14
15    override func viewWillDisappear(_ animated: Bool) {
16        super.viewWillDisappear(animated)
17        listenTask?.cancel()
18        listenTask = nil
19    }
20
21    deinit {
22        print("ChatViewController deinit")
23    }
24}

例子里的 ChatService 是一个每秒产生一条消息的模拟数据源,任务被取消时它也会停下。

 1enum ChatService {
 2    static func messages() -> AsyncStream<String> {
 3        AsyncStream { continuation in
 4            let task = Task {
 5                var n = 0
 6                while !Task.isCancelled {
 7                    try? await Task.sleep(nanoseconds: 1_000_000_000)
 8                    n += 1
 9                    continuation.yield("消息 \(n)")
10                }
11                continuation.finish()
12            }
13            continuation.onTermination = { _ in task.cancel() }
14        }
15    }
16}

guard let self 写在循环里面,不要写在 for await 之前。写在前面,整个循环期间又变回强引用。

场景 7:SwiftUI 里的 class 状态对象

SwiftUI 的 View 是结构体,body 里的闭包捕获的是结构体的值,不会形成循环引用,不需要 [weak self]。会泄漏的是你用 @State、@StateObject 保存的 class,问题和前面一样出在它内部的定时器、订阅和任务上。

泄漏代码。模型在 init 里启动了定时器,定时器强引用模型,页面关掉后模型和定时器都还在。

 1@Observable
 2final class StopwatchModel {
 3    var seconds = 0
 4    private var timer: Timer?
 5
 6    init() {
 7        timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
 8            self.seconds += 1
 9        }
10    }
11
12    deinit {
13        print("StopwatchModel deinit")
14    }
15}
16
17struct StopwatchView: View {
18    @State private var model = StopwatchModel()
19
20    var body: some View {
21        Text("\(model.seconds) 秒")
22    }
23}

修复代码。把循环改成 async 函数,交给 .task 修饰符启动。页面消失时 SwiftUI 自动取消这个任务,Task.sleep 抛出取消错误,循环退出,模型随页面释放。

 1@MainActor
 2@Observable
 3final class StopwatchModel {
 4    var seconds = 0
 5
 6    func run() async {
 7        while !Task.isCancelled {
 8            try? await Task.sleep(nanoseconds: 1_000_000_000)
 9            seconds += 1
10        }
11    }
12
13    deinit {
14        print("StopwatchModel deinit")
15    }
16}
17
18struct StopwatchView: View {
19    @State private var model = StopwatchModel()
20
21    var body: some View {
22        Text("\(model.seconds) 秒")
23            .task {
24                await model.run()
25            }
26    }
27}

@Observable 需要 iOS 17。支持更低版本的,用 ObservableObject 加 @StateObject,做法相同。另外两个 SwiftUI 里常见的「看起来像泄漏」的问题也顺带检查。

  • 模型被全局变量或单例保存。例如放进一个静态字典做缓存,页面关掉后仍被引用。缓存要有上限,或改用 NSCache。
  • 用 @ObservedObject var model = Model() 创建模型。父视图每次刷新都会新建一个,旧的被丢弃,表现是状态莫名重置。自己创建的模型用 @StateObject 或 @State,从外面传进来的才用 @ObservedObject。

七种循环引用场景分别怎么改?

场景 环是怎么形成的 改法
存进属性的回调闭包 对象 → 闭包 → self [weak self]
自己写的 delegate 子对象 → delegate → 持有它的页面 weak var delegate,协议加 AnyObject
target/selector 的 Timer、CADisplayLink RunLoop → Timer → target block 形式加 [weak self],页面消失时 invalidate()
NotificationCenter block 观察者 通知中心 → block → self [weak self],保存返回值并移除,或改用 selector 形式
Combine sink self → cancellables → 闭包 → self [weak self],或 assign(to: &$属性)
不结束的 Task 循环 Task → self,任务一直不结束 保存任务并 cancel(),循环内 guard let self
SwiftUI 状态对象内部的定时器、订阅 模型 → 定时器或订阅 → 模型 用 .task 启动循环,随页面自动取消

weak 和 unowned 怎么选?

两者都不增加引用计数,区别在对象释放之后。

weak unowned
类型 可选类型,声明为 var 非可选类型
对象释放后 自动变成 nil 引用还在,再访问 App 直接崩溃
使用前 要解包,self?.xxx 或 guard let self 直接用
适合 对方可能先释放,例如 delegate、页面里的回调、定时器、通知 对方的生命周期一定不短于自己,例如信用卡和它的持卡人
出错时的表现 什么也不做,可能看起来「点了没反应」 崩溃,Debug 下调试器停在访问它的那一行

Swift 官方的说法是,另一个实例生命周期更短时用 weak,生命周期相同或更长时用 unowned,原文见 The Swift Programming Language:Automatic Reference Counting。下面是 unowned 的典型用法,信用卡不可能比持卡人活得久。

 1final class Customer {
 2    let name: String
 3    var card: CreditCard?
 4
 5    init(name: String) {
 6        self.name = name
 7    }
 8
 9    deinit {
10        print("\(name) deinit")
11    }
12}
13
14final class CreditCard {
15    let number: String
16    unowned let customer: Customer
17
18    init(number: String, customer: Customer) {
19        self.number = number
20        self.customer = customer
21    }
22}

闭包里用 [unowned self] 的典型情况是闭包只被 self 自己持有,例如 lazy 属性里的闭包。

1final class PriceFormatter {
2    let prefix = "¥"
3
4    lazy var format: (Double) -> String = { [unowned self] value in
5        "\(self.prefix)\(value)"
6    }
7}

拿不准时用 weak。页面回调、网络请求、定时器这些地方用 unowned,一旦页面先关掉、回调后到,就是一次线上崩溃。

在 Windows 上怎么排查 Swift 内存泄漏?

下面几种方法从轻到重,按需组合。

用 deinit 和日志确认是否释放

调试时看「输出 → KXBuild Console」里有没有 deinit 的打印,见运行与调试。不想每次都进出页面数打印的,在 Debug 配置下加一个检查,页面关闭两秒后对象还在就打印提示。

 1#if DEBUG
 2enum LeakCheck {
 3    @MainActor
 4    static func expectRelease(_ object: AnyObject, after seconds: UInt64 = 2) {
 5        let name = String(describing: type(of: object))
 6        Task { [weak object] in
 7            try? await Task.sleep(nanoseconds: seconds * 1_000_000_000)
 8            if object != nil {
 9                print("[LeakCheck] \(name) 关闭 \(seconds) 秒后仍未释放,可能泄漏")
10            }
11        }
12    }
13}
14#endif
15
16final class DetailViewController: UIViewController {
17    override func viewDidDisappear(_ animated: Bool) {
18        super.viewDidDisappear(animated)
19        #if DEBUG
20        if isMovingFromParent || isBeingDismissed {
21            LeakCheck.expectRelease(self)
22        }
23        #endif
24    }
25}

isMovingFromParent 对应从导航栈返回,isBeingDismissed 对应关闭模态页面。页面嵌在别的容器里时按你的跳转方式调整判断条件。

不连调试器、或者要用 Release 包测试时,改用 Logger 打日志,再用 kxdevice 直接读手机日志。

 1import os
 2
 3private let memoryLog = Logger(subsystem: "com.example.myapp", category: "memory")
 4
 5final class OrderDetailViewController: UIViewController {
 6    private let orderID: String
 7
 8    init(orderID: String) {
 9        self.orderID = orderID
10        super.init(nibName: nil, bundle: nil)
11        memoryLog.notice("OrderDetailViewController init \(orderID, privacy: .public)")
12    }
13
14    required init?(coder: NSCoder) {
15        fatalError("init(coder:) has not been implemented")
16    }
17
18    deinit {
19        memoryLog.notice("OrderDetailViewController deinit")
20    }
21}
1kxdevice ostrace --subsystem com.example.myapp --match OrderDetailViewController

进出页面几次,init 和 deinit 应该成对出现。只有 init 没有 deinit,就是这个页面没释放。ostrace 的其他过滤参数见 kxdevice 命令参考。

用断点和日志点找到还活着的闭包

知道哪个页面没释放以后,要找出是哪条引用把它留住了。最快的办法是看页面关掉之后还有哪段代码在跑。

  • 日志点。在可疑闭包的第一行右键「添加日志点」,填 闭包仍在执行 {self}。返回上一页后调试控制台还在打印,说明这个闭包(定时器、通知、订阅、任务)还活着,它持有的对象也就还在。日志点不用改代码、不用重新编译。
  • 断点。在定时器回调、通知回调里打普通断点,关掉页面后仍然停下,看「调用栈」面板就知道是谁触发的。
  • 调试控制台。停在断点上时输入 po self 看是哪个实例,地址和之前进入页面时打印的一样,就是旧页面没释放。

断点、日志点和调试控制台的用法见真机调试。

用 kxdevice 看内存分配和曲线

不确定是哪个页面在泄漏、或者想用数据确认修好了,用 kxdevice 记录内存分配。先启动 App 拿到 PID,App 已经在运行时用 apps ps 查。

1kxdevice apps launch com.example.myapp
2kxdevice apps ps --apps

然后记录分配和回溯,记录期间在手机上反复进出同一个页面五六次。

1kxdevice instruments allocations --pid 1234 --backtrace -n 20

只增不减的分配就是嫌疑对象,回溯指向创建它的代码和类。想看整体趋势,执行 kxdevice web,浏览器打开 http://localhost:8780,在「Instruments」页选中你的 App 进程看内存曲线。每进出一次涨一截、退出后不回落,基本就是这个页面泄漏了。这些命令的说明见性能分析,更多内存排查思路见没有 Instruments 怎么查卡顿和内存。

完整演示:订单详情页的泄漏怎么查?

用一个具体例子把上面的方法串起来。订单列表点进详情页,详情页每 5 秒刷新一次物流状态。测试反馈「看过几十个订单后 App 越来越卡」。

1. 复现并确认

给 OrderDetailViewController 加上 deinit 打印,用 Debug 配置按 F5。进入详情页再返回,重复三次。KXBuild Console 里一次 deinit 都没有打印,确认详情页没有释放。

2. 量化

在终端里拿到 PID,记录分配,再进出详情页五次。

1kxdevice apps ps --apps
2kxdevice instruments allocations --pid 1234 --backtrace -n 20

输出里 OrderDetailViewController 相关的分配每进一次多一份,没有减少。kxdevice web 的内存曲线每进出一次涨一截,和测试反馈一致。

3. 定位

打开详情页的代码,按上面的速查表逐个检查长期保存的引用。找到两处嫌疑。

1override func viewDidLoad() {
2    super.viewDidLoad()
3    refreshTimer = Timer.scheduledTimer(timeInterval: 5, target: self,
4                                        selector: #selector(refreshStatus),
5                                        userInfo: nil, repeats: true)
6    service.onUpdate = { status in
7        self.statusLabel.text = status
8    }
9}

在 refreshStatus 第一行加日志点 刷新物流 {self},返回列表后调试控制台每 5 秒还打印一次,地址是三个不同的实例。说明三个旧页面都被各自的定时器留住了,而且都还在请求网络。

4. 修复

两处都改掉。定时器换成 block 形式加 [weak self],页面消失时停掉。service 是详情页自己持有的,它的回调同样弱引用 self。

 1override func viewDidAppear(_ animated: Bool) {
 2    super.viewDidAppear(animated)
 3    refreshTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { [weak self] timer in
 4        guard let self else {
 5            timer.invalidate()
 6            return
 7        }
 8        MainActor.assumeIsolated {
 9            self.refreshStatus()
10        }
11    }
12}
13
14override func viewWillDisappear(_ animated: Bool) {
15    super.viewWillDisappear(animated)
16    refreshTimer?.invalidate()
17    refreshTimer = nil
18}
19
20override func viewDidLoad() {
21    super.viewDidLoad()
22    service.onUpdate = { [weak self] status in
23        self?.statusLabel.text = status
24    }
25}

只改定时器、不改回调,页面仍然释放不了。一个对象可能同时被几条环留住,每改一处都重新看一次 deinit。

5. 验证

停止调试,重新按 F5。进出详情页三次,每次返回都打印 OrderDetailViewController deinit,日志点也不再打印。再用 Release 配置装一次,启动后用同样的命令记录分配,PID 换成 apps launch 打印的新值。

1kxbuild build .\MyApp.xcodeproj -c Release --install
2kxdevice apps launch com.example.myapp
3kxdevice instruments allocations --pid 1234 --backtrace -n 20

进出页面后分配能回落,内存曲线不再阶梯式上涨,才算修好。最后把 LeakCheck.expectRelease(self) 留在这个页面里,以后再有人引入循环引用导致的 Swift 内存泄漏,Debug 运行时会第一时间提示。

常见问题

Swift 闭包里什么时候要写 [weak self]?

闭包被别的对象长期保存、而这个对象又直接或间接被 self 持有时要写,例如存进属性的回调、定时器、通知观察者、Combine 订阅、不结束的 Task。UIView.animate、map 这类立即执行完的闭包,以及只执行一次的请求回调,不会形成循环引用,可以不写。

weak 和 unowned 有什么区别?

两者都不增加引用计数。weak 是可选类型,对象释放后自动变成 nil;unowned 是非可选类型,对象释放后再访问会直接崩溃。对方可能先释放时用 weak,确定对方活得不比自己短时才用 unowned,拿不准就用 weak。

没有 Xcode 的 Memory Graph,怎么在 Windows 上查内存泄漏?

先在页面和 ViewModel 的 deinit 里打印,按 F5 进出页面看「输出 → KXBuild Console」有没有打印。确认没释放后,在可疑闭包里加日志点找出还在运行的回调。要用数据确认,执行 kxdevice instruments allocations --pid <pid> --backtrace 记录分配,或用 kxdevice web 看内存曲线。

SwiftUI 的 View 里需要写 [weak self] 吗?

不需要。View 是结构体,body 里的闭包不会和它形成循环引用。要检查的是 @State、@StateObject 保存的 class 内部有没有定时器、订阅、任务强引用了它自己,长时间运行的工作用 .task 启动,页面消失时会自动取消。

deinit 不打印一定是内存泄漏吗?

不一定,也可能只是释放得晚。例如还有一个网络请求的回调捕获了它,请求结束后才会释放。等几秒再看,或者用 LeakCheck 这类延时检查,一直不释放才是泄漏。