前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >iOS卡顿优化

iOS卡顿优化

作者头像
莫空9081
修改2021-03-01 14:29:16
3.2K0
修改2021-03-01 14:29:16
举报
文章被收录于专栏:iOS 备忘录iOS 备忘录

卡顿的概念:

  • FPS:Frame Per Second,表示每秒渲染的帧数,通过用于衡量画面的流畅度,数值越高则表示画面越流畅。
  • CPU:负责对象的创建销毁、对象属性的调整、布局计算、文本计算、和排版、图片的格式转换和解码、图像的绘制(Core Graphics)。
  • GPU:负责纹理的渲染(将数据渲染到屏幕)。
  • 垂直同步技术:让CPU和GPU在收到vSync信号后开始准备数据,防止撕裂感和跳帧,即保证每秒输出的帧数不高于屏幕显示的帧数。
  • 双缓冲技术:iOS是双缓冲机制,前帧缓存和后帧缓存,cpu计算完GPU渲染后放入缓冲区中,当gpu下一帧已经渲染完放入缓冲区,且视频控制器已经读完前帧,GPU会等待vSync(垂直同步信号)发出后,瞬间切换前后帧缓存,并让cpu开始准备下一帧数据。

图像显示:

图像的显示可以理解为先经过CPU的计算、排版、编解码等操作,然后交有GPU去完成渲染放入缓冲中,当视频控制器受到vSync时会从缓冲中读取已经渲染完成的帧并显示到屏幕上。

屏幕显示的过程:CPU计算显示内容,例如视图创建、布局计算、图片解码、文本绘制等;接着CPU将计算好的内容提交到GPU进行合成、渲染。然后GPU把渲染结果提交到帧缓冲区,等待VSync信号到来时显示到屏幕上。如果此时下一个VSync信号到来时,CPU或者GPU没有完成相应的工作时,那一帧就会丢失,就会看到屏幕卡顿。

按照60FPS的帧率,每隔16ms就会有一次VSync信号,1秒是1000ms,1000/60 = 16

卡顿的原因:

iOS默认刷新频率是60HZ,所以GPU渲染只要达到60fps就不会产生卡顿。如果在60fps(16.67ms)内没有准备好下一帧数据就会使画面停留在上一帧。

只要能使CPU的计算和GPU的渲染能在规定时间内完成,就不会出现卡顿。所以目标是减少CPU和GPU的资源消耗。

卡顿造成的原因是CPU和GPU导致的掉帧引起的:

  • 主线程在进行大量I/O操作:直接主线程写入大量数据
  • 主线程进行大量计算:主线程进行大量复杂的计算
  • 大量UI绘制:界面过于复杂,绘制UI需要大量的时间
  • 主线程在等锁

优化卡顿:

CPU:

减少计算,减少耗时操作

  • 提前计算好布局,列表页高度在请求完成数据后,就计算好高度,显示时直接使用。
  • 尽量使用轻量级的对象,比如用不到事件处理的地方使用CALayer代替UIView
  • hook setNeedsLayout、setNeedDisplay、setNeedsDisplayInRect方法,保证方法在主线程运行
  • 查找因重复执行导致卡顿的方法,比如多个地方监听同一个通知,通知中执行多次的清除缓存的方法
  • 保证后台运行时,不调用接口
  • 把耗时的操作放到子线程
  • 文本处理(尺寸计算、绘制、CoreText和YYText)
    • 计算文本宽高boundingRectWithSize:options:context:和文本绘制drawWithRect:options:context放在子线程操作。
    • 使用CoreText自定义文本空间,在创建对象过程中可以缓存宽高等信息,避免像UILabel/UITextView需要多次计算(调整和绘制都要计算一次),且CoreText直接使用了CoreGraphics占用内存小,效率高。(YYText)
  • 图片解码:当使用UIImage或者CGImageSource创建图片时,图片数据并不会立即解码。图片设置到UIImageView或CALayer.content中,并且CALayer被提交到GPU前,CGImage中到数据才会得到解码,这一步是发生在主线程的,并且不可避免。SDWebImage处理方式:在后台线程先把图片绘制到CGBitmapmapContext中,然后直接从Bitmap创建图片。

GPU:

减少渲染

  • 避免短时间内大量图片的显示,尽可能将多张图片合成一张显示
  • GPU能处理的最大纹理尺寸是4096*4096,一旦超过这个尺寸,就会占用CPU资源进行处理,所以纹理尽量不要超过这个尺寸
  • GPU会将多个视图混合在一起再去显示,混合的过程中会消耗CPU资源,尽量减少视图数量和层次
  • 减少透明的视图(alpha < 1),不透明的设置opacity为YES,GPU就不会进行alpha通道的合成
  • 尽量避免出现离屏渲染

离屏渲染:

离屏渲染对GPU资源消耗极大。在OpenGL中,GPU有两种渲染方式,分别是屏幕渲染(On-Screen Rending)和离屏渲染(Off-Screen Rendering),区别在于渲染操作是在当前用于显示的屏幕缓冲区进行还是新开辟一个缓冲区进行渲染,渲染完成后再在当前显示的屏幕展示。

离屏渲染消耗性能的原因,在于需要创建新的缓冲区,并且在渲染的整个过程中,需要多次切换上下文环境,先是从当前屏幕(On-Screen)切换到离屏(Off-Screen);等到离屏渲染结束以后,将离屏缓冲区的渲染结果显示到屏幕上,又需要将上下文环境从离屏切换到当前屏幕,造成了资源到极大消耗。

一些会触发离屏渲染的操作:

  • 光栅化,layer.shouldRasterize = YES
  • 遮罩,layer.mask
  • 圆角,同时设置layer.masksToBounds=YES、layer.cornerRadius大于0,考虑通过CoreGraphics绘制裁剪圆角,或者直接使用圆角图片
  • 阴影

画圆角避免离屏渲染:

CAShapeLayer与UIBezierPath配合画圆角

代码语言:javascript
复制
- (void)drawCornerPicture{
    UIImageView *imageView = [[UIImageView alloc] initWithFrame:CGRectMake(200, 400, 200, 200)];
    imageView.image = [UIImage imageNamed:@"1"];
    // 开启图片上下文
    // UIGraphicsBeginImageContext(imageView.bounds.size);
    // 一般使用下面的方法
    UIGraphicsBeginImageContextWithOptions(imageView.bounds.size, NO, 0);
    // 绘制贝塞尔曲线
    UIBezierPath *bezierPath = [UIBezierPath bezierPathWithRoundedRect:imageView.bounds cornerRadius:100];
    // 按绘制的贝塞尔曲线剪切
    [bezierPath addClip];
    // 画图
    [imageView drawRect:imageView.bounds];
    // 获取上下文中的图片
    imageView.image = UIGraphicsGetImageFromCurrentImageContext();
    // 关闭图片上下文
    UIGraphicsEndImageContext();
    [self.view addSubview:imageView];
}

使用Core Graphics绘制圆角

代码语言:javascript
复制
- (void)circleImage{
    UIImageView *imageView = [[UIImageView alloc] initWithFrame:CGRectMake(200, 400, 200, 200)];
    imageView.image = [UIImage imageNamed:@"001.jpeg"];
    // NO代表透明
    UIGraphicsBeginImageContextWithOptions(imageView.bounds.size, NO, 0.0);
    // 获得上下文
    CGContextRef ctx = UIGraphicsGetCurrentContext();
    // 添加一个圆
    CGRect rect = CGRectMake(0, 0, imageView.bounds.size.width, imageView.bounds.size.height);
    CGContextAddEllipseInRect(ctx, rect);
    // 裁剪
    CGContextClip(ctx);
    // 将图片画上去
//    [imageView drawRect:rect];
    [imageView.image drawInRect:rect];
    imageView.image = UIGraphicsGetImageFromCurrentImageContext();
    // 关闭上下文
    UIGraphicsEndImageContext();
    [self.view addSubview:imageView];
}

卡顿监控:

Xcode自带的Instruments

在开发阶段,可以直接使用Instrument来检测性能问题,TimeProfiler查看与CPU相关的耗时操作,CoreAnimation查看与GPU相关的渲染操作。

比如查看离屏渲染,模拟器中选中"Debug - Color Off-screen Rendered"开启调试,真机用Instrments - Core Animation - Debug Options - Color Offscreen - Rendered Yellow开启调试,开启后,有离屏渲染的图层会变成高亮的黄色。

FPS(CADisplayLink)监测

通常情况下,屏幕会保持60hz/s的刷新率,每次刷新时会发出一个屏幕刷新信号,通过CADisplayLink可以注册一个与刷新信号同步的回调处理。可以通过屏幕刷新机制来展示fps值。

代码语言:javascript
复制
@implemention ViewController {
    UILable *_fpsLabel;
    CADisplayLink *_link;
    NSTimeInterval _lastTime;
    float _fps;
}

- (void)startMonitoring {
    if (_link) {
        [_link removeFromRunloop:[NSRunloop mainRunloop] forMode:NSRunloopCommonModes];
        [_link invalidate];
        _link = nil;
    }
    _link = [CADisplayLink displayLinkWithTarget:self selector:@selector(fpsDisplayLinkAction:)];
    [_link addToRunloop:[NSRunloop mainRunloop] forMode:NSRunloopCommonModes];
}

- (void)fpsDisplayLinkAction:(CADisplaylink *)link {
    if (_lastTime == 0) {
        _lastTime = link.timestamp;
        return;
    }
    
    self.count++;
    NSTimeInterval delta = link.timestamp - _lastTime;
    if (delta < 1) return;
    _lastTime = link.timestamp;
    _fps = _count / delta;
    self.count = 0;
    _fpsLabel.text = [NSString stringWithFormat:@"FPS: %.0f", _fps];
}

卡顿发生时,fps会有明显下滑,但转场动画等特殊场景也存在下滑情况;

采集精度低,回调总是需要cpu空闲时才能处理,无法及时采集调用栈的信息;

会有性能损耗,监听屏幕刷新会频繁唤醒runloop,闲置状态下有一定损耗;

实现成本低,单纯的采用CADisplayLink实现;

更适用于开发阶段。

RunLoop监听

原理:卡顿是在主线程进行了耗时的操作,可以添加Observer到主线程的Runloop中,通过Runloop状态切换的耗时,达到监控卡顿的目的。

通知Observer即将进入Runloop

Loop

-> 通知Observer即将处理事件

-> 处理事件

-> 通知Observer线程即将休眠

-> 休眠,等待被唤醒

通知Observer即将退出Runloop

其中核心方法CFRunloopRun简化后的逻辑大概是这样的:

代码语言:javascript
复制
/// 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);
    
    /// 5. GCD处理main block;
    _CFRUNLOOP_IS_CALLING_OUT_TO_A_BLOCK__(block);
    
    /// 6. 通知Observers即将进入休眠,
    /// 此处有Observer释放并新建AutorealasePool: _objc_autorelasePoolPop(); _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);

不难发现NSRunloop调用方法是在kCFRunloopBeforeSources和kCFRunloopBeforeWaiting之间,以及kCFRunloopAfterWaiting之后,如果这两个时间内耗时太长,就可以判定出此时主线程卡顿。

所以在Runloop的最开始和结束最末尾的位置添加Observer,从而获得主线程的开始和结束状态。卡顿监控起一个子线程定时检查主线程的状态,当主线程的状态运行超过一定的阈值,则认为主线程卡顿,从而标记为一个卡顿。

分析实现:

使用Runloop进行卡顿监控,定义一个阈值判断卡顿的出现,记录下来上报到服务器。

比如:

  1. 主程序Runloop超时的阈值是2秒,子线程的检查周期是1秒,每个1秒,子线程检查主线程的运行状态;如果检查到主线程Runloop的运行超过2秒,则认为是卡顿,并获得当前的线程快照。
  2. 假定连续5次超时50ms认为卡顿(也包含单次超时250ms)

代码语言:javascript
复制
// 开始监听
- (void)startMonitor {
    if (observer) {
        return;
    }
    
    // 创建信号
    semaphore = dispatch_semaphore_create(0);
    NSLog(@"dispatch_semaphore_create: %@", [PerformanceMonitor getCurTime];);
    
    // 注册Runloop状态观察
    CFRunloopContextObserver context = {0, (__bridge void*)self, NULL, NULL};
    // 创建Run loop observer对象
    //第一个参数用于分配observer对象的内存
    //第二个参数用以设置observer所要关注的事件,详见回调函数myRunLoopObserver中注释
    //第三个参数用于标识该observer是在第一次进入run loop时执行还是每次进入run loop处理时均执行
    //第四个参数用于设置该observer的优先级
    //第五个参数用于设置该observer的回调函数
    //第六个参数用于设置该observer的运行环境
    observer = CFRunloopObserverCreate(kCFAllocatorDefault,
                                       kCFRunLoopAllActivities,
                                       YES,
                                       0,
                                       &runLoopObserverCallBack,
                                       &context);
    CFRunLoopAddObserver(CFRunLoopGetMain(), observer, kCFRunLoopCommonModes);
    
    // 在子线程监控时长
    dispatch_async(dispatch_get_global_queue(0, 0), ^{
        while (YES) { // 有信号的话,就查询Runloop的状态
            // 假定连续5次超时50ms认为卡顿(当然也包含了单次超时250ms)
            // 因为下面runloop状态改变的回调方法runLoopObserverCallback中会将信号量递增1,所以每次runloop状态改变后,下面的语句都会执行一次。
            // dispatch_semaphore_wait: Returns zero on success, or non-zero if timeout occured.
            long st = dispatch_semaphore_wait(semaphore, dispatch_time(DISPATCH_TIME_NOW, 50 * NSEC_PER_MSEC));
            NSLog(@"dispatch_semaphore_wait: st=%ld, time:%@", st, [self getCurTime]);
            
            if (st != 0) { // 信号量超时了 - 即 runloop 的状态长时间没有发生变更,长期处于某一个状态下
                if (!observer) {
                    timeoutCount = 0;
                    semaphore = 0;
                    activity = 0;
                    return;
                }
                
                NSLog(@"st = %ld, activity = %lu, timeoutCount = %d, time: %@", st, activity, timeoutCount, [self getCurTime]);
                
                // kCFRunLoopBeforeSources - 即将处理Sources
                // kCFRunLoopAfterWaiting - 刚从休眠中唤醒
                // 获取kCFRunLoopBeforeSources和kCFRunLoopBeforeWaiting,再到kCFRunLoopAfterWaiting的状态就可以知道是否有卡顿的情况。
                // kCFRunLoopBeforeSources: 停留在这个状态,表示在做很多事情
                if (activity == kCFRunLoopBeforeSources ||
                   activity == kCFRunLoopAfterWaiting) {
                    if (++timeoutCount < 5) {
                        continue; // 不足5次,直接continue当次循环,不将timeoutCount置为0
                    }
                    
                    // 收集Crash信息也可用于实时获取各线程的调用堆栈
                    PLCrashReporterConfig *config = [[PLCrashReporterConfig alloc] initWithSignalHandlerType:PLCrashReporterSignalHandlerTypeBSD symbolicationStrategy:PLCrashReporterSymbolicationStrategyAll];
                    
                    PLCrashReporter *crashReporter = [[PLCrashReporter alloc] initWithConfiguration:config];
                    
                    NSData *data = [crashReporter generateLiveReport];
                    PLCrashReport *reporter = [[PLCrashReport alloc] initWithData:data error:NULL];
                    NSString *report = [PLCrashReportTextFormatter stringValueForCrashReport:reporter withTextFormat:PLCrashReportTextFormatiOS];
                    
                    NSLog(@"---------卡顿信息\n%@\n--------------",report);
                }
            }
            NSLog(@"dispatch_semaphore_wait timeoutCount = 0,time:%@",[self getCurTime]);
            timeoutCount = 0;
        };
    };
}

source0处理的是app内部事件,包括UI事件,每次处理的开始和结束的耗时决定了当前页面刷新是否正常。即kCFRunloopBeforeSources和kCFRunLoopAfterWaiting之间。因此创建一个子线程去监听主线程状态变化,通过dispatch_semaphore在主线程进入上面两个状态时发送信号量,子线程设置超时时间循环等待信号量,若超时时间后还未收到主线程发出的信号量即可判断为卡顿。

子线程Ping

根据卡顿发生时,主线程无响应的原理,创建子线程去循环ping主线程,ping之前先设置卡顿标志为True,再派发到主线程执行后设置标志为false,子线程在设置阈值时间内休眠结束后,根据标志判断主线程有无响应。准确性和性能损耗与ping频率成正比。

代码语言:javascript
复制
private class AppPingThread: Thread {
    private let semaphore = DispathchSemaphore(value: 0)
    // 判断主线程是否卡顿的标志
    private var isMainThreadBlock = True
    
    private var threshould: Double = 0.4
    fileprivate var handler: (() -> Void)?
    
    func start(threshould: Double, handler: @escaping AppPingThreadCallback) {
        self.handler = handler
        self.threshould = threshould
        self.start()
    }
    
    override func main() {
        while self.isCancelled == false {
            self.isMainThreadBlock = true
            // 主线程去重置标识
            DispatchQueue.main.async {
                self.isMainThreadBlock = false
                self.semaphore.signal()
            }
            
            Thread.sleep(forTimeInterval: self.threshould)
            // 若标志未重置成功则说明再设置的阈值时间内主线程未响应
            if self.isMainThredBlock {
                // 采集卡顿调用栈信息
                self.handler?()
            }
            
            _ = self.semaphore.wait(timeout: DispatchTime.distantFuture)
        }
    }
}

参考:

https://juejin.cn/post/6844904004053368846

https://www.cnblogs.com/jys509/p/13296128.html

https://blog.ibireme.com/2015/05/18/runloop/

本文系转载,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文系转载前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 卡顿的概念:
  • 图像显示:
  • 卡顿的原因:
  • 优化卡顿:
    • CPU:
      • GPU:
        • 离屏渲染:
        • 卡顿监控:
          • Xcode自带的Instruments
            • FPS(CADisplayLink)监测
              • RunLoop监听
                • 子线程Ping
                领券
                问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档