Canoe

VCProfiler源码阅读

2018.07.16

GitHub - panmingyang2009/VCProfiler: An easy and simple tool to measure the time cost of every view controller.

VCProfiler 主要是利用 KVO 自动生成子类的特性来检测 ViewController 的加载时长,只需要一个分类,对原项目没有任何侵入性,而且检测效果准确。

痛点

ViewController 的时长检测可以更好的帮助我们发现页面加载过程中耗时的操作,也可以体现 app 的流畅度。

方案

一般项目中对 ViewController 的时长检测是使用 Method Swizzle 替换了 VC 的 loadViewViewDidLoadViewWillAppear 还有 ViewDidAppear 来对页面的加载时间进行检测,但是这种方式不太理想,原因在于我们替换的是 ViewController 的方法,而通常我们自己定义的 ViewController 是调用了父类的方法之后调用很多操作,而这些方法时长我们无法检测到。
VCProfiler 用了一种很巧妙的方式,利用 KVO 会创建一个子类的原理,对子类的一些关键方法进行 hook,这样就会在原类执行完成之后才会调用时间的记录方法,能够准确的测量 VC 的加载耗时。

实现

实现只有两个文件,但是里面的内容却很丰富,主要有两个类,一个分类,我逐个进行分析。

MTHFakeKVOObserver

这个类很简单,就是一个单例,作为观察者,监听 ViewController 一个没有意义的属性,目的是为了触发 KVO。

MTHFakeKVORemover

@interface MTHFakeKVORemover : NSObject
@property (nonatomic, unsafe_unretained) id target;
@property (nonatomic, copy) NSString *keyPath;
@end

@implementation MTHFakeKVORemover
- (void)dealloc {
    VCLog(@"dealloc: %@", _target);
    [_target removeObserver:[MTHFakeKVOObserver shared] forKeyPath:_keyPath];
    _target = nil;
}
@end

这个类的作用主要是为了移除观察者,有两个属性,target 弱引用保存了当前的 ViewControllerkeyPath 保存对应的 keypath
具体的使用以及为何使用 unsafe_unretained 而不是 weak 我们在下面一起分析。

UIViewController+VCDetector

主要的内容就是在这个分类中了,首先我们可以看这一部分代码:

+ (void)load {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        Class class = [UIViewController class];
        [self swizzleMethodInClass:class originalMethod:@selector(initWithNibName:bundle:) swizzledSelector:@selector(pmy_initWithNibName:bundle:)];
        [self swizzleMethodInClass:class originalMethod:@selector(initWithCoder:) swizzledSelector:@selector(pmy_initWithCoder:)];
    });
}

- (instancetype)pmy_initWithNibName:(nullable NSString *)nibNameOrNil bundle:(nullable NSBundle *)nibBundleOrNil {
    [self createAndHookKVOClass];
    [self pmy_initWithNibName:nibNameOrNil bundle:nibBundleOrNil];
    return self;
}

- (nullable instancetype)pmy_initWithCoder:(NSCoder *)aDecoder {
    [self createAndHookKVOClass];
    [self pmy_initWithCoder:aDecoder];
    return self;
}

很明显,这一段的目的是在程序启动时,交换 UIViewControllerinitWithNibName:bundle: 和自己实现的 pmy_initWithNibName:bundle: 以及 initWithCoder:pmy_initWithCoder:,这里为什么不交换 init 方法呢?因为 init 方法最终也是调用 initWithNibName:bundle: 来实现,所以不需要再多做一次操作了。
总的来说,这一部分就是为了让 viewController 在初始化的时候调用 createAndHookKVOClass 这个方法。

- (void)createAndHookKVOClass {
    //监听一个没有意义的属性,目的只是为了触发KVO实现一个NSKVONotifying_ViewController子类并且将 isa 指针指向子类
    [self addObserver:[MTHFakeKVOObserver shared] forKeyPath:kUniqueFakeKeyPath options:NSKeyValueObservingOptionNew context:nil];

    // 给ViewController添加一个MTHFakeKVORemover对象,利用关联对象的原理,ViewController调用delloc的时候,会释放所有的关联对象。
    //MTHFakeKVORemover弱引用ViewController
    MTHFakeKVORemover *remover = [[MTHFakeKVORemover alloc] init];
    remover.target = self;
    remover.keyPath = kUniqueFakeKeyPath;
    objc_setAssociatedObject(self, &kAssociatedRemoverKey, remover, OBJC_ASSOCIATION_RETAIN_NONATOMIC);

    // NSKVONotifying_ViewController
    Class kvoCls = object_getClass(self);

    //对比当前的viewDidLoad的指针和当前方法的指针,确保在这之前没有viewDidLoad被hook
    IMP currentViewDidLoadImp = class_getMethodImplementation(kvoCls, @selector(viewDidLoad));
    if (currentViewDidLoadImp == (IMP)pmy_viewDidLoad) {
        return;
    }

    // ViewController
    Class originCls = class_getSuperclass(kvoCls);

    VCLog(@"Hook %@", kvoCls);

    // 获取原来实现的encoding
    const char *originLoadViewEncoding = method_getTypeEncoding(class_getInstanceMethod(originCls, @selector(loadView)));
    const char *originViewDidLoadEncoding = method_getTypeEncoding(class_getInstanceMethod(originCls, @selector(viewDidLoad)));
    const char *originViewDidAppearEncoding = method_getTypeEncoding(class_getInstanceMethod(originCls, @selector(viewDidAppear:)));
    const char *originViewWillAppearEncoding = method_getTypeEncoding(class_getInstanceMethod(originCls, @selector(viewWillAppear:)));

    // 重点,为 NSKVONotifying_ViewController 添加常用的检测方法。
      // 当调用 VC 的下列方法的时候,会直接调用 NSKVONotifying_ViewController 添加的方法
    class_addMethod(kvoCls, @selector(loadView), (IMP)pmy_loadView, originLoadViewEncoding);
    class_addMethod(kvoCls, @selector(viewDidLoad), (IMP)pmy_viewDidLoad, originViewDidLoadEncoding);
    class_addMethod(kvoCls, @selector(viewDidAppear:), (IMP)pmy_viewDidAppear, originViewDidAppearEncoding);
    class_addMethod(kvoCls, @selector(viewWillAppear:), (IMP)pmy_viewWillAppear, originViewWillAppearEncoding);
}

以上这一段就是利用 KVO 以及 hook 子类的关键方法实现耗时检测,其中每一部分的意思我都写上了注释,配合代码很容易理解。

为什么不使用 weak 而使用 unsafe_unretained,我们知道,对象在调用 dealloc 内部会调用 _object_remove_assocations(obj) 方法,也就是释放所有的关联对象,那么在 ViewController 销毁的时候,会调用 MTHFakeKVORemover 的 dealloc 方法,在 MTHFakeKVORemover 调用 dealloc 的时候,会调用 objc_clear_deallocating(obj) 清除弱引用表,那么在 remover 进入 -dealloc 方法的时候,上述代码中的 if (_obj) 判断将永远为 false。因此使用 unsafe_unretained 来修饰 VC 实例。

static void pmy_viewDidLoad(UIViewController *kvo_self, SEL _sel) {
    Class kvo_cls = object_getClass(kvo_self);
    Class origin_cls = class_getSuperclass(kvo_cls);
    IMP origin_imp = method_getImplementation(class_getInstanceMethod(origin_cls, _sel));
    assert(origin_imp != NULL);

    void (*func)(UIViewController *, SEL) = (void (*)(UIViewController *, SEL))origin_imp;

    VCLog(@"VC: %p -viewDidLoad \t\tbegin  at CF time:\t%lf", kvo_self, CFAbsoluteTimeGetCurrent());
    func(kvo_self, _sel);
    VCLog(@"VC: %p -viewDidLoad \t\tfinish at CF time:\t%lf", kvo_self, CFAbsoluteTimeGetCurrent());
}

然后就是实现时间统计的操作了,首先我们拿到父类也就是 ViewController 的 viewDidLoad 方法的 IMP指针,在调用第一次时间统计之后,传入 NSKVONotifying_ViewController 和方法名,类似于是调用了 [super viewDidLoad],然后在之后再统计一次时间,整个时间统计的过程就完成了。

- (void)dealloc {
    VCLog(@"dealloc: %@", _target);
    [_target removeObserver:[MTHFakeKVOObserver shared] forKeyPath:_keyPath];
    _target = nil;
}

最后就是移除监听者,至此,整个流程也分析完了,最后再来梳理一遍整个流程。


1、load 方法中交换 ViewController 的 init 的方法实现。
2、TestVC 调用 init 方法的时候,会调用 addObserver 监听一个不会使用的对象,触发 KVO 生成一个子类 NSKVONotifying_ TestVC 并将 isa 指针指向 NSKVONotifying_ TestVC。
3、TestVC 关联对象 MTHFakeKVORemover用于 dealloc 的时候移除监听,MTHFakeKVORemover 对 TestVC 弱引用,修饰符使用 unsafe_unretained
4、NSKVONotifying_ TestVC 重写一些常用来检测的方法。
5、TestVC 调用 viewDidLoad 或者其他检测的方法,优先调用 NSKVONotifying_ TestVC 中的方法,开始统计。
6、然后 viewDidLoad 内方法执行完成之后,结束统计。
7、TestVC 调用 dealloc 方法,释放关联对象,MTHFakeKVORemover 调用 dealloc 方法,移除监听,完成。


总结

VCProfiler 代码不多,只有 200 来行,但是留给我的思考很多,而且内部涉及到了很多的底层原理,包括 Method Swizzle,关联对象的原理,dealloc 的原理,KVO 的原理,以及runtime 的运用,以及 weak 和 unsafe_unretained 的区别等。只有对各种机制了解透彻,并且运用熟练才能想出这种技巧来实现我们所需要的功能,正因如此,我们才需要对我们的程序运行过程深入的理解,只有清楚程序核心原理,才能慢慢熟悉,最终才能做到随心所欲,收放自如。


参考文档:
ARC下dealloc过程及.cxx_destruct的探究 · sunnyxx的技术博客
巧妙利用KVO实现精准的VC耗时检测 | Punmy
一种基于KVO的页面加载,渲染耗时监控方法 | SatanWoo

Comments
Write a Comment