本节摘要:ARC 是 Swift 的自动引用计数内存管理机制:编译器为每个类对象记一本引用账,账清零即调用 deinit 释放。本节用可观测实验看清一个对象从出生到谢幕的全过程,讲清计数加减的时机、值类型为何免记账。结论先行:你不需要手动管理内存,但必须能预测任何一个对象的账本何时清零。
后台账本开张。上一幕(第3章)结尾留的问题是:类的对象被多个名字共享,最后一个名字撒手后谁来收场——这一节的舞台监督 ARC 给出答案。
先造一个能"报幕"的类,让出生和谢幕都打印出来:
final class Projectionist { let name: String init(name: String) { self.name = name print("放映员 \(name) 上岗") } deinit { print("放映员 \(name) 下班") // deinit:账本清零瞬间自动调用 } } do { let staff = Projectionist(name: "老陈") // 计数 +1 → 1 print("放映中:\(staff.name)") } // 作用域结束,staff 消失,计数 -1 → 0,触发 deinit print("do 块已退出") // 输出: // 放映员 老陈 上岗 // 放映中:老陈 // 放映员 老陈 下班 // do 块已退出
四行输出的顺序是关键证据:"下班"发生在 do 块结束、print("do 块已退出")之前——deinit 是账本清零的瞬间行为,不是"程序结束时的打扫"。把 staff 声明挪到 do 外面再试,"下班"会推迟到不再有人引用它的那一刻。
ARC 的记账规则只有一句话:强引用出现加一,强引用消失减一,归零即谢幕。下面这段代码把所有常见瞬间走一遍:
var a: Projectionist? = Projectionist(name: "小赵") // 出生即被 a 引用:计数 1 var b: Projectionist? = a // b 也指向它:计数 2 var roster: [Projectionist] = [] // 排班表(数组持有强引用) if let real = a { roster.append(real) // 计数 3(if let 的 real 也是强引用,块内临时 +1 又 -1) } print("此刻持有者:a、b、roster,计数 3") b = nil // 计数 -1:2 a = nil // 计数 -1:1 —— 还没谢幕,数组还拉着 print("数组还在,对象未谢幕") roster.removeAll() // 计数 -1:0 —— 此刻才打印 下班 print("全部收队") // 输出(节选): // 放映员 小赵 上岗 // 数组还在,对象未谢幕 // 放映员 小赵 下班 // 全部收队
实验解读:置 nil、换值、容器移除、作用域退出,每一种"引用消失"都会记账。变式:把 roster.removeAll() 挪到 a = nil 之前,"下班"的打印时机会跟着挪——账本清零的顺序完全由引用消失的顺序决定。预测打印顺序,是检验 ARC 理解的最好自测。

⚠️ 常见坑:以为 iOS 里有垃圾回收器(GC)兜底。Swift 用的是确定性的 ARC——deinit 时机可预测是优点(可在里面关定时器、断通知);代价是环引用没人自动拆。Java 式"创建完就不管"的肌肉记忆在这里会漏水。
第 3 章说过结构体是复印剧本(值语义):赋值即复制,每份副本独立生死,不存在"共享的同一个对象",自然无需账本。验证只需一行思路:把 Projectionist 改成 struct,deinit 就不允许写了——deinit 是引用类型专属的谢幕台词。这也解释了 Swift 标准库的选择:Array、String 全是结构体,复制即分家,永远不存在"两个界面共享同一个数组导致的计数问题"(共享状态的问题交给第7章的状态对象与值语义更新来解决)。
💡 关键直觉:看见 deinit 打印,就知道对象真死了;看不到打印,先别怀疑 ARC,去找"谁还拉着他"。4.2 节就是那个"谁"的抓捕现场。
背景:一个简化的界面对象,被导航数组和一个回调闭包同时持有。操作:模拟用户返回,审计账本变化:
final class BoardScreen { let title = "今日排片" var onRefresh: (() -> Void)? // 界面持有闭包(2.2 节的逃逸位) deinit { print("界面对象 \(title) 已释放") } } var navigationStack: [BoardScreen] = [] // 导航栈 var leakHolder: [() -> Void] = [] // 假装是某个全局回调仓库 do { let screen = BoardScreen() navigationStack.append(screen) // 计数 1 screen.onRefresh = { print("刷新 \(screen.title)") } // 闭包捕获 screen:计数 2 leakHolder.append(screen.onRefresh!) // 闭包也被别人持有 print("用户点返回,导航栈弹出") navigationStack.removeAll() // 计数 1:screen 局部量仍在 } // do 结束:screen 名字消失,但闭包(在 leakHolder 里)还捕获着它 → 计数停在 1 print("审计结束,看有没有打印 已释放") // 输出(节选): // 用户点返回,导航栈弹出 // 审计结束,看有没有打印 已释放 ← 没有"已释放"!对象泄漏了
结果解读:导航栈弹掉了、局部名字到期了,但逃逸到 leakHolder 的闭包还捕获着 screen——账本永远差一笔。变式:把捕获改成 [weak screen](4.2 节的主角)再跑,"已释放"立刻出现。这个实验就是下一节的引子:环不一定只有两个对象,"对象—闭包—仓库"三段链条同样能锁死账本。
下一节进入抓捕现场:把 4.1 审计出的泄漏链条拆开,讲清循环引用的成因与 weak、unowned 的取舍。