iOS開發runtime原理與實踐: 消息轉發篇(Message Forwarding) (類,對象

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

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

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

  1. 類,對象,方法

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

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

1.1 OC的方法與C的函數

Objective-C方法最終被生成爲C函數,並帶有一些額外的參數。Objective-C中的方法默認被隱藏了兩個參數:self和_cmd。你可能知道self是作爲一個隱式參數傳遞的,它最終成爲一個明確的參數。鮮爲人知的隱式參數_cmd(它保存了正在發送的消息的選擇器)是第二個這樣的隱式參數。總之,self指向對象本身,_cmd指向方法本身。舉兩個例子來說明:

例1:- (NSString *)name
這個方法實際上有兩個參數:self和_cmd。

例2:- (void)setValue:(int)val
這個方法實際上有三個參數:self,_cmd 和 val。

在編譯時你寫的 Objective-C 函數調用的語法都會被翻譯成一個 C 的函數調用 objc_msgSend() 。比如,下面兩行代碼就是等價的:

OC
[array insertObject:foo atIndex:5];

C

objc_msgSend(array, @selector(insertObject:atIndex:), foo, 5);

1.2 類,對象,方法的C表達

在 Objective-C 中,類、對象和方法都是一個 C 的結構體,從 objc/runtime.h 以及 objc/objc.h頭文件中,我們可以找到他們的定義:

objc_class

struct objc_class {
Class _Nonnull isa OBJC_ISA_AVAILABILITY;
#if !OBJC2
Class _Nullable super_class OBJC2_UNAVAILABLE;
const char _Nonnull name OBJC2_UNAVAILABLE;
long version OBJC2_UNAVAILABLE;
long info OBJC2_UNAVAILABLE;
long instance_size OBJC2_UNAVAILABLE;
struct objc_ivar_list
_Nullable ivars OBJC2_UNAVAILABLE;
struct objc_method_list _Nullable _Nullable methodLists OBJC2_UNAVAILABLE;
struct objc_cache _Nonnull cache OBJC2_UNAVAILABLE;
struct objc_protocol_list
_Nullable protocols OBJC2_UNAVAILABLE;
#endif
} OBJC2_UNAVAILABLE;
/ Use Class instead of `struct objc_class ` */

objc_object

/// Represents an instance of a class.
struct objc_object {
Class _Nonnull isa OBJC_ISA_AVAILABILITY;
};

objc_method

struct objc_method {
SEL _Nonnull method_name OBJC2_UNAVAILABLE;
char * _Nullable method_types OBJC2_UNAVAILABLE;
IMP _Nonnull method_imp OBJC2_UNAVAILABLE;
} OBJC2_UNAVAILABLE;

objc_method_list

struct objc_method_list {
struct objc_method_list _Nullable obsolete OBJC2_UNAVAILABLE;
int method_count OBJC2_UNAVAILABLE;
#ifdef LP64
int space OBJC2_UNAVAILABLE;
#endif
/
variable length structure */
struct objc_method method_list[1] OBJC2_UNAVAILABLE;
}

1.3 消息發送

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

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

  1. 動態特性:方法解析和消息轉發

沒有方法的實現,程序會在運行時掛掉並拋出 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

消息轉發機制執行前,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:方法,我們可以將消息轉發給其它的對象。

  1. 應用實戰:消息轉發

3.1 特定奔潰預防處理

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

Test2ViewController

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

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的整體流程:

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

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

由於轉發調用的API與原始調用的API不同,這裏我們新建一個用於消息調用的NSInvocation對象viewControllerInvocation並配置好對應的target與selector

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

消息轉發

3.2.3 驗證對比新方案

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

不註釋並導入UIScrollView+Forwarding類

註釋掉UIScrollView+Forwarding的功能代碼

會如下圖所示奔潰:

  1. 總結

4.1 模擬多繼承

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

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

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

4.2 消息機制總結

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

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

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

如果 resolve 方法返回 NO,Runtime 就發送 -forwardingTargetForSelector: 允許你把這個消息轉發給另一個對象;

如果沒有新的目標對象返回, Runtime 就會發送-methodSignatureForSelector: 和 -forwardInvocation: 消息。你可以發送 -invokeWithTarget: 消息來手動轉發消息或者發送 -doesNotRecognizeSelector: 拋出異常。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章