iOS卡顿监控

4,225 阅读2分钟

本文主要是从FPS主线程卡顿和子线程操作UI三个方面监测卡顿

一、FPS

1.监控原理

主要是基于CADisplayLink以屏幕刷新频率同步绘图的特性,尝试根据这点去实现一个可以观察屏幕当前帧数的指示器。 基于CADisplayLink实现的 FPS 在生产场景中只有指导意义,不能代表真实的 FPS,因为基于CADisplayLink实现的 FPS 无法完全检测出当前 Core Animation 的性能情况,它只能检测出当前 RunLoop 的帧率。 FPS 的刷新频率非常快,并且容易发生抖动,因此直接通过比较 FPS 来侦测卡顿是比较困难的。

2.实现方式

  1. CADisplayLink 默认每秒 60次;
  2. 将 CADisplayLink add 到 mainRunLoop 中;
  3. 使用 CADisplayLink 的 timestamp 属性,在 CADisplayLink 每次 tick 时,记录上一次的 timestamp;
  4. 用 count 记录 CADisplayLink tick 的执行次数;
  5. 计算此次 tick 时, CADisplayLink 的当前 timestamp 和 lastTimeStamp 的差值;
  6. 如果差值大于1,fps = count / delta,计算得出 FPS 数。

代码如下:

- (void)startMonitorWithBlock:(MCXFPSMonitorBlock)monitorBlock {
    self.isMonitoring = YES;
    self.monitorBlock = monitorBlock;
    if (self.displayLink) {
        self.displayLink.paused = NO;
        return;
    }
    __weak typeof(self) weakSelf = self;
    self.displayLink = [CADisplayLink mcx_displayLinkWithBlock:^(CADisplayLink * _Nonnull displayLink) {
        [weakSelf tick:displayLink];
    }];
//self.displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(tick:)];
    [self.displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
}

- (void)tick:(CADisplayLink *)link {
    if (self.lastTime == 0) {
        self.lastTime = link.timestamp;
        return;
    }
    self.count++;
    NSTimeInterval delta = link.timestamp - self.lastTime;
    if (delta < 1) {
        return;
    }
    self.lastTime = link.timestamp;
    double fps = self.count / delta;
    if (self.monitorBlock) {
        self.monitorBlock(self, fps);
    }
    self.count = 0;
}

3.使用CADisplayLink的坑

通常代码如下 self.displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(tick:)];

CADisplayLink/NSTimer出于设计考虑会强引用target,同时NSRunLoop又会持有持有CADisplayLink/NSTimer,使用方同时又持有了CADisplayLink/NSTimer,这就造成了循环引用,示意图如下:

解决方案一、Block形式

封装一个CADisplayLink的Category,提供block形式的接口: /* 在初始化NSTimer/CADisplayLink对象时候,指定target时候,会保留其目标对象。我们的目的是绕开这个定时器对象强引用目标对象这个问题。在分类中,定时器对象指定的target是NSTimer/CADisplayLink类对象,这是个单例,因此计时器是否会保留它都无所谓。这么做,循环引用依然存在,但是因为类对象无需回收,所以能解决问题。

+ (CADisplayLink *)mcx_displayLinkWithBlock:(MCXDisplayLinkBlock)block {
    CADisplayLink *displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(mcx_displayLink:)];
    displayLink.mcx_displayLinkBlock = [block copy];
    return displayLink;
}

+ (void)mcx_displayLink:(CADisplayLink *)displayLink {
    if (displayLink.mcx_displayLinkBlock) {
        displayLink.mcx_displayLinkBlock(displayLink);
    }
}

- (void)setMcx_displayLinkBlock:(MCXDisplayLinkBlock)mcx_displayLinkBlock {
    objc_setAssociatedObject(self, @selector(mcx_displayLinkBlock), mcx_displayLinkBlock, OBJC_ASSOCIATION_COPY_NONATOMIC);
}

- (MCXDisplayLinkBlock)mcx_displayLinkBlock {
    return objc_getAssociatedObject(self, @selector(mcx_displayLinkBlock));
}

解决方案二、WeakProxy形式

NSProxy大家在日常开发中使用较少,其实就是一个代理中间人,可以代理其他的类/对象,用在这里很合适。先贴一张示意图:

部分代码如下:

[CADisplayLink displayLinkWithTarget:[MCXWeakProxy proxyWithTarget:self] selector:@selector(tick:)];

MCXWeakProxy.h
...
@property (nullable, nonatomic, weak, readonly) id target;
...

MCXWeakProxy.m
...
- (instancetype)initWithTarget:(id)target {
    _target = target;
    return self;
}

+ (instancetype)proxyWithTarget:(id)target {
    return [[MCXWeakProxy alloc] initWithTarget:target];
}

- (id)forwardingTargetForSelector:(SEL)selector {
    return _target;
}
...

二、主线程卡顿监控

1.监控原理

这是业内常用的一种检测卡顿的方法,通过开辟一个子线程来监控主线程的 RunLoop,当两个状态区域之间的耗时大于阈值时,就记为发生一次卡顿。美团的移动端性能监控方案 Hertz 采用的就是这种方式,如下图

主线程卡顿监控的实现思路:开辟一个子线程,然后实时计算 kCFRunLoopBeforeSources 和 kCFRunLoopAfterWaiting 两个状态区域之间的耗时是否超过某个阀值,来断定主线程的卡顿情况,可以将这个过程想象成操场上跑圈的运动员,我们会每隔一段时间间隔去判断是否跑了一圈,如果发现在指定时间间隔没有跑完一圈,则认为在消息处理的过程中耗时太多,视为主线程卡顿。

2.图解原理

  • 正常情况下

  • 卡顿情况下

3.实现方式

  1. 创建一个观察者runLoopObserver,用于观察主线程的runloop状态。
  2. 还要创建一个信号量dispatchSemaphore,用于保证同步操作。
  3. 将观察者runLoopObserver添加到主线程runloop中观察。
  4. 开启一个子线程,并且在子线程中开启一个持续的loop来监控主线程runloop的状态。
  5. 如果发现主线程runloop的状态卡在为BeforeSources或者AfterWaiting超过一定时间时,即表明主线程当前卡顿。

具体代码如下

static void runLoopObserverCallBack(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info){
    MCXCatonMonitor *monitor = (__bridge MCXCatonMonitor*)info;
    monitor->runLoopActivity = activity;
    
    dispatch_semaphore_t semaphore = monitor->dispatchSemaphore;
    dispatch_semaphore_signal(semaphore);
}

- (void)startMonitor {
    self.isMonitoring = YES;
    //监测卡顿
    if (runLoopObserver) {
        return;
    }
    dispatchSemaphore = dispatch_semaphore_create(0); //Dispatch Semaphore保证同步
    //创建一个观察者
    CFRunLoopObserverContext context = {0,(__bridge void*)self,NULL,NULL};
    runLoopObserver = CFRunLoopObserverCreate(kCFAllocatorDefault,
                                              kCFRunLoopAllActivities,
                                              YES,
                                              0,
                                              &runLoopObserverCallBack,
                                              &context);
    //将观察者添加到主线程runloop的common模式下的观察中
    CFRunLoopAddObserver(CFRunLoopGetMain(), runLoopObserver, kCFRunLoopCommonModes);
    
    //创建子线程监控
    dispatch_async(dispatch_get_global_queue(0, 0), ^{
        //子线程开启一个持续的loop用来进行监控
        while (YES) {
            long semaphoreWait = dispatch_semaphore_wait(self->dispatchSemaphore, dispatch_time(DISPATCH_TIME_NOW, kMCXCatonMonitorDuration * NSEC_PER_MSEC));
            if (semaphoreWait != 0) {
                if (!self->runLoopObserver) {
                    self->timeoutCount = 0;
                    self->dispatchSemaphore = 0;
                    self->runLoopActivity = 0;
                    return;
                }
                //两个runloop的状态,BeforeSources和AfterWaiting这两个状态区间时间能够检测到是否卡顿
                if (self->runLoopActivity == kCFRunLoopBeforeSources || self->runLoopActivity == kCFRunLoopAfterWaiting) {
                    //出现三次出结果
                    if (++self->timeoutCount < kMCXCatonMonitorMaxCount) {
                        continue;
                    }
                    //进行堆栈记录保存

                } //end activity
            }// end semaphore wait
            self->timeoutCount = 0;
        }// end while
    });
}

runloop观察者:Runloop Observer有7种状态

/* Run Loop Observer Activities */
typedef CF_OPTIONS(CFOptionFlags, CFRunLoopActivity) {
    kCFRunLoopEntry = (1UL << 0),//运行runloop的入口。
    kCFRunLoopBeforeTimers = (1UL << 1),//(在处理任何Timer计时器之前)
    kCFRunLoopBeforeSources = (1UL << 2),//(在处理任何Sources源之前)
    kCFRunLoopBeforeWaiting = (1UL << 5),//(在等待源Source和计时器Timer之前)
    kCFRunLoopAfterWaiting = (1UL << 6),//(在等待源Source和计时器Timer后,同时在被唤醒之前。)
    kCFRunLoopExit = (1UL << 7),//(runloop的出口)
    kCFRunLoopAllActivities = 0x0FFFFFFFU
};

三、子线程操作UI

子线程操作也会导致界面卡顿,任何操作UI过程中,正常会触发UIView中setNeedsLayoutsetNeedsDisplaysetNeedsDisplayInRectlayoutSubviews四个方法中至少一个,只需要利用runtime交换这四个方法,然后进行线程检查,然后记录当前线程调用堆栈即可。

+ (void)load {
    [self mcx_swizzleSEL:@selector(setNeedsLayout) withSEL:@selector(mcx_setNeedsLayout)];
    [self mcx_swizzleSEL:@selector(setNeedsDisplay) withSEL:@selector(mcx_setNeedsDisplay)];
    [self mcx_swizzleSEL:@selector(setNeedsDisplayInRect:) withSEL:@selector(mcx_setNeedsDisplayInRect:)];
    [self mcx_swizzleSEL:@selector(layoutSubviews) withSEL:@selector(mcx_layoutSubviews)];
}

- (void)mcx_setNeedsLayout {
    [self mcx_setNeedsLayout];
    [self childThreadUICheck];
}

- (void)mcx_setNeedsDisplay {
    [self mcx_setNeedsDisplay];
    [self childThreadUICheck];
}

- (void)mcx_setNeedsDisplayInRect:(CGRect)rect {
    [self mcx_setNeedsDisplayInRect:rect];
    [self childThreadUICheck];
}

- (void)mcx_layoutSubviews {
    [self mcx_layoutSubviews];
    [self childThreadUICheck];
}

- (void)childThreadUICheck {
    if ([NSThread isMainThread]) {
        return;
    }
    //此处有子线程操作UI情况,记录当前线程堆栈信息

}