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實戰:實時卡頓監控

更多資料,歡迎關注我的公衆號,不定時分享各類技術文章。

相關文章
相關標籤/搜索