阅读 3728

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

作者简介

李永光,饿了么资深 iOS 工程师。

前言

      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++) {
    }
  }];
}
复制代码
测试结果

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

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

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

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

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];
    }
  }];
}
复制代码
测试结果

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的结果截图:

继续展开:

由上可以分析出影响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的结果截图:

展开:

与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等应用场景。

谢谢观看,如有错误,请指出!