Swift之你真的知道為什麼使用weak嗎?

Jani發表於2018-04-20

寫在前面:最近在學習上有一些煩躁,莫名的,學海無涯苦作舟吧!希望再接再厲的堅持下去,學習,創業!!!

閉包捕獲的是變數的引用而不是當前變數的拷貝

注意,這裡的變數包含了值型別和引用型別。如果是引用型別,則是捕獲了物件的引用,即在閉包中複製了一份物件的引用,物件的引用計數加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("大哥")
}
複製程式碼

輸出

大哥
複製程式碼

顯然,果然方法中傳入的是小弟, 但是輸出的是閉包,哎呀,這個太簡單了,不就是方法中傳入的是指標嗎?大家應該都知道吧~

結語

希望可以給大家一些參考吧,我覺得在學習的過程中,還是應該稍微多想一些,不要淺嘗輒止。共同進步吧!

相關文章