亮剑 - Stinger 是如何在速度上吊打 Aspects 的

前言

Aspects 是 iOS 老牌的 AOP 库,通过替换原方法函数指针为 _objc_msgForward_objc_msgForward_stret 以手动触发消息转发。同时把被Hook类的 -(void)forwardInvocation:(NSInvocation *)invocation 方法的函数指针替换为参数对齐的C函数__ASPECTS_ARE_BEING_CALLED__(NSObject *self, SEL selector, NSInvocation *invocation),在该函数里通过invocation执行原方法实现和前后数个切面block。

Stinger 是饿了么开源的 AOP 库, 没有使用手动消息转发。解析原方法签名,使用 libffi 中的ffi_closure_alloc 构造与原方法参数一致的"函数" -- _stingerIMP ,以替换原方法函数指针;此外,生成了原方法和Block的调用的参数模板 cif 和 blockCif。方法调用时,最终会调用到 void _st_ffi_function(ffi_cif *cif, void *ret, void **args, void *userdata) , 在该函数内,可获取到方法调用的所有参数、返回值位置,主要通过 ffi_call 根据 cif 调用原方法实现和切面block。

两个库的 API 是相似的, 都支持 hook 类的实例方法和类方法,添加多个切面代码块;并支持针对单个实例对象进行方法级别的 hook。

近日,Stinger 发布了 0.2.8 版本,支持了被 hook 方法的参数和返回值为结构体;在从消息发出到原方法实现、所有切面 Block 执行完成的速度也有数倍的提升(PS: 之前版本本来也比 Aspects 快好几倍😀😁)。这篇文章就是向 Aspects 亮剑,Stinger 最终到底能比 Aspects 快多少?请看以下测试。

速度测试

1.设备与环境

  • 测试设备:iPhone 7,iOS 13.2
  • Xcode:Version 11.3 (11C29)
  • Stinger:https://github.com/eleme/Stinger 0.2.8
  • Aspects:https://github.com/steipete/Aspects 1.4.1

2.测试场景

对于一个空方法,hook 该方法,在前后各增加一个空的切面 Block。执行该方法 1000000 次。

3.测试方式

release 模式下,针对每个 case,使用 Xcode 单元测试中的 - (void)measureBlock:(XCT_NOESCAPE void (^)(void))block 测试10次,记录每次的执行时间,单位为s,并计算平均值。

4.Test Case

case 0:"皮儿"

为了减少不必要的影响,我们测下 for 循环执行 1000000 次这个"皮儿"的执行时间。

测试代码

- (void)testBlank {
  [self measureBlock:^{
    for (NSInteger i = 0; i < 1000000; i++) {
    }
  }];
}

测试结果

image.png
AVG 1 2 3 4 5 6 7 8 9 10
0.000114 0.000175 0.000113 0.000113 0.000104 0.000153 0.000102 0.0000999 0.0000936 0.000094 0.000094

可以看到, for 循环执行 1000000 次的执行时间在 0.0001s 的数量级,对比发现,对后续的测试结果可以说几乎没影响。

现在,我们来测下实际的 case.

* 额外代码准备

先列下被测试类的代码。这里我们新建了一个类,实现一些空方法。

@interface TestClassC : NSObject
- (void)methodBeforeA;
- (void)methodA;
- (void)methodAfterA;
- (void)methodA1;
- (void)methodB1;
- (void)methodA2;
- (void)methodB2;
- (void)methodA3:(NSString *)str num:(double)num rect:(CGRect)rect;
- (void)methodB3:(NSString *)str num:(double)num rect:(CGRect)rect;
...
@end

@implementation TestClassC
- (void)methodBeforeA {
}
- (void)methodA {
}
- (void)methodAfterA {
}
- (void)methodA1 {
}
- (void)methodB1 {
}
- (void)methodA2 {
}
- (void)methodB2 {
}
- (void)methodA3:(NSString *)str num:(double)num rect:(CGRect)rect {
}
- (void)methodB3:(NSString *)str num:(double)num rect:(CGRect)rect {
}
...
@end

Case1: 针对特定类的某个方法的 hook

这里分别使用 Stinger 和 Aspects 对 TestClassC类 的实例方法 - (void)methodA1 - (void)methodB1 前后各增加一个切面 block。测量实例对象执行1000000 次方法的时间。

测试代码

Stinger

- (void)testStingerHookMethodA1 {
  [TestClassC st_hookInstanceMethod:@selector(methodA1) option:STOptionBefore usingIdentifier:@"hook methodA1 before" withBlock:^(id<StingerParams> params) {
     }];
  [TestClassC st_hookInstanceMethod:@selector(methodA1) option:STOptionAfter usingIdentifier:@"hook methodA1 After" withBlock:^(id<StingerParams> params) {
  }];

  TestClassC *object1 = [TestClassC new];
  [self measureBlock:^{
    for (NSInteger i = 0; i < 1000000; i++) {
      [object1 methodA1];
    }
  }];
}

Aspects

- (void)testAspectHookMethodB1 {
  [TestClassC aspect_hookSelector:@selector(methodB1) withOptions:AspectPositionBefore usingBlock:^(id<AspectInfo> params) {
   } error:nil];
  [TestClassC aspect_hookSelector:@selector(methodB1) withOptions:AspectPositionAfter usingBlock:^(id<AspectInfo> params) {
  } error:nil];

  TestClassC *object1 = [TestClassC new];
  [self measureBlock:^{
    for (NSInteger i = 0; i < 1000000; i++) {
      [object1 methodB1];
    }
  }];
}

测试结果

Stinger

image.png
AVG 1 2 3 4 5 6 7 8 9 10
0.283 0.368 0.273 0.277 0.273 0.271 0.271 0.272 0.271 0.273 0.270

Aspects

image.png
AVG 1 2 3 4 5 6 7 8 9 10
6.135 6.34 6.19 6.12 6.19 6.11 6.1 6.12 6.12 6.09 6.1

结论

这个case,Stinger的执行速度是Aspects的21倍多。

在本case,我们测试了无需任何参数的方法的 Hook,在其他 case 中,也测试了有参数、无返回值,无参数、有返回值,有参数、有返回值的情况。Stinger的执行速度均为 Aspects 的 15-22 倍. 更多 case,请参阅: github.com/eleme/Sting…

Case2: 针对特定实例对象的某个方法的 hook

这里分别使用 Stinger 和 Aspects 对 TestClassC的一个实例 的实例方法 - (void)methodA2 - (void)methodB2 前后各增加一个切面 block。测量该实例对象执行 1000000 次方法的时间。

测试代码

Stinger

- (void)testStingerHookMethodA2 {
  TestClassC *object1 = [TestClassC new];
  [object1 st_hookInstanceMethod:@selector(methodA2) option:STOptionBefore usingIdentifier:@"hook methodA2 before" withBlock:^(id<StingerParams> params) {
     }];
  [object1 st_hookInstanceMethod:@selector(methodA2) option:STOptionAfter usingIdentifier:@"hook methodA2 After" withBlock:^(id<StingerParams> params) {
  }];

  [self measureBlock:^{
    for (NSInteger i = 0; i < 1000000; i++) {
      [object1 methodA2];
    }
  }];
}

Aspects

- (void)testAspectHookMethodB2 {
  TestClassC *object1 = [TestClassC new];
  [object1 aspect_hookSelector:@selector(methodB2) withOptions:AspectPositionBefore usingBlock:^(id<AspectInfo> params) {
   } error:nil];
  [object1 aspect_hookSelector:@selector(methodB2) withOptions:AspectPositionAfter usingBlock:^(id<AspectInfo> params) {
  } error:nil];

  [self measureBlock:^{
    for (NSInteger i = 0; i < 1000000; i++) {
      [object1 methodB2];
    }
  }];
}

测试结果

Stinger

image.png
AVG 1 2 3 4 5 6 7 8 9 10
0.547 0.567 0.546 0.543 0.556 0.543 0.542 0.545 0.54 0.544 0.542

Aspects


image.png
AVG 1 2 3 4 5 6 7 8 9 10
6.261 6.32 6.24 6.34 6.25 6.25 6.23 6.24 6.26 6.23 6.24

结论

这个 case,Stinger 的执行速度是 Aspects 的 11 倍多.

case3:method-swizzing

这里模拟使用 method-swizzing 方式对 TestClassC 类的实例方法 - (void)methodA 前后各调用一个方法。测量实例对象执行 1000000 次方法的时间。

测试代码

- (void)testMethodA {
  TestClassC *object1 = [TestClassC new];
  [self measureBlock:^{
    for (NSInteger i = 0; i < 1000000; i++) {
      [object1 methodBeforeA];
      [object1 methodA];
      [object1 methodAfterA];
    }
  }];
}

测试结果

image.png
AVG 1 2 3 4 5 6 7 8 9 10
0.015 0.0219 0.0149 0.0149 0.0141 0.0148 0.0153 0.0147 0.013 0.0146 0.0116

结论

这个 case,原始 method-swizzing 是Stinger的执行速度的大约18倍;是 Aspects 的执行速度大约 409 倍;

4. 测试结论

  • 在针对类的 hook 中,从发送消息到执行完原始实现和前后切面 block,Stinger 比 Aspects 大约快15到22倍.
  • 在针对特定实例对象的 hook 中,从发送消息到执行完原始实现和前后切面 block,Stinger 比 Aspects 大约快10倍.
  • 意料之中,朴素的 method-swizzing 比两个AOP库都要快。

分析Aspects和Stinger的速度

分析方式

与上面case类似,HooK空方法前后各增加一个空的切面 block,执行 1000000 次,使用 instrument 中的 time profile 分析(隐藏系统函数和倒置调用栈)。

Aspects

在上文中,方法调用 1000000 次,统计从消息发送到原方法和从发送消息到执行完原始实现和前后切面 block,平均花费 6.135s,下面看下profile的结果截图:

image.png

继续展开:

image.png
image.png
image.png
image.png

由上可以分析出影响Aspects执行速度的几个原因,按照比重

  1. 被 hook 方法调用时走了消息转发,消息转发的过程。
  2. static SEL aspect_aliasForSelector(SEL selector) 中对 AspectsMessagePrefix 前缀SEL的获取
  3. - (BOOL)invokeWithInfo:(id<AspectInfo>)infoinvocation 的创建,执行。
  4. static void __ASPECTS_ARE_BEING_CALLED__(__unsafe_unretained NSObject *self, SEL selector, NSInvocation *invocation) 中临时变量的创建,invotion 的执行.

其中,2 和 4 是可以优化的😀。 下面看看Stinger.

Stinger

在上文中,方法调用1000000次,统计从消息发送到原方法和从发送消息到执行完原始实现和前后切面block,平均花费小于0.3s,下面看下profile的结果截图:

image.png

展开:


image.png
image.png
image.png

与 Aspects 相比: 节省的时间在

  1. 原方法最终不走消息转发,走正常的函数指针搜索,调用。
  2. 预存了 _st_ 前缀的 SEL 避免繁重计算获取;
  3. 尽可能使用ffi_call调用原方法实现和block.
  4. 避免在 NS_INLINE void _st_ffi_function(ffi_cif *cif, void *ret, void **args, void *userdata) 中生成大的临时对象;延时生成Invocation作为参数可能供使用方在 instead block 中调用;
  5. 直接变量引用参数,不使用 getter ;尽量不使用 oc 消息获取其他参数,提前保存,如参数数量;
  6. 尽可能内敛化其他函数。

method swizzling/Aspects/Stinger对比

对比项 swizzling Aspects Stinger
速度 极快😁 慢😭 非常快😀
Api友好度 非常差😭 非常好😁 非常好 😁
类的hook 支持😀 支持 😀 支持😀
实例对象的hook 不支持😭 支持 😁 支持 😁
调用原方法时改变selector 修改😭 修改😭 不修改😁(ffi_call或invokeUsingIMP:)
方法可能因命名冲突 会😭 不会 😁 不会 😁
兼容其他hook方式(RAC, JSPactch..) 兼容😁 不兼容 😭 兼容 😁
支持多线程增加hook 自己加锁🙄 支持 😀 支持 😀
hook可预见性,可追溯性 非常差😭 好🙂 非常好 😀
修改父类方法实现 可能会😭 不会😀 不会 😀
... ... ... ...

so,请君用下 Stinger(github.com/eleme/Sting…) 啊,可以实现更快速、更安全的实现AOP,高效率的执行原方法实现及切面代码,以显著改善代码结构;也能利用实例对象hook满足 KVO/RACObserve/rac_signalForselector 等应用场景。

最后打个广告,阿里巴巴本地生活-蜂鸟即配大前端团队也在招人中,Base地可以选择上海或北京,欢迎各位 iOS Android 前端小伙伴的加入,和我们一起做一些酷事情!具体招聘信息在这里 https://www.jianshu.com/p/fb78b25c335b

关于作者

李永光(https://juejin.im/user/5a308d8ef265da43305e72b7),饿了么上海物流研发部移动组资深 iOS 工程师,曾先后主导蜂鸟团队版 iOS 端定位、网络等核心模块的架构升级和改造,也有着非常丰富的 iOS 性能优化实践经验。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,319评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,801评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,567评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,156评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,019评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,090评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,500评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,192评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,474评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,566评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,338评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,212评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,572评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,890评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,169评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,478评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,661评论 2 335

推荐阅读更多精彩内容