本篇文章解決的問題
- Category 的實現原理
- Category 和 Extension 的區別是什麼?
- Category 中有 load 方法嗎?load 方法時什麼時候呼叫的?load 方法能繼承嗎?
- load、initialize 方法的區別是什麼?他們在 category 中的呼叫的順序?以及出現繼承時他們之間的呼叫過程?
- Category為什麼只能加方法不能加屬性?
- Category 能否新增成員變數?如果可以,如何給 Category 新增成員變數?
1. Category 的實現原理
答案:
分類在編譯之後的結構是一個 _category_t
型別的結構體,它裡面儲存著分類的類名、物件方法、類方法、屬性、協議的相關資訊。在執行過程中,runtime
會將這些資訊合併到類物件與元類物件中去。合併之後分類的資訊會插入到原來資訊的前邊。
分析:
假設我們現在有如下的一個類和分類
@interface Person
@end
@implmentation
- (void)run;
@end
**************************************************
@interface Person (test)
@end
@implementation Person (test)
- (void)testInstanceMethod;
+ (void)testClassMethod;
@end
複製程式碼
我們回想一下,當我們的一個 Person 物件呼叫 run
方法時:
Person *person = [[Person alloc] init];
[person run];
複製程式碼
實際上是編譯器將程式碼 [person run]
轉成 objc_msgSend(person, @selector(run))
,系統給 person 傳送訊息,找到 person 的 isa,通過 isa 找到 Person class 物件,在 Person class 物件的方法列表中去找到 run
的實現並進行呼叫。
現在使用 person
呼叫分類的方法:
[person test];
複製程式碼
實際上的流程也還是和上面是一樣的,系統會先找到 person 的 isa,通過 isa 找到 Person class 物件,在 Person class 的方法列表中找到 test 的實現並進行呼叫。在程式執行的過程中,系統通過 runtime 動態地將分類中的物件方法新增進了 Person class 物件的方法列表中。
如果使用 Person 呼叫分類的類方法,情況也是類似的:
[Person test1];
複製程式碼
在程式執行的過程中,系統通過 runtime 動態地將分類的類方法新增到了 Person 的 meta-class 物件的方法列表中去。呼叫方法的時候,系統先找到 Person class 的 isa,然後找到 Person meta-class 物件,在其方法列表中找到 test1
的實現並進行呼叫。
我們來看一下分類的底層結構。
先建立一個工程,建立一個Person
類,在建立一個 Person+test
的分類,在裡面新增好我們上面說到的方法。
進入 Person+test.m
所在的目錄,執行命令:
$ xcrun -sdk iphoneos clang -arch i386 -rewrite-objc Person+test.m
將 Person+test.m
的程式碼轉成 c/c++ 程式碼,然後開啟它。
我們還可以找到這樣一段程式碼
這就是 Person+test 編譯後的的結構體,我們可以看到它傳遞了兩個有效的成員變數,一個是類名Person
,另一個是物件方法列表 &_OBJC_$_CATEGORY_INSTANCE_METHODS_Person_$_test
。我們來看一下
&_OBJC_$_CATEGORY_INSTANCE_METHODS_Person_$_test
這個東西是什麼:
我們可以在 .cpp 檔案中找到這段程式碼,我們在建立分類的時候有一個物件方法- (void)test;
這段程式碼中我們也看見了有"test"的相關描述,所以我們可以判斷出 &_OBJC_$_CATEGORY_INSTANCE_METHODS_Person_$_test
就是描述分類的物件方法資訊的一個東西。
綜上,我們可以判斷出分類在編譯之後確實是編譯成了 _category_t
這個型別。
我們現在得出的結論是,新增分類後:在編譯時,編譯器將分類編譯成了 _category_t
型別的結構體,它裡面儲存的有分類中的類名、物件方法、類方法、屬性資訊、協議資訊。在執行的過程中,Runtime 將所有分類對應的結構體中的物件方法列表合併到類物件的方法列表中,將類方法合併到元類物件的方法列表中。
如果閱讀原始碼的話,還可以發現:合併之後,Category 的資料,會插入到原來資料的前面。 再聯想到物件呼叫方法的流程,現在我們就可以解釋為什麼如果類和分類中都有某個方法時,總是會先呼叫分類中的方法了。如果多個分類中都有這個方法,呼叫哪個分類中的方法,和編譯順序有關。
2. Category 和 Extension 的區別是什麼?
答案:
Extension 中的資料,在編譯的時候就已經合併到類裡邊去了。Category 中在編譯的時候會編譯成一個結構體,在執行時才會合併到類裡邊去。(Extension 的作用是可以把一些需要私有化的屬性、方法寫在 .m 檔案中去。)
3. Category 中有 load 方法嗎?load 方法時什麼時候呼叫的?load 方法能繼承嗎?
答案:
Category
中有load
方法。- 在
runtime
載入類、分類的時候呼叫,每個類、分類的load
方法,在程式執行的過程中只呼叫一次。 load
方法可以繼承。但是一般情況不會主動去呼叫load
方法,都是讓系統自動呼叫。
分析:
新建立一個工程。
建立一個類 Person
,再建立其分類 Person+test
。
在 Person
和 Person+test
的實現中都加上如下程式碼:
+ (void)load
{
NSLog(@"Person +load");// NSLog(@"Person (test) +load");
}
複製程式碼
然後點選執行。
我們發現了以下這些現象:
- 我們還沒有在任何地方呼叫
+ (void)load;
方法,甚至沒有在任何地方匯入過類或者分類的標頭檔案。 + (void)load;
在main函式
之前執行,因為main函式
裡面的hello world
先列印。- 之前我們學習的:如果分類和類中都有某個方法,那麼只會執行分類中的方法,這裡卻都執行了
+ (void)load;
方法。
解釋這個現象需要看一下原始碼:
在objc-os.mm
檔案中,可以找到這段原始碼,可以看出 _objc_init
是在程式啟動之初被呼叫的。最後一行有一個
load_images
,點進去看一下,看到這段程式碼:
可以看見這段程式碼就是系統在做 load
方法的相關處理,最後還呼叫了 call_load_methods()
。因為這段程式碼是在程式啟動時呼叫的,所以 load
方法是在程式啟動時呼叫的,用官方的原話來說是:load
方法在 runtime 載入類、分類的時候呼叫。這也就解釋了前兩個現象。
繼續來看 call_load_methods
做了什麼:
load
方法,然後呼叫了類中的 load
方法:call_class_loads();
,然後又通過同樣的方法直接呼叫了分類的 load
方法:call_categry_loads();
。它不像 objc_msgSend()
方法那樣會有找 isa 、找元類物件等操作,系統會自己呼叫 load
方法。這解釋了第三個現象。
再深入看原始碼的話可以發現,如果有多種類和分類,呼叫順序是這樣的:
先按照編譯順序呼叫所有類的 load
方法,再按照呼叫所有子類的 load
方法,最後再按照編譯順序呼叫所有分類的 load
方法。
4. load、initialize 方法的區別是什麼?他們在 category 中的呼叫的順序?以及出現繼承時他們之間的呼叫過程?
答:
load
方法是在runtime
載入類和分類的時候,通過函式指標找到每個類和分類的load
方法,直接進行呼叫;initialize
是類在第一次接收訊息的時候,會使用訊息機制objc_msgSend(類, @selector(initialize))
去給類傳送訊息進行呼叫。- 當有分類時:類和分類的
load
方法都會被呼叫,因為load
的原理是找到每個類和分類的函式指標直接呼叫,先呼叫父類的load
方法,然後在按照編譯順序先後呼叫所有分類的load
方法;而initialize
它通過訊息機制objc_msgSend()
進行呼叫的,所以它會通過類物件的isa
找到元類物件的方法列表,在列表中去找initialize
的實現並進行呼叫,如果分類中有initialize
方法,那麼先找到的是分類的initialize
方法,就會先呼叫它。 - 出現繼承時:系統會先呼叫所有類的
load
方法,然後再呼叫所有子類的load
方法,最後再呼叫所有分類的load
方法。當第一次給子類物件傳送訊息時,會先給父類傳送訊息objc_msgSend(父類,@selector(initialize))
,讓父類去呼叫initialize
,然後給子類傳送訊息objc_msgSend(子類, @selector(initialize))
,讓子類去呼叫initialize
;
分析:
initialize
的原理是在類第一次接受到訊息的時候進行呼叫。比如說在第一次執行程式碼 [NSObject alloc]
、[Class alloc]
的時候,都向類傳送了訊息,這個時候都會呼叫。也就是說,如果我們使用了這個類,那麼 initialize
就會被呼叫,如果我們從來都沒有使用到這個類,那麼 initialize
就永遠都不會被呼叫。可以建立一個類,在類的.m檔案中實現 + (void)initialize
去進行驗證。
我們在在類、它的分類或者多個分類中都實現 + (void)initialize
方時,我們發現第一次個類傳送訊息時,只會呼叫一個 initialize
方法,呼叫的是分類中的方法,而 load
方法是:在 runtime
載入類的時候會呼叫所有的 load
方法。我們可以猜想 initialize
的呼叫是使用的訊息機制 objc_msgSend()
來進行呼叫的,通過 Class 的 isa 找到元類物件,在元類物件的方法列表中找 initialize
方法,由於分類中的 initialize
方法已經被合併到了元類物件方法列表的前面,所以呼叫的其實是分類中的 initialize
方法。
再寫出一個子類進行測試,可以發現:在呼叫子類的 initialize
之前,會先呼叫父類的 initialize
(如果父類的已經呼叫過了,就不呼叫了)。
我們可以猜測,在編譯後肯定是呼叫了這樣的程式碼:
objc_msgSend(父類, @selector(initialize));
objc_msgSend(子類, @selector(initialize));
複製程式碼
我們已經知道,當類接收到這訊息的時候,它會通過類物件的 isa 找到元類物件,找到元類物件的方法列表,然後在裡面查詢方法。
我們可以在原始碼中找到這一段:
這其實就是上述過程中查詢方法用到的函式。 我們按照lookUpImpOrNil
-> lookUpImpOrForward
-> _class_initialize
的順序點進去。然後我們主要注意這個函式的兩個部分:
我們先來看一下 callInitialize()
點進去:
callInitialize(cls)
簡單的理解成 objc_msgSend(cls, @selector(initialize))
。
接下來把剛才找到的這一大段程式碼再結合前一段lookUpImpOrForward
中的大嗎用虛擬碼做一簡化,只留下我們關注的部分,得到如下的樣子:
if (cls 沒有被初始化) {
if (有父類 && 父類沒有初始化) {
objc_msgSend(父類,@selector(initialize));
初始化父類;
}
objc_msgSend(cls, @selector(initialize));
初始化 cls;
}
複製程式碼
這段虛擬碼就完全說明白了 + (void)initialize
方法的根本實現。
所以上邊測試子類的現象就可以理解了。
父類的 initialize
方法可能會被呼叫很多次,我們來看一個例子。
@interface Person : NSObject
@end
@implementation Person
+ (void)initialize
{
NSLog(@"Person - initialize");
}
@end
**************************************************
@interface Person (test)
@end
@implementation Person (test)
+ (void)initialize
{
NSLog(@"Person test - initialize");
}
@end
**************************************************
@interface Student : Person
@end
@implementation Student
@end
**************************************************
@interface Teacher : Student
@end
@implementation Teacher
@end
複製程式碼
然後呼叫下列方法:
[Student alloc];
[Teacher alloc];
複製程式碼
你可以試著猜測一下列印出來的語句是什麼。
5. Category為什麼只能加方法不能加屬性?
答:
分類能新增方法是因為分類編譯之後的結構體 _category_t
中有可以儲存方法列表的成員變數。
分類其實也是可以新增屬性的,因為 _category_t
中也有可以儲存屬性的成員變數。
分類不能直接新增成員變數,因為 _category_t
不能儲存成員變數。
分析:
再看一下分類編譯之後的結構體:
因為它有instance_methods
與 class_methods
,所以分類是可以新增方法的。
分類其實是可以新增屬性的,因為 _category_t
中有 properties
,它是用來儲存屬性的
Person (test)
分類中新增屬性 @property (nonatomic, copy) NSString *name;
之後,還是可以編譯成功的。
一般我們在類中新增 @property
之後,系統會自動生成成員變數,在 @interface
中新增 setter
、getter
方法宣告,在 @implementation
中實現 setter
和 getter
。
在分類中新增 @property
,系統只會在 @interface
中新增setter
和 getter
的方法宣告。
6. Category 能否新增成員變數?如果可以,如何給 Category 新增成員變數?
答:
不能直接新增,可以間接使用關聯物件實現 `Category` 有成員變數的效果。分析:
Category 中是不能直接新增成員變數
可以看到,如果在分類中直接新增成員變數 Xcode 會報錯。關聯物件提供了一下 API [6. 你使用過 runtime 中的哪些方法?]:
- 新增關聯物件
/*
引數 object 表示的是要給哪個物件進行關聯,一般傳例項物件;
第二個引數 key ,它的型別是 void *,指標,也就是一個地址;
第三個引數 value ,表示要關聯的是哪個值,把 value 和 object 通過 key 關聯起來;
第四個引數 policy ,關聯策略,設定它就類似於設定 @property 後邊的修飾符,它有以下選項:
OBJC_ASSOCIATION_ASSIGN = 0,
OBJC_ASSOCIATION_RETAIN_NONATOMIC = 1,
OBJC_ASSOCIATION_COPY_NONATOMIC = 3,
OBJC_ASSOCIATION_RETAIN = 01401,
OBJC_ASSOCIATION_COPY = 01403
*/
void objc_setAssociatedObject(id object, const void * key, id value, objc_AssociationPolicy policy)
複製程式碼
- 獲得關聯物件
/*
取出與 object 通過 key 關聯的值 value
*/
id objc_getAssociatedObject(id object, const void * key)
複製程式碼
- 移除所有的關聯物件
id objc_removeAssociatedObjects(id object)
引數 key 的三種簡便的設定方式:
第一種方式
Person+test.h
@interface Person (test)
@property (nonatomic, assign) int weight;
@end
Person+test.m
const int WeightKey;
@implementation Person (test)
- (void)setWeight:(int)weight
{
objc_setAssociatedObject(self, &WeightKey, @(weight), OBJC_ASSOCIATION_RETAIN_NONATOMIC);
//因為 value 是 id 型別,所以把它轉成 NSNumber
}
- (int)weight
{
return [objc_getAssociatedObject(self, &WeightKey) intValue];
}
@end
複製程式碼
通過這種方式確實可以實現想要的效果。但是存在兩個問題:
a. 我們定義的全域性變數可以被外部的檔案訪問。可以在前面用 static
修飾,限制它的作用域。
static const int WeightKey;
複製程式碼
b. 它只是作為一個 key,如果用 int 型別,需要佔用 4 個位元組。可以把它改成 char 型別的,就只用佔 1 個位元組了。
static const char WeightKey;
複製程式碼
第二種方式,在 .m 中這樣設定 key :
- (void)setWeight:(int)weight
{
objc_setAssociatedObject(self, @"weight", @(weight), OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
- (int)weight
{
return objc_getAssociatedObject(self, @"weight");
}
複製程式碼
key 的型別是指標,將 @"weight"
傳進去實際上是將這個字串的地址傳進去。
那麼,這裡產生了一個疑問,這裡用了兩個 @"weight"
,他們的地址是一樣的嗎?
是一樣的。在 iOS 中我們這種直接寫字串的方式,字串是儲存在常量區的。 儲存在常量區中的資料一經初始化就不能再修改,程式結束後由系統釋放。所以不論我們寫多少遍 @"weight"
,它其實都是存放在常量區的一個 @"weight"。
這裡可以延伸到另個一面試題:判斷兩個字串字面量是否相同,為什麼要用 isEqualToString
, 而不能用 ==
來判斷?在以後的問題裡會有講到。
第三種比較好的方式,使用 @selector()
:
- (void)setWeight:(int)weight
{
objc_setAssociatedObject(self, @selector(weight), @(weight), OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
- (int)weight
{
return [objc_getAssociatedObject(self, @selectory(weight)) intValue];
}
複製程式碼
測試一下,可以發現 @selector(weight)
的地址也都是一樣的:
NSLog(@"%p %p %p", @selector(weight), @selector(weight), @selector(weight));
複製程式碼
上面介紹了可以採用關聯物件的方式間接實現分類中新增成員變數的效果和引數 key 的三種常用的方式。