0%

统计方法调用时间

统计方法调用时间

最近在做性能监控,思路大致是 hook 一些 cpu 耗时比较严重(layoutSubviews,cellForRow 等)的方法,获取方法调用的时间写入日志。后期对日志进行分析,得到优化的方向。

因此写了一个类用于方便我们统计方法调用时长,这是所有统计的基础。

此文说一下一路走过来的坑

方案一:FowardInvocation

参考 JSPatch 的实现,把指定的方法都替换成 FowardInvocation,嵌入自己的统计时间代码,然后重新包装一次 NSInvocation 派发到原来的对象上。

原理

假设业务代码如下

@interface TestView : UIView
@end
@implementation TestView
- (void)layoutSubview {
   [super layoutSubview];
}
@end

TestView 有两个关键的东西

  • Sel : layoutSubview
  • IMP : imp_origin

用一个全局的 IMP _objc_msgForward 替换 imp_origin

IMP msgForwardIMP = _objc_msgForward;
Method method = class_getInstanceMethod(cls, selector);
const char *typeDescription = (char *)method_getTypeEncoding(method);
class_replaceMethod(cls, selector, msgForwardIMP, typeDescription);

保存原始实现

 if (class_respondsToSelector(cls, selector)) {// 保存原始的 IMP
        NSString *originalSelectorName = XXXOriginalSelectorName(selectorName);
        SEL originalSelector = NSSelectorFromString(originalSelectorName);
        class_addMethod(cls, originalSelector, originalImp, typeDescription);
    }

调用原始实现

static void XXXForwardInvocation(__unsafe_unretained id assignSlf, SEL selector, NSInvocation *invocation)
{
    NSMethodSignature *methodSignature = [invocation methodSignature];
    id slf = assignSlf;
    Class cls = object_getClass(slf);
    NSString *selectorName = NSStringFromSelector(invocation.selector);
    SEL originSelector = NSSelectorFromString(XXXOriginalSelectorName(selectorName));

    [invocation setSelector:originSelector];
    [invocation setTarget:slf];
    [invocation invoke];
}

super 导致了死循环

同时替换父子类的 func 会导致死循环。

假设的业务代码如下

@interface TestObject1 : NSObject
- (void)func;
@end
@interface TestObject2 : TestObject1
@end
@implementation TestObject1
- (void)func {
    NSLog(@"SUPER");
}
@end

@implementation TestObject2
- (void)func {
    [super func];
    NSLog(@"SUB");
}
@end

现象总结
只 Hook TestObject2 的话,objc 调用 [super func] 是不会进入 ForwardInvocation 中(消息能正常派发)。所以只 Hook TestObject2 或者 TestObject1 都能得到预期的结果。但是一旦父类和基类都被 Hook 了,就会导致调用

死循环原因
objc_msgSendSuperobjc_msgSend 只在 在 forward 之后没有区别了。
FowardInvocation 中无法区分 obj2 调用的是 [super func] 还是 [self func],重新包装 NSInvocation 进行消息派发的时候出错了。

方案一失败总结

没有办法在 FowardInvocation 中区分 obj2 到底调用了 [super func] 还是 [self func]

JSPatch 为什么能正常工作不会死循环?

JSPatch 实现原理详解中我们可以知道。
目的不同

JSPatch 是一个 iOS 动态更新框架,只需在项目中引入极小的引擎,就可以使用 JavaScript 调用任何 Objective-C 原生接口,获得脚本语言的优势:为项目动态添加模块,或替换项目原生代码动态修复 bug。

内置 super 关键字。

而我们的设想是运行时懒替换一部分类的指定方法。

方案二:Aspects

Aspects
原理和方案一一样。
相比第一套自己写的粗略方案来说有几个优点:

  1. 异常处理
  2. 清理 hook 的行为
  3. 用 block 包装 imp
  4. 封装更完整

方案二用了一段时间,还是挺不爽。还是因为继承链那个循环问题。Aspects 为了避免死循环直接记录了继承链,下一次试图在这个继承链上再次 hook 的行为都会被拒绝。
有人也提出了这个问题,Support for Multiple Hooks into Methods in Class Hierarchy

This is related to #2. The basic problem is that once we have an NSInvocation, there's no way to check the underlying IMP location to detect if it points to self or super... so hooks can only safely be added to the topmost class.

Currently I opted to block this, but it's by far not the best solution. We can also collect and apply this in alloc-time. Downside: Hooks might be called whose method is not (you might wanna omit super, but the hook for the parent would still be called - also the timing would be different)

Alternatively we could fix the underlying problem, but it requires custom jump tables ala https://github.com/OliverLetterer/SPLMessageLogger/blob/master/SPLMessageLogger/spl_forwarding_trampoline_armv7.s#L14-L18 (or maybe libffi)

有人提供了一套蹦床的解决方案,Use trampolines to allow hooking methods multiple times per class hierarchy。但是缺少arm64 x86 架构的代码,被作者拒绝了

关于这个问题的讨论还挺有意思的,感兴趣的可以进去看看。

方案三:JRSwizzle

JRSwizzle 解决了上面两个问题中继承链关系的问题。核心方法是 method_exchangeImplementations

相比方案一、二相对来说代码量会多一丢丢,因为你需要自己自己包装 ivocation 去调用原始实现

因为我们还要在调用原始实现之前记录时间,这一块逻辑省不了,就是代码不够优雅罢了。

该写的代码还是要写的

总之,这套方案暂时够用了

后期

可能会有业务方会提出再运行时动态添加需要观察的方法这种能力,考虑接入 JSPatch 或者 Nu 通过动态下发文件执行代码的形式。
但是需要把需要替换的方法体整个重新写一遍。

但是有这个时间写脚本,干嘛不直接写原生代码编译一遍?

这几乎是一个瓶颈了。最开始的设想是希望业务方在使用的时候能够方便将自己关心的方法的调用也统计在我们的日志中。

在没有什么比较优雅的解决方案之前,目前只提供一个写日志的接口让业务方接入写日志的行为。