iOS ARC 内存管理与循环引用排查
iOS ARC 内存管理与循环引用排查
自动引用计数解决了对象释放时机的大量手工管理问题,但它无法判断两个对象之间的强引用是否仍有业务价值。当引用形成闭环时,即使页面已经退出,对象的引用计数也不会归零。理解所有权关系,比在每个闭包前机械添加 weak 更重要。
一、从对象所有权理解 ARC
强引用表示对象拥有另一个对象;弱引用不增加引用计数,并在目标释放后自动变为 nil;无主引用同样不增加引用计数,但默认假设目标在访问时一定存在。
常见的父子关系中,父对象强持有子对象,子对象通过弱引用回到父对象:
protocol DetailViewDelegate: AnyObject {
func detailViewDidClose(_ view: DetailView)
}
final class DetailView: UIView {
weak var delegate: DetailViewDelegate?
}
协议声明继承 AnyObject 后,代理属性才能使用弱引用。代理是否应该为弱引用,取决于所有权设计,而不只是命名习惯。
二、闭包为什么容易产生循环引用
对象强持有闭包,闭包又捕获对象,就会形成环。
final class ProfileViewController: UIViewController {
private let viewModel: ProfileViewModel
override func viewDidLoad() {
super.viewDidLoad()
viewModel.onStateChanged = { [weak self] state in
self?.render(state)
}
}
}
这里 viewModel 被控制器持有,onStateChanged 又是长期保存在 viewModel 中的闭包,因此弱捕获是合理的。
但并非所有闭包都需要弱捕获。一个立即执行且不被保存的闭包,不会长期持有 self。过度使用弱引用可能让必要操作无声跳过,反而掩盖生命周期问题。
三、根据闭包寿命选择捕获方式
可以把闭包分为三类:
非逃逸闭包
闭包在函数返回前执行完毕,通常直接捕获即可。
短期异步闭包
闭包在一次操作完成后释放。是否弱捕获要看对象是否应该被操作保活。例如保存操作需要在控制器退出后继续完成,就可能有意强持有负责保存的服务,但不一定需要持有控制器。
长期保存闭包
回调属性、观察者和定时器会长期存在,应明确解除时机,并检查是否与持有者形成闭环。
判断标准应是业务所有权:闭包执行期间,目标对象是否必须存活;如果目标已释放,操作应该取消、继续,还是交给其他对象完成。
四、定时器与显示刷新任务
重复定时器经常同时存在两个问题:计时器持有回调,外部对象又持有计时器。即使闭包使用弱引用,也应在任务结束或对象释放时主动停止计时器,避免无意义唤醒。
final class CountdownController {
private var timer: Timer?
private(set) var remaining: Int = 0
func start(seconds: Int) {
stop()
remaining = seconds
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) {
[weak self] timer in
guard let self = self else {
timer.invalidate()
return
}
self.tick()
}
}
func stop() {
timer?.invalidate()
timer = nil
}
deinit {
stop()
}
}
项目最低系统版本不支持某种语法或能力时,应使用项目既有写法,而不能仅为了简洁提高兼容要求。
五、观察者的注册与移除
系统通知、键值观察和自定义事件总线的持有方式并不完全相同,不能套用同一条规则。使用基于闭包的观察接口时,通常需要保存返回的令牌,并在适当时机移除。
final class ThemeObserver {
private var token: NSObjectProtocol?
func start() {
token = NotificationCenter.default.addObserver(
forName: .themeDidChange,
object: nil,
queue: .main
) { [weak self] notification in
self?.handleThemeChange(notification)
}
}
deinit {
if let token = token {
NotificationCenter.default.removeObserver(token)
}
}
}
自定义事件组件应遵循项目原有约定,明确发布者、订阅者和令牌分别由谁持有。
六、异步任务也有持有关系
对象持有任务,任务闭包强捕获对象,也可能形成持续到任务结束的引用关系。无限序列或长期监听任务如果没有取消点,就可能让对象长期无法释放。
final class MessageObserver {
private var observeTask: Task<Void, Never>?
func start() {
observeTask = Task { [weak self] in
guard let stream = self?.messageStream else { return }
for await message in stream {
guard !Task.isCancelled else { return }
await self?.handle(message)
}
}
}
func stop() {
observeTask?.cancel()
observeTask = nil
}
deinit {
observeTask?.cancel()
}
}
任务是否会自然结束、取消是否能传递到数据源,都需要在设计时确认。
七、建立可重复的排查流程
发现页面疑似泄漏时,可以按以下顺序排查:
- 在控制器和页面模型的
deinit中增加临时调试记录。 - 重复进入并退出页面,确认对象数量是否持续增长。
- 查看内存对象之间的强引用路径。
- 优先检查闭包属性、代理、定时器、观察者和长期异步任务。
- 修复所有权关系后重复原路径,确认对象能够释放。
只观察某一次内存峰值并不能证明泄漏。图片缓存和系统缓存可能暂时保留内存,关键是对象是否在可预期时机失去强引用,以及重复操作后数量是否持续增长。
八、常见误区
所有闭包都使用 weak self
这会把本该完成的逻辑变成可选执行。应先判断闭包是否逃逸、由谁保存,以及对象是否应该被任务保活。
deinit 没打印就是控制器泄漏
调试环境、转场容器和交互式返回都可能改变释放时机。需要结合引用关系和重复操作验证。
用 unowned 解决所有循环引用
无主引用在目标已释放时访问会导致运行时错误。只有生命周期关系能够被严格保证时才适合使用。
总结
ARC 问题本质上是所有权问题。明确谁拥有谁、回调保存多久、异步任务何时结束,再选择强引用、弱引用或无主引用,比机械添加捕获列表更可靠。配合可重复的页面进出路径和引用图分析,可以快速定位真正的循环链路。
- 点赞
- 收藏
- 关注作者
评论(0)