闭包访问类的属性
在 Swift 中,在闭包内访问类的属性(比如 products),需要明确地引用 self。这样做是为了确保开发者清楚闭包会捕获 self,避免意外的循环引用,如果不使用self会引起相关的报错。
class IAPManager:ObservableObject {
@Published var products: [Product] = []
func loadProduct() async {
do {
print("加载产品中...")
let fetchedProducts = try await fetchProduct()
DispatchQueue.main.async {
self.products = fetchedProducts // 在闭包中使用self关键字
}
print("成功加载产品: \(products)")
} catch {
print("加载产品失败:\(error)")
}
}
}
self.products:添加 self 可以确保 Swift 明确知道是在访问类实例的属性,而不是闭包内的局部变量。
这样做也是一种常见的做法,以提醒开发者闭包内的代码会捕获 self,尤其是在异步或长时间运行的任务中,以避免潜在的内存泄漏。
闭包为什么会保存它引用的外部变量的值?
闭包会捕获并保存它在定义时引用的外部变量,是因为闭包是一种独立的代码块,它可能会在它的作用域外执行。为了确保闭包在执行时可以访问它需要的数据(包括变量、常量、属性等),闭包会捕获这些外部变量的值,并将它们保留在闭包的上下文中。
func makeIncrementer() -> () -> Int {
var total = 0
let incrementer: () -> Int = {
total += 1
return total
}
return incrementer
}
let increment = makeIncrementer()
print(increment()) // 输出: 1
print(increment()) // 输出: 2
在上面的代码中,total 是在函数 makeIncrementer 内部声明的局部变量,但闭包 incrementer 引用了 total。当 incrementer 被返回并在 makeIncrementer 的作用域外执行时,它仍然能够访问并修改 total 的值。这是因为闭包在创建时捕获并保存了 total。

深入分析
从上图可以了解整个代码的运行流程以及各变量在内存中的位置。
1)在调用let increment = makeIncrementer()代码时,makeIncrementer方法首先在栈内存上创建total和incrementer这两个变量。
2)incrementer变量指向一个闭包,这个闭包被创建在堆内存上。
3)编译器在执行过程中,发现闭包中的total是一个外部变量,所以会将total变量从栈内存移动到堆内存的捕获环境中,确保total生命周期和闭包一致。
4)makeIncrementer()执行后,会返回incrementer变量,因为incrementer变量存储的是闭包的地址,所以increment被赋值的也是闭包的地址。
5)当makeIncrementer执行完毕后,栈内存上的incrementer随函数栈帧的结束销毁,堆上的捕获环境持有 total,捕获环境的生命周期与闭包绑定,因此 total 不会被销毁。
6)到这里,increment存储闭包地址(引用闭包,闭包的引用计数为1).
7)当调用increment()时,实际是调用闭包,闭包从捕获环境中获取total的值并进行运算,total+=1时,闭包堆捕获环境中的total变量进行操作,所以total变成了1,并将1返回给print,输出1。
8)再次调用increment(),闭包仍然执行上面的操作,捕获环境中的total变成2,输出2。
9)最后,只有当increment不再引用闭包(赋值为nil或超出作用域时),闭包和捕获环境和捕获环境的引用计数归零,才会被释放。
不理解捕获环境的话,可以看一下《Swift深入理解闭包捕获机制》。
循环引用问题
闭包捕获变量
闭包会捕获并“持有”它上下文中的变量。如果这些变量是引用类型(例如类实例),闭包会对它们建立强引用(strong reference)。
这意味着,只要闭包存在,它就会保持对这些变量的引用,导致这些变量无法被释放。
对象持有闭包
当一个对象(类实例)持有一个闭包(比如作为属性存储),这个对象就对该闭包有强引用。
如果这个闭包在其代码中又捕获了对象自身(即 self),那么闭包就会对 self 也有一个强引用。
循环引用
未进入循环引用时
下面将通过实际的代码展示循环引用
class SomeClass {
var closure: (() -> Void)?
func startTask() {
closure = {
print("Task started!")
// 捕获 self
self.doSomething()
}
}
func doSomething() {
print("Doing something...")
}
deinit {
print("SomeClass instance is being deinitialized") // 释放时输出
}
}
var some: SomeClass? = SomeClass()
上面的代码中,首先创建一个名为some的SomeClass实例并设置为可选类型,以便后面赋值nil。
some?.doSomething()
some = nil
通过调用doSomething()方法,查看对应的输出,然后将some赋值为nil。

我们可以看到Xcode的Playground环境中,doSomething和deinit都有输出。
当实例被释放时,deinit代码就会被执行。
一切都没有问题。

未进入循环引用的代码分析
1、代码段
类 SomeClass 的代码和方法实现存储在 代码段 中,编译后这些信息不占用栈或堆内存。
代码本身是静态的,只有在运行时实例化或调用方法时才涉及栈和堆内存。
2、创建 SomeClass 实例
当执行 some = SomeClass():
堆内存:
在堆内存中分配了 SomeClass 实例。
closure 属性也存储在该实例的内存区域中(默认值为 nil)。
栈内存:
栈内存中创建一个局部变量 some,用于存储对堆内存中 SomeClass 实例的引用(指针)。
3、调用 some?.doSomething()
方法调用过程:
doSomething 方法在代码段中实现。
调用该方法时:
1)栈内存:
栈内存中为方法调用分配临时的调用帧,用于存储参数和局部变量(本例中无参数和局部变量)。
2)执行内容:
方法中的 print 指令执行后,将输出结果。
捕获环境:
因为 doSomething 没有引用外部变量,不涉及捕获环境。
方法调用完成后,栈内存中分配的调用帧被释放。
4、执行 some = nil
栈内存:
some 是一个栈上的局部变量。
将 some 设置为 nil,表示 some 不再引用堆内存中的 SomeClass 实例。
变量 some 本身仍然存在于栈中,但其值变为 nil。
堆内存:
因为 some 是实例的唯一引用,设置为 nil 后,SomeClass 的引用计数变为 0。
SomeClass 实例被标记为可销毁,触发 deinit 方法。
5、调用 deinit 方法
执行过程:
Swift 调用 deinit 方法清理资源。
栈内存:
临时分配栈空间用于执行 deinit 方法。
执行内容:
在 deinit 方法中输出 “SomeClass instance is being deinitialized”。
堆内存:
deinit 方法执行完毕后,堆内存中 SomeClass 的实例和 closure 属性占用的内存一并被释放。
栈内存清理:
deinit 方法对应的栈空间被回收。
6、最终状态
栈内存:
some 仍然是栈上的局部变量,但值为 nil。
堆内存:
SomeClass 实例以及它的 closure 属性已被释放,堆内存回收。
进入循环引用
在上面的代码中,添加实例对于startTask()方法的调用:
some?.startTask()
some?.doSomething()
some = nil

这时,我们会看到deinit没有再输出,这说明我在们给实例赋值nil后,实例并没有被释放。相反,因为startTask方法中closure有对self的捕获,导致了循环引用的出现。

进入循环引用的代码分析
1、调用 startTask() 方法时的栈内存
在栈内存中,创建了一个临时的调用栈帧,用于执行 startTask() 方法的代码。
2、闭包的创建
代码 closure = { … } 会创建一个闭包。
闭包被分配到堆内存中,它包含:
闭包的代码逻辑 { print(“Task started!”); self.doSomething() }。
捕获环境,用于捕获外部变量 self。
3、捕获 self
编译器检测到闭包中引用了 self,这是一个外部变量。
为了确保闭包的代码能够正常访问 self,闭包会在其捕获环境中存储对 self 的引用。
捕获的 self 是强引用,即捕获环境中的 self 指向 SomeClass 实例,存储的是 SomeClass 实例的内存地址。
4、闭包的引用
closure 属性是 SomeClass 的实例属性,存储在堆内存的 SomeClass 实例中。
closure 属性被赋值为闭包的引用(即闭包的堆内存地址)。
因此:
SomeClass 的 closure 属性指向堆内存中的闭包。
闭包的捕获环境中的 self 引用堆内存中的 SomeClass 实例。
5、循环引用的形成
SomeClass 实例持有对 closure 闭包的强引用。
闭包的捕获环境持有对 self(即 SomeClass 实例)的强引用。
这导致 SomeClass 和闭包通过强引用形成了循环。
6、栈帧的销毁
当 startTask() 方法执行完毕后,方法的栈帧被销毁。
但堆内存中的 closure 和捕获的 self 依然存活,因为它们被相互引用着。
Swift不会检测和提示循环引用,因为内存泄漏是一种在程序运行时资源未被释放的问题,而不是编译时可以发现的语法错误。因此,即使出现循环引用,程序可以正常运行,只是未被释放的内存会累积。
闭包捕获self的注意事项
1、异步任务和长时间运行的任务:
如果在异步任务中直接捕获 self,这段异步代码执行完毕前,self 会一直被保持在内存中,无法被释放。
如果 self 是一个视图控制器,而用户已经导航离开了该视图控制器,那么这个控制器本应该被释放,但由于异步任务的闭包还持有 self,它会继续占用内存。
2、如何避免循环引用?
可以在闭包中显式使用 [weak self] 或 [unowned self] 来避免强引用 self,从而打破循环引用。
例如:
closure = { [weak self] in
self?.doSomething()
}
使用 [weak self] 告诉闭包对 self 进行弱引用捕获。
如果闭包使用 self 时,self 已经释放了,那么 self 会是 nil,这样就不会再持有强引用。
这样一来,self 持有 closure,但 closure 只是弱引用 self,从而避免了循环引用。
再返回到前面的循环引用部分,尝试给循环引用的代码添加弱引用:
closure = { [weak self] in
print("Task started!")
// 捕获 self
self?.doSomething()
}

再次执行代码,会发现deinit正常输出,说明弱引用成功的避免了循环引用的问题,并且释放了SomeClass实例。

调用 startTask 的执行过程
1、调用 some?.startTask() 时:
在堆内存中创建一个闭包,闭包捕获 self(使用 [weak self] 弱引用)。
捕获环境中的 self 是弱引用,指向 SomeClass 实例的地址。
堆内存中的闭包地址赋值给 SomeClass 实例的 closure 属性。
方法执行完成后,栈内存中的临时空间被释放。
2、闭包的捕获环境:
捕获环境存储在堆内存,与闭包绑定。
捕获的 self 是弱引用,不会增加 SomeClass 实例的引用计数。
3、执行闭包时:
如果 SomeClass 实例未释放,self 可正常访问,执行 self.doSomething()。
如果 SomeClass 实例已释放,捕获环境中的 self 解包为 nil,闭包不会崩溃。
4、当 some = nil 时:
SomeClass 实例的引用计数降为 0,调用 deinit。
SomeClass 实例和其 closure 属性一同销毁。
闭包和捕获环境也被释放。
补充理解:捕获环境和循环引用
在使用 [weak self] 时,捕获环境对 self 是弱引用,不会延长 self 的生命周期。因此,即使闭包未被销毁,SomeClass 实例仍可能被释放。这正是 [weak self] 避免循环引用的核心机制。
闭包为何明确捕获 self?
Swift 强制在闭包中显式使用 self,这是为了提醒开发者注意捕获 self 可能带来的问题。特别是在使用异步任务和长时间运行的任务时,这种捕获会导致意想不到的内存保留行为,因此需要谨慎。
总结
闭包捕获外部变量的原因:为了在闭包执行时确保它所需的外部变量仍然可用,闭包会保存这些变量。
循环引用的原因:self 持有闭包,闭包又持有 self,这导致它们相互引用,无法释放。
如何避免:使用 [weak self] 或 [unowned self] 来打破强引用链条,从而防止循环引用和内存泄漏。
SomeClass代码
class SomeClass {
var closure: (() -> Void)?
func startTask() {
closure = { [weak self] in
print("Task started!")
// 捕获 self
self?.doSomething()
}
}
func doSomething() {
print("Doing something...")
}
deinit {
print("SomeClass instance is being deinitialized") // 释放时输出
}
}
var some: SomeClass? = SomeClass()
some?.startTask()
some?.doSomething()
some = nil
相关文章
1、Xcode报错:Reference to property ‘products’ in closure requires explicit use of ‘self’ to make capture semantics explicit:https://fangjunyu.com/2024/10/22/xcode%e6%8a%a5%e9%94%99%ef%bc%9areference-to-property-products-in-closure-requires-explicit-use-of-self-to-make-capture-semantics-explicit/
2、Swift闭包的三种形式以及理解闭包的嵌套函数:https://fangjunyu.com/2024/10/23/swift%e9%97%ad%e5%8c%85%e7%9a%84%e4%b8%89%e7%a7%8d%e5%bd%a2%e5%bc%8f%e4%bb%a5%e5%8f%8a%e7%90%86%e8%a7%a3%e9%97%ad%e5%8c%85%e7%9a%84%e5%b5%8c%e5%a5%97%e5%87%bd%e6%95%b0/
3、Swift深入理解闭包捕获机制:https://fangjunyu.com/2024/11/28/swift%e6%b7%b1%e5%85%a5%e7%90%86%e8%a7%a3%e9%97%ad%e5%8c%85%e6%8d%95%e8%8e%b7%e6%9c%ba%e5%88%b6/