iOS 開發:『Runtime』詳解(一)基礎知識

行走少年郎發表於2019-07-02

本文首發於我的個人部落格:『不羈閣』

文章連結:bujige.net/blog/iOS-Ru…


本文用來介紹 iOS開發中 『Runtime』的基礎知識。通過本文您將瞭解到:

  1. 什麼是 Runtime?
  2. 訊息機制的基本原理
  3. Runtime 中的概念解析
  4. Runtime 訊息轉發
  5. 訊息傳送以及轉發機制總結

1. 什麼是 Runtime?

我們都知道,將原始碼轉換為可執行的程式,通常要經過三個步驟:編譯連結執行。不同的編譯語言,在這三個步驟中所進行的操作又有些不同。

C 語言 作為一門靜態類語言,在編譯階段就已經確定了所有變數的資料型別,同時也確定好了要呼叫的函式,以及函式的實現。

Objective-C 語言 是一門動態語言。在編譯階段並不知道變數的具體資料型別,也不知道所真正呼叫的哪個函式。只有在執行時間才檢查變數的資料型別,同時在執行時才會根據函式名查詢要呼叫的具體函式。這樣在程式沒執行的時候,我們並不知道呼叫一個方法具體會發生什麼。

Objective-C 語言 把一些決定性的工作從編譯階段、連結階段推遲到 執行時階段 的機制,使得 Objective-C 變得更加靈活。我們甚至可以在程式執行的時候,動態的去修改一個方法的實現,這也為大為流行的『熱更新』提供了可能性。

而實現 Objective-C 語言 執行時機制 的一切基礎就是 Runtime

Runtime 實際上是一個庫,這個庫使我們可以在程式執行時動態的建立物件、檢查物件,修改類和物件的方法。


2. 訊息機制的基本原理

Objective-C 語言 中,物件方法呼叫都是類似 [receiver selector]; 的形式,其本質就是讓物件在執行時傳送訊息的過程。

我們來看看方法呼叫 [receiver selector]; 在『編譯階段』和『執行階段』分別做了什麼?

  1. 編譯階段:[receiver selector]; 方法被編譯器轉換為:
    1. objc_msgSend(receiver,selector) (不帶引數)
    2. objc_msgSend(recevier,selector,org1,org2,…)(帶引數)
  2. 執行時階段:訊息接受者 recever 尋找對應的 selector
    1. 通過 recevierisa 指標 找到 recevierClass(類)
    2. Class(類)cache(方法快取) 的雜湊表中尋找對應的 IMP(方法實現)
    3. 如果在 cache(方法快取) 中沒有找到對應的 IMP(方法實現) 的話,就繼續在 Class(類)method list(方法列表) 中找對應的 selector,如果找到,填充到 cache(方法快取) 中,並返回 selector
    4. 如果在 Class(類) 中沒有找到這個 selector,就繼續在它的 superClass(父類)中尋找;
    5. 一旦找到對應的 selector,直接執行 recever 對應 selector 方法實現的 IMP(方法實現)
    6. 若找不到對應的 selector,訊息被轉發或者臨時向 recever 新增這個 selector 對應的實現方法,否則就會發生崩潰。

在上述過程中涉及了好幾個新的概念:objc_msgSendisa 指標Class(類)IMP(方法實現) 等,下面我們來具體講解一下各個概念的含義。


3. Runtime 中的概念解析

3.1 objc_msgSend

所有 Objective-C 方法呼叫在編譯時都會轉化為對 C 函式 objc_msgSend 的呼叫。objc_msgSend(receiver,selector);[receiver selector]; 對應的 C 函式。

3.2 Class(類)

objc/runtime.h 中,Class(類) 被定義為指向 objc_class 結構體 的指標,objc_class 結構體 的資料結構如下:

/// An opaque type that represents an Objective-C class.
typedef struct objc_class *Class;

struct objc_class {
    Class _Nonnull isa;                                          // objc_class 結構體的例項指標

#if !__OBJC2__
    Class _Nullable super_class;                                 // 指向父類的指標
    const char * _Nonnull name;                                  // 類的名字
    long version;                                                // 類的版本資訊,預設為 0
    long info;                                                   // 類的資訊,供執行期使用的一些位標識
    long instance_size;                                          // 該類的例項變數大小;
    struct objc_ivar_list * _Nullable ivars;                     // 該類的例項變數列表
    struct objc_method_list * _Nullable * _Nullable methodLists; // 方法定義的列表
    struct objc_cache * _Nonnull cache;                          // 方法快取
    struct objc_protocol_list * _Nullable protocols;             // 遵守的協議列表
#endif

};
複製程式碼

從中可以看出,objc_class 結構體 定義了很多變數:自身的所有例項變數(ivars)、所有方法定義(methodLists)、遵守的協議列表(protocols)等。objc_class 結構體 存放的資料稱為 後設資料(metadata)

objc_class 結構體 的第一個成員變數是 isa 指標isa 指標 儲存的是所屬類的結構體的例項的指標,這裡儲存的就是 objc_class 結構體的例項指標,而例項換個名字就是 物件。換句話說,Class(類) 的本質其實就是一個物件,我們稱之為 類物件

3.3 Object(物件)

接下來,我們再來看看 objc/objc.h 中關於 Object(物件) 的定義。 Object(物件)被定義為 objc_object 結構體,其資料結構如下:

/// Represents an instance of a class.
struct objc_object {
    Class _Nonnull isa;       // objc_object 結構體的例項指標
};

/// A pointer to an instance of a class.
typedef struct objc_object *id;
複製程式碼

這裡的 id 被定義為一個指向 objc_object 結構體 的指標。從中可以看出 objc_object 結構體 只包含一個 Class 型別的 isa 指標

換句話說,一個 Object(物件)唯一儲存的就是它所屬 Class(類) 的地址。當我們對一個物件,進行方法呼叫時,比如 [receiver selector];,它會通過 objc_object 結構體isa 指標 去找對應的 objc_class 結構體,然後在 objc_class 結構體methodLists(方法列表) 中找到我們呼叫的方法,然後執行。

3.4 Meta Class(元類)

從上邊我們看出,物件(objc_object 結構體)isa 指標 指向的是對應的 類物件(objc_class 結構體)。那麼 類物件(objc_class 結構體)的 isa 指標 又指向什麼呢?

objc_class 結構體isa 指標 實際上指向的的是 類物件 自身的 Meta Class(元類)

那麼什麼是 Meta Class(元類)

Meta Class(元類) 就是一個類物件所屬的 。一個物件所屬的類叫做 類物件,而一個類物件所屬的類就叫做 元類

Runtime 中把類物件所屬型別就叫做 Meta Class(元類),用於描述類物件本身所具有的特徵,而在元類的 methodLists 中,儲存了類的方法連結串列,即所謂的「類方法」。並且類物件中的 isa 指標 指向的就是元類。每個類物件有且僅有一個與之相關的元類。

2. 訊息機制的基本原理 中我們講解了 物件方法的呼叫過程,我們是通過物件的 isa 指標 找到 對應的 Class(類);然後在 Class(類)method list(方法列表) 中找對應的 selector

類方法的呼叫過程 和物件方法呼叫差不多,流程如下:

  1. 通過類物件 isa 指標 找到所屬的 Meta Class(元類)
  2. Meta Class(元類)method list(方法列表) 中找到對應的 selector;
  3. 執行對應的 selector

下面看一個示例:

NSString *testString = [NSString stringWithFormat:@"%d,%s",3, "test"];
複製程式碼

上邊的示例中,stringWithFormat: 被髮送給了 NSString 類NSString 類 通過 isa 指標 找到 NSString 元類,然後在該元類的方法列表中找到對應的 stringWithFormat: 方法,然後執行該方法。

3.5 例項物件、類、元類之間的關係

上面,我們講解了 例項物件(Object)類(Class)Meta Class(元類) 的基本概念,以及簡單的指向關係。下面我們通過一張圖來清晰地表示出這種關係。

iOS 開發:『Runtime』詳解(一)基礎知識

我們先來看 isa 指標

  1. 水平方向上,每一級中的 例項物件isa 指標 指向了對應的 類物件,而 類物件isa 指標 指向了對應的 元類。而所有元類的 isa 指標 最終指向了 NSObject 元類,因此 NSObject 元類 也被稱為 根源類
  2. 垂直方向上, 元類isa 指標父類元類isa 指標 都指向了 根元類。而 根源類isa 指標 又指向了自己。

我們再來看 父類指標

  1. 類物件父類指標 指向了 父類的類物件父類的類物件 又指向了 根類的類物件根類的類物件 最終指向了 nil。
  2. 元類父類指標 指向了 父類物件的元類父類物件的元類父類指標指向了 根類物件的元類,也就是 根元類。而 根元類父親指標 指向了 根類物件,最終指向了 nil。

3.6 方法(Method)

objc_class 結構體methodLists(方法列表)中存放的元素就是 方法(Method)

先來看下 objc/runtime.h 中,表示 方法(Method)objc_method 結構體 的資料結構:

/// An opaque type that represents a method in a class definition.
/// 代表類定義中一個方法的不透明型別
typedef struct objc_method *Method;

struct objc_method {
    SEL _Nonnull method_name;                    // 方法名
    char * _Nullable method_types;               // 方法型別
    IMP _Nonnull method_imp;                     // 方法實現
};
複製程式碼

可以看到,objc_method 結構體 中包含了 方法名(method_name)方法型別(method_types)方法實現(method_imp)。下面,我們來了解下這三個變數。

  1. SEL method_name; // 方法名
/// An opaque type that represents a method selector.
typedef struct objc_selector *SEL;
複製程式碼

SEL 是一個指向 objc_selector 結構體 的指標,但是在 runtime 相關標頭檔案中並沒有找到明確的定義。不過,通過測試我們可以得出: SEL 只是一個儲存方法名的字串。

SEL sel = @selector(viewDidLoad);
NSLog(@"%s", sel);              // 輸出:viewDidLoad
SEL sel1 = @selector(test);
NSLog(@"%s", sel1);             // 輸出:test
複製程式碼
  1. IMP method_imp; // 方法實現
/// A pointer to the function of a method implementation. 
#if !OBJC_OLD_DISPATCH_PROTOTYPES
typedef void (*IMP)(void /* id, SEL, ... */ ); 
#else
typedef id _Nullable (*IMP)(id _Nonnull, SEL _Nonnull, ...); 
#endif
複製程式碼

IMP 的實質是一個函式指標,所指向的就是方法的實現。IMP用來找到函式地址,然後執行函式。

  1. char * method_types; // 方法型別

方法型別 method_types 是個字串,用來儲存方法的引數型別和返回值型別。

到這裡, Method 的結構就已經很清楚了,MethodSEL(方法名)IMP(函式指標) 關聯起來,當對一個物件傳送訊息時,會通過給出的 SEL(方法名) 去找到 IMP(函式指標) ,然後執行。


4. Runtime 訊息轉發

2. 訊息機制的基本原理 最後一步中我們提到:若找不到對應的 selector,訊息被轉發或者臨時向 recever 新增這個 selector 對應的實現方法,否則就會發生崩潰。

當一個方法找不到的時候,Runtime 提供了 訊息動態解析訊息接受者重定向訊息重定向 等三步處理訊息,具體流程如下圖所示:

iOS 開發:『Runtime』詳解(一)基礎知識

4.1 訊息動態解析

Objective-C 執行時會呼叫 +resolveInstanceMethod: 或者 +resolveClassMethod:,讓你有機會提供一個函式實現。我們可以通過重寫這兩個方法,新增其他函式實現,並返回 YES, 那執行時系統就會重新啟動一次訊息傳送的過程。

主要用的的方法如下:

// 類方法未找到時調起,可以在此新增類方法實現
+ (BOOL)resolveClassMethod:(SEL)sel;
// 物件方法未找到時調起,可以在此物件方法實現
+ (BOOL)resolveInstanceMethod:(SEL)sel;

/** 
 * class_addMethod    向具有給定名稱和實現的類中新增新方法
 * @param cls         被新增方法的類
 * @param name        selector 方法名
 * @param imp         實現方法的函式指標
 * @param types imp   指向函式的返回值與引數型別
 * @return            如果新增方法成功返回 YES,否則返回 NO
 */
BOOL class_addMethod(Class cls, SEL name, IMP imp, 
                const char * _Nullable types);
複製程式碼

舉個例子:

#import "ViewController.h"
#include "objc/runtime.h"

@interface ViewController ()

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    
    // 執行 fun 函式
    [self performSelector:@selector(fun)];
}

// 重寫 resolveInstanceMethod: 新增物件方法實現
+ (BOOL)resolveInstanceMethod:(SEL)sel {
    if (sel == @selector(fun)) { // 如果是執行 fun 函式,就動態解析,指定新的 IMP
        class_addMethod([self class], sel, (IMP)funMethod, "v@:");
        return YES;
    }
    return [super resolveInstanceMethod:sel];
}

void funMethod(id obj, SEL _cmd) {
    NSLog(@"funMethod"); //新的 fun 函式
}

@end
複製程式碼

列印結果: 2019-06-12 10:25:39.848260+0800 runtime[14884:7977579] funMethod

從上邊的例子中,我們可以看出,雖然我們沒有實現 fun 方法,但是通過重寫 resolveInstanceMethod: ,利用 class_addMethod 方法新增物件方法實現 funMethod 方法,並執行。從列印結果來看,成功調起了funMethod 方法。

我們注意到 class_addMethod 方法中的特殊引數 v@:,具體可參考官方文件中關於 Type Encodings 的說明:傳送門

4.2 訊息接受者重定向

如果上一步中 +resolveInstanceMethod: 或者 +resolveClassMethod: 沒有新增其他函式實現,執行時就會進行下一步:訊息接受者重定向。

如果當前物件實現了 -forwardingTargetForSelector:,Runtime 就會呼叫這個方法,允許我們將訊息的接受者轉發給其他物件。

用到的方法:

// 重定向方法的訊息接收者,返回一個類或例項物件
- (id)forwardingTargetForSelector:(SEL)aSelector;
複製程式碼

注意:這裡+resolveInstanceMethod: 或者 +resolveClassMethod:無論是返回 YES,還是返回 NO,只要其中沒有新增其他函式實現,執行時都會進行下一步。

舉個例子:

#import "ViewController.h"
#include "objc/runtime.h"

@interface Person : NSObject

- (void)fun;

@end

@implementation Person

- (void)fun {
    NSLog(@"fun");
}

@end

@interface ViewController ()

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    
    // 執行 fun 方法
    [self performSelector:@selector(fun)];
}

+ (BOOL)resolveInstanceMethod:(SEL)sel {
    return YES; // 為了進行下一步 訊息接受者重定向
}

// 訊息接受者重定向
- (id)forwardingTargetForSelector:(SEL)aSelector {
    if (aSelector == @selector(fun)) {
        return [[Person alloc] init];
        // 返回 Person 物件,讓 Person 物件接收這個訊息
    }
    
    return [super forwardingTargetForSelector:aSelector];
}
複製程式碼

列印結果: 2019-06-12 17:34:05.027800+0800 runtime[19495:8232512] fun

可以看到,雖然當前 ViewController 沒有實現 fun 方法,+resolveInstanceMethod: 也沒有新增其他函式實現。但是我們通過 forwardingTargetForSelector 把當前 ViewController 的方法轉發給了 Person 物件去執行了。列印結果也證明我們成功實現了轉發。

我們通過 forwardingTargetForSelector 可以修改訊息的接收者,該方法返回引數是一個物件,如果這個物件是不是 nil,也不是 self,系統會將執行的訊息轉發給這個物件執行。否則,繼續進行下一步:訊息重定向流程。

4.3 訊息重定向

如果經過訊息動態解析、訊息接受者重定向,Runtime 系統還是找不到相應的方法實現而無法響應訊息,Runtime 系統會利用 -methodSignatureForSelector: 方法獲取函式的引數和返回值型別。

  • 如果 -methodSignatureForSelector: 返回了一個 NSMethodSignature 物件(函式簽名),Runtime 系統就會建立一個 NSInvocation 物件,並通過 -forwardInvocation: 訊息通知當前物件,給予此次訊息傳送最後一次尋找 IMP 的機會。
  • 如果 -methodSignatureForSelector: 返回 nil。則 Runtime 系統會發出 -doesNotRecognizeSelector: 訊息,程式也就崩潰了。

所以我們可以在 -forwardInvocation: 方法中對訊息進行轉發。

用到的方法:

// 獲取函式的引數和返回值型別,返回簽名
- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector;

// 訊息重定向
- (void)forwardInvocation:(NSInvocation *)anInvocation;
複製程式碼

舉個例子:

#import "ViewController.h"
#include "objc/runtime.h"

@interface Person : NSObject

- (void)fun;

@end

@implementation Person

- (void)fun {
    NSLog(@"fun");
}

@end

@interface ViewController ()

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    
    // 執行 fun 函式
    [self performSelector:@selector(fun)];
}

+ (BOOL)resolveInstanceMethod:(SEL)sel {
    return YES; // 為了進行下一步 訊息接受者重定向
}

- (id)forwardingTargetForSelector:(SEL)aSelector {
    return nil; // 為了進行下一步 訊息重定向
}

// 獲取函式的引數和返回值型別,返回簽名
- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector {
    if ([NSStringFromSelector(aSelector) isEqualToString:@"fun"]) {
        return [NSMethodSignature signatureWithObjCTypes:"v@:"];
    }
    
    return [super methodSignatureForSelector:aSelector];
}

// 訊息重定向
- (void)forwardInvocation:(NSInvocation *)anInvocation {
    SEL sel = anInvocation.selector;   // 從 anInvocation 中獲取訊息
    
    Person *p = [[Person alloc] init];

    if([p respondsToSelector:sel]) {   // 判斷 Person 物件方法是否可以響應 sel
        [anInvocation invokeWithTarget:p];  // 若可以響應,則將訊息轉發給其他物件處理
    } else {
        [self doesNotRecognizeSelector:sel];  // 若仍然無法響應,則報錯:找不到響應方法
    }
}
@end
複製程式碼

列印結果: 2019-06-13 13:23:06.935624+0800 runtime[30032:8724248] fun

可以看到,我們在 -forwardInvocation: 方法裡面讓 Person 物件去執行了 fun 函式。

既然 -forwardingTargetForSelector:-forwardInvocation: 都可以將訊息轉發給其他物件處理,那麼兩者的區別在哪?

區別就在於 -forwardingTargetForSelector: 只能將訊息轉發給一個物件。而 -forwardInvocation: 可以將訊息轉發給多個物件。

以上就是 Runtime 訊息轉發的整個流程。

結合之前講的 2. 訊息機制的基本原理,就構成了整個訊息傳送以及轉發的流程。下面我們來總結一下整個流程。


5. 訊息傳送以及轉發機制總結

呼叫 [receiver selector]; 後,進行的流程:

  1. 編譯階段:[receiver selector]; 方法被編譯器轉換為:
    1. objc_msgSend(receiver,selector) (不帶引數)
    2. objc_msgSend(recevier,selector,org1,org2,…)(帶引數)
  2. 執行時階段:訊息接受者 recever 尋找對應的 selector
    1. 通過 recevierisa 指標 找到 recevierclass(類)
    2. Class(類)cache(方法快取) 的雜湊表中尋找對應的 IMP(方法實現)
    3. 如果在 cache(方法快取) 中沒有找到對應的 IMP(方法實現) 的話,就繼續在 Class(類)method list(方法列表) 中找對應的 selector,如果找到,填充到 cache(方法快取) 中,並返回 selector
    4. 如果在 class(類) 中沒有找到這個 selector,就繼續在它的 superclass(父類)中尋找;
    5. 一旦找到對應的 selector,直接執行 recever 對應 selector 方法實現的 IMP(方法實現)
    6. 若找不到對應的 selector,Runtime 系統進入訊息轉發機制。
  3. 執行時訊息轉發階段:
    1. 動態解析:通過重寫 +resolveInstanceMethod: 或者 +resolveClassMethod:方法,利用 class_addMethod方法新增其他函式實現;
    2. 訊息接受者重定向:如果上一步新增其他函式實現,可在當前物件中利用 -forwardingTargetForSelector: 方法將訊息的接受者轉發給其他物件;
    3. 訊息重定向:如果上一步沒有返回值為 nil,則利用 -methodSignatureForSelector:方法獲取函式的引數和返回值型別。
      1. 如果 -methodSignatureForSelector: 返回了一個 NSMethodSignature 物件(函式簽名),Runtime 系統就會建立一個 NSInvocation 物件,並通過 -forwardInvocation: 訊息通知當前物件,給予此次訊息傳送最後一次尋找 IMP 的機會。
      2. 如果 -methodSignatureForSelector: 返回 nil。則 Runtime 系統會發出 -doesNotRecognizeSelector: 訊息,程式也就崩潰了。

參考資料


以上就是 iOS 開發:『Runtime』詳解(一):基礎知識 的所有內容了。 整篇文章主要就講了一件事:訊息傳送以及轉發機制的原理和流程。這也是 Runtime 系統的工作原理。

下一篇筆者準備講一下『Runtime』的黑魔法 Method Swizzling


iOS 開發:『Runtime』詳解 系列文章:

尚未完成:

  • iOS 開發:『Runtime』詳解(五)Crash 防護系統
  • iOS 開發:『Runtime』詳解(六)Objective-C 2.0 結構解析
  • iOS 開發:『Runtime』詳解(七)KVO 底層實現

相關文章