iOS問題整理03----Category

根本停不下來發表於2018-07-13

本篇文章解決的問題

  1. Category 的實現原理
  2. Category 和 Extension 的區別是什麼?
  3. Category 中有 load 方法嗎?load 方法時什麼時候呼叫的?load 方法能繼承嗎?
  4. load、initialize 方法的區別是什麼?他們在 category 中的呼叫的順序?以及出現繼承時他們之間的呼叫過程?
  5. Category為什麼只能加方法不能加屬性?
  6. 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++ 程式碼,然後開啟它。

iOS問題整理03----Category
在這個檔案中,我們可以找到這個結構體。在編譯階段,編譯器會將分類先轉成這種結構體型別,將分類相關的資訊儲存在這個結構體中。從名稱我們可以猜出來:name 儲存的是類名,instance_methods 用於儲存分類中的物件方法,class_method 用於儲存分類中的類方法,protocols 用於儲存分類的協議相關資訊,properties 儲存的是分類中屬性相關的資訊。

我們還可以找到這樣一段程式碼

iOS問題整理03----Category
這就是 Person+test 編譯後的的結構體,我們可以看到它傳遞了兩個有效的成員變數,一個是類名Person,另一個是物件方法列表 &_OBJC_$_CATEGORY_INSTANCE_METHODS_Person_$_test
我們來看一下 &_OBJC_$_CATEGORY_INSTANCE_METHODS_Person_$_test 這個東西是什麼:

iOS問題整理03----Category
我們可以在 .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
PersonPerson+test 的實現中都加上如下程式碼:

+ (void)load
{
    NSLog(@"Person +load");// NSLog(@"Person (test) +load");
}
複製程式碼

然後點選執行。

我們發現了以下這些現象:

  • 我們還沒有在任何地方呼叫 + (void)load; 方法,甚至沒有在任何地方匯入過類或者分類的標頭檔案。
  • + (void)load;main函式之前執行,因為 main函式 裡面的 hello world 先列印。
  • 之前我們學習的:如果分類和類中都有某個方法,那麼只會執行分類中的方法,這裡卻都執行了+ (void)load; 方法。

解釋這個現象需要看一下原始碼:

iOS問題整理03----Category
objc-os.mm 檔案中,可以找到這段原始碼,可以看出 _objc_init 是在程式啟動之初被呼叫的。
最後一行有一個 load_images,點進去看一下,看到這段程式碼:

iOS問題整理03----Category
可以看見這段程式碼就是系統在做 load 方法的相關處理,最後還呼叫了 call_load_methods()。因為這段程式碼是在程式啟動時呼叫的,所以 load 方法是在程式啟動時呼叫的,用官方的原話來說是:load 方法在 runtime 載入類、分類的時候呼叫。這也就解釋了前兩個現象。

繼續來看 call_load_methods 做了什麼:

iOS問題整理03----Category
它先通過函式的指標直接找到了類中的 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 找到元類物件,找到元類物件的方法列表,然後在裡面查詢方法。

我們可以在原始碼中找到這一段:

iOS問題整理03----Category
這其實就是上述過程中查詢方法用到的函式。 我們按照 lookUpImpOrNil -> lookUpImpOrForward -> _class_initialize 的順序點進去。然後我們主要注意這個函式的兩個部分:

iOS問題整理03----Category

iOS問題整理03----Category

我們先來看一下 callInitialize() 點進去:

iOS問題整理03----Category
可以就把 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 不能儲存成員變數。

分析:

再看一下分類編譯之後的結構體:

iOS問題整理03----Category
因為它有 instance_methodsclass_methods ,所以分類是可以新增方法的。

分類其實是可以新增屬性的,因為 _category_t 中有 properties,它是用來儲存屬性的

iOS問題整理03----Category
我們可以看到在 Person (test) 分類中新增屬性 @property (nonatomic, copy) NSString *name; 之後,還是可以編譯成功的。

一般我們在類中新增 @property 之後,系統會自動生成成員變數,在 @interface 中新增 settergetter 方法宣告,在 @implementation 中實現 settergetter

在分類中新增 @property,系統只會在 @interface 中新增settergetter的方法宣告。

6. Category 能否新增成員變數?如果可以,如何給 Category 新增成員變數?

答:

不能直接新增,可以間接使用關聯物件實現 `Category` 有成員變數的效果。

分析:

Category 中是不能直接新增成員變數

iOS問題整理03----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 的三種常用的方式。

關於 AssociatedObjects 的實現原理等,可以參考這篇這篇文章。

相關文章