4.2 谁拉住了谁:闭包循环引用与weak


4.2 谁拉住了谁:闭包循环引用与 weak

本节摘要:循环引用是 ARC 体系下最经典的内存事故:对象持有闭包,闭包又捕获对象,双方账本各欠一笔,永不归零。本节构造一个真实泄漏、用 deinit 打印取证、用捕获列表 [weak self] 拆环,并讲清 weak 与 unowned 的选择边界。结论先行:凡是逃逸闭包捕获 self,默认先写 weak,除非你能证明闭包的生命周期短于或绑定于对象。

上一节审计结束时账本差一笔(4.1 的 leakHolder 实验),这一节把真凶按在台上。

案发现场:一个排片界面的定时刷新

背景:排片界面要每几秒刷新余票,做法是给一个定时器挂回调,回调里引用界面的属性。先看会泄漏的写法:

final class BoardViewModel { let title = "今日排片" var seats = 42 var onTick: (() -> Void)? // ① 对象持有闭包 deinit { print("ViewModel 已释放") // 取证点:谢幕时打印 } func startTimer() { onTick = { // ② 闭包捕获 self(隐式:seats 要经由 self 取) self.seats -= 1 print("余票 \(self.seats)") } } } do { let vm = BoardViewModel() vm.startTimer() vm.onTick?() // 输出:余票 41 } // do 结束:vm 名字消失……但 deinit 没打印! print("do 已退出") // 输出(节选): // 余票 41 // do 已退出 ← 没有"ViewModel 已释放":vm 泄漏了

抓捕逻辑分两步。第一步找环:vm 持有 onTick(①),onTick 闭包捕获 vm(②)——对象拉闭包、闭包拉对象,环成立。第二步看账本:vm 的计数因闭包的存在至少为 1,闭包又因 vm 的存在至少为 1,两个"至少 1"互相担保,谁也归不了零。ARC 忠实地记着账,但它拆不了环——就像两个演员互相说"你先退场我才退",结果双双留在台上。

环与断环:一图看懂 weak 的作用

环与断环:一图看懂 weak 的作用

修复一:捕获列表 [weak self]

打断环的标准姿势是在闭包开头写捕获列表,声明"这一票不记账":

func startTimerFixed() { onTick = { [weak self] in // weak:我不为 self 记账,self 死了我就拿到 nil guard let self = self else { return } // self 已谢幕?本回合直接退场 self.seats -= 1 print("余票 \(self.seats)") } } do { let vm2 = BoardViewModel() vm2.startTimerFixed() vm2.onTick?() // 输出:余票 41 } // do 结束 print("第二个 do 已退出") // 输出: // 余票 41 // ViewModel 已释放 ← 取证成功:谢幕台词出现了 // 第二个 do 已退出

[weak self] 把捕获从"强引用"降级为"弱引用":弱引用不进账本,且当对象谢幕时自动置 nil。闭包醒来先 guard let 检查——对象还在就干活,不在就安静离场。这两行是 iOS 工程里出现频率最高的组合之一,值得形成肌肉记忆。

weak 引用是可选值(对象可能已死),所以 guard let 解包的本质又是 1.2 节的功课:把"对象可能缺席"写进类型——ARC 与可选值在这里胜利会师。

修复二:unowned,赌上生命周期的担保

unowned 也是不记账的引用,但语义更强:"我担保被引用者一定活着,死了就崩溃"。它不是可选值,访问时无需解包:

final class TicketPane { let viewModel: BoardViewModel lazy var closeAction: () -> Void = { [unowned self] in // 面板与模型同生共死 print("关闭面板,余票 \(self.viewModel.seats)") } init(viewModel: BoardViewModel) { self.viewModel = viewModel } deinit { print("面板已释放") } }

选择边界只有一条:闭包的生命周期不超过对象时才可用 unowned(比如对象自己持有的、绝不外传的闭包);凡是闭包可能逃逸、可能活得比对象久(定时器、网络回调、通知中心),一律 weak。unowned 换来的是免解包的便利,赌注是"对象提前死,闭包再跑就崩"——线上事故里这个赌注输过太多次。

⚠️ 常见坑三连:一,非逃逸闭包(比如数组排序的比较器)不需要 weak——函数返回时闭包已消亡,构不成环;二,weak 之后不解包,直接 self?.seats -= 1 也行,但连续多个 self?. 说明你该用 guard let 一次解开;三,把 deinit 当救命稻草在 release 版验证泄漏——打印照样可靠,但更系统的做法是 Xcode 的内存图调试与 Instruments(第8章工具登场)。

完整案例:给泄漏的定时器收尾

背景:真实的定时刷新还牵涉"界面退场后定时器仍在跑"。综合案例如下,把 deinit、weak、清理串成闭环:

final class TickerViewModel { var seats = 42 private var tickHandler: (() -> Void)? deinit { tickHandler = nil // 谢幕台词里顺手清掉闭包(双保险) print("Ticker 已释放") } func bind(handler: @escaping () -> Void) { tickHandler = handler } func fire() { tickHandler?() } } let ticker = TickerViewModel() ticker.bind { [weak ticker] in // 逃逸闭包 weak 捕获 guard let ticker = ticker else { return } ticker.seats -= 1 print("tick 余票 \(ticker.seats)") } ticker.fire() // 输出:tick 余票 41 ticker.fire() // 输出:tick 余票 40

结果解读:闭包逃逸(被属性持有),所以必须 weak;即便如此,deinit 里仍清空 handler——如果未来有人强引用了这个闭包,双保险能把环剪断在对象内部。变式:把 weak 换成 unowned 再做"对象先死、闭包后跑"的实验(fire 放到对象释放之后),亲眼看到崩溃,对 unowned 的敬畏会立刻建立。

💡 关键直觉:写 [weak self] 不是仪式,而是一句生命周期声明——"这个闭包可能活得比我久"。每次敲下它,其实是在回答设计问题:谁拥有谁、谁可能先走。

后台结案陈词

  • 环的本质:强引用互拉,账本各自至少为 1,永不归零,ARC 无法自动拆环;
  • 取证手段:deinit 打印(教学与小规模验证)、内存图调试与 Instruments(工程化);
  • [weak self] 把捕获降级为不记账的弱引用,对象死则自动 nil,闭包内 guard let 守卫;
  • unowned 免解包但赌生命周期,仅限"闭包不逃逸出对象"的场合;
  • 非逃逸闭包不用 weak:先判断逃逸性,再决定捕获方式——2.2 节的 @escaping 知识在这里兑现。

后台账本结案。下一幕回到台前:网络与解码不能卡住主舞台,第四幕讲同台演出——GCD 队列与 async/await,而"界面退场时取消未完成任务"的收尾动作,正是本章 weak 思想在并发世界的续集。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U