提高界面流畅性

首页 / iOS / 正文

图像显示原理

截屏2021-11-23 下午3.04.04.png

显示器通过从上到下一行一行的进行扫描,扫描完成后就显示一帧画面,随后回到起始位置继续扫描。为了显示同步,显示器会产生一系列的定时信号。当换行的时候会发出一个水平同步信号HSync,当一帧画面绘制完成后重新开始绘制下一帧之前,会发出一个垂直同步信号VSync。我们通常所说的屏幕刷新率,指的就是VSync信号的产生频率

截屏2021-11-23 下午3.21.01.png

在计算机系统中CPU、GPU、显示器以上述方式进行协同工作。CPU计算显示内容然后提交至GPU,GPU渲染完成后将渲染结果放入帧缓冲区,随后显示器会按照VSync信号逐行读取帧缓冲区的数据,然后经过转换传递显示到屏幕

通常GPU会开启垂直同步机制,GPU会等待显示器的VSync信号发出后,才进行新的一帧的渲染和缓冲区更新,这样能解决画面撕裂现象,也增加了画面流畅度,

卡顿原因

在垂直同步机制下,当VSync信号到来后,系统通知CPU在主线程中计算显示内容,比如视图的创建、布局计算、图片解码、文本绘制等。随后 CPU 会将计算好的内容提交到 GPU 去,由 GPU 进行变换、合成、渲染。随后 GPU 会把渲染结果提交到帧缓冲区去,等待下一次 VSync 信号到来时显示到屏幕上。如果在一个 VSync 时间内,CPU 或者 GPU 没有完成内容提交,则那一帧就会被丢弃,等待下一次机会再显示,而这时显示屏会保留之前的内容不变。这就是界面卡顿的原因,基于这个,我们分别来分析CPU和GPU中可能存在的问题,并给出一定的解决方案和实践

解决方案

CPU

对象创建

对象的创建会分配内存、调整属性、甚至还有读取文件等操作,比较消耗 CPU 资源。尽量采取轻量级对象,尽量放到后台线程处理,尽量推迟对象的创建时间。例如使用懒加载等等

lazy var tableView: UITableView = {
        let tableView = UITableView(frame: self.view.bounds)
        return tableView
    }()

对象调整

对象的调整也经常是消耗 CPU 资源的地方。UIView 的关于显示相关的属性(比如 frame/bounds/transform)等实际上都是 CALayer 属性映射来的,所以对 UIView 的这些属性进行调整时,消耗的资源要远大于一般的属性。尽可能的避免频繁的调整属性,调整视图层次和删除添加等等

布局计算

我相信平常开发过程中一定是大量使用Autolayout,无论使用何种布局方式,最终都是对于View.frame/bounds/center 等属性的调整,尽可能的提前计算好布局,在需要调整的时候也一次性调整,不要高频率的调整和计算这些属性。虽然带来的性能提升不大,但是当视图层级较为复杂的时候,产生的消耗是巨大的

文本计算与渲染

如果一个界面中包含大量文本,文本的宽高计算会占用很大一部分资源,并且不可避免。你可以提前计算好高度来避免主线程阻塞。在文本渲染上也可以使用CoreText来异步绘制,尽管非常麻烦,但是可以大幅度的提升性能,这里我实现了提前计算高度的功能

func setSize(with text: String) -> CGFloat {
        self.text = text
        let size = sizeThatFits(CGSize(width: 200, height: CGFloat(MAXFLOAT)))
        return size.height + 1
    }

图片加载

由于网络请求的存在,参考了SDWebImage简单实现了图片的异步请求和缓存,将图片存入缓存和沙盒中,提高加载的速度。当你创建一个UIImage的时候,默认发生实际的解码,只有当图片要被显示到屏幕上的时候,才会发生实际的解码,解码是在CPU上进行了。同时也附带了异步解码的方法

class ImageTool: NSObject {
    static let shared = ImageTool()

    override private init() {
        cache = NSCache<AnyObject, AnyObject>()
    }

    typealias imageHandler = (_ image: UIImage?, _ url: String) -> Void

    private var cache: NSCache<AnyObject, AnyObject>
}

extension ImageTool {
    public func setImageWithUrl(url: String, handler: @escaping imageHandler) {
        if let image = cache.object(forKey: url as AnyObject) as? UIImage {
            DispatchQueue.global(qos: .userInteractive).async {
                let decodeImage = self.DecodedImage(image: image)
                DispatchQueue.main.async {
                    handler(decodeImage, url)
                }
            }
            return
        } else {
            let filePath = NSHomeDirectory().appending("/Documents/").appending(url)
            if let image = UIImage(contentsOfFile: filePath) {
                DispatchQueue.global(qos: .userInteractive).async {
                    let decodeImage = self.DecodedImage(image: image)
                    DispatchQueue.main.async {
                        handler(decodeImage, url)
                    }
                }
                cache.setObject(image, forKey: url as AnyObject)
            } else {
                let downloadTask: URLSessionDataTask = URLSession.shared.dataTask(with: URL(string: url)!) { data, _, error in
                    if error != nil {
                        handler(nil, url)
                        return
                    } else {
                        let image = UIImage(data: data!)!
                        self.cache.setObject(image, forKey: url as AnyObject)
                        DispatchQueue.global(qos: .userInteractive).async {
                            let decodeImage = self.DecodedImage(image: image)
                            DispatchQueue.main.async {
                                handler(decodeImage, url)
                            }
                        }
                        let filePath = NSHomeDirectory().appending("/Documents/").appending(url)
                        let imageData = image.jpegData(compressionQuality: 400) as NSData?
                        imageData?.write(toFile: filePath, atomically: true)
                        return
                    }
                }
                downloadTask.resume()
            }
        }
    }
    
    public func DecodedImage(image: UIImage) -> UIImage?{
        guard let cgImage = image.cgImage else { return nil}
        guard let colorSpace = cgImage.colorSpace else { return nil}
        let width = cgImage.width
        let height = cgImage.height
        let bytesPerRow = width * 4
        let ctx = CGContext(data: nil,
                                width: width,
                                height: height,
                                bitsPerComponent: 8,
                                bytesPerRow: bytesPerRow,
                                space: colorSpace,
                                bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue)
        guard let context = ctx else { return nil}
        let rect = CGRect(x: 0, y: 0, width: width, height: height)
        context.draw(cgImage, in: rect)
        guard let drawedImage = context.makeImage() else { return nil}
        let result = UIImage(cgImage: drawedImage, scale: image.scale, orientation: image.imageOrientation)
        return result
    }
}

GPU

视图混合

当多个视图重叠在一起显示时,GPU 会首先把他们混合到一起。如果视图结构过于复杂,混合的过程也会消耗很多 GPU 资源。为了减轻这种情况的 GPU 消耗,应用应当尽量减少视图数量和层次,并在不透明的视图里标明 opaque 属性以避免无用的 Alpha 通道合成。例如如果UIlabel的背景为纯色的话,应该给UIlabel设置背景色来避免图层混合。

图形的生成

CALayer 的 、圆角、阴影、遮罩,CASharpLayer 的矢量图形显示,通常会触发离屏渲染,而离屏渲染通常发生在 GPU 中。减少使用圆角、阴影、遮罩等属性,或者将这些属性用图片来实现上述这些属性。这里也简单提一下离屏渲染的概念

如果要在显示屏上显示内容,我们至少需要一块与屏幕像素数据量一样大的frame buffer,作为像素数据存储区域,而这也是GPU存储渲染结果的地方。如果有时因为面临一些限制,无法把渲染结果直接写入frame buffer,而是先暂存在另外的内存区域,之后再写入frame buffer,那么这个过程被称之为离屏渲染

关于这方面,有一篇更有深度的文章有体现,我在这里也只是简单的提一下。

总结

最终效果如下:

2021-12-02 14.36.07.gif

对于提高界面流畅度方面,就是充分发挥CPU和GPU的性能,将一些能够异步的任务放入后台,不能异步的提前计算好。善用Instuments的Time Profiler来分析CPU在整个过程中都干了什么,有哪些能够改善的地方,以上都只是我个人的一些浅薄理解,如果有错误的地方欢迎指出,谢谢。

无标签
评论区
头像