闭包中的循环引用
本教程共 93 篇 · 第 77 篇 · 更新于 2026-08-08 · 约 8 分钟阅读
本节目标:看懂类实例和闭包互相抓住造成的循环强引用,掌握用捕获列表
[weak self]/[unowned self]破解它。
循环强引用不只在两个类之间发生,类和一个闭包之间也会中招。机制其实一样:类实例抓着闭包(闭包是属性),闭包又抓着类实例(self 被捕获),谁也放不开谁。
闭包之所以麻烦,是因为它在语法上藏得深。你写 self.someProperty 时只是顺手一抓,没意识到这一抓就建立了强引用。这一章把这套陷阱拆开讲。
1-1 闭包也是引用类型
先建立一个关键认知:闭包和类一样,是引用类型。你把一个闭包赋值给属性,存的是对这段代码的引用,而不是拷贝。所以”实例持有闭包属性”和”实例持有另一个对象的引用”在 ARC 眼里没有区别。
问题出在闭包体内部。如果闭包里访问了 self 的属性或方法(比如 self.name、self.text),闭包就会把 self 捕获进来,形成对实例的强引用。于是:
- 实例的某属性(比如
lazy var asHTML)强引用着闭包。 - 闭包又通过捕获强引用着实例(
self)。
一个环就闭上了。哪怕你后面把外部指向这个实例的变量设成 nil,环里的两股强引用还在互相拉扯,计数到不了零,谁都不释放。
1-2 一个会泄漏的闭包例子
下面这个 HTMLElement 类模拟 HTML 标签,有一个惰性属性 asHTML,它是一个闭包,负责把标签渲染成字符串:
class HTMLElement {
let name: String
let text: String?
lazy var asHTML: () -> String = {
if let text = self.text {
return "<\(self.name)>\(text)</\(self.name)>"
} else {
return "<\(self.name) />"
}
}
init(name: String, text: String? = nil) {
self.name = name
self.text = text
}
deinit {
print("\(name) 被销毁了")
}
}
var paragraph: HTMLElement? = HTMLElement(name: "p", text: "你好,世界")
print(paragraph!.asHTML())
// 打印:<p>你好,世界</p>
paragraph = nil
// 注意:这里不会打印 "p 被销毁了"
表面看一切正常,但 paragraph = nil 之后,deinit 没被调用。HTMLElement 实例和 asHTML 闭包互相强引用,形成了循环。实例赖在内存里,再也清不掉。
Note
lazy惰性属性在这里是”允许在闭包里写self”的前提——因为惰性属性在初始化完成后才访问,那时self已经确定存在。但这恰恰给了循环引用可乘之机。
1-3 捕获列表:给抓握”降级”
Swift 的解法是捕获列表(capture list)。你在闭包的参数列表前面、用方括号写一组”怎么捕获”,把 self 捕获成 weak 或 unowned,而不是默认的强引用。
捕获列表写在闭包最前面,放在方括号里,用逗号分隔:
lazy var someClosure = {
[unowned self, weak delegate = self.delegate]
(index: Int, string: String) -> String in
// 闭包体
}
如果闭包没有显式写参数和返回类型(靠类型推断),捕获列表放在最开头,后面直接跟 in:
lazy var someClosure = {
[unowned self, weak delegate = self.delegate] in
// 闭包体
}
1-4 用 [unowned self] 修掉泄漏
回到 HTMLElement,只要给闭包加上捕获列表 [unowned self],循环就破了:
class HTMLElement {
let name: String
let text: String?
lazy var asHTML: () -> String = {
[unowned self] in
if let text = self.text {
return "<\(self.name)>\(text)</\(self.name)>"
} else {
return "<\(self.name) />"
}
}
init(name: String, text: String? = nil) {
self.name = name
self.text = text
}
deinit {
print("\(name) 被销毁了")
}
}
var paragraph: HTMLElement? = HTMLElement(name: "p", text: "你好,世界")
print(paragraph!.asHTML())
// 打印:<p>你好,世界</p>
paragraph = nil
// 打印:p 被销毁了
这次 paragraph = nil 后,deinit 正常触发。因为闭包只”无主地”抓着实例,不增加计数;外部强引用一断,实例计数归零被回收,闭包随之也失去依靠。
为什么这里用 unowned 而不是 weak?因为 asHTML 这个闭包存在的意义就是服务于它所属的实例,两者同生共死,闭包不会在实例销毁后还被单独调用。符合”对方永不先消失”的条件。
1-5 用 [weak self] 更安全的选择
更常见的做法是 [weak self],尤其当这个闭包可能”活”得比实例久(比如异步回调、网络请求完成后的处理块)。用了 weak 后,self 在闭包里变成可选值,必须用 guard let self = self 或 if let 解包:
class Loader {
var data: String = "原始数据"
var onComplete: (() -> Void)?
func start() {
onComplete = { [weak self] in
guard let self = self else { return }
print("处理:\(self.data)")
}
}
deinit { print("Loader 被销毁了") }
}
guard let self = self 是个喜闻乐见的惯用法:在闭包体作用域内,把可选 self 解包成一个临时的非可选 self,后面的代码就能像平时一样写 self.xxx。必须解包,否则编译不过——这也强制你正视”实例可能已不在”的现实。
Tip异步闭包(延时回调、网络完成块)几乎永远该用
[weak self]。因为从发请求到收到响应这段时间,用户可能早就关掉页面、实例早被销毁了。用weak能让闭包优雅地发现”self 没了”,然后直接return,而不是崩溃。
1-6 编译器的小提醒
Swift 有个贴心规定:在闭包里引用 self 的成员时,必须显式写出 self.。你不能直接写 name,而必须写 self.name。这看似啰嗦,实则是编译器在敲你脑袋:“嘿,你正抓着 self 呢,小心循环引用!”
// 闭包内必须写 self.
let closure = { [weak self] in
guard let self = self else { return }
print(self.name) // 正确,显式 self.
}
Warning初学者最常见的内存泄漏,就是给类加了带
self捕获的闭包属性却忘了写捕获列表。下次看到某个对象的deinit迟迟不打印,先检查它有没有”藏着 self 的闭包”。
1-7 什么场景下闭包最容易泄漏
知道了解法,更要知道”哪些闭包最危险”。给你排个序。
最高危是异步完成回调:网络请求、动画结束、定时器触发的闭包。它们可能在实例早已该销毁之后才执行,一旦闭包里捕获了 self,这个实例就被硬生生多留了一命。这类闭包几乎一律 [weak self]。
其次是被存为属性的闭包(像本章的 asHTML、各种 onComplete),因为它和实例”长期共处一室”,互相抓住的时间最长。
相对安全的是一次性局部闭包,比如 sort { $0 < $1 } 这种传进去立刻用完就丢的,闭包不会长期持有实例,循环风险低。但即便如此,只要闭包体里访问了 self 的属性且闭包本身被存起来,照样要小心。
Warning一个快速判断:这个闭包会不会”活得比实例可能久”?会,就
[weak self];不会且同生共死,才考虑[unowned self]。拿不准,永远选weak。
1-8 一句话记忆法
把本章浓缩成一句好记的话:闭包里用到 self,先想会不会循环;异步就 weak,同生死才 unowned。
再补两个细节帮你少出错。其一,捕获列表里你不仅能写 self,还能写别的引用,比如 [weak self, weak delegate = self.delegate],把多个可能形成循环的引用一次性降级。其二,捕获列表写在闭包最前面,位置在参数列表和 in 之前;如果闭包连参数都没有,捕获列表后面直接跟 in 即可,二者不要写反。
Tip新人写闭包常忘记捕获列表的存在,等内存泄漏了才回头查。建议你把”闭包 + self”当成一个条件反射式的检查项:每次在闭包里敲下
self.,就顺手问一句”这里要 weak 吗”。
1-9 小结
类与闭包之间的循环引用,根源是”闭包捕获 self 形成强引用”。破解靠捕获列表:
[unowned self]:闭包与实例同生共死,代码简洁不用解包,但用错会崩溃。[weak self]:闭包可能活得比实例久,解包后安全退出,是异步场景的默认选择。
拿不准、尤其是涉及异步时,统一用 [weak self] + guard let self = self。至此,模块九内存管理部分的三大难题(ARC、类间循环、闭包循环)你已经全部拿下。下一章转向值语义和写时复制。