我們知道,在 Objective-C 中可以通過 Category 給一個現有的類新增屬性,但是卻不能新增例項變數,這似乎成為了 Objective-C 的一個明顯短板。然而值得慶幸的是,我們可以通過 Associated Objects 來彌補這一不足。本文將結合 runtime 原始碼深入探究 Objective-C 中 Associated Objects 的實現原理。
在閱讀本文的過程中,讀者需要著重關注以下三個問題:
- 關聯物件被儲存在什麼地方,是不是存放在被關聯物件本身的記憶體中?
- 關聯物件的五種關聯策略有什麼區別,有什麼坑?
- 關聯物件的生命週期是怎樣的,什麼時候被釋放,什麼時候被移除?
這是我寫這篇文章的初衷,也是本文的價值所在。
使用場景
按照 Mattt Thompson 大神的文章 Associated Objects 中的說法,Associated Objects 主要有以下三個使用場景:
- 為現有的類新增私有變數以幫助實現細節;
- 為現有的類新增公有屬性;
- 為
KVO
建立一個關聯的觀察者。
從本質上看,第 1
、2
個場景其實是一個意思,唯一的區別就在於新新增的這個屬性是公有的還是私有的而已。就目前來說,我在實際工作中使用得最多的是第 2
個場景,而第 3
個場景我還沒有使用過。
相關函式
與 Associated Objects 相關的函式主要有三個,我們可以在 runtime 原始碼的 runtime.h 檔案中找到它們的宣告:
1 2 3 |
void objc_setAssociatedObject(id object, const void *key, id value, objc_AssociationPolicy policy); id objc_getAssociatedObject(id object, const void *key); void objc_removeAssociatedObjects(id object); |
這三個函式的命名對程式設計師非常友好,可以讓我們一眼就看出函式的作用:
objc_setAssociatedObject
用於給物件新增關聯物件,傳入nil
則可以移除已有的關聯物件;objc_getAssociatedObject
用於獲取關聯物件;objc_removeAssociatedObjects
用於移除一個物件的所有關聯物件。
注:objc_removeAssociatedObjects
函式我們一般是用不上的,因為這個函式會移除一個物件的所有關聯物件,將該物件恢復成“原始”狀態。這樣做就很有可能把別人新增的關聯物件也一併移除,這並不是我們所希望的。所以一般的做法是通過給 objc_setAssociatedObject
函式傳入 nil
來移除某個已有的關聯物件。
key 值
關於前兩個函式中的 key
值是我們需要重點關注的一個點,這個 key
值必須保證是一個物件級別(為什麼是物件級別?看完下面的章節你就會明白了)的唯一常量。一般來說,有以下三種推薦的 key
值:
- 宣告
static char kAssociatedObjectKey;
,使用&kAssociatedObjectKey
作為key
值; - 宣告
static void *kAssociatedObjectKey = &kAssociatedObjectKey;
,使用kAssociatedObjectKey
作為key
值; - 用
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 找到,其中關鍵程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
@interface ViewController (AssociatedObjects) @property (assign, nonatomic) NSString *associatedObject_assign; @property (strong, nonatomic) NSString *associatedObject_retain; @property (copy, nonatomic) NSString *associatedObject_copy; @end @implementation ViewController (AssociatedObjects) - (NSString *)associatedObject_assign { return objc_getAssociatedObject(self, _cmd); } - (void)setAssociatedObject_assign:(NSString *)associatedObject_assign { objc_setAssociatedObject(self, @selector(associatedObject_assign), associatedObject_assign, OBJC_ASSOCIATION_ASSIGN); } - (NSString *)associatedObject_retain { return objc_getAssociatedObject(self, _cmd); } - (void)setAssociatedObject_retain:(NSString *)associatedObject_retain { objc_setAssociatedObject(self, @selector(associatedObject_retain), associatedObject_retain, OBJC_ASSOCIATION_RETAIN_NONATOMIC); } - (NSString *)associatedObject_copy { return objc_getAssociatedObject(self, _cmd); } - (void)setAssociatedObject_copy:(NSString *)associatedObject_copy { objc_setAssociatedObject(self, @selector(associatedObject_copy), associatedObject_copy, OBJC_ASSOCIATION_COPY_NONATOMIC); } @end |
在 ViewController+AssociatedObjects.h
中宣告瞭三個屬性,限定符分別為 assign, nonatomic
、strong, nonatomic
和 copy, nonatomic
,而在 ViewController+AssociatedObjects.m
中相應的分別用 OBJC_ASSOCIATION_ASSIGN
、OBJC_ASSOCIATION_RETAIN_NONATOMIC
、OBJC_ASSOCIATION_COPY_NONATOMIC
三種關聯策略為這三個屬性新增“例項變數”。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
__weak NSString *string_weak_assign = nil; __weak NSString *string_weak_retain = nil; __weak NSString *string_weak_copy = nil; @implementation ViewController - (void)viewDidLoad { [super viewDidLoad]; self.associatedObject_assign = [NSString stringWithFormat:@"leichunfeng1"]; self.associatedObject_retain = [NSString stringWithFormat:@"leichunfeng2"]; self.associatedObject_copy = [NSString stringWithFormat:@"leichunfeng3"]; string_weak_assign = self.associatedObject_assign; string_weak_retain = self.associatedObject_retain; string_weak_copy = self.associatedObject_copy; } - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { // NSLog(@"self.associatedObject_assign: %@", self.associatedObject_assign); // Will Crash NSLog(@"self.associatedObject_retain: %@", self.associatedObject_retain); NSLog(@"self.associatedObject_copy: %@", self.associatedObject_copy); } @end |
在 ViewController
的 viewDidLoad
方法中,我們對三個屬性進行了賦值,並宣告瞭三個全域性的 __weak
變數來觀察相應物件的釋放時機。此外,我們重寫了 touchesBegan:withEvent:
方法,在方法中分別列印了這三個屬性的當前值。
在繼續閱讀下面章節前,建議讀者先自行思考一下 self.associatedObject_assign
、self.associatedObject_retain
和 self.associatedObject_copy
指向的物件分別會在什麼時候被釋放,以加深理解。
實驗
我們先在 viewDidLoad
方法的第 28
行打上斷點,然後執行程式,點選導航欄右上角的按鈕 Push
到 ViewController
介面,程式將停在斷點處。接著,我們使用 lldb
的 watchpoint
命令來設定觀察點,觀察全域性變數 string_weak_assign
、string_weak_retain
和 string_weak_copy
的值的變化。正確設定好觀察點後,將會在 console
中看到如下的類似輸出:
點選繼續執行按鈕,有一個觀察點將被命中。我們先檢視 console
中的輸出,通過將這一步列印的 old value
和上一步的 new value
進行對比,我們可以知道本次命中的觀察點是 string_weak_assign
,string_weak_assign
的值變成了 0x0000000000000000
,也就是 nil
。換句話說 self.associatedObject_assign
指向的物件已經被釋放了,而通過檢視左側呼叫棧我們可以知道,這個物件是由於其所在的 autoreleasepool
被 drain
而被釋放的,這與我前面的文章《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
指向的物件也由於關聯物件的移除被最終釋放。
結論
由這個實驗,我們可以得出以下結論:
- 關聯物件的釋放時機與被移除的時機並不總是一致的,比如上面的
self.associatedObject_assign
所指向的物件在ViewController
出現後就被釋放了,但是self.associatedObject_assign
仍然有值,還是儲存的原物件的地址。如果之後再使用self.associatedObject_assign
就會造成 Crash ,所以我們在使用弱引用的關聯物件時要非常小心; - 一個物件的所有關聯物件是在這個物件被釋放時呼叫的
_object_remove_assocations
函式中被移除的。
接下來,我們就一起看看 runtime 中的原始碼,來驗證下我們的實驗結論。
objc_setAssociatedObject
我們可以在 objc-references.mm
檔案中找到 objc_setAssociatedObject
函式最終呼叫的函式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 |
void _object_set_associative_reference(id object, void *key, id value, uintptr_t policy) { // retain the new value (if any) outside the lock. ObjcAssociation old_association(0, nil); id new_value = value ? acquireValue(value, policy) : nil; { AssociationsManager manager; AssociationsHashMap &associations(manager.associations()); disguised_ptr_t disguised_object = DISGUISE(object); if (new_value) { // break any existing association. AssociationsHashMap::iterator i = associations.find(disguised_object); if (i != associations.end()) { // secondary table exists ObjectAssociationMap *refs = i->second; ObjectAssociationMap::iterator j = refs->find(key); if (j != refs->end()) { old_association = j->second; j->second = ObjcAssociation(policy, new_value); } else { (*refs)[key] = ObjcAssociation(policy, new_value); } } else { // create the new association (first time). ObjectAssociationMap *refs = new ObjectAssociationMap; associations[disguised_object] = refs; (*refs)[key] = ObjcAssociation(policy, new_value); object->setHasAssociatedObjects(); } } else { // setting the association to nil breaks the association. AssociationsHashMap::iterator i = associations.find(disguised_object); if (i != associations.end()) { ObjectAssociationMap *refs = i->second; ObjectAssociationMap::iterator j = refs->find(key); if (j != refs->end()) { old_association = j->second; refs->erase(j); } } } } // release the old value (outside of the lock). if (old_association.hasValue()) ReleaseValue()(old_association); } |
在看這段程式碼前,我們需要先了解一下幾個資料結構以及它們之間的關係:
AssociationsManager
是頂級的物件,維護了一個從spinlock_t
鎖到AssociationsHashMap
雜湊表的單例鍵值對對映;AssociationsHashMap
是一個無序的雜湊表,維護了從物件地址到ObjectAssociationMap
的對映;ObjectAssociationMap
是一個C++
中的map
,維護了從key
到ObjcAssociation
的對映,即關聯記錄;ObjcAssociation
是一個C++
的類,表示一個具體的關聯結構,主要包括兩個例項變數,_policy
表示關聯策略,_value
表示關聯物件。
每一個物件地址對應一個 ObjectAssociationMap
物件,而一個 ObjectAssociationMap
物件儲存著這個物件的若干個關聯記錄。
弄清楚這些資料結構之間的關係後,再回過頭來看上面的程式碼就不難了。我們發現,在蘋果的底層程式碼中一般都會充斥著各種 if else
,可見寫好 if else
後我們就距離成為高手不遠了。開個玩笑,我們來看下面的流程圖,一圖勝千言:
objc_getAssociatedObject
同樣的,我們也可以在 objc-references.mm
檔案中找到 objc_getAssociatedObject
函式最終呼叫的函式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
id _object_get_associative_reference(id object, void *key) { id value = nil; uintptr_t policy = OBJC_ASSOCIATION_ASSIGN; { AssociationsManager manager; AssociationsHashMap &associations(manager.associations()); disguised_ptr_t disguised_object = DISGUISE(object); AssociationsHashMap::iterator i = associations.find(disguised_object); if (i != associations.end()) { ObjectAssociationMap *refs = i->second; ObjectAssociationMap::iterator j = refs->find(key); if (j != refs->end()) { ObjcAssociation &entry = j->second; value = entry.value(); policy = entry.policy(); if (policy & OBJC_ASSOCIATION_GETTER_RETAIN) ((id(*)(id, SEL))objc_msgSend)(value, SEL_retain); } } } if (value & (policy & OBJC_ASSOCIATION_GETTER_AUTORELEASE)) { ((id(*)(id, SEL))objc_msgSend)(value, SEL_autorelease); } return value; } |
看懂了 objc_setAssociatedObject
函式後,objc_getAssociatedObject
函式對我們來說就是小菜一碟了。這個函式先根據物件地址在 AssociationsHashMap
中查詢其對應的 ObjectAssociationMap
物件,如果能找到則進一步根據 key
在 ObjectAssociationMap
物件中查詢這個 key
所對應的關聯結構 ObjcAssociation
,如果能找到則返回 ObjcAssociation
物件的 value
值,否則返回 nil
。
objc_removeAssociatedObjects
同理,我們也可以在 objc-references.mm
檔案中找到 objc_removeAssociatedObjects
函式最終呼叫的函式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
void _object_remove_assocations(id object) { vector ObjcAssociation,ObjcAllocatorObjcAssociation> > elements; { AssociationsManager manager; AssociationsHashMap &associations(manager.associations()); if (associations.size() == 0) return; disguised_ptr_t disguised_object = DISGUISE(object); AssociationsHashMap::iterator i = associations.find(disguised_object); if (i != associations.end()) { // copy all of the associations that need to be removed. ObjectAssociationMap *refs = i->second; for (ObjectAssociationMap::iterator j = refs->begin(), end = refs->end(); j != end; ++j) { elements.push_back(j->second); } // remove the secondary table. delete refs; associations.erase(i); } } // the calls to releaseValue() happen outside of the lock. for_each(elements.begin(), elements.end(), ReleaseValue()); } |
這個函式負責移除一個物件的所有關聯物件,具體實現也是先根據物件的地址獲取其對應的 ObjectAssociationMap
物件,然後將所有的關聯結構儲存到一個 vector
中,最終釋放 vector
中儲存的所有關聯物件。根據前面的實驗觀察到的情況,在一個物件被釋放時,也正是呼叫的這個函式來移除其所有的關聯物件。
給類物件新增關聯物件
看完原始碼後,我們知道物件地址與 AssociationsHashMap
雜湊表是一一對應的。那麼我們可能就會思考這樣一個問題,是否可以給類物件新增關聯物件呢?答案是肯定的。我們完全可以用同樣的方式給類物件新增關聯物件,只不過我們一般情況下不會這樣做,因為更多時候我們可以通過 static
變數來實現類級別的變數。我在分類 ViewController+AssociatedObjects
中給 ViewController
類物件新增了一個關聯物件 associatedObject
,讀者可以親自在 viewDidLoad
方法中呼叫一下以下兩個方法驗證一下:
1 2 |
+ (NSString *)associatedObject; + (void)setAssociatedObject:(NSString *)associatedObject; |
總結
讀到這裡,相信你對開篇的那三個問題已經有了一定的認識,下面我們再梳理一下:
- 關聯物件與被關聯物件本身的儲存並沒有直接的關係,它是儲存在單獨的雜湊表中的;
- 關聯物件的五種關聯策略與屬性的限定符非常類似,在絕大多數情況下,我們都會使用
OBJC_ASSOCIATION_RETAIN_NONATOMIC
的關聯策略,這可以保證我們持有關聯物件; - 關聯物件的釋放時機與移除時機並不總是一致,比如實驗中用關聯策略
OBJC_ASSOCIATION_ASSIGN
進行關聯的物件,很早就已經被釋放了,但是並沒有被移除,而再使用這個關聯物件時就會造成 Crash 。
在弄懂 Associated Objects 的實現原理後,可以幫助我們更好地使用它,在出現問題時也能儘快地定位問題,最後希望本文能夠對你有所幫助。