Objective-C 格致余论 1 - Selector

Selector 作为 Objective-C 中一个非常重要的概念,虽然在 Swift 中几乎已经不见踪影,但是在某些混编的项目中仍旧起到举足轻重的作用,所以仍然有必要深入理解一下。


Selector 是什么

所谓对概念的掌握,一定是能够用简短的语言描述出核心含义。并且还要理解这个概念的应用场景和常见上下文,这才算是『掌握』。

在我的理解来看,所谓 selector,实际上是函数指针的一种实现形式,我们用一个 C string 来表示对象中的某个函数,所以就可以把这个函数作为参数,传到其他的方法中去进行调用。

为了理解 selector,就先要搞明白,Objective-C 中的 Object,或者说 Class,到底是什么。我们知道 C 语言中是没有『类』这个概念的,只有 struct,所以 Objective-C 的 Class 在编译时会变成 C struct,Class 中包含的方法也会转换成 C function。之后在运行的时候,runtime 会建立起从 Objective-C Method 到 C function 的映射(可以认为是一个 virtual table)。

举个例子,我们写了一个简单的类

@interface Wdx : NSObject {
int mode;
}
@end

编译之后会变成

typedef struct {
int mode;
} Wdx;

所以你会发现,其实用无论用 Objective-C 还是 C 的方式进行调用,都没有问题:

Wdx *wdx = [[Wdx alloc] init];
wdx->mode = 42;

具体发生了什么?其实也很简单。Runtime 会为每个类准备一个 virtual table,里面是一个个键值对,key 称为 selector,类型是 SEL,value 实际上是 C function 的函数指针,类型是 IMP。而这里的 SEL 类型实际上就是 C string,可以用下面语句来进行输出:

NSLog(@"%s", (char *)(@selector(doSomething)));

了解了这个之后,我们就会发现,实际上调用某个方法,至少有以下三种方式:

  1. 直接通过对象进行调用:[myObject doSomething];
  2. 通过 performSelector: 调用:[myObject performSelector:@selector(doSomething)];
    • 这里 performSelector 是基类 NSObject 的方法
  3. 使用 objc_msgSend 调用:objc_msgSend(myObject, @selector(doSomething), NULL);

这里一定要清楚的意识到,其实一个类中有什么方法,是在 runtime 里决定的,既然如此,肯定有某种方法动态添加方法(比如说 category 和 swift 中的 extension,不过这里不展开)

也是因为这个机制,所以编译的时候,即使编译器没有发现类中对应的方法,也只是会发出警告而已;甚至如果使用 performSelector: 的话,连警告的不会有,只有在运行的时候才会发生 unrecognized selector sent to instance 错误导致程序崩溃。

比方说,我们可以通过 class_addMethod 方法来动态给一个类添加方法:

void myMethodIMP(id self, SEL _cmd){
doSomething();
}
// 另一个文件
# import <objc/runtime.h>
class_addMethod([MyClass class], @selector(myMethod), (IMP)myMethodIMP, "v@:");

接下来就可以这么用

MyClass *myObejct = [[MyClass alloc] init];
[myObject myMethod];

当然,直接这么做 Xcode 会给出警告的,这里只是用来介绍基本原理,实际开发中除非确实需要,否则不要这么做

Selector 用在哪

因为 selector 可以看做是函数的另一个名字,所以很多需要调用函数或者建立连接的地方,都可以用到,以下是一些具体的使用场景

Target/Action 模式

这个模式非常常用,比方说我们新建的一个按钮,通过把 controller 中对应的 IBAction 方法和它连接,整个过程就是一个 Target/Action 模式,这个 controller 是 Button 的 target,而对应执行的方法,就是 action。

所以现在我们知道,其实在 storyboard 中做得代码和界面元素的连接,实际上就是建立一个 Target/Action 模式。不过 UIKit,也就是 iOS 上的 Target/Action 会稍微复杂一些(因为可以一次建立多个),这里用 AppKit 做例子(一次建立一个),比方说我们想要让一个按钮有自定义的行为,可以这么做:

@interface MyButton : NSView
{
id target;
SEL action;
}
@property (assign) IBOutlet id target;
@property (assign) SEL action;
@end
@implementation MyButton
- (void)mouseDown:(NSEvent *)e
{
[super mouseDown:e];
[target performSelector:action withObject:self];
}
@synthesize target, action;
@end

上面代码的意思是,在鼠标按下的时候,执行之前指定的 action,所以整个绑定的过程用下面的代码:

[(MyButton *)button setTarget:self];
[(MyButton *)button setAction:@selector(clickAction:)];

我们可以看到在 Objective-C 中是用字符串来作为不同方法的标识符,在 C 语言会直接传递指针,其他抽象层级更高的语言有不同的处理方式,比如说把一段代码当做字符串传递,然后使用的时候去 evaluate,也可以把函数本身看做对象,直接像传递对象一样使用(也就是匿名函数),Objective-C 中的匿名函数实际上就是 block,不过这里不展开。

检查 method 是否存在

这里一般用来配合向下兼容,比方说我们的代码需要调用一个新版本 iOS 才有的 API,那么最好先检查下对应方法是否存在,如果不存在,则做一些额外的处理,不然程序在较低版本的 iOS 就会崩溃,具体的检测方法也很简单,如:

BOOL scale = 1.0;
if ([[UIScreen mainScreen] respondsToSelector:@selector(scale)]){
scale = [UIScreen].scale;
}

不过一般来说,在其他语言里,try catch 的方法是最常用的处理 method 是否存在的方式,不过因为 Objective-C 不算原生支持垃圾回收,try catch 可能会导致比较严重的内存泄露。好在 iOS 通过 runtime 提供了 ARC(Automatic Reference Conter) 来管理内存。

传统 Objective-C 使用 auto-release 的机制来释放内存(然而并不算太『自动』),会把要释放的内存放到下一轮 runloop 进行释放,这也是为什么不建议使用 try catch,因为实际上 try catch 是程序流中的非可控跳转,跳出了原来的 runloop,就会导致原来应该释放的内存没有释放。

Timer

另一个常见的用法是延迟调用某个方法,我们可以使用 NSObject 中的 performSelector:withObject:afterDelay: 在一定的延迟后调用某个方法,如:

[self performSelector:@selector(doSomething) withObject:nil afterDelay:1.0];

甚至还可以在方法执行之前取消方法的执行,如:

[NSObject cancelPreviousPerformRequestsWithTarget:self];

具体的实现方法可以是通过 NSTimer,如:

# timer 要做的事情
- (void)doSomething:(NSTimer *)timer
{
// Do Something
}

然后通过 doSomething: 的 selector 来建立 timer:

NSTimer *timer = [NSTimer scheduledTimerWithTimeInterval:1.0
target:someObject
selector:@selector(doSomething:)
userInfo:nil
repeats:YES];

也可以通过 NSInvocation 来进行调用,实际上是把 target, action 和参数这三个东西包装成一个对象,然后进行调用,如:

NSMethodSignature *sig = [MyClass instanceMethodSignatureForSelector:
@selector(doSomething:)];
NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:sig];
[invocation setTarget:someObject];
[invocation setSelector:@selector(doSomething:)];
[invocation setArgument:&anArgument atIndex:2];
NSTimer *timer = [NSTimer scheduledTimerWithTimeInterval:1.0
invocation:invocation
repeats:YES];

注意设置参数位置的时候要从 2 开始,参数 0 是 self, 参数 1 是 selector。

NSNotification

通知的机制其实也依赖于 selector,相当于指定一个回调函数在接收到通知的时候执行对应的操作,这部分后面会专门介绍,这里就不过分展开了。

在线程中执行方法

NSObject 实际上还有很多多线程执行的方法,如:

一般来说,如果一个操作需要的时间比较多,应该放到不同线程去执行

[self performSelectorInBackground:@selector(doSomething) withObject:nil];

注意在线程中需要建立 auto-release pool,执行完毕之后通过 performSelectorOnMainThread:withObjectwaitUntilDone: 通知主线程,如

- (void)doSomething
{
@autoreleasepool{
// 在这里执行需要时间比较久的工作
[self performSelectorOnMainThread:@selector(doAnotherThing)
withObject:nil
waitUntilDone:NO];
}
}

数组排序

我们实际上也可以把一个 comparator 传给排序函数,NSString, NSDate, NSNumber, NSIndexPath 都有 compare: 方法,可以用来进行排序,比较有用的方法是 localizedCompare:,会自动根据当前语言来排序。

这里简单列出两个例子:

NSArray *sortedArray = [anArray sortedArrayUsingSelector:@selector(localizedCompare:)];

也可以利用 selector 让数组中的每个元素都做一次指定的操作:

[anArray makeObjectsPerformSelector:@selector(doSomething)];

代替 if else / switch

因为 selector 其实就是 C string,所以可以放在数组或者字典中备用,因为如此可以用来做条件选择来代替 if else 或者 switch,比如下面这个例子:

switch(condition) {
case 0:
[object doSomething];
break;
case 1:
[object doAnotherThing];
break;
default:
break;
}
// 可以改写为
[object performSelector:NSSelectorFromString(@[@"doSomething", @"doAnotherThing"][condition])];

调用私有 API

这里的私有 API,值得就是官方文档里没有说明,但是实际上可以通过 performSelector: 来调用的内部方法,不过如果打算上架的话,是不能使用的,可以平时用来测试或了解系统本身的运作机制。

注意事项

使用 selector 时也有一些需要注意的地方,比方说我们使用 super 来调用父类的方法:

[super doSomething];

会执行父类的 doSomething 方法,而如果是用

[super performSelector:@selector(doSomething)];

实际的效果等同于 [self doSomething]

优势与限制

Selector 的机制本身有其便捷性,但是反过来,也造成了一定的限制。

在 Objective-C 中,一个对象的方法都会保存在 virtual table 中,而因为这个表是在 runtime 决定的,所以其实是非常动态的,比如说不用继承就可以增加方法,或者是直接交换不同的 selector 的指向(也就是 method swizzling)。

但是因为一个 selector 能且仅能对应一个方法,所以不会有 C++, Java, C# 中的重载功能,这也是为什么 Objective-C 的函数名称普遍比较长,毕竟不能根据参数列表来具体判断要执行的函数,只能在起名字上下功夫了。如果有同一个名称的方法,那么新的会覆盖旧的。

在 Objective-C 中,我们调用某个方法,实际上是在 virtual table 中找寻对应这个 selector 的方法,而 C++ 或 Java 则是直接指定执行 vitual table 中的某个方法。问题就来了,每次函数调用都要查表,那效率肯定不会很高,这也是为什么之前 Objective-C 一直不流行的原因。

不过这样做也有好处,实际上 Objective-C 没有所谓固定版本的 runtime,只要 selector 不变,通过查表一样可以找到,新系统不用保留旧系统的库,避免了 C++ 等语言中 dll 地狱的问题

而最新的 Swift 中,实际上苹果放弃了这个做法,选择了和 C++, Java 类似的设计,这也是为什么 Swift 的性能反而会更好一些的原因,不过这样一样,就需要包含对应版本的 swift runtime 了,这也是为什么 swift 会出现更多因为版本不一致而导致的兼容问题的原因。

参考资料

  1. KKBOX iOS/Mac OS X 基本开发教材
  2. Programming with Objective-C
  3. Objective-C Runtime Programming Guide
  4. Cocoa Core Competencies - Selector
  5. Understanding the Objective-C Runtime
捧个钱场?