4.1 舞台监督的账本:ARC与对象一生


4.1 舞台监督的账本:ARC 与对象一生

本节摘要: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 节的主角)再跑,"已释放"立刻出现。这个实验就是下一节的引子:环不一定只有两个对象,"对象—闭包—仓库"三段链条同样能锁死账本。

账本小结

  • ARC 自动记账:强引用出现加一、消失减一,归零瞬间调 deinit,时机确定可预测;
  • deinit 是引用类型专属,适合关定时器、断开观察、释放非内存资源;
  • 置 nil、容器移除、作用域退出都触发减一;预测打印顺序是最好的自测;
  • 值类型不记账:复制即分家,标准库集合因此全是结构体;
  • 账本不清零 ≠ ARC 失灵,先找"谁还拉着"——多半是逃逸闭包或循环引用。

下一节进入抓捕现场:把 4.1 审计出的泄漏链条拆开,讲清循环引用的成因与 weak、unowned 的取舍。


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