卡顿检测,UI卡顿检查和测试与符号化速度优化

前言

在很早从前就有超过实际现一套本身的iOS监控系统,但首先是instrument足足的非凡,大概拥有监察和控制相关的操作都有相应的工具。二来,也是小编没(lan)时(de)间(zuo),项目大多也集成了第②方的总括SDK,所以迟迟没有去贯彻。那段时光,因为代码设计上存在的弱点,导致项目在iphone5s以下的设施运营时会出现相比分明的卡顿现象。尽管instrument足足美观,但我更愿目的在于程序运营时期能即刻获得卡顿消息,因而开头入手要好的卡顿检测方案。

本文参考以下小说,做了好几优化,进步了卡顿监测的准头,品质,符号化速度等等。
iOS实时卡顿监察和控制深远明白RunLoopiOS版微信界面卡顿监测方案深切剖析
iOS
质量优化
BSBacktraceLogger

获得栈上下文

其余监控系统在监督到对象事件发生时,获取线程的调用栈上下文是必须的,问题在于如何挂起最近线程并且获得线程新闻。幸亏网上有大神分享了足够多的材质供作者查阅,让小编能够站在巨人的双肩上来实现那有的工作。

demo中拿走调用栈代码重写自BSBacktraceLogger,在利用此前建议能结合下方的参考资料和源代码一起读书,知其然知其所以然。栈是一种后进先出(LIFO)的数据结构,对于二个线程来说,其调用栈的构造如下:

调用栈上每个单位被称作栈帧(stack
frame),每二个栈帧由函数参数再次来到地址以及栈帧中的变量组成,其中Frame Pointer针对内部存款和储蓄器存款和储蓄了上一栈帧的地点音讯。换句话说,只要能得到到栈顶的Frame Pointer就能递归遍历整个栈上的帧,遍历栈帧的基本代码如下:

#define MAX_FRAME_NUMBER 30
#define FAILED_UINT_PTR_ADDRESS 0

NSString * _lxd_backtraceOfThread(thread_t thread) {
    uintptr_t backtraceBuffer[MAX_FRAME_NUMBER];
    int idx = 0;

    ......

    LXDStackFrameEntry frame = { 0 };
    const uintptr_t framePtr = lxd_mach_framePointer(&machineContext);
    if (framePtr == FAILED_UINT_PTR_ADDRESS ||
        lxd_mach_copyMem((void *)framePtr, &frame, sizeof(frame)) != KERN_SUCCESS) {
        return @"failed to get frame pointer";
    }
    for (; idx < MAX_FRAME_NUMBER; idx++) {
        backtraceBuffer[idx] = frame.return_address;
        if (backtraceBuffer[idx] == FAILED_UINT_PTR_ADDRESS ||
            frame.previous == NULL ||
            lxd_mach_copyMem(frame.previous, &frame, sizeof(frame)) != KERN_SUCCESS) {
            break;
        }
    }
}

从栈帧中大家只可以获得到调用函数的地点消息,为了输出上下文数据,大家还必要依照地方实行符号化,即找到地方所在的内部存款和储蓄器镜像,然后定位该镜像中的符号表,最终从符号表中优良地址对应的标志输出。

符号化进程中回顾不限于以下的数据结构:

typedef struct dl_info {
    const char   *dli_fname;
    void         *dli_fbase;
    const char   *dli_sname;
    void         *dli_saddr;
} Dl_info;

Dl_info积存了总结路径名、镜像起先地址、符号地址和符号名等音信

struct symtab_command {
    uint32_t    cmd;
    uint32_t    cmdsize;
    uint32_t    symoff;
    uint32_t    nsyms;
    uint32_t    stroff;
    uint32_t    strsize;
};

提供了符号表的偏移量,以及成分个数,还有字符串表的偏移和其长度。越来越多堆栈的资料能够参见文末最终四个链接学习。符号化的中坚函数lxd_dladdr如下:

bool lxd_dladdr(const uintptr_t address, Dl_info * const info) {
    info->dli_fname = NULL;
    info->dli_fbase = NULL;
    info->dli_sname = NULL;
    info->dli_saddr = NULL;

    const uint32_t idx = lxd_imageIndexContainingAddress(address);
    if (idx == UINT_MAX) { return false; }

    const struct mach_header * header = _dyld_get_image_header(idx);
    const uintptr_t imageVMAddressSlide = (uintptr_t)_dyld_get_image_vmaddr_slide(idx);
    const uintptr_t addressWithSlide = address - imageVMAddressSlide;
    const uintptr_t segmentBase = lxd_segmentBaseOfImageIndex(idx) + imageVMAddressSlide;
    if (segmentBase == FAILED_UINT_PTR_ADDRESS) { return false; }

    info->dli_fbase = (void *)header;
    info->dli_fname = _dyld_get_image_name(idx);

    const LXD_NLIST * bestMatch = NULL;
    uintptr_t bestDistance = ULONG_MAX;
    uintptr_t cmdPtr = lxd_firstCmdAfterHeader(header);
    if (cmdPtr == FAILED_UINT_PTR_ADDRESS) { return false; }

    for (uint32_t iCmd = 0; iCmd < header->ncmds; iCmd++) {
        const struct load_command * loadCmd = (struct load_command *)cmdPtr;
        if (loadCmd->cmd == LC_SYMTAB) {
            const struct symtab_command * symtabCmd = (struct symtab_command *)cmdPtr;
            const LXD_NLIST * symbolTable = (LXD_NLIST *)(segmentBase + symtabCmd->symoff);
            const uintptr_t stringTable = segmentBase + symtabCmd->stroff;

            for (uint32_t iSym = 0; iSym < symtabCmd->nsyms; iSym++) {
                if (symbolTable[iSym].n_value == FAILED_UINT_PTR_ADDRESS) { continue; }
                uintptr_t symbolBase = symbolTable[iSym].n_value;
                uintptr_t currentDistance = addressWithSlide - symbolBase;
                if ( (addressWithSlide >= symbolBase && currentDistance <= bestDistance) ) {
                    bestMatch = symbolTable + iSym;
                    bestDistance = currentDistance;
                }
            }
            if (bestMatch != NULL) {
                info->dli_saddr = (void *)(bestMatch->n_value + imageVMAddressSlide);
                info->dli_sname = (char *)((intptr_t)stringTable + (intptr_t)bestMatch->n_un.n_strx);
                if (*info->dli_sname == '_') {
                    info->dli_sname++;
                }
                if (info->dli_saddr == info->dli_fbase && bestMatch->n_type == 3) {
                    info->dli_sname = NULL;
                }
                break;
            }
        }
        cmdPtr += loadCmd->cmdsize;
    }
    return true;
}

整个符号化过程可以用下面的图表示ps:经过joy__证实,前面放上的图即使在操作上看似,然而图示是fishhook的进度,由此删除旧图片

卡顿检查和测试

通过监测主runloop循环次数来判定是还是不是发送卡顿。

什么是runloop?
runloop正是个巡回,不自然是死循环,退出该循环的原则是程序截止,程序如什么日期候结束自身定,iOS的正是苹果来定。类似MFC中国国际信资集团息循环,安卓的looper等等等等。

干什么会有runloop?
线程频仍创设销毁功耗源,线程中执行到了很深的函数里,有事态存在,不容许每一遍都从头再去到那多少个场地,须要个巡回保持住线程不进入销毁态,不让线程截至。比如你进到二个很深的controller里了,非常小概每回都从main走到你的controller里。

怎么监督主runloop循环次数?
runloop循环的经过中会抛布告出来,创造二个观察者监听这一个布告即可。

怎么检查和测试UI产生了卡顿?
监理主runloop循环次数,流畅情状下,一般循环陆十四回,对应60帧。
比方认为掉了3帧,人眼能显然感受到卡顿,3 * 16.67 ms 约 50
ms,那么runloop超越50ms没回调公告给本身的观望者,判定为卡顿,并且要“非凡特别及时”获取下主线程的调用栈,栈顶的不二法门正是产生卡顿的方式。

看下图:那是网络朋友依据runloop源码画的流程图

图片 1

起四个线程用信号量卡着,每50ms执行一回,用个变量last记录最后一回runloop抛出来公告,借使发生50ms超时,去看
last 是何等值,借使是 kCFRunLoopBeforeSources 和
kCFRunLoopAfterWaiting,表示友好的代码爆发卡顿,因为那多个关照后边处理的是
source0种类代码,source1用户代码,timer代码事件。产生了卡顿就在督察线程将其调用栈获取下来。

其余页面切换速度,FPS帧率,都跟主循环正相关,因为viewDidLoad等等事件都在主线程执行,UI也在主线程绘制。监测了主runloop,那多个指标其实能够不用在监测。

关于RunLoop

RunLoop是贰个重复收取着端口信号和事件源的死循环,它不止的唤醒沉睡,主线程的RunLoop在运用跑起来的时候就机关运行,RunLoop的施行流程由下图表示:

CFRunLoop.c中,能够见见RunLoop的推行代码差不多如下:

{
    /// 1. 通知Observers,即将进入RunLoop
    /// 此处有Observer会创建AutoreleasePool: _objc_autoreleasePoolPush();
    __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__(kCFRunLoopEntry);
    do {

        /// 2. 通知 Observers: 即将触发 Timer 回调。
        __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__(kCFRunLoopBeforeTimers);
        /// 3. 通知 Observers: 即将触发 Source (非基于port的,Source0) 回调。
        __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__(kCFRunLoopBeforeSources);
        __CFRUNLOOP_IS_CALLING_OUT_TO_A_BLOCK__(block);

        /// 4. 触发 Source0 (非基于port的) 回调。
        __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__(source0);
        __CFRUNLOOP_IS_CALLING_OUT_TO_A_BLOCK__(block);

        /// 6. 通知Observers,即将进入休眠
        /// 此处有Observer释放并新建AutoreleasePool: _objc_autoreleasePoolPop(); _objc_autoreleasePoolPush();
        __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__(kCFRunLoopBeforeWaiting);

        /// 7. sleep to wait msg.
        mach_msg() -> mach_msg_trap();


        /// 8. 通知Observers,线程被唤醒
        __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__(kCFRunLoopAfterWaiting);

        /// 9. 如果是被Timer唤醒的,回调Timer
        __CFRUNLOOP_IS_CALLING_OUT_TO_A_TIMER_CALLBACK_FUNCTION__(timer);

        /// 9. 如果是被dispatch唤醒的,执行所有调用 dispatch_async 等方法放入main queue 的 block
        __CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__(dispatched_block);

        /// 9. 如果如果Runloop是被 Source1 (基于port的) 的事件唤醒了,处理这个事件
        __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE1_PERFORM_FUNCTION__(source1);


    } while (...);

    /// 10. 通知Observers,即将退出RunLoop
    /// 此处有Observer释放AutoreleasePool: _objc_autoreleasePoolPop();
    __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__(kCFRunLoopExit);
}

透过源码简单发现RunLoop处总管件的日子主要出在七个阶段:

  • kCFRunLoopBeforeSourceskCFRunLoopBeforeWaiting之间
  • kCFRunLoopAfterWaiting之后

符号化

符号化正是给二个内部存款和储蓄器地址 0x00001234 找到其标志 -[ViewController
viewDidLoad]
的进度,因为mach-o文件中富含了LC_SYMTAB段,该段中包括符号表。
在意,iOS系统做了优化,系统库的号子不在内存中,会唤醒 <redacted>

符号化参考BSBacktraceLogger所写

创新的地点有:
1.预拍卖全部image,记录下image中所须求的逐条segment基址,image内部存款和储蓄器地址范围等等,防止每便用个for循环来寻找segment基址,预处理后查找基址从
O(N) 降到
O(1),注意点:image能够动态加载,动态删除,幸好iOS中不会删除,只会在APP运维时会日趋加载,加完后image数量不会变卦,假设image数量有变,那么得重复预处理一遍。

2.追寻2个内部存款和储蓄器地址 address 在哪多少个 image 内,再次来到该 image 索引
出于有预处理image地址范围,并对地点排了序,qsort()排序,并且image地址不重叠,那么那里追寻一个image直接用二分查找,从原来的七个for循环的O(n^2)降到了O(log
n),为啥不用哈希查找是因为地址空间太大,6二个人下有2^六贰12个地点,差不离16777216T,太大了存不下。

3.加缓存,缓存 (address, symbol) 地址到symbol符号结构体的二元组
使用自身完毕的LRU缓存,比NSCache快4倍,小说地址:https://www.jianshu.com/p/1f8e36285539

4.监察线程只得到调用栈,另起2个线程进行符号化,约等于监察和控制线程是生产者,其他线程是顾客,一对毕生产消费模型。

优化结果:1000次符号化调用
7个栈:
优化后:50ms,,,优化前:1800ms

70个栈:
优化后:800ms,,,优化前:11800ms

那么除以一千就是二次符号化的时刻,大概是 0.05ms 到 0.8ms
之间能博获得任何调用栈,升高了准确性。因为在50ms产生了卡顿,该卡顿只怕在51ms消失,调用栈变化相当慢,必供给在最长期内捕捉到调用栈,才能规范,要是要在几飞秒后才捕捉到,那恐怕就不是爆发卡顿的调用栈了,导致结果不准。

优化后的代码临时未贴出,未来会设想开源的。

监察和控制RunLoop状态检查和测试超时

通过RunLoop的源码大家早就清楚了主线程处总管件的大运,那么怎样检查和测试选取是不是产生了卡顿呢?为了找到合理的处理方案,作者先监听RunLoop的事态并且输出:

static void lxdRunLoopObserverCallback(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void * info) {
    SHAREDMONITOR.currentActivity = activity;
    dispatch_semaphore_signal(SHAREDMONITOR.semphore);
    switch (activity) {
        case kCFRunLoopEntry:
            NSLog(@"runloop entry");
            break;

        case kCFRunLoopExit:
            NSLog(@"runloop exit");
            break;

        case kCFRunLoopAfterWaiting:
            NSLog(@"runloop after waiting");
            break;

        case kCFRunLoopBeforeTimers:
            NSLog(@"runloop before timers");
            break;

        case kCFRunLoopBeforeSources:
            NSLog(@"runloop before sources");
            break;

        case kCFRunLoopBeforeWaiting:
            NSLog(@"runloop before waiting");
            break;

        default:
            break;
    }
};

运转之后输出的结果是滚动引发的Sources事件接二连三被高速的履行到位,然后进入到kCFRunLoopBeforeWaiting动静下。即使在滚动进程中产生了卡顿现象,那么RunLoop自然会维持kCFRunLoopAfterWaiting或者kCFRunLoopBeforeSources这七个情景之一。

为了达成卡顿的检查和测试,首先要求注册RunLoop的监听回调,保存RunLoop气象;其次,通过成立子线程循环监听主线程RunLoop的气象来检查和测试是还是不是存在停留卡顿现象:
收到Sources相关的事件时,将超时阙值时间内分割成多个时间片段,重复去获取当前RunLoop的状态。如果多次处在处理事件的状态下,那么可以视作发生了卡顿现象

#define SHAREDMONITOR [LXDAppFluecyMonitor sharedMonitor]

@interface LXDAppFluecyMonitor : NSObject

@property (nonatomic, assign) int timeOut;
@property (nonatomic, assign) BOOL isMonitoring;
@property (nonatomic, assign) CFRunLoopActivity currentActivity;

+ (instancetype)sharedMonitor;
- (void)startMonitoring;
- (void)stopMonitoring;

@end

- (void)startMonitoring {
    dispatch_async(lxd_fluecy_monitor_queue(), ^{
    while (SHAREDMONITOR.isMonitoring) {
        long waitTime = dispatch_semaphore_wait(self.semphore, dispatch_time(DISPATCH_TIME_NOW, lxd_wait_interval));
            if (waitTime != LXD_SEMPHORE_SUCCESS) {
                if (SHAREDMONITOR.currentActivity == kCFRunLoopBeforeSources || 
                   SHAREDMONITOR.currentActivity == kCFRunLoopAfterWaiting) {
                    if (++SHAREDMONITOR.timeOut < 5) {
                        continue;
                    }
                    [LXDBacktraceLogger lxd_logMain];
                    [NSThread sleepForTimeInterval: lxd_restore_interval];
                }
            }
            SHAREDMONITOR.timeOut = 0;
        }
    });
}

标志位检查和测试线程超时

与UI卡顿分化的事,事件处理往往是处在kCFRunLoopBeforeWaiting的情事下收受了Sources事件源,最初阶小编尝试同样以多个小时有个别查询的格局处理。可是由于主线程的RunLoop在闲置时主旨处于Before Waiting气象,这就导致了即便没有产生其余卡顿,那种检查和测试方法也总能认定主线程处在卡顿状态。

就在此时寒神(南栀倾寒)推荐给自个儿一套Swift的卡顿检查和测试第①方ANREye,那套卡顿监察和控制方案差不多思路为:制造三个子线程实行巡回检查和测试,每一遍检查和测试时设置标记位为YES,然后派发任务到主线程大校标志位设置为NO。接着子线程沉睡超时阙值时间长度,判断标志位是不是成功设置成NO。假如没有认证主线程产生了卡顿,不或者处理派发职务:

从此发未来一定情景下,那种检查和测试方法会出错:当主线程被async大气的推行职分时,种种任务执行时间小于卡即刻间阙值,即对操作无影响。那时候由于设置标志位的async职分地点过于靠后,导致子线程沉睡后不可能成功安装,造成卡顿误报的景观。(ps:当然,实地衡量结果是核心不大概爆发那种情景)那套方案化解了地方监听RunLoop的缺点。结合那套方案,当主线程处在Before Waiting处境的时候,通过派发任务到主线程来设置标记位的不二法门处理常态下的卡顿检测:

dispatch_async(lxd_event_monitor_queue(), ^{
    while (SHAREDMONITOR.isMonitoring) {
        if (SHAREDMONITOR.currentActivity == kCFRunLoopBeforeWaiting) {
            __block BOOL timeOut = YES;
            dispatch_async(dispatch_get_main_queue(), ^{
                timeOut = NO;
                dispatch_semaphore_signal(SHAREDMONITOR.eventSemphore);
            });
            [NSThread sleepForTimeInterval: lxd_time_out_interval];
            if (timeOut) {
                [LXDBacktraceLogger lxd_logMain];
            }
            dispatch_wait(SHAREDMONITOR.eventSemphore, DISPATCH_TIME_FOREVER);
        }
    }
});

CADisplayLink监控

这几天看了iOS应用UI线程卡顿监察和控制后,对卡顿有了更深的明亮。从前文的描述来看卡顿就是主线程在某段时间内不可能处理其余事件。不过从电脑的角度来说,假使显示屏在连年的显示屏刷新周期之内不大概刷新显示屏内容,便是产生了卡顿。如下图第三个荧屏刷新周期出现了掉帧现象:

对此上述的八个方案。监听RunLoop实实在在会污染主线程。死循环在线程间通讯会造成大气的不用要花费,就算GCD的品质已经很好了。因而,借鉴于MrPeak的稿子,第三种方案选用CADisplayLink的法门来拍卖。思路是每种显示屏刷新周期派发标记位设置职分到主线程中,假如频仍超过16.7ms的基础代谢阙值,即可看作是发生了卡顿。

#define LXD_RESPONSE_THRESHOLD 10

dispatch_async(lxd_fluecy_monitor_queue(), ^{
    CADisplayLink * displayLink = [CADisplayLink displayLinkWithTarget: self selector: @selector(screenRenderCall)];
    [self.displayLink invalidate];
    self.displayLink = displayLink;

    [self.displayLink addToRunLoop: [NSRunLoop currentRunLoop] forMode: NSDefaultRunLoopMode];
    CFRunLoopRunInMode(kCFRunLoopDefaultMode, CGFLOAT_MAX, NO);
});

- (void)screenRenderCall {
    __block BOOL flag = YES;
    dispatch_async(dispatch_get_main_queue(), ^{
        flag = NO;
        dispatch_semaphore_signal(self.semphore);
    });
    dispatch_wait(self.semphore, 16.7 * NSEC_PER_MSEC);
    if (flag) {
        if (++self.timeOut < LXD_RESPONSE_THRESHOLD) { return; }
        [LXDBacktraceLogger lxd_logMain];
    }
    self.timeOut = 0;
}

尾言

就算市面上存在着多量的监察和控制种类轮子,不过小编认为如果不去思想轮子是如何是好的,不去品味造轮子,很多技术点难以融会贯通,使用起来。

多数开发者对于RunLoop兴许并不曾展开实际的接纳开发过,大概说就算了然RunLoop也只是处于理论的回味上。当然,也囊括调用堆栈追溯的技能。本文目的在于通过自笔者实现的卡顿监察和控制代码来让更加多开发者去理解那么些深层次的选拔与履行。

本文demo:LXDAppMonitor

参考资料

深深明白RunLoop
挪动端监控体系之技术原理
趣探 Mach-O:FishHook 解析
iOS中线程Call
Stack的捕获和平化解析1-2