Objective-C Associated Objects 的實現原理

發表於2016-03-19

我們知道,在 Objective-C 中可以通過 Category 給一個現有的類新增屬性,但是卻不能新增例項變數,這似乎成為了 Objective-C 的一個明顯短板。然而值得慶幸的是,我們可以通過 Associated Objects 來彌補這一不足。本文將結合 runtime 原始碼深入探究 Objective-C 中 Associated Objects 的實現原理。

在閱讀本文的過程中,讀者需要著重關注以下三個問題:

  1. 關聯物件被儲存在什麼地方,是不是存放在被關聯物件本身的記憶體中?
  2. 關聯物件的五種關聯策略有什麼區別,有什麼坑?
  3. 關聯物件的生命週期是怎樣的,什麼時候被釋放,什麼時候被移除?

這是我寫這篇文章的初衷,也是本文的價值所在。

使用場景

按照 Mattt Thompson 大神的文章 Associated Objects 中的說法,Associated Objects 主要有以下三個使用場景:

  1. 為現有的類新增私有變數以幫助實現細節;
  2. 為現有的類新增公有屬性;
  3. KVO 建立一個關聯的觀察者。

從本質上看,第 12 個場景其實是一個意思,唯一的區別就在於新新增的這個屬性是公有的還是私有的而已。就目前來說,我在實際工作中使用得最多的是第 2 個場景,而第 3 個場景我還沒有使用過。

相關函式

與 Associated Objects 相關的函式主要有三個,我們可以在 runtime 原始碼的 runtime.h 檔案中找到它們的宣告:

這三個函式的命名對程式設計師非常友好,可以讓我們一眼就看出函式的作用:

  • objc_setAssociatedObject 用於給物件新增關聯物件,傳入 nil 則可以移除已有的關聯物件;
  • objc_getAssociatedObject 用於獲取關聯物件;
  • objc_removeAssociatedObjects 用於移除一個物件的所有關聯物件。

objc_removeAssociatedObjects 函式我們一般是用不上的,因為這個函式會移除一個物件的所有關聯物件,將該物件恢復成“原始”狀態。這樣做就很有可能把別人新增的關聯物件也一併移除,這並不是我們所希望的。所以一般的做法是通過給 objc_setAssociatedObject 函式傳入 nil 來移除某個已有的關聯物件。

key 值

關於前兩個函式中的 key 值是我們需要重點關注的一個點,這個 key 值必須保證是一個物件級別(為什麼是物件級別?看完下面的章節你就會明白了)的唯一常量。一般來說,有以下三種推薦的 key 值:

  1. 宣告 static char kAssociatedObjectKey; ,使用 &kAssociatedObjectKey 作為 key 值;
  2. 宣告 static void *kAssociatedObjectKey = &kAssociatedObjectKey; ,使用 kAssociatedObjectKey 作為 key 值;
  3. selector ,使用 getter 方法的名稱作為 key 值。

我個人最喜歡的(沒有之一)是第 3 種方式,因為它省掉了一個變數名,非常優雅地解決了計算科學中的兩大世界難題之一(命名)。

關聯策略

在給一個物件新增關聯物件時有五種關聯策略可供選擇:

 

關聯策略 等價屬性 說明
OBJC_ASSOCIATION_ASSIGN @property (assign) or @property (unsafe_unretained) 弱引用關聯物件
OBJC_ASSOCIATION_RETAIN_NONATOMIC @property (strong, nonatomic) 強引用關聯物件,且為非原子操作
OBJC_ASSOCIATION_COPY_NONATOMIC @property (copy, nonatomic) 複製關聯物件,且為非原子操作
OBJC_ASSOCIATION_RETAIN @property (strong, atomic) 強引用關聯物件,且為原子操作
OBJC_ASSOCIATION_COPY @property (copy, atomic) 複製關聯物件,且為原子操作

其中,第 2 種與第 4 種、第 3 種與第 5 種關聯策略的唯一差別就在於操作是否具有原子性。由於操作的原子性不在本文的討論範圍內,所以下面的實驗和討論就以前三種以例進行展開。

實現原理

在探究 Associated Objects 的實現原理前,我們還是先來動手做一個小實驗,研究一下關聯物件什麼時候會被釋放。本實驗主要涉及 ViewController 類和它的分類 ViewController+AssociatedObjects:本實驗的完整程式碼可以在這裡 AssociatedObjects 找到,其中關鍵程式碼如下:

ViewController+AssociatedObjects.h 中宣告瞭三個屬性,限定符分別為 assign, nonatomicstrong, nonatomiccopy, nonatomic ,而在 ViewController+AssociatedObjects.m 中相應的分別用 OBJC_ASSOCIATION_ASSIGNOBJC_ASSOCIATION_RETAIN_NONATOMICOBJC_ASSOCIATION_COPY_NONATOMIC 三種關聯策略為這三個屬性新增“例項變數”。

ViewControllerviewDidLoad 方法中,我們對三個屬性進行了賦值,並宣告瞭三個全域性的 __weak 變數來觀察相應物件的釋放時機。此外,我們重寫了 touchesBegan:withEvent: 方法,在方法中分別列印了這三個屬性的當前值。

在繼續閱讀下面章節前,建議讀者先自行思考一下 self.associatedObject_assignself.associatedObject_retainself.associatedObject_copy 指向的物件分別會在什麼時候被釋放,以加深理解。

實驗

我們先在 viewDidLoad 方法的第 28 行打上斷點,然後執行程式,點選導航欄右上角的按鈕 PushViewController 介面,程式將停在斷點處。接著,我們使用 lldbwatchpoint 命令來設定觀察點,觀察全域性變數 string_weak_assignstring_weak_retainstring_weak_copy 的值的變化。正確設定好觀察點後,將會在 console 中看到如下的類似輸出:

設定觀察點

點選繼續執行按鈕,有一個觀察點將被命中。我們先檢視 console 中的輸出,通過將這一步列印的 old value 和上一步的 new value 進行對比,我們可以知道本次命中的觀察點是 string_weak_assignstring_weak_assign 的值變成了 0x0000000000000000 ,也就是 nil 。換句話說 self.associatedObject_assign 指向的物件已經被釋放了,而通過檢視左側呼叫棧我們可以知道,這個物件是由於其所在的 autoreleasepooldrain 而被釋放的,這與我前面的文章《Objective-C Autorelease Pool 的實現原理
中的表述是一致的。提示,待會你也可以放開 touchesBegan:withEvent: 中第 31 行的註釋,在 ViewController 出現後,點選一下它的 view ,進一步驗證一下這個結論。

設定觀察點

接下來,我們點選 ViewController 導航欄左上角的按鈕,返回前一個介面,此時,又將有一個觀察點被命中。同理,我們可以知道這個觀察點是 string_weak_retain 。我們檢視左側的呼叫棧,將會發現一個非常敏感的函式呼叫 _object_remove_assocations ,呼叫這個函式後 ViewController 的所有關聯物件被全部移除。最終,self.associatedObject_retain 指向的物件被釋放。

設定觀察點

點選繼續執行按鈕,最後一個觀察點 string_weak_copy 被命中。同理,self.associatedObject_copy 指向的物件也由於關聯物件的移除被最終釋放。

設定觀察點

結論

由這個實驗,我們可以得出以下結論:

  1. 關聯物件的釋放時機與被移除的時機並不總是一致的,比如上面的 self.associatedObject_assign 所指向的物件在 ViewController 出現後就被釋放了,但是 self.associatedObject_assign 仍然有值,還是儲存的原物件的地址。如果之後再使用 self.associatedObject_assign 就會造成 Crash ,所以我們在使用弱引用的關聯物件時要非常小心;
  2. 一個物件的所有關聯物件是在這個物件被釋放時呼叫的 _object_remove_assocations 函式中被移除的。

接下來,我們就一起看看 runtime 中的原始碼,來驗證下我們的實驗結論。

objc_setAssociatedObject

我們可以在 objc-references.mm 檔案中找到 objc_setAssociatedObject 函式最終呼叫的函式:

在看這段程式碼前,我們需要先了解一下幾個資料結構以及它們之間的關係:

  1. AssociationsManager 是頂級的物件,維護了一個從 spinlock_t 鎖到 AssociationsHashMap 雜湊表的單例鍵值對對映;
  2. AssociationsHashMap 是一個無序的雜湊表,維護了從物件地址到 ObjectAssociationMap 的對映;
  3. ObjectAssociationMap 是一個 C++ 中的 map ,維護了從 keyObjcAssociation 的對映,即關聯記錄;
  4. ObjcAssociation 是一個 C++ 的類,表示一個具體的關聯結構,主要包括兩個例項變數,_policy 表示關聯策略,_value 表示關聯物件。

每一個物件地址對應一個 ObjectAssociationMap 物件,而一個 ObjectAssociationMap 物件儲存著這個物件的若干個關聯記錄。

弄清楚這些資料結構之間的關係後,再回過頭來看上面的程式碼就不難了。我們發現,在蘋果的底層程式碼中一般都會充斥著各種 if else ,可見寫好 if else 後我們就距離成為高手不遠了。開個玩笑,我們來看下面的流程圖,一圖勝千言:

objc_setAssociatedObject

objc_getAssociatedObject

同樣的,我們也可以在 objc-references.mm 檔案中找到 objc_getAssociatedObject 函式最終呼叫的函式:

看懂了 objc_setAssociatedObject 函式後,objc_getAssociatedObject 函式對我們來說就是小菜一碟了。這個函式先根據物件地址在 AssociationsHashMap 中查詢其對應的 ObjectAssociationMap 物件,如果能找到則進一步根據 keyObjectAssociationMap 物件中查詢這個 key 所對應的關聯結構 ObjcAssociation ,如果能找到則返回 ObjcAssociation 物件的 value 值,否則返回 nil

objc_removeAssociatedObjects

同理,我們也可以在 objc-references.mm 檔案中找到 objc_removeAssociatedObjects 函式最終呼叫的函式:

這個函式負責移除一個物件的所有關聯物件,具體實現也是先根據物件的地址獲取其對應的 ObjectAssociationMap 物件,然後將所有的關聯結構儲存到一個 vector 中,最終釋放 vector 中儲存的所有關聯物件。根據前面的實驗觀察到的情況,在一個物件被釋放時,也正是呼叫的這個函式來移除其所有的關聯物件。

給類物件新增關聯物件

看完原始碼後,我們知道物件地址與 AssociationsHashMap 雜湊表是一一對應的。那麼我們可能就會思考這樣一個問題,是否可以給類物件新增關聯物件呢?答案是肯定的。我們完全可以用同樣的方式給類物件新增關聯物件,只不過我們一般情況下不會這樣做,因為更多時候我們可以通過 static 變數來實現類級別的變數。我在分類 ViewController+AssociatedObjects 中給 ViewController 類物件新增了一個關聯物件 associatedObject ,讀者可以親自在 viewDidLoad 方法中呼叫一下以下兩個方法驗證一下:

總結

讀到這裡,相信你對開篇的那三個問題已經有了一定的認識,下面我們再梳理一下:

  1. 關聯物件與被關聯物件本身的儲存並沒有直接的關係,它是儲存在單獨的雜湊表中的;
  2. 關聯物件的五種關聯策略與屬性的限定符非常類似,在絕大多數情況下,我們都會使用 OBJC_ASSOCIATION_RETAIN_NONATOMIC 的關聯策略,這可以保證我們持有關聯物件;
  3. 關聯物件的釋放時機與移除時機並不總是一致,比如實驗中用關聯策略 OBJC_ASSOCIATION_ASSIGN 進行關聯的物件,很早就已經被釋放了,但是並沒有被移除,而再使用這個關聯物件時就會造成 Crash 。

在弄懂 Associated Objects 的實現原理後,可以幫助我們更好地使用它,在出現問題時也能儘快地定位問題,最後希望本文能夠對你有所幫助。

參考連結

http://nshipster.com/associated-objects/

http://kingscocoa.com/tutorials/associated-objects/

相關文章