iOS使用RunLoop监控线上卡顿

本文首发于个人我的博客html

前言

关于性能优化,我以前写过iOS性能优化,通过优化以后,咱们的APP,冷启动,从2.7秒优化到了0.6秒。git

关RunLoop,写过RunLoop详解之源码分析,以及详解RunLoop与多线程 ,那么使用RunLoop如何来监控性能卡顿呢。 经过iOS性能优化 咱们知道,简单来讲App卡顿,就是FPS达不到60帧率,丢帧现象,就会卡顿。可是不少时候,咱们只知道丢帧了。具体为何丢帧,却不是很清楚,那么咱们要怎么监控呢,首先咱们要明白,要找出卡顿,就是要找出主线程作了什么,而线程消息,是依赖RunLoop的,因此咱们可使用RunLoop来监控。github

RunLoop是用来监听输入源,进行调度处理的。若是RunLoop的线程进入睡眠前方法的执行时间过长而致使没法进入睡眠,或者线程唤醒后接收消息时间过长而没法进入下一步,就能够认为是线程受阻了。若是这个线程是主线程的话,表现出来的就是出现了卡顿。数据库

RunLoop和信号量

咱们可使用CFRunLoopObserverRef来监控NSRunLoop的状态,经过它能够实时得到这些状态值的变化。swift

runloop

关于runloop,能够参照 RunLoop详解之源码分析 这篇文章详细了解。这里简单总结一下:性能优化

  • runloop的状态
/* Run Loop Observer Activities */
typedef CF_OPTIONS(CFOptionFlags, CFRunLoopActivity) {
    kCFRunLoopEntry = (1UL << 0),           // 即将进入Loop
    kCFRunLoopBeforeTimers = (1UL << 1),    //即将处理Timer
    kCFRunLoopBeforeSources = (1UL << 2),   //即将处理Source
    kCFRunLoopBeforeWaiting = (1UL << 5),   //即将进入休眠
    kCFRunLoopAfterWaiting = (1UL << 6),    //刚从休眠中唤醒
    kCFRunLoopExit = (1UL << 7),            //即将退出Loop
    kCFRunLoopAllActivities = 0x0FFFFFFFU   //全部状态改变
};
复制代码
  • CFRunLoopObserverRef 的使用流程bash

    1. 设置Runloop observer的运行环境
    CFRunLoopObserverContext context = {0, (__bridge void *)self, NULL, NULL};
    复制代码
    1. 建立Runloop observer对象
    第一个参数:用于分配observer对象的内存
    第二个参数:用以设置observer所要关注的事件
    第三个参数:用于标识该observer是在第一次进入runloop时执行仍是每次进入runloop处理时均执行
    第四个参数:用于设置该observer的优先级
    第五个参数:用于设置该observer的回调函数
    第六个参数:用于设置该observer的运行环境
     // 建立Runloop observer对象
    _observer = CFRunLoopObserverCreate(kCFAllocatorDefault,
                                        kCFRunLoopAllActivities,
                                        YES,
                                        0,
                                        &runLoopObserverCallBack,
                                        &context);
    复制代码
    1. 将新建的observer加入到当前thread的runloop
    CFRunLoopAddObserver(CFRunLoopGetMain(), _observer, kCFRunLoopCommonModes);
    复制代码
    1. 将observer从当前thread的runloop中移除
    CFRunLoopRemoveObserver(CFRunLoopGetMain(), _observer, kCFRunLoopCommonModes);
    复制代码
    1. 释放 observer
    CFRelease(_observer); _observer = NULL;
    复制代码

信号量

关于信号量,能够详细参考 GCD信号量-dispatch_semaphore_t服务器

简单来讲,主要有三个函数多线程

dispatch_semaphore_create(long value); // 建立信号量
dispatch_semaphore_signal(dispatch_semaphore_t deem); // 发送信号量
dispatch_semaphore_wait(dispatch_semaphore_t dsema, dispatch_time_t timeout); // 等待信号量

复制代码

dispatch_semaphore_create(long value);和GCD的group等用法一致,这个函数是建立一个dispatch_semaphore_类型的信号量,而且建立的时候须要指定信号量的大小。 dispatch_semaphore_wait(dispatch_semaphore_t dsema, dispatch_time_t timeout); 等待信号量。若是信号量值为0,那么该函数就会一直等待,也就是不返回(至关于阻塞当前线程),直到该函数等待的信号量的值大于等于1,该函数会对信号量的值进行减1操做,而后返回。 dispatch_semaphore_signal(dispatch_semaphore_t deem); 发送信号量。该函数会对信号量的值进行加1操做。 一般等待信号量和发送信号量的函数是成对出现的。并发执行任务时候,在当前任务执行以前,用dispatch_semaphore_wait函数进行等待(阻塞),直到上一个任务执行完毕后且经过dispatch_semaphore_signal函数发送信号量(使信号量的值加1),dispatch_semaphore_wait函数收到信号量以后判断信号量的值大于等于1,会再对信号量的值减1,而后当前任务能够执行,执行完毕当前任务后,再经过dispatch_semaphore_signal函数发送信号量(使信号量的值加1),通知执行下一个任务......如此一来,经过信号量,就达到了并发队列中的任务同步执行的要求。并发

监控卡顿

原理: 利用观察Runloop各类状态变化的持续时间来检测计算是否发生卡顿

一次有效卡顿采用了“N次卡顿超过阈值T”的断定策略,即一个时间段内卡顿的次数累计大于N时才触发采集和上报:举例,卡顿阈值T=500ms、卡顿次数N=1,能够断定为单次耗时较长的一次有效卡顿;而卡顿阈值T=50ms、卡顿次数N=5,能够断定为频次较快的一次有效卡顿

主要代码

// minimum
static const NSInteger MXRMonitorRunloopMinOneStandstillMillisecond = 20;
static const NSInteger MXRMonitorRunloopMinStandstillCount = 1;

// default
// 超过多少毫秒为一次卡顿
static const NSInteger MXRMonitorRunloopOneStandstillMillisecond = 50;
// 多少次卡顿纪录为一次有效卡顿
static const NSInteger MXRMonitorRunloopStandstillCount = 1;

@interface YZMonitorRunloop(){
    CFRunLoopObserverRef _observer;  // 观察者
    dispatch_semaphore_t _semaphore; // 信号量
    CFRunLoopActivity _activity;     // 状态
}
@property (nonatomic, assign) BOOL isCancel; //f是否取消检测
@property (nonatomic, assign) NSInteger countTime; // 耗时次数
@property (nonatomic, strong) NSMutableArray *backtrace;

复制代码
-(void)registerObserver{
//    1. 设置Runloop observer的运行环境
    CFRunLoopObserverContext context = {0, (__bridge void *)self, NULL, NULL};
    // 2. 建立Runloop observer对象
  
//    第一个参数:用于分配observer对象的内存
//    第二个参数:用以设置observer所要关注的事件
//    第三个参数:用于标识该observer是在第一次进入runloop时执行仍是每次进入runloop处理时均执行
//    第四个参数:用于设置该observer的优先级
//    第五个参数:用于设置该observer的回调函数
//    第六个参数:用于设置该observer的运行环境
    _observer = CFRunLoopObserverCreate(kCFAllocatorDefault,
                                        kCFRunLoopAllActivities,
                                        YES,
                                        0,
                                        &runLoopObserverCallBack,
                                        &context);
    // 3. 将新建的observer加入到当前thread的runloop
    CFRunLoopAddObserver(CFRunLoopGetMain(), _observer, kCFRunLoopCommonModes);
    // 建立信号  dispatchSemaphore的知识参考:https://www.jianshu.com/p/24ffa819379c
    _semaphore = dispatch_semaphore_create(0); ////Dispatch Semaphore保证同步
    
    __weak __typeof(self) weakSelf = self;
    
    //    dispatch_queue_t queue = dispatch_queue_create("kadun", NULL);
    
    // 在子线程监控时长
    dispatch_async(dispatch_get_global_queue(0, 0), ^{
        //      dispatch_async(queue, ^{
        __strong __typeof(weakSelf) strongSelf = weakSelf;
        if (!strongSelf) {
            return;
        }
        while (YES) {
            if (strongSelf.isCancel) {
                return;
            }
            // N次卡顿超过阈值T记录为一次卡顿
            // 等待信号量:若是信号量是0,则阻塞当前线程;若是信号量大于0,则此函数会把信号量-1,继续执行线程。此处超时时间设为limitMillisecond 毫秒。
            // 返回值:若是线程是唤醒的,则返回非0,不然返回0
            long semaphoreWait = dispatch_semaphore_wait(self->_semaphore, dispatch_time(DISPATCH_TIME_NOW, strongSelf.limitMillisecond * NSEC_PER_MSEC));
            
            if (semaphoreWait != 0) {
                
                // 若是 RunLoop 的线程,进入睡眠前方法的执行时间过长而致使没法进入睡眠(kCFRunLoopBeforeSources),或者线程唤醒后接收消息时间过长(kCFRunLoopAfterWaiting)而没法进入下一步的话,就能够认为是线程受阻。
                //两个runloop的状态,BeforeSources和AfterWaiting这两个状态区间时间可以监测到是否卡顿
                if (self->_activity == kCFRunLoopBeforeSources || self->_activity == kCFRunLoopAfterWaiting) {
                    
                    if (++strongSelf.countTime < strongSelf.standstillCount){
                        NSLog(@"%ld",strongSelf.countTime);
                        continue;
                    }
                    [strongSelf logStack];
                    [strongSelf printLogTrace];
                    
                    NSString *backtrace = [YZCallStack yz_backtraceOfMainThread];
                    NSLog(@"++++%@",backtrace);
                    
                    [[YZLogFile sharedInstance] writefile:backtrace];
                    
                    if (strongSelf.callbackWhenStandStill) {
                        strongSelf.callbackWhenStandStill();
                    }
                }
            }
            strongSelf.countTime = 0;
        }
    });
}
复制代码

demo测试

我把demo放在了github demo地址

使用时候,只须要

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    // Override point for customization after application launch.
    
    [[YZMonitorRunloop sharedInstance] startMonitor];
    [YZMonitorRunloop sharedInstance].callbackWhenStandStill = ^{
        NSLog(@"eagle.检测到卡顿了");
    };
    return YES;
}

复制代码

控制器中,每次点击屏幕,休眠1秒钟,以下

#import "ViewController.h"

@interface ViewController ()

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    
}
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
     usleep(1 * 1000 * 1000); // 1秒
   
}

@end
复制代码

点击屏幕以后,打印以下

YZMonitorRunLoopDemo[10288:1915706] ==========检测到卡顿以后调用堆栈==========
 (
    "0 YZMonitorRunLoopDemo 0x00000001022c653c -[YZMonitorRunloop logStack] + 96",
    "1 YZMonitorRunLoopDemo 0x00000001022c62a0 __36-[YZMonitorRunloop registerObserver]_block_invoke + 484",
    "2 libdispatch.dylib 0x00000001026ab6f0 _dispatch_call_block_and_release + 24",
    "3 libdispatch.dylib 0x00000001026acc74 _dispatch_client_callout + 16",
    "4 libdispatch.dylib 0x00000001026afad4 _dispatch_queue_override_invoke + 876",
    "5 libdispatch.dylib 0x00000001026bddc8 _dispatch_root_queue_drain + 372",
    "6 libdispatch.dylib 0x00000001026be7ac _dispatch_worker_thread2 + 156",
    "7 libsystem_pthread.dylib 0x00000001b534d1b4 _pthread_wqthread + 464",
    "8 libsystem_pthread.dylib 0x00000001b534fcd4 start_wqthread + 4"
) 

libsystem_kernel.dylib          0x1b52ca400 __semwait_signal + 8
libsystem_c.dylib               0x1b524156c nanosleep + 212
libsystem_c.dylib               0x1b5241444 usleep + 64
YZMonitorRunLoopDemo            0x1022c18dc -[ViewController touchesBegan:withEvent:] + 76
UIKitCore                       0x1e1f4fcdc <redacted> + 336
UIKitCore                       0x1e1f4fb78 <redacted> + 60
UIKitCore                       0x1e1f5e0f8 <redacted> + 1584
UIKitCore                       0x1e1f5f52c <redacted> + 3140
UIKitCore                       0x1e1f3f59c <redacted> + 340
UIKitCore                       0x1e2005714 <redacted> + 1768
UIKitCore                       0x1e2007e40 <redacted> + 4828
UIKitCore                       0x1e2001070 <redacted> + 152
CoreFoundation                  0x1b56bf018 <redacted> + 24
CoreFoundation                  0x1b56bef98 <redacted> + 88
CoreFoundation                  0x1b56be880 <redacted> + 176
CoreFoundation                  0x1b56b97
复制代码

便可定位到卡顿位置

-[ViewController touchesBegan:withEvent:]

卡顿日志写入本地

上面已经监控到了卡顿,和调用堆栈。若是是debug模式下,能够直接看日志,若是想在线上查看的话,能够写入本地,而后上传到服务器

写入本地数据库

  • 建立本地路径
-(NSString *)getLogPath{
    NSArray *paths  = NSSearchPathForDirectoriesInDomains(NSCachesDirectory,NSUserDomainMask,YES);
    NSString *homePath = [paths objectAtIndex:0];
    
    NSString *filePath = [homePath stringByAppendingPathComponent:@"Caton.log"];
    return filePath;
}

复制代码
  • 若是是第一次写入,带上设备信息,手机型号等信息
NSString *filePath = [self getLogPath];
    NSFileManager *fileManager = [NSFileManager defaultManager];
    
    if(![fileManager fileExistsAtPath:filePath]) //若是不存在
    {
        NSString *str = @"卡顿日志";
        NSString *systemVersion = [NSString stringWithFormat:@"手机版本: %@",[YZAppInfoUtil iphoneSystemVersion]];
        NSString *iphoneType = [NSString stringWithFormat:@"手机型号: %@",[YZAppInfoUtil iphoneType]];
        str = [NSString stringWithFormat:@"%@\n%@\n%@",str,systemVersion,iphoneType];
        [str writeToFile:filePath atomically:YES encoding:NSUTF8StringEncoding error:nil];
        
    }
复制代码
  • 若是本地文件已经存在,就先判断大小是否过大,决定是否直接写入,仍是先上传到服务器
float filesize = -1.0;
 if ([fileManager fileExistsAtPath:filePath]) {
            NSDictionary *fileDic = [fileManager attributesOfItemAtPath:filePath error:nil];
            unsigned long long size = [[fileDic objectForKey:NSFileSize] longLongValue];
            filesize = 1.0 * size / 1024;
  }
        
 NSLog(@"文件大小 filesize = %lf",filesize);
 NSLog(@"文件内容 %@",string);
 NSLog(@" ---------------------------------");
        
if (filesize > (self.MAXFileLength > 0 ? self.MAXFileLength:DefaultMAXLogFileLength)) {
     // 上传到服务器
       NSLog(@" 上传到服务器");
       [self update];
       [self clearLocalLogFile];
       [self writeToLocalLogFilePath:filePath contentStr:string];
   }else{
        NSLog(@"继续写入本地");
        [self writeToLocalLogFilePath:filePath contentStr:string];
   }
复制代码

压缩日志,上传服务器

由于都是文本数据,因此咱们能够压缩以后,打打下降占用空间,而后进行上传,上传成功以后,删除本地,而后继续写入,等待下次写日志

压缩工具

使用 SSZipArchive具体使用起来也很简单,

// Unzipping
NSString *zipPath = @"path_to_your_zip_file";
NSString *destinationPath = @"path_to_the_folder_where_you_want_it_unzipped";
[SSZipArchive unzipFileAtPath:zipPath toDestination:destinationPath];
// Zipping
NSString *zippedPath = @"path_where_you_want_the_file_created";
NSArray *inputPaths = [NSArray arrayWithObjects:
                       [[NSBundle mainBundle] pathForResource:@"photo1" ofType:@"jpg"],
                       [[NSBundle mainBundle] pathForResource:@"photo2" ofType:@"jpg"]
                       nil];
[SSZipArchive createZipFileAtPath:zippedPath withFilesAtPaths:inputPaths];
复制代码

代码中

NSString *zipPath = [self getLogZipPath];
    NSString *password = nil;
    NSMutableArray *filePaths = [[NSMutableArray alloc] init];
    [filePaths addObject:[self getLogPath]];
    BOOL success = [SSZipArchive createZipFileAtPath:zipPath withFilesAtPaths:filePaths withPassword:password.length > 0 ? password : nil];
    
    if (success) {
        NSLog(@"压缩成功");
        
    }else{
        NSLog(@"压缩失败");
    }
复制代码

具体若是上传到服务器,使用者能够用AFN等将本地的 zip文件上传到文件服务器便可,就不赘述了。

至此,咱们作到了,用runloop,监控卡顿,写入日志,而后压缩上传服务器,删除本地的过程。

详细代码见demo地址

参考资料 :

BSBacktraceLogger

GCD信号量-dispatch_semaphore_t

SSZipArchive

简单监测iOS卡顿的demo

RunLoop实战:实时卡顿监控

更多资料,欢迎关注我的公众号,不定时分享各类技术文章。

相关文章
相关标签/搜索