Swift闭包在类中的引用问题
Swift闭包在类中的引用问题

Swift闭包在类中的引用问题

闭包访问类的属性

在 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/

如果您认为这篇文章给您带来了帮助,您可以在此通过支付宝或者微信打赏网站开放者。

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注