• iOS之卡顿检测


    很多iOS 开发,开发过程中都会面临到解决App卡顿问题,从而也衍生出很多的方法去解决卡顿,这篇文章来描述下iOS卡顿产生的原因,以及如何进行iOS卡顿检测分析。

    iOS卡顿原理

    像素是如何显示到屏幕上?

    从最初的电子枪显示器说起,电子枪逐行读取像素点,逐行发射到屏幕上,每当一行扫描完成,显示器会发出水平同步信号HSync;然后继续下一行,直到最后一行完成一帧的绘制,电子枪恢复到起点继续下一帧的绘制,显示器会发出一个垂直同步信号VSync。对于iOS设备,VSync信号的间隔是16.7ms,也就是1秒60帧。

    实际绘制过程中:

    1.由CPU 计算好显示的内容:如视图的创建,布局的计算,图片的解码,文本的绘制,

    2.然后GPU完成渲染,得到最终的像素,像素会输出到帧缓存(Frame Buffer)中

    3.Video Controller (视频控制器)发出垂直信号(每16.67ms读取一次)进行读取Frame Buffer

    4.最终输出到Monitor(显示器)上面。

    假设只有一个Frame Buffer,意味着GPU和CPU必须在VSync发出的瞬间完成前面所有的工作,否则在视频控制器显示的过程中修改Frame Buffer,那么显示器就会在这一帧的前半部分显示上一帧的内容,下半部分显示当前帧的内容,造成画面断层的怪异现象。为了解决这个问题,大多数平台都引入了多缓存机制,比如iOS平台的双缓存, Android平台的三缓存机制;

    因为苹果使用双缓冲区,根据上图,当垂直信号过来之后,但是GPU还没有渲染完成,就会出现掉帧(卡顿)显现。

    iOS卡顿检测分析

    市面上的iOS卡顿分析方案有三种:监控FPS、监控RunLoop、ping主线程

    方案一:监控FPS

    一般来说,我们约定60FPS即为流畅。那么反过来,如果App在运行期间出现了掉帧,即可认为出现了卡顿。

    监控FPS的方案几乎都是基于CADisplayLink实现的。简单介绍一下CADisplayLink:CADisplayLink是一个和屏幕刷新率保持一致的定时器,一但 CADisplayLink 以特定的模式注册到runloop之后,每当屏幕需要刷新的时候,runloop就会调用CADisplayLink绑定的target上的selector。
    可以通过向RunLoop中添加CADisplayLink,根据其回调来计算出当前画面的帧数。

    1. //
    2. // FPSMonitor.m
    3. // DemoTest2022
    4. //
    5. // Created by wangyun on 2022/6/21.
    6. //
    7. #import "FPSMonitor.h"
    8. #import "UIKit/UIKit.h"
    9. @interface FPSMonitor()
    10. @property(nonatomic,strong) CADisplayLink *link;
    11. @property(nonatomic,assign) NSInteger count;
    12. @property(nonatomic,assign) NSTimeInterval lastTime;
    13. @end
    14. @implementation FPSMonitor
    15. -(void)beginMonitor{
    16. _link = [CADisplayLink displayLinkWithTarget:self selector:@selector(fpsInfoCaculate:)];
    17. [_link addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
    18. }
    19. -(void)fpsInfoCaculate:(CADisplayLink *)sender{
    20. if (_lastTime==0) {
    21. _lastTime = sender.timestamp;
    22. }
    23. _count ++;
    24. double deltaTime = sender.timestamp - _lastTime;
    25. if (deltaTime >= 1) {
    26. NSInteger FPS = _count/deltaTime;
    27. _lastTime = sender.timestamp;
    28. _count = 0;
    29. NSLog(@"FPS:%li",(NSInteger)ceil(FPS+0.5));
    30. }
    31. }
    32. @end

     FPS的好处就是直观,小手一划后FPS下降了,说明页面的某处有性能问题。坏处就是只知道这是页面的某处,不能准确定位到具体的堆栈。

    如tableview快速滑动FPS会降低,说明有性能损耗比较大。

    方案二:监控RunLoop

    首先来介绍下什么是RunLoop。RunLoop是维护其内部事件循环的一个对象,它在程序运行过程中重复的做着一些事情,例如接收消息、处理消息、休眠等等。

    所谓的事件循环,就是对事件/消息进行管理,没有消息时,休眠线程以避免资源消耗,从用户态切换到内核态。

    有事件/消息需要进行处理时,立即唤醒线程,回到用户态进行处理。

    1. #import <UIKit/UIKit.h>
    2. #import "AppDelegate.h"
    3. int main(int argc, char * argv[]) {
    4. NSString * appDelegateClassName;
    5. @autoreleasepool {
    6. appDelegateClassName = NSStringFromClass([AppDelegate class]);
    7. }
    8. return UIApplicationMain(argc, argv, nil, appDelegateClassName);
    9. }

    UIApplicationMain函数内部会启动主线程的RunLoop,使得iOS程序持续运行。

    iOS系统中有两套API来使用RunLoop,NSRunLoop(CFRunLoopRef的封装)和CFRunLoopRef。Foundation框架是不开源的,可以通过开源的CoreFoundation来分析RunLoop内部实现。

    RunLoop对象底层就是一个CFRunLoopRef结构体,内部数据如下:

    1. struct __CFRunLoop {
    2. pthread_t _pthread; // 与RunLoop一一对应的线程
    3. CFMutableSetRef _commonModes; // 存储着NSString(mode名称)的集合
    4. CFMutableSetRef _commonModeItems; // 存储着被标记为commonMode的Source0/Source1/Timer/Observer
    5. CFRunLoopModeRef _currentMode; // RunLoop当前的运行模式
    6. CFMutableSetRef _modes; // 存储着RunLoop所有的 Mode(CFRunLoopModeRef)模式
    7. // 其他属性略
    8. };
    1. struct __CFRunLoopMode {
    2. CFStringRef _name; // mode 类型,如:NSDefaultRunLoopMode
    3. CFMutableSetRef _sources0; // 事件源 sources0
    4. CFMutableSetRef _sources1; // 事件源 sources1
    5. CFMutableArrayRef _observers; // 观察者
    6. CFMutableArrayRef _timers; // 定时器
    7. // 其他属性略
    8. };

    Source0被添加到RunLoop上时并不会主动唤醒线程,需要手动去唤醒。Source0负责对触摸事件的处理以及performSeletor:onThread:

    Source1具备唤醒线程的能力,使用的是基于Port的线程间通信。Source1负责捕获系统事件,并将事件交由Source0处理。

    1. struct __CFRunLoopSource {
    2. CFRuntimeBase _base;
    3. uint32_t _bits;
    4. pthread_mutex_t _lock;
    5. CFIndex _order; /* immutable */
    6. CFMutableBagRef _runLoops;
    7. union {
    8. CFRunLoopSourceContext version0; // 表示 sources0
    9. CFRunLoopSourceContext1 version1; // 表示 sources1
    10. } _context;
    11. };

    简单过一下RunLoop的源码。

    1. void CFRunLoopRun(void) { /* DOES CALLOUT */
    2. int32_t result;
    3. do {
    4. result = CFRunLoopRunSpecific(CFRunLoopGetCurrent(), kCFRunLoopDefaultMode, 1.0e10, false);
    5. CHECK_FOR_FORK();
    6. } while (kCFRunLoopRunStopped != result && kCFRunLoopRunFinished != result);
    7. }

    简单来看RunLoop是个 do..while循环,下面来看看循环中具体干了哪些事情。

    1. SInt32 CFRunLoopRunSpecific(CFRunLoopRef rl, CFStringRef modeName, CFTimeInterval seconds, Boolean returnAfterSourceHandled) { /* DOES CALLOUT */
    2. CHECK_FOR_FORK();
    3. if (__CFRunLoopIsDeallocating(rl)) return kCFRunLoopRunFinished;
    4. __CFRunLoopLock(rl);
    5. //根据modeName来查找本次运行的mode
    6. CFRunLoopModeRef currentMode = __CFRunLoopFindMode(rl, modeName, false);
    7. // 如果没找到mode 或者 mode里没有任何的事件,就此停止,不再循环
    8. if (NULL == currentMode || __CFRunLoopModeIsEmpty(rl, currentMode, rl->_currentMode)) {
    9. Boolean did = false;
    10. if (currentMode) __CFRunLoopModeUnlock(currentMode);
    11. __CFRunLoopUnlock(rl);
    12. return did ? kCFRunLoopRunHandledSource : kCFRunLoopRunFinished;
    13. }
    14. CFRunLoopModeRef previousMode = rl->_currentMode;
    15. rl->_currentMode = currentMode;
    16. int32_t result = kCFRunLoopRunFinished;
    17. // 通知 observers 即将进入RunLoop
    18. if (currentMode->_observerMask & kCFRunLoopEntry ) __CFRunLoopDoObservers(rl, currentMode, kCFRunLoopEntry);
    19. // RunLoop具体要做的事情
    20. result = __CFRunLoopRun(rl, currentMode, seconds, returnAfterSourceHandled, previousMode);
    21. // 通知 observers 即将退出RunLoop
    22. if (currentMode->_observerMask & kCFRunLoopExit ) __CFRunLoopDoObservers(rl, currentMode, kCFRunLoopExit);
    23. __CFRunLoopModeUnlock(currentMode);
    24. __CFRunLoopPopPerRunData(rl, previousPerRun);
    25. rl->_currentMode = previousMode;
    26. __CFRunLoopUnlock(rl);
    27. return result;
    28. }

    整体流程如下图所示。

    事件循环机制

    根据这张图可以看出:RunLoop在BeforeSources和AfterWaiting后会进行任务的处理。可以在此时阻塞监控线程并设置超时时间,若超时后RunLoop的状态仍为RunLoop在BeforeSources或AfterWaiting,表明此时RunLoop仍然在处理任务,主线程发生了卡顿。

    1. - (void)beginMonitor {
    2. self.dispatchSemaphore = dispatch_semaphore_create(0);
    3. // 第一个监控,监控是否处于 运行状态
    4. CFRunLoopObserverContext context = {0, (__bridge void *) self, NULL, NULL, NULL};
    5. self.runLoopBeginObserver = CFRunLoopObserverCreate(kCFAllocatorDefault,
    6. kCFRunLoopAllActivities,
    7. YES,
    8. LONG_MIN,
    9. &myRunLoopBeginCallback,
    10. &context);
    11. // 第二个监控,监控是否处于 睡眠状态
    12. self.runLoopEndObserver = CFRunLoopObserverCreate(kCFAllocatorDefault,
    13. kCFRunLoopAllActivities,
    14. YES,
    15. LONG_MAX,
    16. &myRunLoopEndCallback,
    17. &context);
    18. CFRunLoopAddObserver(CFRunLoopGetMain(), self.runLoopBeginObserver, kCFRunLoopCommonModes);
    19. CFRunLoopAddObserver(CFRunLoopGetMain(), self.runLoopEndObserver, kCFRunLoopCommonModes);
    20. // 创建子线程监控
    21. dispatch_async(dispatch_get_global_queue(0, 0), ^{
    22. //子线程开启一个持续的loop用来进行监控
    23. while (YES) {
    24. long semaphoreWait = dispatch_semaphore_wait(self.dispatchSemaphore, dispatch_time(DISPATCH_TIME_NOW, 17 * NSEC_PER_MSEC));
    25. if (semaphoreWait != 0) {
    26. if (!self.runLoopBeginObserver || !self.runLoopEndObserver) {
    27. self.timeoutCount = 0;
    28. self.dispatchSemaphore = 0;
    29. self.runLoopBeginActivity = 0;
    30. self.runLoopEndActivity = 0;
    31. return;
    32. }
    33. // 两个runloop的状态,BeforeSources和AfterWaiting这两个状态区间时间能够检测到是否卡顿
    34. if ((self.runLoopBeginActivity == kCFRunLoopBeforeSources || self.runLoopBeginActivity == kCFRunLoopAfterWaiting) ||
    35. (self.runLoopEndActivity == kCFRunLoopBeforeSources || self.runLoopEndActivity == kCFRunLoopAfterWaiting)) {
    36. // 出现三次出结果
    37. if (++self.timeoutCount < 2) {
    38. continue;
    39. }
    40. NSLog(@"调试:监测到卡顿");
    41. } // end activity
    42. }// end semaphore wait
    43. self.timeoutCount = 0;
    44. }// end while
    45. });
    46. }
    47. // 第一个监控,监控是否处于 运行状态
    48. void myRunLoopBeginCallback(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info) {
    49. RunLoopMonitor2* lagMonitor = (__bridge RunLoopMonitor2 *)info;
    50. lagMonitor.runLoopBeginActivity = activity;
    51. dispatch_semaphore_t semaphore = lagMonitor.dispatchSemaphore;
    52. dispatch_semaphore_signal(semaphore);
    53. }
    54. // 第二个监控,监控是否处于 睡眠状态
    55. void myRunLoopEndCallback(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info) {
    56. RunLoopMonitor2* lagMonitor = (__bridge RunLoopMonitor2 *)info;
    57. lagMonitor.runLoopEndActivity = activity;
    58. dispatch_semaphore_t semaphore = lagMonitor.dispatchSemaphore;
    59. dispatch_semaphore_signal(semaphore);
    60. }

    运行效果,当卡顿发生是能够定位到并打印提示。

    方案三:Ping主线程

    Ping主线程的核心思想是向主线程发送一个信号,一定时间内收到了主线程的回复,即表示当前主线程流畅运行。没有收到主线程的回复,即表示当前主线程在做耗时运算,发生了卡顿。

  • 相关阅读:
    Day10—SQL那些事(特殊场景的查询)
    Maven
    代码随想录 Day39 动态规划 LeetCode T139 单词拆分 动规总结篇1
    数据挖掘与统计分析——T检验,正态性检验和一致性检验——代码复现
    2022-6学习笔记
    算法练习-第四天(链表中倒数最后k个结点)
    手撕双链表
    数字资产革命:Web3带来的新商业机会
    深入理解python虚拟机:黑科技的幕后英雄——描述器
    基于Spring Boot的大学生就业管理系统毕业设计源码290915
  • 原文地址:https://blog.csdn.net/wywinstonwy/article/details/125404355