前言

在实现需求的同时,能写出既优雅性能又高效的代码是每个开发者都在追求的目标,但是在实际开发中,随着每个版本需求的迭代,功能变得越来越复杂,加上开发者的意识不够或者一时疏忽,日渐复杂的工程很容易产生或多或少的问题。
比如,app随机丢失动画、用户反馈app卡死等等的问题,这些问题都严重影响使用,也会降低产品口碑,我们除了在开发过程中,通过instrument来检测这些问题,还可以借助一些第三方监控工具来解决这些问题,KMCGeigerCounter就是一个很好的卡顿检测器。

在分析KMCGeigerCounter这个第三方app卡顿检测工具之前,我们先来分析几种UI卡顿检测方案。

卡顿检测的分析

简单来说,主线程为了达到接近60fps的绘制效率,不能在UI线程有单个超过(1/60s≈16ms)的计算任务。

通过Instrument设置16ms的采样率可以检测出大部分这种费时的任务,但有以下缺点:

1、Instrument profile一次重新编译,时间较长。
2、只能针对特定的操作场景进行检测,要预先知道卡顿产生的场景。
3、每次猜测,更改,再猜测再以此循环,需要重新profile。

我们的目标方案是,检测能够自动发生,并不需要开发人员做任何预先配置或profile。运行时发现卡顿能即时通知开发人员导致卡顿的函数调用栈。

检测方案一:基于Runloop

主线程绝大部分计算或者绘制任务都是以Runloop为单位发生。单次Runloop如果时长超过16ms,就会导致UI体验的卡顿。那如何检测单次Runloop的耗时呢?
Runloop的生命周期及运行机制虽然不透明,但苹果提供了一些API去检测部分行为。我们可以通过如下代码监听Runloop每次进入的事件:

- (void)setupRunloopObserver{static dispatch_once_t onceToken;dispatch_once(&onceToken, ^{CFRunLoopRef runloop = CFRunLoopGetCurrent(); CFRunLoopObserverRef enterObserver;enterObserver = CFRunLoopObserverCreate(CFAllocatorGetDefault(),kCFRunLoopEntry | kCFRunLoopExit,true,-0x7FFFFFFF,BBRunloopObserverCallBack, NULL);CFRunLoopAddObserver(runloop, enterObserver, kCFRunLoopCommonModes);CFRelease(enterObserver);});
}
static void BBRunloopObserverCallBack(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info) {switch (activity) {case kCFRunLoopEntry: {NSLog(@"enter runloop...");}break;case kCFRunLoopExit: {NSLog(@"leave runloop...");}break;default: break;}
}

看起来kCFRunLoopExit的时间,减去kCFRunLoopEntry的时间,即为一次Runloop所耗费的时间,这样就能找出大于16ms的runloop。
但是demo实践结果是:kCFRunLoopExit的时间减去kCFRunLoopEntry,得到的时间差,貌似不准。
缺陷:但无法定位到具体的函数,只能起到预报的作用。

方案一是可以通过监测runloop计算每次主线程的任务执行时间是否超过16ms来判断是否有卡顿,但是缺点在于无法定位卡顿的位置,所以有了方案二。

检测方案二:基于线程

最理想的方案是让UI线程“主动汇报”当前耗时的任务,听起来简单做起来不轻松。

我们可以假设这样一套机制:每隔16ms让UI线程来报道一次,如果16ms之后UI线程没来报道,那就一定是在执行某个耗时的任务。这种抽象的描述翻译成代码,可以用如下表述:
我们启动一个worker线程,worker线程每隔一小段时间(delta)ping一下主线程(发送一个NSNotification),如果主线程此时有空,必然能接收到这个通知,并pong以下(发送另一个NSNotification),如果worker线程超过delta时间没有收到pong的回复,那么可以推测UI线程必然在处理其他任务了,此时我们执行第二步操作,暂停UI线程,并打印出当前UI线程的函数调用栈。

难点在这第二步,如何暂停UI线程,同时获取到callstack。

iOS的多线程编程一般使用NSOperation或者GCD,这两者都无法暂停每个正在执行的线程。
所谓的cancel调用也只能在目标线程空闲的时候,主动检测cancelled状态,然后主动sleep,这显然非我所欲。

如果我们从worker线程给UI线程发送signal,UI线程会被即刻暂停,并进入接收signal的回调,再将callstack打印就接近目标了。
iOS确实允许在主线程注册一个signal处理函数,类似这样:

signal(CALLSTACK_SIG, thread_singal_handler);

代码实现:

//在主线程注册signal handler
signal(CALLSTACK_SIG, thread_singal_handler);//通过NSNotification完成ping pong流程[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(detectPingFromWorkerThread) name:Notification_PMainThreadWatcher_Worker_Ping object:nil];[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(detectPongFromMainThread) name:Notification_PMainThreadWatcher_Main_Pong object:nil];//如果ping超时,pthread_kill主线程。
pthread_kill(mainThreadID, CALLSTACK_SIG);//主线程被暂停,进入signal回调,通过[NSThread callStackSymbols]获取主线程当前callstack。
static void thread_singal_handler(int sig) {NSLog(@"main thread catch signal: %d", sig);if (sig != CALLSTACK_SIG) {return;}NSArray* callStack = [NSThread callStackSymbols];id<PMainThreadWatcherDelegate> del = [PMainThreadWatcher sharedInstance].watchDelegate;if (del != nil && [del respondsToSelector:@selector(onMainThreadSlowStackDetected:)])  {[del onMainThreadSlowStackDetected:callStack];}else {NSLog(@"detect slow call stack on main thread! \n");for (NSString* call in callStack) {NSLog(@"%@\n", call);}}return;
}

说明:
值得一提的是上述代码不能调试,因为调试时gdb会干扰signal的处理,导致signal handler无法进,但UI线程在遇到卡顿的时候还是能正常被中断。
现阶段的实现,worker线程每隔1秒会ping一次UI线程,检测出运行超过16ms的调用栈。开发阶段可以将1s的间隔调至更短,可能会对app整体性能造成少许的负担,但能检测出更多的卡顿调用。

signal相关的知识点
iOS系统的signal可以被归为两类:

第一类内核signal,这类signal由操作系统内核发出,比如当我们访问VM上不属于自己的内存地址时,会触发EXC_BAD_ACCESS异常,内核检测到该异常之后会发出第二类signal:BSD signal,传递给应用程序。

第二类BSD signal,这类signal需要被应用程序自己处理。通常当我们的App进程运行时遇到异常,比如NSArray越界访问。产生异常的线程会向当前进程发出signal,如果这个signal没有别处理,我们的app就会crash了。

平常我们调试的时候很容易遇到第二类signal导致整个程序被中断的情况,gdb同时会将每个线程的调用栈呈现出来。

pthread_kill允许我们向目标线程(UI线程)发送signal,目标线程被暂停,同时进入signal回调,将当前线程的callstack获取并处理,处理完signal之后UI线程继续运行。将callstack打印即可精确定位产生问题的函数调用栈。

方案一监听RunLoop无疑会污染主线程,死循环在线程间通信会造成大量的不必要损耗,即便GCD的性能已经很好了,因此,第三种方案采用CADisplayLink的方式来处理。

检测方案三:CADisplayLink监控

CADisplayLink监控的思路是每个屏幕刷新周期,派发标记位设置任务到主线程中,如果多次超出16.7ms的刷新阙值,即可看作是发生了卡顿。

什么是CADisplayLink?
CADisplayLink是一个能让我们以和屏幕刷新率相同的频率将内容画到屏幕上的定时器。
我们在应用中创建一个新的 CADisplayLink 对象,把它添加到一个runloop中,并给它提供一个 target 和selector 在屏幕刷新的时候调用。
一旦 CADisplayLink 以特定的模式注册到runloop之后,每当屏幕需要刷新的时候,runloop就会调用CADisplayLink绑定的target上的selector,这时target可以读到 CADisplayLink 的每次调用的时间戳,用来准备下一帧显示需要的数据。
例如一个视频应用使用时间戳来计算下一帧要显示的视频数据。在UI做动画的过程中,需要通过时间戳来计算UI对象在动画的下一帧要更新的大小等等。
在添加进runloop的时候我们应该选用高一些的优先级,来保证动画的平滑。可以设想一下,我们在动画的过程中,runloop被添加进来了一个高优先级的任务,那么,下一次的调用就会被暂停转而先去执行高优先级的任务,然后在接着执行CADisplayLink的调用,从而造成动画过程的卡顿,使动画不流畅。
duration属性提供了每帧之间的时间,也就是屏幕每次刷新之间的的时间。我们可以使用这个时间来计算出下一帧要显示的UI的数值。但是 duration只是个大概的时间,如果CPU忙于其它计算,就没法保证以相同的频率执行屏幕的绘制操作,这样会跳过几次调用回调方法的机会。
frameInterval属性是可读可写的NSInteger型值,标识间隔多少帧调用一次selector 方法,默认值是1,即每帧都调用一次。如果每帧都调用一次的话,对于iOS设备来说那刷新频率就是60HZ也就是每秒60次,如果将 frameInterval 设为2 那么就会两帧调用一次,也就是变成了每秒刷新30次。
我们通过pause属性开控制CADisplayLink的运行。当我们想结束一个CADisplayLink的时候,应该调用-(void)invalidate
从runloop中删除并删除之前绑定的 target跟selector
另外CADisplayLink 不能被继承。

#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;
}

经过前面的分析,CADisplayLink监控是一个相对而言比较优的方案,KMCGeigerCounter就是一个借助CADisplayLink进行卡顿检测的第三方工具,接下来分析一下KMCGeigerCounter源码。

KMCGeigerCounter介绍

KMCGeigerCounter是一个iOS帧速计算器,像盖革计数器那样,当动画丢失一帧时它就记录一次。
掉帧通常是不可见的,但是很难区分55fps和60fps之间的不同,而KMCGeigerCounter可以让你观测到掉落5帧的情况,可以通过这个来检测app的卡顿程度。
KMCGeigerCounter弄了一个FPS监控条,通过CADisplayLink来获取屏幕刷新频率,在使用过程中就能即时知道什么页面流畅什么页面会卡顿。

因为官方的demo在didFinishLaunchingWithOptions方法中写了比较复杂的代码 而在Xcode7及以上的SDK不允许在设置rootViewController之前做过于复杂的操作 所以程序一直无法正常启动。

dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{[KMCGeigerCounter sharedGeigerCounter].enabled = YES;
});

KMCGeigerCounter源码分析

程序里面,通过CADisplayLink来检测CPU的卡顿,CADisplayLink 是一个计时器对象,可以使用这个对象来保持应用中的绘制与显示刷新的同步。更通俗的讲,电子显示屏都是由一个个像素点构成,要让屏幕显示的内容变化,需要以一定的频率刷新这些像素点的颜色值,系统会在每次刷新时触发 CADisplayLink。

#define kNormalFrameDuration    (1/60)      //流畅的屏幕刷新时,每帧之间的间隔时间
self.displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(displayLinkWillDraw:)];
[self.displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];- (void)displayLinkWillDraw:(CADisplayLink *)displayLink {//当前屏幕刷新回调的时间CFTimeInterval currentFrameTime = displayLink.timestamp;//这次屏幕刷新和上一次屏幕刷新的时间间隔CFTimeInterval frameDuration = currentFrameTime - [self lastFrameTime];//如果界面不卡顿,那么屏幕刷新频率应该是1秒钟60帧,那么帧间间隔时间应该是1/60秒,如果当前刷新和上一次屏幕刷新的时间间隔,超过这个时间间隔,那么就属于卡顿,则系统响一下,这里设定,屏幕刷新如果是1秒钟少于40帧(60/1.5)则响一下。if (1.5 < frameDuration / kNormalFrameDuration) {AudioServicesPlaySystemSound(self.tickSoundID);}//记录每次屏幕刷新时的时间(60次)[self recordFrameTime:currentFrameTime];//显示帧率和丢帧数[self updateMeterLabel];
}
- (void)recordFrameTime:(CFTimeInterval)frameTime {++self.frameNumber;//通过一个数组(60个元素),来记录屏幕每次刷新时的时间(60次刷新的时间)_lastSecondOfFrameTimes[self.frameNumber % kHardwareFramesPerSecond] = frameTime;
}
//获取上一秒丢失的帧数
- (NSInteger)droppedFrameCountInLastSecond {NSInteger droppedFrameCount = 0;CFTimeInterval lastFrameTime = CACurrentMediaTime() - kNormalFrameDuration;for (NSInteger i = 0; i < kHardwareFramesPerSecond; ++i) {//_lastSecondOfFrameTimes数组记录了前60次屏幕刷新的时间,如果当前时间与这个数组中60次刷新时间超过了1秒钟,那么都会被丢弃而不显示,累加则知道1秒钟丢了多少帧if (1.0 <= lastFrameTime - _lastSecondOfFrameTimes[i]) {++droppedFrameCount;}}return droppedFrameCount;
}
- (void)updateMeterLabel {//一秒钟屏幕刷新时的丢帧数NSInteger droppedFrameCount = self.droppedFrameCountInLastSecond;//一秒钟屏幕刷新时的显示帧数NSInteger drawnFrameCount = self.drawnFrameCountInLastSecond;//...显示代码
}

将CADisplayLink添加到主线程runloop中,一旦屏幕需要刷新时,就会回调CADisplayLink对应的selector方法,在 selector 中可以通过 CADisplayLink 对象的属性 duration、frameInterval 和 timestamp 获取帧率和时间信息。

总结:
上面CADisplayLink是检测CPU的卡顿,但是GPU的卡顿需要用到SKView,库里面用到一个 1×1 的 SKView 来进行监视。

iOS性能优化-UI卡顿检测相关推荐

  1. Android性能优化 - 消除卡顿

    性能优化系列阅读 Android性能优化 性能优化 - 消除卡顿 性能优化 - 内存优化 性能分析工具 - TraceView Android性能分析工具 消除卡顿 什么是卡顿及卡顿的衡量标准 产生卡 ...

  2. UITableView性能优化与卡顿

    UITableView性能优化与卡顿问题 最常用的就是cell的重用, 注册重用标识符 如果不重用cell时,每当一个cell显示到屏幕上时,就会重新创建一个新的cell 如果有很多数据的时候,就会堆 ...

  3. android 北斗定位代码_大牛三步教你解决,BAT资深APP性能优化系列-卡顿定位问题,收藏哦

    前言 讲解的内容大体包含,异步优化,启动优化,卡顿优化,内存优化,ARTHook, 监控耗时盲区,网络,电量,瘦身及APP容灾方案等 性能优化的系统学习方法 330页 PDF Android进阶核心笔 ...

  4. java线程太多卡顿_性能优化之卡顿延迟

    和你一起终身学习,这里是程序员 Android 本篇文章主要介绍 Android 开发中的部分知识点,通过阅读本篇文章,您将收获以下内容: 1.UI 渲染简介 2.识别延迟 3.Visual insp ...

  5. Android渲染优化之卡顿检测、统计fps

    学习自 https://juejin.im/post/5a6fd7b86fb9a01ca47ac6e8 adb shell dumpsys gfxinfo 这个是一个方法,但是用的不多 从looper ...

  6. iOS性能优化 - 耗电优化

    耗电来源: CPU处理: 网络: 定位: 图像. 如何优化: 1. 尽可能降低CPU.GPU功耗; 2. 少用定时器: 3. 优化I/O操作: 尽量不要频繁写入小数据,最好批量一次性写入: 读写大量重 ...

  7. iOS性能优化 - 启动优化

    APP的启动可以分为2种 冷启动(Cold Launch):从零开始启动APP: 热启动(Warm Launch):APP已经在内存中,在后台存活着,再次点击图标启动APP. APP启动时间的优化,主 ...

  8. Android UI性能优化 检测应用中的UI卡顿

    本文已在我的公众号hongyangAndroid首发. 转载请标明出处: http://blog.csdn.net/lmj623565791/article/details/58626355 本文出自 ...

  9. ios实时卡顿检测和优化方案

    在移动设备上开发软件,性能一直是我们最为关心的话题之一,我们作为程序员除了需要努力提高代码质量之外,及时发现和监控软件中那些造成性能低下的"罪魁祸首"也是我们神圣的职责.友盟+U- ...

最新文章

  1. Android 热修复总结
  2. 数据类型,运算符和表达式03 - 零基础入门学习C语言04
  3. MongoError: topology was destroyed解决方法
  4. android ota更新app,企业 OTA 更新  |  Android 开源项目  |  Android Open Source Project
  5. sql 某列数据全部为0则不显示该列_数据产品经理养成记(五):汇总分析
  6. 删除单词后缀(信息学奥赛一本通-T1141)
  7. 人生这场牌,怎么打才是最优解?
  8. 页面无任何操作30秒后退出1
  9. oracle批量更新数据从另一表_全市场期货数据的批量下载和更新
  10. 软启动器说明书_软启动器怎么接线?一张电路图一张实物图供大家参考
  11. S5PV210体系结构与接口10:MMU编程
  12. 系统学习NLP(二十)--文本聚类
  13. 第三方库之 - SDWebImage
  14. 计算机共享打印怎么设置密码,共享打印机需要密码的解决方法
  15. 简单操作stm32f10xIO端口配置
  16. css钢铁侠视角,css练习制作钢铁侠胸口的小型核反应堆
  17. python根据时间序列画折线图_Python如何根据时间序列数据作图
  18. Excel分列时拒绝让超过15位的数字变成科学计数法
  19. uniapp 小程序获取微信收货地址
  20. matlab如何实现动态显示,matlab 坐标图动画,动态显示数据

热门文章

  1. 【FaceRevelio】一种用于智能手机的带有前置摄像头的 人脸活跃度检测系统
  2. 手机 播放音频 切换听筒和
  3. 【Excel】给Excel生成工作表目录
  4. 新款戴尔取消开盖自动开机办法,以戴尔7591为例子如下
  5. 【Java】Java基础
  6. Tableau的特点和案例--可视化和交互化 和 其他
  7. 用户画像标签体系及实现方法
  8. 自学html4,HTML4
  9. 什么是抽象类,什么情况下会用到抽象类?
  10. 操作系统权限提升(十五)之绕过UAC提权-基于白名单DLL劫持绕过UAC提权