iOS開發·runtime原理與實踐: 訊息轉發篇(Message Forwarding) (訊息機制,方法未實現+API不相容奔潰,模擬多繼承)

陳滿iOS發表於2018-05-02

本文Demo傳送門: MessageForwardingDemo

iOS開發·runtime原理與實踐: 訊息轉發篇(Message Forwarding) (訊息機制,方法未實現+API不相容奔潰,模擬多繼承)

摘要:程式設計,只瞭解原理不行,必須實戰才能知道應用場景。本系列嘗試闡述runtime相關理論的同時介紹一些實戰場景,而本文則是本系列的訊息轉發篇。本文中,第一節將介紹方法訊息傳送相關的概念,第二節將總結一下2. 動態特性:方法解析和訊息轉發(Method Resolution,Fast Rorwarding,Normal Forwarding),第三節將介紹方法交換幾種的實戰場景:特定奔潰預防處理(呼叫未實現方法),蘋果系統迭代造成API不相容的奔潰處理,第四節將總結訊息轉發的機制。

1.OC的方法與訊息

在我們開始使用訊息機制之前,我們可以約定我們的術語。例如,很多人不清楚“方法”與“訊息”是什麼,但這對於理解訊息傳遞系統如何在低階別工作至關重要。

  • 方法:與一個類相關的一段實際程式碼,並給出一個特定的名字。例:- (int)meaning { return 42; }
  • 訊息:傳送給物件的名稱和一組引數。示例:向0x12345678物件傳送meaning並且沒有引數。
  • 選擇器:表示訊息或方法名稱的一種特殊方式,表示為型別SEL。選擇器本質上就是不透明的字串,它們被管理,因此可以使用簡單的指標相等來比較它們,從而提高速度。(實現可能會有所不同,但這基本上是他們在外部看起來的樣子。)例如:@selector(meaning)
  • 訊息傳送:接收資訊並查詢和執行適當方法的過程。

1.1 方法與訊息傳送

訊息在OC中方法呼叫是一個訊息傳送的過程。OC方法最終被生成為C函式,並帶有一些額外的引數。這個C函式objc_msgSend就負責訊息傳送。在runtime的objc/message.h中能找到它的API。

objc_msgSend(id _Nullable self, SEL _Nonnull op, ...)`
複製程式碼

1.2 訊息傳送的主要步驟

訊息傳送的時候,在C語言函式中發生了什麼事情?編譯器是如何找到這個方法的呢?訊息傳送的主要步驟如下:

  1. 首先檢查這個selector是不是要忽略。比如Mac OS X開發,有了垃圾回收就不會理會retain,release這些函式。
  2. 檢測這個selector的target是不是nil,OC允許我們對一個nil物件執行任何方法不會Crash,因為執行時會被忽略掉。
  3. 如果上面兩步都通過了,就開始查詢這個類的實現IMP,先從cache裡查詢,如果找到了就執行對應的函式去執行相應的程式碼。
  4. 如果cache中沒有找到就找類的方法列表中是否有對應的方法。
  5. 如果類的方法列表中找不到就到父類的方法列表中查詢,一直找到NSObject類為止。
  6. 如果還是沒找到就要開始進入動態方法解析訊息轉發,後面會說。

其中,為什麼它被稱為 “轉發”? 當某個物件沒有任何響應某個 訊息 的操作就 “轉發” 該 訊息。原因是這種技術主要是為了讓物件讓其他物件為他們處理 訊息,從而 “轉發”。

訊息轉發是一種功能強大的技術,可以大大增加Objective-C的表現力。什麼是訊息轉發?簡而言之,它允許未知的訊息被困住並作出反應。換句話說,無論何時傳送未知訊息,它​​都會以一個很好的包傳送到您的程式碼中,此時您可以隨心所欲地執行任何操作。

1.3 OC的方法本質

OC中的方法預設被隱藏了兩個引數:self_cmd。你可能知道self是作為一個隱式引數傳遞的,它最終成為一個明確的引數。鮮為人知的隱式引數_cmd(它儲存了正在傳送的訊息的選擇器)是第二個這樣的隱式引數。總之,self指向物件本身,_cmd指向方法本身。舉兩個例子來說明:

  • 例1:- (NSString *)name 這個方法實際上有兩個引數:self_cmd

  • 例2:- (void)setValue:(int)val 這個方法實際上有三個引數:self,_cmdval

在編譯時你寫的 OC 函式呼叫的語法都會被翻譯成一個 C 的函式呼叫 objc_msgSend() 。比如,下面兩行程式碼就是等價的:

  • OC
[array insertObject:foo atIndex:5];
複製程式碼
  • C
objc_msgSend(array, @selector(insertObject:atIndex:), foo, 5);
複製程式碼

其中的objc_msgSend就負責訊息傳送。

2. 動態特性:方法解析和訊息轉發

沒有方法的實現,程式會在執行時掛掉並丟擲 unrecognized selector sent to … 的異常。但在異常丟擲前,Objective-C 的執行時會給你三次拯救程式的機會:

  • Method resolution
  • Fast forwarding
  • Normal forwarding

2.1 動態方法解析: Method Resolution

首先,Objective-C 執行時會呼叫 + (BOOL)resolveInstanceMethod:或者 + (BOOL)resolveClassMethod:,讓你有機會提供一個函式實現。如果你新增了函式並返回 YES, 那執行時系統就會重新啟動一次訊息傳送的過程。還是以 foo 為例,你可以這麼實現:

void fooMethod(id obj, SEL _cmd)  
{
    NSLog(@"Doing foo");
}

+ (BOOL)resolveInstanceMethod:(SEL)aSEL
{
    if(aSEL == @selector(foo:)){
        class_addMethod([self class], aSEL, (IMP)fooMethod, "v@:");
        return YES;
    }
    return [super resolveInstanceMethod];
}
複製程式碼

這裡第一字元v代表函式返回型別void,第二個字元@代表self的型別id,第三個字元:代表_cmd的型別SEL。這些符號可在Xcode中的開發者文件中搜尋Type Encodings就可看到符號對應的含義,更詳細的官方文件傳送門 在這裡,此處不再列舉了。

iOS開發·runtime原理與實踐: 訊息轉發篇(Message Forwarding) (訊息機制,方法未實現+API不相容奔潰,模擬多繼承)

2.2 快速轉發: Fast Rorwarding

與下面2.3完整轉發不同,Fast Rorwarding這是一種快速訊息轉發:只需要在指定API方法裡面返回一個新物件即可,當然其它的邏輯判斷還是要的(比如該SEL是否某個指定SEL?)。

訊息轉發機制執行前,runtime系統允許我們替換訊息的接收者為其他物件。通過- (id)forwardingTargetForSelector:(SEL)aSelector方法。如果此方法返回的是nil 或者self,則會進入訊息轉發機制(- (void)forwardInvocation:(NSInvocation *)invocation),否則將會向返回的物件重新傳送訊息。

- (id)forwardingTargetForSelector:(SEL)aSelector {
    if(aSelector == @selector(foo:)){
        return [[BackupClass alloc] init];
    }
    return [super forwardingTargetForSelector:aSelector];
}
複製程式碼

2.3 完整訊息轉發: Normal Forwarding

與上面不同,可以理解成完整訊息轉發,是可以代替快速轉發做更多的事。

- (void)forwardInvocation:(NSInvocation *)invocation {
    SEL sel = invocation.selector;
    if([alternateObject respondsToSelector:sel]) {
        [invocation invokeWithTarget:alternateObject];
    } else {
        [self doesNotRecognizeSelector:sel];
    }
}

- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector {
    NSMethodSignature *methodSignature = [super methodSignatureForSelector:aSelector];
    if (!methodSignature) {
        methodSignature = [NSMethodSignature signatureWithObjCTypes:"v@:*"];
    }
    return methodSignature;
}
複製程式碼

forwardInvocation: 方法就是一個不能識別訊息的分發中心,將這些不能識別的訊息轉發給不同的訊息物件,或者轉發給同一個物件,再或者將訊息翻譯成另外的訊息,亦或者簡單的“吃掉”某些訊息,因此沒有響應也不會報錯。例如:我們可以為了避免直接閃退,可以當訊息沒法處理時在這個方法中給使用者一個提示,也不失為一種友好的使用者體驗。

其中,引數invocation是從哪來的?在forwardInvocation:訊息傳送前,runtime系統會向物件傳送methodSignatureForSelector:訊息,並取到返回的方法簽名用於生成NSInvocation物件。所以重寫forwardInvocation:的同時也要重寫methodSignatureForSelector:方法,否則會丟擲異常。當一個物件由於沒有相應的方法實現而無法響應某個訊息時,執行時系統將通過forwardInvocation:訊息通知該物件。每個物件都繼承了forwardInvocation:方法,我們可以將訊息轉發給其它的物件。

2.4 區別: Fast Rorwarding 對比 Normal Forwarding?

可能有朋友看到,這兩個轉發都是將訊息轉發給其它物件,那麼這兩個有什麼區別?

  • 需要過載的API方法的用法不同

    • 前者只需要過載一個API即可,後者需要過載兩個API。
    • 前者只需在API方法裡面返回一個新物件即可,後者需要對被轉發的訊息進行重籤並手動轉發給新物件(利用 invokeWithTarget:)。
  • 轉發給新物件的個數不同

    • 前者只能轉發一個物件,後者可以連續轉發給多個物件。例如下面是完整轉發:
-(NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector
{
    if (aSelector==@selector(run)) {
        return [NSMethodSignature signatureWithObjCTypes:"v@:"];
    }
    return [super methodSignatureForSelector: aSelector];
}

-(void)forwardInvocation:(NSInvocation *)anInvocation
{
    SEL selector =[anInvocation selector];
    
    RunPerson *RP1=[RunPerson new];
    RunPerson *RP2=[RunPerson new];
    
    if ([RP1 respondsToSelector:selector]) {
        
        [anInvocation invokeWithTarget:RP1];
    }
    if ([RP2 respondsToSelector:selector]) {
        
        [anInvocation invokeWithTarget:RP2];
    }    
}
複製程式碼

3. 應用實戰:訊息轉發

3.1 特定奔潰預防處理

下面有一段因為沒有實現方法而會導致奔潰的程式碼:

  • Test2ViewController
- (void)viewDidLoad {
    [super viewDidLoad];
    [self.view setBackgroundColor:[UIColor whiteColor]];
    self.title = @"Test2ViewController";
    
    //例項化一個button,未實現其方法
    UIButton *button = [UIButton buttonWithType:UIButtonTypeCustom];
    button.frame = CGRectMake(50, 100, 200, 100);
    button.backgroundColor = [UIColor blueColor];
    [button setTitle:@"訊息轉發" forState:UIControlStateNormal];
    [button addTarget:self
               action:@selector(doSomething)
     forControlEvents:UIControlEventTouchUpInside];
    [self.view addSubview:button];
}
複製程式碼

為解決這個問題,可以專門建立一個處理這種問題的分類:

  • NSObject+CrashLogHandle
#import "NSObject+CrashLogHandle.h"

@implementation NSObject (CrashLogHandle)

- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector {
    //方法簽名
    return [NSMethodSignature signatureWithObjCTypes:"v@:@"];
}

- (void)forwardInvocation:(NSInvocation *)anInvocation {
    NSLog(@"NSObject+CrashLogHandle---在類:%@中 未實現該方法:%@",NSStringFromClass([anInvocation.target class]),NSStringFromSelector(anInvocation.selector));
}

@end
複製程式碼

因為在category中複寫了父類的方法,會出現下面的警告:

iOS開發·runtime原理與實踐: 訊息轉發篇(Message Forwarding) (訊息機制,方法未實現+API不相容奔潰,模擬多繼承)

解決辦法就是在Xcode的Build Phases中的資原始檔裡,在對應的檔案後面 -w ,忽略所有警告。

iOS開發·runtime原理與實踐: 訊息轉發篇(Message Forwarding) (訊息機制,方法未實現+API不相容奔潰,模擬多繼承)

3.2 蘋果系統API迭代造成API不相容的奔潰處理

3.2.1 相容系統API迭代的傳統方案

隨著每年iOS系統與硬體的更新迭代,部分效能更優異或者可讀性更高的API將有可能對原有API進行廢棄與更替。與此同時我們也需要對現有APP中的老舊API進行版本相容,當然進行版本相容的方法也有很多種,下面筆者會列舉常用的幾種:

  • 根據能否響應方法進行判斷
if ([object respondsToSelector: @selector(selectorName)]) {
    //using new API
} else {
    //using deprecated API
}
複製程式碼
  • 根據當前版本SDK是否存在所需類進行判斷
if (NSClassFromString(@"ClassName")) {    
    //using new API
}else {
    //using deprecated API
}
複製程式碼
  • 根據作業系統版本進行判斷
#define isOperatingSystemAtLeastVersion(majorVersion, minorVersion, patchVersion)[[NSProcessInfo processInfo] isOperatingSystemAtLeastVersion: (NSOperatingSystemVersion) {
    majorVersion,
    minorVersion,
    patchVersion
}]

if (isOperatingSystemAtLeastVersion(11, 0, 0)) {
    //using new API
} else {
    //using deprecated API
}
複製程式碼
3.2.2 相容系統API迭代的新方案

需求:假設現在有一個利用新API寫好的類,如下所示,其中有一行可能因為執行在低版本系統(比如iOS9)導致奔潰的程式碼:

  • Test3ViewController.m
- (void)viewDidLoad {
    [super viewDidLoad];
    [self.view setBackgroundColor:[UIColor whiteColor]];
    self.title = @"Test3ViewController";
    
    UITableView *tableView = [[UITableView alloc] initWithFrame:CGRectMake(0, 64, 375, 600) style:UITableViewStylePlain];
    tableView.delegate = self;
    tableView.dataSource = self;
    tableView.backgroundColor = [UIColor orangeColor];
    
    // May Crash Line
    tableView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever;
    
    [tableView registerClass:[UITableViewCell class] forCellReuseIdentifier:@"UITableViewCell"];
    [self.view addSubview:tableView];
}
複製程式碼

其中有一行會發出警告,Xcode也給出了推薦解決方案,如果你點選Fix它會自動新增檢查系統版本的程式碼,如下圖所示:

iOS開發·runtime原理與實踐: 訊息轉發篇(Message Forwarding) (訊息機制,方法未實現+API不相容奔潰,模擬多繼承)

方案1:手動加入版本判斷邏輯

以前的適配處理,可根據作業系統版本進行判斷

if (isOperatingSystemAtLeastVersion(11, 0, 0)) {
    scrollView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever;
} else {
    viewController.automaticallyAdjustsScrollViewInsets = NO;
}
複製程式碼

方案2:訊息轉發

在iOS11 Base SDK直接採取最新的API並且配合Runtime的訊息轉發機制就能實現一行程式碼在不同版本作業系統下采取不同的訊息呼叫方式

  • UIScrollView+Forwarding.m
#import "UIScrollView+Forwarding.h"
#import "NSObject+AdapterViewController.h"

@implementation UIScrollView (Forwarding)

- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector { // 1
    
    NSMethodSignature *signature = nil;
    if (aSelector == @selector(setContentInsetAdjustmentBehavior:)) {
        signature = [UIViewController instanceMethodSignatureForSelector:@selector(setAutomaticallyAdjustsScrollViewInsets:)];
    }else {
        signature = [super methodSignatureForSelector:aSelector];
    }
    return signature;
}

- (void)forwardInvocation:(NSInvocation *)anInvocation { // 2
    
    BOOL automaticallyAdjustsScrollViewInsets  = NO;
    UIViewController *topmostViewController = [self cm_topmostViewController];
    NSInvocation *viewControllerInvocation = [NSInvocation invocationWithMethodSignature:anInvocation.methodSignature]; // 3
    [viewControllerInvocation setTarget:topmostViewController];
    [viewControllerInvocation setSelector:@selector(setAutomaticallyAdjustsScrollViewInsets:)];
    [viewControllerInvocation setArgument:&automaticallyAdjustsScrollViewInsets atIndex:2]; // 4
    [viewControllerInvocation invokeWithTarget:topmostViewController]; // 5
}

@end
複製程式碼
  • NSObject+AdapterViewController.m
#import "NSObject+AdapterViewController.h"

@implementation NSObject (AdapterViewController)

- (UIViewController *)cm_topmostViewController {
    UIViewController *resultVC;
    resultVC = [self cm_topViewController:[[UIApplication sharedApplication].keyWindow rootViewController]];
    while (resultVC.presentedViewController) {
        resultVC = [self cm_topViewController:resultVC.presentedViewController];
    }
    return resultVC;
}

- (UIViewController *)cm_topViewController:(UIViewController *)vc {
    if ([vc isKindOfClass:[UINavigationController class]]) {
        return [self cm_topViewController:[(UINavigationController *)vc topViewController]];
    } else if ([vc isKindOfClass:[UITabBarController class]]) {
        return [self cm_topViewController:[(UITabBarController *)vc selectedViewController]];
    } else {
        return vc;
    }
}

@end
複製程式碼

當我們在iOS10呼叫新API時,由於沒有具體對應API實現,我們將其原有的訊息轉發至當前棧頂UIViewController去呼叫低版本API。

關於[self cm_topmostViewController];,執行之後得到的結果可以檢視如下:

iOS開發·runtime原理與實踐: 訊息轉發篇(Message Forwarding) (訊息機制,方法未實現+API不相容奔潰,模擬多繼承)

方案2的整體流程

  1. 為即將轉發的訊息返回一個對應的方法簽名(該簽名後面用於對轉發訊息物件(NSInvocation *)anInvocation進行編碼用)

  2. 開始訊息轉發((NSInvocation *)anInvocation封裝了原有訊息的呼叫,包括了方法名,方法引數等)

  3. 由於轉發呼叫的API與原始呼叫的API不同,這裡我們新建一個用於訊息呼叫的NSInvocation物件viewControllerInvocation並配置好對應的target與selector

  4. 配置所需引數:由於每個方法實際是預設自帶兩個引數的:self和_cmd,所以我們要配置其他引數時是從第三個引數開始配置

  5. 訊息轉發

3.2.3 驗證對比新方案

注意測試的時候,選擇iOS10系統的模擬器進行驗證(沒有的話可以先Download Simulators),安裝完後如下如選擇:

iOS開發·runtime原理與實踐: 訊息轉發篇(Message Forwarding) (訊息機制,方法未實現+API不相容奔潰,模擬多繼承)

  • 不註釋並匯入UIScrollView+Forwarding類

iOS開發·runtime原理與實踐: 訊息轉發篇(Message Forwarding) (訊息機制,方法未實現+API不相容奔潰,模擬多繼承)

  • 註釋掉UIScrollView+Forwarding的功能程式碼

會如下圖所示奔潰:

iOS開發·runtime原理與實踐: 訊息轉發篇(Message Forwarding) (訊息機制,方法未實現+API不相容奔潰,模擬多繼承)

4. 總結

4.1 模擬多繼承

面試挖坑:OC是否支援多繼承?好,你說不支援多繼承,那你有沒有模擬多繼承特性的辦法?

轉發和繼承相似,可用於為OC程式設計新增一些多繼承的效果,一個物件把訊息轉發出去,就好像他把另一個物件中放法接過來或者“繼承”一樣。訊息轉發彌補了objc不支援多繼承的性質,也避免了因為多繼承導致單個類變得臃腫複雜。

雖然轉發可以實現繼承功能,但是NSObject還是必須表面上很嚴謹,像respondsToSelector:isKindOfClass:這類方法只會考慮繼承體系,不會考慮轉發鏈。

4.2 訊息機制總結

iOS開發·runtime原理與實踐: 訊息轉發篇(Message Forwarding) (訊息機制,方法未實現+API不相容奔潰,模擬多繼承)

Objective-C 中給一個物件傳送訊息會經過以下幾個步驟:

  1. 在物件類的 dispatch table 中嘗試找到該訊息。如果找到了,跳到相應的函式IMP去執行實現程式碼;

  2. 如果沒有找到,Runtime 會傳送 +resolveInstanceMethod: 或者 +resolveClassMethod: 嘗試去 resolve 這個訊息;

  3. 如果 resolve 方法返回 NO,Runtime 就傳送 -forwardingTargetForSelector: 允許你把這個訊息轉發給另一個物件;

  4. 如果沒有新的目標物件返回, Runtime 就會傳送-methodSignatureForSelector:-forwardInvocation: 訊息。你可以傳送 -invokeWithTarget: 訊息來手動轉發訊息或者傳送 -doesNotRecognizeSelector: 丟擲異常。

相關文章