Swift 循环引用实例解析

作者: 程序员钙片吃多了 | 来源:发表于2017-10-25 22:00 被阅读108次

    iOS 使用引用计数来进行内存管理。在某些情况下,代码编写不慎会产生循环引用,进而导致内存泄露。根据我们目前项目中的代码来看,开发成员对导致循环引用的原因可能没有完全掌握清楚,所以存在着滥用以及漏用 weak 的现象,这里进行简要分析,并进行规范,防止 weak 的滥用及漏用。

    引用计数的适用对象

    首先要明确,引用计数是对 reference types(即 classes、closures) 而言的,value types(如 struct、enumeration)不使用引用计数进行内存管理,所以也就不存在“循环引用”这个概念。

    代理模式的循环引用

    我们一般会为被代理对象创建一个 delegate 属性,并且 delegate 需要由对象持有,此时如果我们的 delegate 对象同时持有了被代理对象,那么就会导致循环引用。如下例:

    protocol TestBDelegate: class {
        func printMessage() -> String
    }
    
    class TestB {
        var delegate: TestBDelegate
    
        init(delegate: TestBDelegate) {
            self.delegate = delegate
        }
    
        func callDelegate() {
            print(delegate.printMessage())
        }
    
        deinit {
            print("TestB deinit")
        }
    }
    
    class TestC: TestBDelegate {
    
        var testb: TestB?
    
        init() {
            self.testb = TestB(delegate: self)
        }
    
        func printMessage() -> String {
            return "Hello world"
        }
    
        deinit {
            print("TestC deinit")
        }
    
    }
    
    var testc: TestC? = TestC()
    testc = nil
    

    上述示例中,testc 不会被释放,原因如下:

    • testc 是代理对象,testb 是被代理对象;
    • testc 强引用了 testb;
    • TestC 的构造器中为 testb 的 delegate 参数传入了 self;
    • testb 中强引用了 delegate 属性,而此时 delegate 属性正是 testc,形成循环引用;

    我们有两种方式来打破循环引用:weak & unowned。

    weak

    为了打破这种循环引用,我们可以使用 weak 来声明 delegate 属性。weak 表示不对该属性进行强引用,也就是不会将 weak 声明的属性的引用计数加 1,这样就防止了循环引用。当 weak 属性引用的对象被释放后,weak 属性会被 ARC 置为 nil。修改后的 TestB 代码如下:

    class TestB {
    
        weak var delegate: TestBDelegate?
    
        init(delegate: TestBDelegate) {
            self.delegate = delegate
        }
    
        func callDelegate() {
            print(delegate?.printMessage())
        }
    
        deinit {
            print("TestB deinit")
        }
    
    }
    

    这里注意两点:

    • TestB 的 delegate 属性使用了 weak 来声明,这样声明后,TestB 的实例对象就不会再强引用 delegate 对象。
    • 对于 weak 声明的属性,必须声明为 Optional 类型(如上例中的 TestBDelegate?),因为 weak 引用的对象被释放时,会将 weak 引用设置为 nil;

    unowned

    同 weak 一样,使用 unowned 声明的属性表示不对该属性进行强引用,也就是不会将 unowned 声明的属性的引用计数加 1,这样可以防止循环引用。单从这一点上看,unowned 与 weak 的作用是相同的,但是两者还是有一些区别的:

    • unowned 属性引用的对象被释放后,unowned 属性不会被 ARC 置为 nil,如果 unowned 被释放后,仍然使用 unowned 属性,就属于引用无效内存,从而导致 crash;
    • 使用 unowned 声明属性表示该属性的生命周期一定大于对象本身,原因如第一条所述;
    • weak 声明的属性必须声明为 Optional 类型,而 unowned 声明的属性不用声明为 Optional 类型,因为我们确认 unowned 属性的生命周期大于本对象;

    使用 unowned 声明 delegate 的代码如下:

    class TestB {
        unowned var delegate: TestBDelegate
    
        init(delegate: TestBDelegate) {
            self.delegate = delegate
        }
    
        func callDelegate() {
            print(delegate.printMessage())
        }
    
        deinit {
            print("TestB deinit")
        }
    }
    

    这里注意三点:

    • TestB 的 delegate 属性使用了 unowned 来声明,这样声明后,TestB 的实例对象就不会再强引用 delegate 对象。
    • 对于 unowned 声明的属性,我们没有声明为 Optional 类型,因为我们明确知道 testb 的生命周期不会长于 delegate
    • 如果 TestB 对象的 delegate 生命周期比 TestB 对象生命周期短,则会触发 crash。例如下述代码会导致 crash:
    var testc: TestC? = TestC()
    var testb: TestB? = testc?.testb
    testc = nil
    testb?.callDelegate() // testc 已经被释放,此时如果调用 testb 的 delegate 属性,会触发 crash,因为 unowned 声明的属性不会自动被置为 nil
    

    我们可以看到 unowned 存在 crash 的风险,那么为什么还要有 unowned 这种声明方式呢?

    • 使用 unowned 声明,可以明确的告诉代码阅读者,unowned 属性的生命周期大于本对象;
    • 使用 unowned 声明,我们可以不使用 Optional 类型,这样引用的时候就无需使用可选链去调用该属性了;
    • 其余的原因暂时未想到,如果有想到,欢迎补充;

    closure 导致的循环应用

    举例及原因分析

    如下例:

    class TestA {
    
        var testADescription: String?
        var describeTestA: (() -> Void)?
    
        init() {
            describeTestA = {
                self.testADescription = "Hello, TestA"
            }
        }
    
        deinit {
            print("TestA deinit")
        }
    
    }
    
    var testa: TestA? = TestA()
    testa = nil
    
    

    上述示例中不会 testa 不会被释放。原因如下:

    • describeTestA 是一个 closure,而 Swift 的 closure 也是使用引用计数管理内存的;
    • testa 对象强持有了 describeTestA closure;
    • describeTestA 中,我们使用了 testa 对象的属性 testADescription,这时,testa 对象会被 describeTestA closure 捕获,并且是强引用捕获住,这样就导致了循环引用;

    这类循环引用的情况可以归结为:一个对象强持有了一个 closure,同时 closure 强引用捕获了该对象。closure 强捕获对象的情况发生在 closure 内部调用了对象的方法,或者使用了对象的某个属性。

    对于 closure 循环引用问题,可以使用 closure 的捕获参数列表来解决,在捕获参数列表中,我们可以将某个对象声明为 weak 或者 unowned,例如:

    class TestA {
    
        var testADescription: String?
        var describeTestA: (() -> Void)?
    
        init() {
            describeTestA = { [weak self] in
                if let strongSelf = self {
                    strongSelf.testADescription = "Hello, TestA"
                }
            }
        }
    
        deinit {
            print("TestA deinit")
        }
    
    }
    
    var testa: TestA? = TestA()
    testa = nil
    
    

    使用上述方式来定义 describeTestA 则可以避免循环引用。当然上述示例中也可以使用 [unowned self],unowned 与 weak 的区别已在上面详述过。还有一点需要注意,在 describeTestA closure 内部,使用了 if let strongSelf = self 的方式,使用这种方式的原因以及用处请参考:
    I finally figured out weakSelf and strongSelf

    工程中常用的 weak/unowned 的误用

    非逃逸 closure

    对于非逃逸闭包,编译器可以保证在函数返回时闭包会释放它捕获的所有对象,所以对于非逃逸闭包,不会出现循环引用问题,例如:

    class TestA {
    
        var testADescription: String?
    
        func decribe(decribeClosure: () -> Void) {
            decribeClosure()
            print(testADescription)
        }
    
        init() {
            decribe {
                self.testADescription = "Hello world"
            }
        }
    
        deinit {
            print("TestA deinit")
        }
    
    }
    
    var testa: TestA? = TestA()
    testa = nil
    

    这里传递给 describe 函数的闭包就是非逃逸闭包,这类闭包强引用 self 并不会造成循环引用,因为在 init 函数执行结束后,闭包会释放它捕获的对象(此处为 self)。
    所以,如果使用了非逃逸闭包,我们是无需使用 [weak self] 这类方式来声明捕获参数的,因为其不会产生循环引用问题。对于什么是非逃逸闭包,建议仔细阅读:可选型的非逃逸闭包

    对于逃逸闭包而言,是有可能造成循环引用的,因为逃逸闭包可能会被赋值给本对象的某个强引用属性的,这时就导致了循环引用,如下:

    class TestA {
    
        var testADescription: String?
        var describeClosure: (() -> Void)?
    
        func decribe(describeClosure: @escaping () -> Void) {
            describeClosure()
            self.describeClosure = describeClosure
            print(testADescription)
        }
    
        init() {
            decribe {
                self.testADescription = "Hello world"
            }
        }
    
        deinit {
            print("TestA deinit")
        }
    
    }
    
    var testa: TestA? = TestA()
    testa = nil
    

    上述代码产生了循环引用,因为传入 describe 函数的参数 describeClosure 被赋值给了 testa 的 describeClosure 属性。这里再提一点,如果 describe 参数没有被声明为逃逸闭包,那么编译器是不允许我们将其赋值给 testa 的 describeClosure 属性的。

    虽然逃逸闭包可能造成循环引用,但是并不是所有的逃逸闭包都会造成循环引用,下面举几个例子。

    UIView animation

    使用 UIView animation 的接口如下:

    open class func animate(withDuration duration: TimeInterval,
                            delay: TimeInterval,
                            options: UIViewAnimationOptions = [],
                            animations: @escaping () -> Swift.Void,
                            completion: ((Bool) -> Swift.Void)? = nil)
    

    我们可以看到 completion closure 和 animations clousre 都是逃逸闭包(completion 没有使用 escaping 关键字,但是参考 可选型的非逃逸闭包 可知 completion 也是逃逸闭包),所以这两个闭包内会强持有 self 对象。

    但是这里并不会造成循环引用问题,因为我们传入的 animations 和 completion 闭包并没有被 self 持有(使用时我们是直接新建了一个 closure 传给了 animate 函数),这两个闭包被 Core Animation 持有,所以不存在循环引用。

    虽然 animations/completion closure 没有被使用动画的对象持有,但是 animations/completion closure 强持有了使用动画的对象(我们这里假设为 self),那么会不会导致 self 的释放时机受到 animations 的影响而延迟呢?从内存管理的角度分析是会这样的,只要 Core Animation 没有释放 closure,那么 self 就不会被释放掉。由于没有看过动画实现的源码,下述只是推测:

    • 当 View 从 window 上移除后,会立即释放在该 View 以及 subviews 添加的动画,这时,animations/completion closure 被释放,随后 self 被释放。
    • 至于什么时候从 window 上移除,这里举个例子,当 viewController disappear 后,其 view 会从 window 上移除。一般来说,对于动画,我们肯定是要对 UIView 来处理的,而当我们不需要 UIView 时,这个 view 也肯定会被 window 移除,此时,动画其实完全可以停止了(因为 view 已经不在 window 上了,再做动画也没意义),animations/completion closure 就会释放,所以 UIView animate 动画的 closure 不会引起循环引用问题,一般也不会导致与 View 有关的内存释放延迟。

    网络请求 - 以 Alamofire 为例

    Alamofire 发送请求使用方法如下:

    Alamofire.request(baseUrl + url,
                      method: httpMethod,
                      parameters: parameters as? [String: Any],
                      encoding: encoding, headers: header).responseString { (response) in
                          self.completeHandler(response: response, success: success, failure: failure)
                      }
    
    @discardableResult
    public func responseString(
        queue: DispatchQueue? = nil,
        encoding: String.Encoding? = nil,
        completionHandler: @escaping (DataResponse<String>) -> Void)
        -> Self
    

    responseString 的最后一个参数是一个逃逸闭包,虽然在这个闭包里面使用 self 强引用了调用 Alamofire.request 的对象(也就是 self),但是由于传递给 responseString 的 completionHandler 闭包并没有被调用对象所引用,所以这里是不会产生循环引用的。

    但是这里存在一个问题:调用对象的释放时机收到了 completionHandler 生命周期的影响,我们深入看一下 responseString 的源码会发现如下:

    @discardableResult
    public func responseString(
        queue: DispatchQueue? = nil,
        encoding: String.Encoding? = nil,
        completionHandler: @escaping (DataResponse<String>) -> Void)
        -> Self {
        return response(queue: queue,
                        responseSerializer: DataRequest.stringResponseSerializer(encoding: encoding),
                        completionHandler: completionHandler
        )
    }
    
    @discardableResult
    public func response<T: DataResponseSerializerProtocol>(
        queue: DispatchQueue? = nil,
        responseSerializer: T,
        completionHandler: @escaping (DataResponse<T.SerializedObject>) -> Void)
        -> Self {
            // ......
            (queue ?? DispatchQueue.main).async { completionHandler(dataResponse) }
        }
    
        return self
    }
    

    也就是说 completionHandler 最终是传给了 gcd 派发系统,所以
    completionHandler 会被 gcd 强引用住,如果这个 completionHandler 被释放的实际比较晚,那么它所引用的 self 也会延迟释放。

    • request 发送一般属于 controller 的工作,如果 controller 已经被 pop 出去了,我们再去执行数据源刷新、UI 渲染这类操作是完全没有意义的,对于这类情况,需要使用 [weak self] 和 if let strongSelf = self {} 的方式捕获 self 对象,以便对象能够及时释放。
    • 当然,也有例外,例如我们这个请求就是为了刷新一些用户数据,那么我们是完全可以直接捕获 self 的,这样我们可以保证 self 不被释放,能够正确的处理数据。

    SnapKit 布局

    使用 SnapKit 布局的一般代码如下:

    secondLabel.snp.makeConstraints { (make) in
        make.left.equalTo(self.firstLabel.snp.right).offset(7.5)
        make.centerY.equalTo(self.firstLabel.snp.centerY)
        make.height.equalTo(15)
        make.width.equalTo(ThemeSizes.pixelLineWidth1())
    }
    

    我们可以看一下 makeConstraints 这个函数的参数:

    public func makeConstraints(_ closure: (_ make: ConstraintMaker) -> Void) {
        ConstraintMaker.makeConstraints(view: self.view, closure: closure)
    }
    
    // ConstraintMaker 的 makeConstraints 函数
    internal static func makeConstraints(view: ConstraintView, closure: (_ make: ConstraintMaker) -> Void)
    

    这个函数的 closure 参数是一个非逃逸闭包,也就是说这个闭包不会被其他属性所持有,执行完毕后就会被释放,所以,这里不会有循环引用问题。

    总结

    本文分析了造成循环引用的常见原因,并讲述了如何打破循环引用,最后,给出了几种常见的 closure 引用对象的内存分析。

    总的来说,分析 iOS 的内存管理重点在于引用计数,而引用计数在于有几个强引用,我们在分析内存时不要着急,要理清楚相互之间的引用关系,这样才能够正确分析每个对象的释放时机,也就掌握了每个对象的生命周期,更利于编写出我们想要的代码。

    相关文章

      网友评论

        本文标题:Swift 循环引用实例解析

        本文链接:https://www.haomeiwen.com/subject/scicpxtx.html