Swift之你真的知道为什么使用weak吗?

6,655 阅读11分钟

闭包捕获的是变量的引用而不是当前变量的拷贝

在Swift中:变量分为值类型和引用类型。如果是引用类型,则是捕获了对象的引用,即在闭包中复制了一份对象的引用,对象的引用计数加1;如果是值类型呢,捕获的是值类型的指针,如果在闭包中修改值类型的话,同样会改变外界变量的值。

func delay(_ duration: Int, closure: @escaping () -> Void) {
    let times = DispatchTime.now() + .seconds(duration)
    DispatchQueue.main.asyncAfter(deadline: times) {
        print("开始执行闭包")
        closure()
    }
}

func captureValues() {
    var number = 1

    delay(1) {
        print(number)
    }

    number = 2
}

captureValues()

如果按照以前的思路,很可能会的得出结论:输出1,为什么?因为闭包直接捕获的值本身的拷贝,但是在Swift不是这样的,Swift捕获的是变量的引用,而非变量的值的拷贝,所以这里闭包捕捉了number变量的引用,当闭包执行时,指针指向的值类型number的值已经为2了,所以这里的输出为

开始执行闭包
2

在闭包中改变变量的值

在外面改变变量的值之后,闭包执行是捕获到的变量的值会随之发生改变,当然了,如果在闭包内部改变变量的值的话,外界的变量值会发生改变吗?答案当然是yes。在闭包中修改变量的值也是通过指针改变变量实际的值,所以肯定会发生改变啦~

func changeValues() {
    var number = 1

    delay(1) {
        print(number)
        number += 1
    }

    delay(2) {
        print(number)
    }
}

输出的值为:

开始执行闭包
1
开始执行闭包
2

闭包如何捕获变量的值,而不是引用呢?

那么我们有时候肯定会有个需求那就是只想捕捉当前变量的值,不希望在闭包执行前,其他地方对变量值的修改会影响到闭包所捕获的值。为了实现这个,Swift提供了捕获列表,可以实现捕获变量的拷贝,而不是变量的指针!

  func captureStatics() {
      var number = 1

      // 这里在编译的时候,count直接copy了变量的值从而达到了目的
      delay(1) { [count = number] in
          print("count = \(count)")
          print("number = \(number)")
      }

      number += 10
  }

输出如下:

开始执行闭包
count = 1
number = 11

闭包的两个关键字

聊到闭包,就不得不提到闭包的两个关键字@escaping@autoclosure 它们分别代表了逃逸闭包和自动闭包

@escaping

  • 什么是逃逸闭包呢?当一个闭包作为参数传到一个函数中,而这个闭包在函数返回之后才被执行,这个闭包就被称为逃逸闭包
  • 如果闭包在函数体内部做异步操作,一般函数会很快执行完毕并且返回,但是闭包却必须逃逸,这样才可以处理异步回调
  • 在网络请求中,逃逸闭包被大量使用,用来处理网络的回调
func delay(_ duration: Int, closure: @escaping () -> Void) {
    let times = DispatchTime.now() + .seconds(duration)
    DispatchQueue.main.asyncAfter(deadline: times) {
        print("开始执行闭包")
        closure()
    }
    print("方法执行完毕")
}

这个方法就是一个典型的例子,作为参数传递进来的闭包是会延时执行的,所以函数先有返回值,再有闭包执行,所以闭包参数需要添加上@escaping关键字

方法执行完毕
开始执行闭包

@autoclosure

其实自动闭包,大多是听得多,用得少,它的作用是简化参数传递,并且延迟执行时间。 我们来写一个简单的方法

func autoTest(_ closure: () -> Bool) {
    if closure() {

    } else {

    }
}

这是一个以闭包做为参数,而且闭包并不会在函数返回之后才执行,而是在方法体中作为了一个条件而执行,那么我们如何调用这个方法呢?

  autoTest { () -> Bool in
      return "n" == "b"
  }

当然,由于闭包会默认将最后一个表达式作为返回值,所以可以简化为:

autoTest { "n" == "b" }

那么还可以更简洁吗?答案是可以的,在闭包中使用@autoclosure关键字

func autoTest(_ closure: @autoclosure () -> Bool) {
    if closure() {

    } else {

    }
}
autoTest("n" == "b")

没错,连大括号都省略了,直接添加一个表达式即可,这个时候肯定有人有疑问,那我直接使用表达式不行吗,为什么还要使用@autoclosure闭包呢?
理论上其实是可行的,但是如果直接使用表达式的话,在调方法的时候,这个表达式就会进行计算,然后将值作为参数传入方法中;如果是@autoclosure闭包,只会在需要执行它的时候才会去执行,而并不会在一开始去就计算出结果,和懒加载有些类似~

  • @autoclosure 和普通表达式最大的区别就是,普通表达式在传入参数的时候,会马上被执行,然后将执行的结果作为参数传递给函数
  • 使用@autoclosure 标记的参数,虽然我们传入的也是一个表达式,但这个表达式不会马上被执行,而是要由调用的函数内来决定它具体执行的时间

闭包的循环引用

闭包的循环引用的原理:object -> 闭包 -> object 形成环形引用,从而无法释放彼此,形成了循环引用!那么问题来了:

UIView.animate(withDuration: TimeInterval) {

}

DispatchQueue.main.async {

}

在以上两个闭包中使用self调用方法,会造成循环引用吗?
还用想吗?当然不会啦,首先self要持有闭包,才有可能循环引用,但是self不持有闭包,闭包虽然会强引用 self, 却没有形成引用的闭环,所以并不会造成循环引用!关于这里在后面会详细描述到,现在来看看闭包中的两个关键字,WeakOwned Apple建议如果可以确定self在访问时不会被释放的话,使用unowned,如果self存在被释放的可能性就使用weak

[weak self]

我们来看一个简单的例子

class Person {
    var name: String
    lazy var printName: () -> () = {
        print("\(self.name)")
    }
    
    init(name: String) {
        self.name = name
    }
    
    deinit {
        print("\(name) 被销毁")
    }
}

func test() {
  let person = Person.init(name: "小明")
  person.printName()
}

text()

输出结果为:

小明

为什么? 只要是稍微了解一点循环引用的人都知道,发生这种情况的主要原因是self持有了closure,而closure有持有了self,所以就造成了循环引用,从而小明对象没有被释放。 所以在这个时候可以选择使用weak,这样Person对象是可以被正常释放的,只不过,如果是异步操作的话,当Person对象被释放之后,再执行闭包中语句的时候,是不会执行的,因为self已经是nil了

class Person {
    var name: String
    lazy var printName: () -> Void = { [weak self] in
        print("\(self?.name)")
    }
    
    init(name: String) {
        self.name = name
    }
    
    deinit {
        print("\(name) 被销毁")
    }
    
    func delay(_ duration: Int, closure: @escaping () -> Void) {
        let times = DispatchTime.now() + .seconds(duration)
        DispatchQueue.main.asyncAfter(deadline: times) {
            print("开始执行闭包")
            closure()
        }
    }
}

let person = Person.init(name: "小明")
person.delay(2, closure: person.printName)

结果如下:

小明 被销毁
开始执行闭包
nil

这即是使用weak的好处,也是坏处,确实可以避免循坏引用的发生,但是却无法保证闭包中的语句全部执行,所以就可以考虑到OC中的strongSelf的方式,使用strongSelf就是让闭包中的语句要么全部执行,要么全部不执行:

lazy var printName: () -> Void = { [weak self] in
    guard let strongSelf = self else {
        return
    }
    print(strongSelf.name)
}

这也是我们在实际的应用中使用最多的一种方式,要么都执行,要么都不执行; 那么有没有一种方法是,既可以避免循环引用,又要保证代码的完整执行呢?答案是有的,在唐巧的一篇博客中提到过,要使得一个block避免循环引用有两种方式:

  1. 事前预防,即使用weak,unowne
  2. 事后补救,即在传入block后,自己手动的去断开block的连接
  lazy var printName: () -> Void = {
       print(self.name)
      self.printName = {}
  }

输出结果如下:

-------开始执行闭包--------
小明
-------结束执行闭包---------
小明对象被销毁

其实相当于我在执行完毕之后,主动断开闭包对self的持有!!通过这种方式的好处就是,我不会造成循环引用,也可以保证闭包中的代码段执行完全,不过这种做法是有风险的,那就是如果忘记了主动断开的话,依旧是会造成循环引用的。

[unowned self]

这种其实非常好理解,就是如果self的生命周期和闭包的生命周期一致,或者比闭包的生命周期还长的话,那就使用unowned关键字。在实际的使用中,还是遵循Apple的推荐:

如果可以确定self在访问时不会被释放的话,使用unowned,如果self存在被释放的可能性就使用weak


真正的循环引用

为什么要提到正在的循环引用,当然我主要是针对闭包去谈这个问题,因为很多时候在使用的过程中很多人疯狂的使用weak,但是却不知道到底在什么情况下会造成循环引用! 其实很简单,就是在self持有闭包的时候,即闭包是self的属性时才会发生循环引用!

class Person {
    var name: String
    lazy var printName: () -> Void = {
         print(self.name)
        self.printName = {}
    }

    init(name: String) {
        self.name = name
    }

    deinit {
        print("\(name)对象被销毁")
    }

    func delay2(_ duration: Int) {
        let times = DispatchTime.now() + .seconds(duration)
        DispatchQueue.main.asyncAfter(deadline: times) {
            print("-------开始执行闭包--------")
            print(self.name)
            print("-------结束执行闭包---------")
        }
    }
}

func test2() {
    let person = Person.init(name: "小明")

    person.delay2(2)
}

test2()

可以猜测一下,对象会销毁吗?

-------开始执行闭包--------
小明
-------结束执行闭包---------
小明对象被销毁

有人问了?不对啊,我在闭包中使用了self啊,为什么不会造成循环引用呢?因为循环引用最起码有两个持有才是循环,一个是self -> 闭包 还有一个是闭包 -> self,显然这里是后者,所以包括我们大多少时候使用的网络请求,只要self不持有回调闭包,其实是不会造成循环引用的!

问题来了,为什么很多人都在网络请求中使用weak self呢? 其实我个人感觉还是有必要的,因为很多时候你都不确定网络请求的类是否持有你传入的闭包,所以还是应该使用weak或者unowned的

好,看到这里是不是又有了一个疑问,那就是明明self不持有闭包,为什么闭包还没有释放呢? 这就又涉及另一个知识点了,就是在Swift中闭包和类都是引用类型,你将闭包作为参数传入网络请求中,其实最后是被系统所持有的,比如使用Alamofilre请求数据,调用某个请求方法最后会走到如下区域

(queue ?? DispatchQueue.main).async { completionHandler(dataResponse) }

而我们使用的UIView的动画,DispatchQueue等其实都是闭包被系统所持有才不会被释放的,这个要明白,当然这只是我的推断,如果哪位大牛知道更详细,或者我理解错误了,希望可以告诉我,很谢谢~

然后提一嘴我的小结论,就是如果使用DispatchQueue的方式捕获的并不是闭包的引用,而是闭包的拷贝。(这里讲闭包作为一个对象,系统捕获这个对象的时候,到底捕获的是拷贝,还是引用呢?

var test = {
    print("first")
}

UIView.animate(withDuration: 0.2, delay: 0.5, options: UIViewAnimationOptions.curveLinear, animations: {
    test()
}, completion: nil)

test = {
    print("second")
}

输出:

first

所以可以很显然得得知,其实系统捕获的是闭包的拷贝,而不是闭包的引用!!!

那么如果将闭包作为方法的参数呢?方法中是不是捕获的也是闭包的拷贝呢?我们来测试一下:

class Person {
    var name: String

    init(name: String) {
        self.name = name
    }
    
    func test(cloure: () -> Void) {
        cloure()
    }
}


var cloure = {
    print("小弟")
}

DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 2.0) {
    person.test(cloure: cloure)
}

cloure = {
    print("大哥")
}

输出

大哥

显然,果然方法中传入的是小弟, 但是输出的是大哥,哎呀,这个太简单了,不就是方法中传入的是指针吗?大家应该都知道吧~ 方法中获取的是闭包的引用!

结语

希望可以给大家一些参考吧,我觉得在学习的过程中,还是应该稍微多想一些,不要浅尝辄止。共同进步吧!