深入理解 weak-strong dance

發表於2016-08-16

weak-strong dance 簡介

使用 Block 時可以通過__weak來避免迴圈引用已經是眾所周知的事情:

這時handler持有 Block 物件,而 Block 物件雖然捕獲了weakSelf,延長了weakSelf這個區域性變數的生命週期,但weakSelf是附有__weak修飾符的變數,它並不會持有物件,一旦它指向的物件被廢棄了,它將自動被賦值為nil。在多執行緒情況下,可能weakSelf指向的物件會在 Block 執行前被廢棄,這在上例中無傷大雅,只會輸出Self is nil,但在有些情況下(譬如weakSelf作為 KVO 的觀察者被移除時)就會導致 crash。這時可以在 Block 內部再持有一次weakSelf指向的物件,延長該物件的生命週期,這就是所謂的 weak-strong dance:

typeof(weakSelf) strongSelf = weakSelf這一句等於__strong typeof(weakSelf) strongSelf = weakSelf,在 ARC 模式下,id 型別和 OC 物件型別預設的所有權修飾符就是__strong,所以是可以省略的。

問題

上面就是對 weak-strong dance 的掃盲級描述。不知道大家怎麼想,反正我剛聽說這個東西的時候,是有幾個疑惑的:

  • self指向的物件已經被廢棄的情況下,_handler成員變數也不存在了,在 ARC 下會自動釋放它指向的 Block 物件,這個時候 Block 物件應該已經沒有被變數所持有了,它的引用計數應該已經為0了,它應該被廢棄了啊,為什麼它還能繼續存在並執行。
  • 本來在 Block 內部使用weakSelf就是為了讓 Block 物件不持有self指向的物件,那在 Block 內部又把weakSelf賦給strongSelf不就又持有self物件了麼?又迴圈引用了?

要解決以上疑惑,需要對 ARC、Block、GCD 這些概念有比較深入的瞭解,主要是要清楚 Block 的實現原理。離職前不久我在公司做過一個關於函數語言程式設計的內部分享,講完 PPT 後有個同學問我“閉包”是怎麼實現的,我當時沒有細說,因為不同語言在實現同一個概念時肯定會有一些差異,我也不是什麼語言都精通,所以不敢妄議。現在我也不敢說對所有語言的“閉包”實現都瞭如指掌,但至少對 OC 的閉包實現——Block 還算心中有數的。下面先簡單介紹一下 Block 的實現,當然篇幅所限,會略過一些跟今天的主題關係不大的細節。

Block 的實現

Block 是 C 語言的擴充套件功能,支援 Block 的編譯器會把含有 Block 的程式碼轉換成一般的 C 程式碼執行。之前我一直有用到“Block 物件”這個詞,因為一個 Block 例項就是一個含有“isa”指標的結構體,跟一般的 OC 物件的結構是一樣的:

所以跟一般的 OC 物件一樣,這個isa指標也指向該 Block 例項的型別結構體(類物件,也有叫單件類的),Block 有三種型別:

  • _NSConcreteStackBlock
  • _NSConcreteGlobalBlock
  • _NSConcreteMallocBlock

這三種 Block 類的例項設定在不同的記憶體區域,_NSConcreteStackBlock 的例項設定在 stack 上,_NSConcreteGlobalBlock 的例項設定在 data segment(一般用來放置已初始化的全域性變數),_NSConcreteMallocBlock 的例項設定在 heap。如果 Block 在記述全域性變數的地方被設定或者 Block 沒有捕獲外部變數,那就生成一個 _NSConcreteGlobalBlock 例項。其它情況都會生成一個 _NSConcreteStackBlock 例項,也就是說,它是在棧上的,所以一旦它所屬的變數超出了變數作用域,該 Block 就被廢棄了。而當發生以下任一情況時:

  • 手動呼叫 Block 的例項方法copy
  • Block 作為函式返回值返回
  • 將 Block 賦值給附有__strong修飾符的成員變數
  • 在方法名中含有usingBlock的 Cocoa 框架方法或 GCD 的 API 中傳遞 Block

如果此時 Block 在棧上,那就複製一份到堆上,並將複製得到的 Block 例項的isa指標設為 _NSConcreteMallocBlock:

而如果此時 Block 已經在堆上,那就把該 Block 的引用計數加1

解答疑惑一

說到這裡,已經可以回答上文的第一個疑惑了。把 Block 賦值給self.handler的時候,在棧上生成的 Block 被複制了一份,放到堆上,並被_handler持有。而之後如果你把這個 Block 當作 GCD 引數使用(比較常見的需要使用 weak-strong dance 的情況),GCD 函式內部會把該 Block 再 copy 一遍,而此時 Block 已經在堆上,則該 Block 的引用計數加1。所以此時 Block 的引用計數是大於1的,即使self物件被廢棄(譬如執行了退出當前頁面之類的操作),Block 會被 release 一次,但它的引用計數仍然大於0,故而不會被廢棄。

捕獲物件變數

Block 捕獲外部變數其實可分為三種情況:

  • 捕獲變數的瞬時值
  • 捕獲__block變數
  • 捕獲物件
    前兩種情況跟今天的主題關係不大,先按下不表。第三種情況,也就是本文所舉例子的情況,如果不用__weak,而是直接捕獲self的話,程式碼大概是這個樣子:

也就是說,表示 Block 例項的結構體中會多出一個OCClass型別的成員變數,它會在結構體初始化時被賦值。而結構體中的函式指標void *FuncPtr顯然是用來存放真正的 Block 操作的,它會在結構體初始化的時候被賦值為__xx_block_func_y__xx_block_func_y以表示 Block 物件的結構體例項為引數,從而得到occlass這個物件(即被捕獲的self)。顯然,這裡會導致迴圈引用,而使用了__weak之後,表示 Block 物件的結構體中的成員變數occlass也將附有__weak修飾符:

順便說一下,__weak修飾的變數不會持有物件,它用一張 weak 表(類似於引用計數表的雜湊表)來管理物件和變數。賦值的時候它會以賦值物件的地址作為 key,變數的地址為 value,註冊到 weak 表中。一旦該物件被廢棄,就通過物件地址在 weak 表中找到變數的地址,賦值為 nil,然後將該條記錄從 weak 表中刪除。

那當我們使用 weak-strong dance 的時候是怎麼個情況呢,會再次持有物件從而造成迴圈引用麼?程式碼大致如下:

解答疑惑二

__weak是個神奇的東西,每次使用__weak變數的時候,都會取出該變數指向的物件並 retain,然後將該物件註冊到 autoreleasepool 中。通過上述程式碼我們可以發現,在__xx_block_func_y中,區域性變數occlass會持有捕獲的物件,然後物件會被註冊到 autoreleasepool。這是延長物件生命週期的關鍵,但這不會造成迴圈引用,當函式執行結束,變數occlass超出作用域,過一會兒(一般一次 RunLoop 之後),物件就被釋放了。所以 weak-strong dance 的行為非常符合預期:延長捕獲物件的生命週期,一旦 Block 執行完,物件被釋放,而 Block 也會被釋放(如果被 GCD 之類的 API copy 過一次增加了引用計數,那最終也會被 GCD 釋放)。

額外好處

上文說了每使用一次_weak變數就會把物件註冊到 autoreleasepool 中,所以如果短時間內大量使用_weak變數的話,會導致註冊到 autoreleasepool 中的物件大量增加,佔用一定記憶體。而 weak-strong dance 恰好無意中解決了這個隱患,在執行 Block 時,把_weak變數(weakSelf)賦值給一個臨時變數(strongSelf),之後一直都使用這個臨時變數,所以_weak變數只使用了一次,也就只有一個物件註冊到 autoreleasepool 中。