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

本文Demo傳送門: MessageForwardingDemohtml

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

1.OC的方法與消息

在咱們開始使用消息機制以前,咱們能夠約定咱們的術語。例如,不少人不清楚「方法」與「消息」是什麼,但這對於理解消息傳遞系統如何在低級別工做相當重要。github

  • 方法:與一個類相關的一段實際代碼,並給出一個特定的名字。例:- (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. 若是仍是沒找到就要開始進入動態方法解析消息轉發,後面會說。

其中,爲何它被稱爲 「轉發」? 當某個對象沒有任何響應某個 消息 的操做就 「轉發」 該 消息。緣由是這種技術主要是爲了讓對象讓其餘對象爲他們處理 消息,從而 「轉發」。bash

消息轉發是一種功能強大的技術,能夠大大增長Objective-C的表現力。什麼是消息轉發?簡而言之,它容許未知的消息被困住並做出反應。換句話說,不管什麼時候發送未知消息,它​​都會以一個很好的包發送到您的代碼中,此時您能夠爲所欲爲地執行任何操做。app

1.3 OC的方法本質

OC中的方法默認被隱藏了兩個參數:self_cmd。你可能知道self是做爲一個隱式參數傳遞的,它最終成爲一個明確的參數。不爲人知的隱式參數_cmd(它保存了正在發送的消息的選擇器)是第二個這樣的隱式參數。總之,self指向對象自己,_cmd指向方法自己。舉兩個例子來講明:ide

  • 例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就可看到符號對應的含義,更詳細的官方文檔傳送門 在這裏,此處再也不列舉了。

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中複寫了父類的方法,會出現下面的警告:

解決辦法就是在Xcode的Build Phases中的資源文件裏,在對應的文件後面 -w ,忽略全部警告。

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它會自動添加檢查系統版本的代碼,以下圖所示:

方案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];,執行以後獲得的結果能夠查看以下:

方案2的總體流程

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

  2. 開始消息轉發((NSInvocation *)anInvocation封裝了原有消息的調用,包括了方法名,方法參數等)

  3. 因爲轉發調用的API與原始調用的API不一樣,這裏咱們新建一個用於消息調用的NSInvocation對象viewControllerInvocation並配置好對應的target與selector

  4. 配置所需參數:因爲每一個方法實際是默認自帶兩個參數的:self和_cmd,因此咱們要配置其餘參數時是從第三個參數開始配置

  5. 消息轉發

3.2.3 驗證對比新方案

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

  • 不註釋並導入UIScrollView+Forwarding類

  • 註釋掉UIScrollView+Forwarding的功能代碼

會以下圖所示奔潰:

4. 總結

4.1 模擬多繼承

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

轉發和繼承類似,可用於爲OC編程添加一些多繼承的效果,一個對象把消息轉發出去,就好像他把另外一個對象中放法接過來或者「繼承」同樣。消息轉發彌補了objc不支持多繼承的性質,也避免了由於多繼承致使單個類變得臃腫複雜。

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

4.2 消息機制總結

Objective-C 中給一個對象發送消息會通過如下幾個步驟:

  1. 在對象類的 dispatch table 中嘗試找到該消息。若是找到了,跳到相應的函數IMP去執行實現代碼;

  2. 若是沒有找到,Runtime 會發送 +resolveInstanceMethod: 或者 +resolveClassMethod: 嘗試去 resolve 這個消息;

  3. 若是 resolve 方法返回 NO,Runtime 就發送 -forwardingTargetForSelector: 容許你把這個消息轉發給另外一個對象;

  4. 若是沒有新的目標對象返回, Runtime 就會發送-methodSignatureForSelector:-forwardInvocation: 消息。你能夠發送 -invokeWithTarget: 消息來手動轉發消息或者發送 -doesNotRecognizeSelector: 拋出異常。

相關文章
相關標籤/搜索