iOS消息轉發
消息轉發是一種功能強大的技術,可以大大增加Objective-C的表現力。什麽是消息轉發?簡而言之,它允許未知的消息被困住並作出反應。換句話說,無論何時發送未知消息,它??都會以一個很好的包發送到您的代碼中,此時您可以隨心所欲地執行任何操作。
為什麽它被稱為 “轉發”? 當某個對象沒有任何響應某個 消息 的操作就 “轉發” 該 消息。原因是這種技術主要是為了讓對象讓其他對象為他們處理 消息,從而 “轉發”。
1. 類,對象,方法
在我們開始使用消息機制之前,我們可以約定我們的術語。例如,很多人不清楚“方法”與“消息”是什麽,但這對於理解消息傳遞系統如何在低級別工作至關重要。
- 方法:與一個類相關的一段實際代碼,並給出一個特定的名字。例:
- (int)meaning { return 42; }
- 消息:發送給對象的名稱和一組參數。示例:向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類為止。
- 如果還是沒找到就要開始進入動態方法解析,後面會說
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
消息轉發機制執行前,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:
方法,我們可以將消息轉發給其它的對象。
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叠代造成的奔潰處理
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過時導致奔潰的代碼:
- 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的功能代碼
會如下圖所示奔潰:
4. 總結
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:
拋出異常。
iOS消息轉發