Objective-C按需弃用消息的动态派发

2,273 阅读6分钟

0. 引言

本文简单介绍了Objective-C的消息动态派发,以及某些情况下为什么我们需要弃用这个特性,从而带来一些正向的收益。

1. 消息的动态派发

Objective-C语言是一门动态语言,它将很多静态语言在编译和链接时期做的事放到了运行时来处理,使得我们代码更具灵活性,例如我们可以把消息转发给我们想要的对象,或者随意交换一个方法的实现等。

当一条消息被发送到一个实例对象时:

  1. 通过对象的 isa 指针找到类结构体,在该类结构中查找分派表中的方法选择器。
  2. 如果找不到选择器, objc_msgSend 将找到父类的类结构体,在父类结构中查找分派表中的方法选择器。
  3. 如果一直找不到,继续查找父类直到 NSObject 类。
  4. 一旦找到选择器,函数就会调用表中的方法,并将接收对象的数据结构传递给它。

为了加快消息传递过程,运行时系统会在 使用方法时缓存方法的选择器和地址

每个类都有一个单独的缓存,它可以包含继承方法和类中定义的方法的选择器。在搜索分派表之前,消息传递例程首先检查接收对象类的缓存。如果方法选择器在缓存中,则消息传递仅比函数调用稍慢。一旦程序运行了足够长的时间来“预热”其缓存,它发送的几乎所有消息都会找到一个缓存方法。当程序运行时,缓存会动态增长以容纳新消息。

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 ,节约了 6759=867-59=8 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:]; ,那么就会有 602=12060*2=120objc_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:]; 调用,那么此时只会有 60+1=6160+1=61objc_msgSend ,从而节约了大量代码。

我们对比之前两个MachO文件也可以看到 viewDidLoad 方法的大小从 0xB02F 下降到了 0x901C

(0xB02F0x901C)/1024/8=1 KB(0xB02F-0x901C) / 1024 / 8 = 1\ KB

3. 总结

在下面两种情况下可以考虑进行代码优化:

  • 某一个方法会被大量反复调用时

    • 直接获取IMP直接调用,不使用消息发送
  • 长调用反复在代码中出现,我们又不需要使用其动态性时

    • 例如,类似 self.listData.firstObject.user.IDself.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;
        }
        
      • 不需要使用 settergetter 方法时,使用成员变量直接访问(底层访问为地址偏移)

        _name = @"Name";
        NSString *name = _name;
        

虽然这些方法单个出现时可能提升有限,但一个大项目,日积月累还是能产生巨大的提升效果。

大家如果还有其他小技巧,也欢迎在评论区分享哦~


如果觉得本文对你有所帮助,给我点个赞吧~ 👍🏻