本文参阅引证:
iOS-底层原理 34:界面优化计划,在此致谢

本文首要介绍界面卡顿的原理以及优化

界面卡顿

通常来说,核算机中的显现进程是下面这样的,经过CPUGPU协同工作来将图片显现到屏幕上

iOS:界面优化方案

  • 1、CPU核算好显现内容,提交至GPU
  • 2、GPU经过烘托完结后将烘托的成果放入FrameBuffer(帧缓存区)
  • 3、随后视频控制器会依照VSync信号逐行读取FrameBuffer的数据
  • 4、经过可能的数模转换传递给显现器进行显现

最开端时,FrameBuffer只要一个,这种情况下FrameBuffer的读取和改写有很大的功率问题,为了处理这个问题,引入了双缓存区。即双缓冲机制。在这种情况下,GPU会预先烘托好一帧放入FrameBuffer,让视频控制器读取,当下一帧烘托好后,GPU会直接将视频控制器的指针指向第二个FrameBuffer

双缓存机制尽管处理了功率问题,但是随之而言的是新的问题,当视频控制器还未读取完结时,例如屏幕内容刚显现一半,GPU将新的一帧内容提交到FrameBuffer,并将两个FrameBuffer而进行交流后,视频控制器就会将新的一帧数据的下半段显现到屏幕上,形成屏幕撕裂现象

为了处理这个问题,选用了笔直同步信号机制。当开启笔直同步后,GPU会等候显现器的VSync信号宣布后,才进行新的一帧烘托和FrameBuffer更新。而现在iOS设备中选用的正是双缓存区+VSync

更多的关于屏幕卡顿烘托流程,请检查二、屏幕卡顿 及 iOS中的烘托流程解析文章

屏幕卡顿原因

下面咱们来说说,屏幕卡顿的原因

VSync信号到来后,体系图形服务会经过 CADisplayLink 等机制告诉 App,App 主线程开端在CPU中核算显现内容。随后 CPU 会将核算好的内容提交到 GPU 去,由GPU进行改换、组成、烘托。随后 GPU 会把烘托成果提交到帧缓冲区去,等候下一次 VSync 信号到来时显现到屏幕上。由于笔直同步的机制,假如在一个 VSync 时刻内,CPU 或许 GPU 没有完结内容提交,则那一帧就会被丢掉,等候下一次时机再显现,而这时显现屏会保存之前的内容不变。所以能够简单理解掉帧过时不候

如下图所示,是一个显现进程,第1帧在VSync到来前,处理完结,正常显现,第2帧在VSync到来后,仍在处理中,此刻屏幕不改写,依旧显现第1帧,此刻就呈现了掉帧情况,烘托时就会呈现明显的卡顿现象

iOS:界面优化方案

从图中能够看出,CPU和GPU不论是哪个阻止了显现流程,都会形成掉帧现象,所以为了给用户提供更好的体验,在开发中,咱们需求进行卡顿检测以及相应的优化

卡顿监控

卡顿监控的计划一般有两种:

  • FPS监控:为了坚持流程的UI交互,App的改写奋斗应该坚持在60fps左右,其原因是由于iOS设备默认的改写频率是60次/秒,而1次改写(即VSync信号宣布)的间隔是 1000ms/60 = 16.67ms,所以假如在16.67ms内没有准备好下一帧数据,就会发生卡顿
  • 主线程卡顿监控:经过子线程监测主线程的RunLoop,判别两个状况(kCFRunLoopBeforeSourceskCFRunLoopAfterWaiting)之间的耗时是否到达一定阈值

FPS监控

FPS的监控,参照YYKit中的YYFPSLabel,首要是经过CADisplayLink完成。借助link的时刻差,来核算一次改写改写所需的时刻,然后经过 改写次数 / 时刻差 得到改写频次,并判别是否其规模,经过显现不同的文字色彩来表明卡顿严峻程度。代码完成如下:

class CJLFPSLabel: UILabel {
    fileprivate var link: CADisplayLink = {
        let link = CADisplayLink.init()
        return link
    }()
    fileprivate var count: Int = 0
    fileprivate var lastTime: TimeInterval = 0.0
    fileprivate var fpsColor: UIColor = {
        return UIColor.green
    }()
    fileprivate var fps: Double = 0.0
    override init(frame: CGRect) {
        var f = frame
        if f.size == CGSize.zero {
            f.size = CGSize(width: 80.0, height: 22.0)
        }
        super.init(frame: f)
        self.textColor = UIColor.white
        self.textAlignment = .center
        self.font = UIFont.init(name: "Menlo", size: 12)
        self.backgroundColor = UIColor.lightGray
        //经过虚拟类
        link = CADisplayLink.init(target: CJLWeakProxy(target:self), selector: #selector(tick(_:)))
        link.add(to: RunLoop.current, forMode: RunLoop.Mode.common)
    }
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    deinit {
        link.invalidate()
    }
    @objc func tick(_ link: CADisplayLink){
        guard lastTime != 0 else {
            lastTime = link.timestamp
            return
        }
        count += 1
        //时刻差
        let detla = link.timestamp - lastTime
        guard detla >= 1.0 else {
            return
        }
        lastTime = link.timestamp
        //改写次数 / 时刻差 = 改写频次
        fps = Double(count) / detla
        let fpsText = "(String.init(format: "%.2f", fps)) FPS"
        count = 0
        let attrMStr = NSMutableAttributedString(attributedString: NSAttributedString(string: fpsText))
        if fps > 55.0 {
            //流畅
            fpsColor = UIColor.green
        }else if (fps >= 50.0 && fps <= 55.0){
            //一般
            fpsColor = UIColor.yellow
        }else{
            //卡顿
            fpsColor = UIColor.red
        }
        attrMStr.setAttributes([NSAttributedString.Key.foregroundColor: fpsColor], range: NSMakeRange(0, attrMStr.length - 3))
        attrMStr.setAttributes([NSAttributedString.Key.foregroundColor: UIColor.white], range: NSMakeRange(attrMStr.length - 3, 3))
        DispatchQueue.main.async {
            self.attributedText = attrMStr
        }
    }
}

假如只是简单的监测,运用FPS足够了。

主线程卡顿监控

除了FPS,还能够经过RunLoop来监控,由于卡顿的是业务,而业务是交由主线程RunLoop处理的。

完成思路:检测主线程每次执行音讯循环的时刻,当这个时刻大于规则的阈值时,就记为发生了一次卡顿。这个也是微信卡顿三方matrix的原理

以下是一个简易版RunLoop监控的完成

//
//  CJLBlockMonitor.swift
//  UIOptimizationDemo
//
//  Created by 陈嘉琳 on 2020/12/2.
//
import UIKit
class CJLBlockMonitor: NSObject {
    static let share = CJLBlockMonitor.init()
    fileprivate var semaphore: DispatchSemaphore!
    fileprivate var timeoutCount: Int!
    fileprivate var activity: CFRunLoopActivity!
    private override init() {
        super.init()
    }
    public func start(){
        //监控两个状况
        registerObserver()
        //发动监控
        startMonitor()
    }
}
fileprivate extension CJLBlockMonitor{
    func registerObserver(){
        let controllerPointer = Unmanaged<CJLBlockMonitor>.passUnretained(self).toOpaque()
        var context: CFRunLoopObserverContext = CFRunLoopObserverContext(version: 0, info: controllerPointer, retain: nil, release: nil, copyDescription: nil)
        let observer: CFRunLoopObserver = CFRunLoopObserverCreate(nil, CFRunLoopActivity.allActivities.rawValue, true, 0, { (observer, activity, info) in
            guard info != nil else{
                return
            }
            let monitor: CJLBlockMonitor = Unmanaged<CJLBlockMonitor>.fromOpaque(info!).takeUnretainedValue()
            monitor.activity = activity
            let sem: DispatchSemaphore = monitor.semaphore
            sem.signal()
        }, &context)
        CFRunLoopAddObserver(CFRunLoopGetMain(), observer, CFRunLoopMode.commonModes)
    }
    func  startMonitor(){
        //创立信号
        semaphore = DispatchSemaphore(value: 0)
        //在子线程监控时长
        DispatchQueue.global().async {
            while(true){
                // 超时时刻是 1 秒,没有比及信号量,st 就不等于 0, RunLoop 一切的任务
                let st = self.semaphore.wait(timeout: DispatchTime.now()+1.0)
                if st != DispatchTimeoutResult.success {
                    //监听两种状况kCFRunLoopBeforeSources 、kCFRunLoopAfterWaiting,
                    if self.activity == CFRunLoopActivity.beforeSources || self.activity == CFRunLoopActivity.afterWaiting {
                        self.timeoutCount += 1
                        if self.timeoutCount < 2 {
                            print("timeOutCount = (self.timeoutCount)")
                            continue
                        }
                        // 一秒左右的衡量尺度 很大可能性接连来 防止大规模打印!
                        print("检测到超越两次接连卡顿")
                    }
                }
                self.timeoutCount = 0
            }
        }
    }
}

运用时,直接调用即可

CJLBlockMonitor.share.start()

也能够直接运用三方库

  • Swift的卡顿检测第三方ANREye,其首要思路是:创立子线程进行循环监测,每次检测时设置符号置为true,然后派发任务到主线程,符号置为false,接着子线程睡眠超越阈值时,判别符号是否为false,假如没有,阐明主线程发生了卡顿
  • OC能够运用微信matrix、滴滴DoraemonKit

界面优化

CPU层面的优化

  • 1、尽量用轻量级的目标替代重量级的目标,能够对功能有所优化,例如 不需求相应触摸事件的控件,用CALayer替代UIView

  • 2、尽量减少对UIViewCALayer的特点修正

    • CALayer内部并没有特点,当调用特点办法时,其内部是经过运行时resolveInstanceMethod为目标暂时增加一个办法,并将对应特点值保存在内部的一个Dictionary中,同时还会告诉delegate、创立动画等,十分耗时
    • UIView相关的显现特点,例如frame、bounds、transform等,实际上都是从CALayer映射来的,对其进行调整时,耗费的资源比一般特点要大
  • 3、当有很多目标开释时,也是十分耗时的,尽量挪到后台线程去开释

  • 4、尽量提前核算视图布局,即预排版,例如cell的行高

  • 5、Autolayout在简单页面情况下们能够很好的提升开发功率,但是对于杂乱视图而言,会发生严峻的功能问题,随着视图数量的增长,Autolayout带来的CPU耗费是呈指数上升的。所以尽量运用代码布局

  • 6、文本处理的优化:当一个界面有很多文本时,其行高的核算、制作也是十分耗时的

    • 1)假如对文本没有特殊要求,能够运用UILabel内部的完成方式,且需求放到子线程中进行,防止阻塞主线程

      • 核算文本宽高:[NSAttributedString boundingRectWithSize:options:context:]
      • 文本制作:[NSAttributedString drawWithRect:options:context:]
    • 2)自定义文本控件,利用TextKit 或最底层的 CoreText 对文本异步制作。并且CoreText 目标创立好后,能直接获取文本的宽高等信息,防止了屡次核算(调整和制作都需求核算一次)。CoreText直接运用了CoreGraphics占用内存小,功率高

  • 7、图片处理(解码 + 制作)

  • 1)当运用UIImageCGImageSource 的办法创立图片时,图片的数据不会当即解码,而是在设置时解码(即图片设置到UIImageView/CALayer.contents中,然后在CALayer提交至GPU烘托前,CGImage中的数据才进行解码)。这一步是无可防止的,且是发生在主线程中的。想要绕开这个机制,常见的做法是在子线程中先将图片制作到CGBitmapContext,然后从Bitmap 直接创立图片,例如SDWebImage三方结构中对图片编解码的处理。这就是Image的预解码

  • 当运用CG开头的办法制作图画到画布中,然后从画布中创立图片时,能够将图画的制作子线程中进行

8、图片优化

  • 1)尽量运用PNG图片,不运用JPGE图片
  • 2)经过子线程预解码,主线程烘托,即经过Bitmap创立图片,在子线程赋值image
  • 3)优化图片大小,尽量防止动态缩放
  • 4)尽量将多张图合为一张进行显现

9、尽量防止运用通明view,由于运用通明view,会导致在GPU中核算像素时,会将通明view基层图层的像素也核算进来,即色彩混合处理,能够参阅六、OpenGL 烘托技巧:深度测试、多边形偏移、 混合这篇文章中提及的混合

  • 10、按需加载,例如在TableView中滑动时不加载图片,运用默认占位图,而是在滑动停止时加载
  • 11、少运用addViewcell动态增加view

GPU层面优化

相对于CPU而言,GPU首要是接收CPU提交的纹路+顶点,经过一系列transform,最终混兼并烘托,输出到屏幕上。

  • 1、尽量减少在短时刻内很多图片的显现,尽可能将多张图片合为一张显现,首要是由于当有很多图片进行显现时,无论是CPU的核算还是GPU的烘托,都是十分耗时的,很可能呈现掉帧的情况

  • 2、尽量防止图片的尺度超越40964096,由于当图片超越这个尺度时,会先由CPU进行预处理,然后再提交给GPU处理,导致额外CPU资源耗费

  • 3、尽量减少视图数量和层次,首要是由于视图过多且重叠时,GPU会将其混合,混合的进程也是十分耗时的

  • 4、尽量防止离屏烘托,能够检查这篇文章四、深入剖析【离屏烘托】原理

  • 5、异步烘托,例如能够将cell中的一切控件、视图组成一张图片进行显现。能够参阅Graver三方结构