0. 引言
本文简单介绍了Objective-C的消息动态派发,以及某些情况下为什么我们需要弃用这个特性,从而带来一些正向的收益。
1. 消息的动态派发
Objective-C语言是一门动态语言,它将很多静态语言在编译和链接时期做的事放到了运行时来处理,使得我们代码更具灵活性,例如我们可以把消息转发给我们想要的对象,或者随意交换一个方法的实现等。
当一条消息被发送到一个实例对象时:
- 通过对象的 isa 指针找到类结构体,在该类结构中查找分派表中的方法选择器。
- 如果找不到选择器, objc_msgSend 将找到父类的类结构体,在父类结构中查找分派表中的方法选择器。
- 如果一直找不到,继续查找父类直到 NSObject 类。
- 一旦找到选择器,函数就会调用表中的方法,并将接收对象的数据结构传递给它。
为了加快消息传递过程,运行时系统会在 使用方法时缓存方法的选择器和地址 。
每个类都有一个单独的缓存,它可以包含继承方法和类中定义的方法的选择器。在搜索分派表之前,消息传递例程首先检查接收对象类的缓存。如果方法选择器在缓存中,则消息传递仅比函数调用稍慢。一旦程序运行了足够长的时间来“预热”其缓存,它发送的几乎所有消息都会找到一个缓存方法。当程序运行时,缓存会动态增长以容纳新消息。
2. 按需弃用消息的动态派发
2.1 耗时问题
2.1.1 耗时示例
上面介绍了动态派发的过程,这个过程是需要耗时的。虽然系统为了加快消息传递的过程,会做一些缓存,但还是耗时还是存在的。大部分情况下我们可以忽略这部分耗时,但如果我们需要反复调用某一个方法时,还是可以考虑使用静态调用。
这里用 [[NSUserDefaults standardUserDefaults]setInteger:forKey:]; 为例,调用10w次并打印耗时:
- (void)viewDidLoad {
[super viewDidLoad];
[self msgSend];
[self imp];
}
- (void)msgSend {
CFAbsoluteTime start = CFAbsoluteTimeGetCurrent();
NSString *key = @"DemoIntegerKey";
for (int i = 0; i < 100000; i++) {
[[NSUserDefaults standardUserDefaults] setInteger:i forKey:key];
}
NSLog(@"%d", (int)[[NSUserDefaults standardUserDefaults] integerForKey:key]);
CFAbsoluteTime end = CFAbsoluteTimeGetCurrent();
NSLog(@"%f", end - start);
}
- (void)imp {
NSUserDefaults *userDefaults = [NSUserDefaults standardUserDefaults];
SEL sel = @selector(setInteger:forKey:);
void(*imp)(id, SEL, NSInteger, NSString *) = (void(*)(id, SEL, NSInteger, NSString *))[userDefaults methodForSelector:sel];
CFAbsoluteTime start = CFAbsoluteTimeGetCurrent();
NSString *key = @"DemoIntegerKey";
for (int i = 0; i < 100000; i++) {
imp(userDefaults, sel, i, key);
}
NSLog(@"%d", (int)[[NSUserDefaults standardUserDefaults] integerForKey:key]);
CFAbsoluteTime end = CFAbsoluteTimeGetCurrent();
NSLog(@"%f", end - start);
}
// 输出
2020-08-01 19:50:49.571729+0800 CodeDemo[11169:7332544] 99999
2020-08-01 19:50:49.571848+0800 CodeDemo[11169:7332544] 14.801419
2020-08-01 19:51:03.651570+0800 CodeDemo[11169:7332544] 99999
2020-08-01 19:51:03.651680+0800 CodeDemo[11169:7332544] 14.079725
从输出可以看到从动态派发转为静态调用,逻辑上是不会存在问题的,调用10w次可以带来0.8s的收益。
测试信息:
Xcode 11.6
iPhone 11 Pro模拟器
MacBook Pro (13-inch, 2016)
- 处理器 2 GHz 双核Intel Core i5
- 内存 8 GB 1867 MHz LPDDR3
2.1.2 +load
说到这里,可以顺带说一个话题 +load 的调用。
我们可能看过一些文章说“ +load 会在程序启动的时候进行调用,+initialize 会在第一次使用类的时候进行调用”。
有些同学可能疑惑,第一次使用类 不是在调用 +load 的时候么?
其实,这个说法是不准确的,应该是 第一次给类发消息的时候,在方法查找或转发过程中,会触发+initialize ,如下面代码所示。
IMP lookUpImpOrForward(Class cls, SEL sel, id inst,
bool initialize, bool cache, bool resolver)
{
...
// 第一次调用当前类的话,执行initialize的代码
if (slowpath((behavior & LOOKUP_INITIALIZE) && !cls->isInitialized())) {
// 对类进行初始化,并开辟内存空间
cls = initializeAndLeaveLocked(cls, inst, runtimeLock);
// runtimeLock may have been dropped but is now locked again
}
...
return imp;
}
而 +load 则是直接使用静态调用的方式。
typedef void(*load_method_t)(id, SEL);
static void call_class_loads(void)
{
int i;
// Detach current loadable list.
struct loadable_class *classes = loadable_classes;
int used = loadable_classes_used;
loadable_classes = nil;
loadable_classes_allocated = 0;
loadable_classes_used = 0;
// Call all +loads for the detached list.
// 遍历loadable_class,获取到+load的IMP,然后直接调用
for (i = 0; i < used; i++) {
Class cls = classes[i].cls;
load_method_t load_method = (load_method_t)classes[i].method;
if (!cls) continue;
if (PrintLoading) {
_objc_inform("LOAD: +[%s load]\n", cls->nameForLogging());
}
(*load_method)(cls, @selector(load)); // imp直接调用
}
// Destroy the detached list.
if (classes) free(classes);
}
注意:
+load 方法的调用是通过直接使用函数内存地址的方式实现的。
这就意味着,类、父类与分类之间+load方法的调用是互不影响的。
子类不会主动调用父类的 +load 方法,如果类与分类都实现了 +load ,那么两个 +load 方法都会分别被调用。
2.2 二进制大小
2.2.1 二进制大小变化的示例
我们还是用 NSUserDefaults 的相关代码为例。项目中,我们很多轻量级的持久化是使用 NSUserDefaults 完成的,例如下面的代码:
[[NSUserDefaults standardUserDefaults] setInteger:0 forKey:@"XXXKey"];
但这样的代码一旦多了之后,就会对二进制代码的大小产生影响。例如下面的6行 set 代码,我们重复20次,一共120条调用,然后查看包大小。
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
[[NSUserDefaults standardUserDefaults] setInteger:0 forKey:@"CodeDemoInteger0Key"];
[[NSUserDefaults standardUserDefaults] setInteger:1 forKey:@"CodeDemoInteger1Key"];
[[NSUserDefaults standardUserDefaults] setInteger:2 forKey:@"CodeDemoInteger2Key"];
[[NSUserDefaults standardUserDefaults] setBool:YES forKey:@"CodeDemoBool0Key"];
[[NSUserDefaults standardUserDefaults] setBool:YES forKey:@"CodeDemoBool1Key"];
[[NSUserDefaults standardUserDefaults] setBool:YES forKey:@"CodeDemoBool2Key"];
... x 20
}
@end
可以看到这时的MachO文件是 67KB 。
我们尝试将 [NSUserDefaults standardUserDefaults] 抽出来再来看一下:
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
NSUserDefaults *userDefaults = [NSUserDefaults standardUserDefaults];
[userDefaults setInteger:0 forKey:@"CodeDemoInteger0Key"];
[userDefaults setInteger:1 forKey:@"CodeDemoInteger1Key"];
[userDefaults setInteger:2 forKey:@"CodeDemoInteger2Key"];
[userDefaults setBool:YES forKey:@"CodeDemoBool0Key"];
[userDefaults setBool:YES forKey:@"CodeDemoBool1Key"];
[userDefaults setBool:YES forKey:@"CodeDemoBool2Key"];
... x 20
}
可以看到这时的MachO文件是 59KB ,节约了 KB 。
测试信息:
Debug包
Xcode 11.6
iPhone 11 Pro模拟器
MacBook Pro (13-inch, 2016)
- 处理器 2 GHz 双核Intel Core i5
- 内存 8 GB 1867 MHz LPDDR3
2.2.2 二进制大小变化的原因
我们看一看只调用一次 [[NSUserDefaults standardUserDefaults]setInteger:forKey:]; 的情况。
// main.m
#import <Foundation/Foundation.h>
int main(int argc, char * argv[]) {
[[NSUserDefaults standardUserDefaults] setInteger:0 forKey:@"CodeDemoInteger0Key"];
return 1;
}
我们对上面的代码进行Clang重写,在终端中输入:
$ xcrun -sdk iphoneos clang -arch arm64 -w -rewrite-objc -fobjc-arc -mios-version-min=8.0.0 -fobjc-runtime=ios-8.0.0 main.m
在得到的 main.cpp 文件中会看到下面的代码:
// main.cpp
int main(int argc, char * argv[]) {
((void (*)(id, SEL, NSInteger, NSString * _Nonnull __strong))(void *)objc_msgSend)((id)((NSUserDefaults * _Nonnull (*)(id, SEL))(void *)objc_msgSend)((id)objc_getClass("NSUserDefaults"), sel_registerName("standardUserDefaults")), sel_registerName("setInteger:forKey:"), (NSInteger)0, (NSString *)&__NSConstantStringImpl__var_folders_n5_gxnsjp4916v3h9jltxf49yqr0000gp_T_ViewController_bc73cd_mi_0);
return 1;
}
我们化简一下:
// main.cpp
int main(int argc, char * argv[]) {
objc_msgSend(objc_msgSend(objc_getClass("NSUserDefaults"), sel_registerName("standardUserDefaults")), sel_registerName("setInteger:forKey:"), (NSInteger)0, (NSString *)&__NSConstantStringImpl__var_folders_n5_gxnsjp4916v3h9jltxf49yqr0000gp_T_ViewController_df801d_mi_0);
return 1;
}
可以看到底层代码会执行2个 objc_msgSend ,如果我们写了60个 [[NSUserDefaults standardUserDefaults]setInteger:forKey:]; ,那么就会有 个 objc_msgSend 。
如果我们优化一下:
// main.m
static NSString *Integer0Key = @"CodeDemoInteger0Key";
int main(int argc, char * argv[]) {
NSUserDefaults *userDefaults = [NSUserDefaults standardUserDefaults];
[userDefaults setInteger:0 forKey:Integer0Key];
[userDefaults setInteger:0 forKey:Integer0Key];
return 1;
}
// 简化后的main.cpp
static NSString *Integer0Key = (NSString *)&__NSConstantStringImpl__var_folders_n5_gxnsjp4916v3h9jltxf49yqr0000gp_T_ViewController_caa7f7_mi_0;
int main(int argc, char * argv[]) {
NSUserDefaults *userDefaults = objc_msgSend(objc_getClass("NSUserDefaults"), sel_registerName("standardUserDefaults"));
objc_msgSend(userDefaults, sel_registerName("setInteger:forKey:"), (NSInteger)0, (NSString *)Integer0Key);
objc_msgSend(userDefaults, sel_registerName("setInteger:forKey:"), (NSInteger)0, (NSString *)Integer0Key);
return 1;
}
可以看到我们将一个objc_msgSend 的返回值抽出一个本地变量,然后再进行调用,那么久可以节约很多重复代码。如果按原来的需求,有60个 [[NSUserDefaults standardUserDefaults]setInteger:forKey:]; 调用,那么此时只会有 个 objc_msgSend ,从而节约了大量代码。
我们对比之前两个MachO文件也可以看到 viewDidLoad 方法的大小从 0xB02F 下降到了 0x901C 。
3. 总结
在下面两种情况下可以考虑进行代码优化:
-
某一个方法会被大量反复调用时
- 直接获取IMP直接调用,不使用消息发送
-
长调用反复在代码中出现,我们又不需要使用其动态性时
-
例如,类似 self.listData.firstObject.user.ID 、 self.listData.firstObject.user.name 这种长调用,会反复在代码中出现时
-
如果是在同一个函数中,可以先获取到最长公共部分,再分别进行调用
User *user = self.listData.firstObject.user; NSInteger ID = user.ID; NSString *name = user.name;
-
如果是在多个函数中,可以考虑写一个 getter 来获取这个值
- (User *)firstUser { return self.listData.firstObject.user; }
-
不需要使用 setter 、 getter 方法时,使用成员变量直接访问(底层访问为地址偏移)
_name = @"Name"; NSString *name = _name;
-
-
虽然这些方法单个出现时可能提升有限,但一个大项目,日积月累还是能产生巨大的提升效果。
大家如果还有其他小技巧,也欢迎在评论区分享哦~
如果觉得本文对你有所帮助,给我点个赞吧~ 👍🏻