09.渲染原理及优化

将图形渲染到屏幕是CPUGPU协同完成的一个过程.

一、 职责与分工

  • CPU

    • 计算视图Frame

    • 图片解码

    • 将需要绘制的纹理图片通过数据总线传递给GPU

  • GPU

    • 纹理混合

    • 顶点变换与计算

    • 像素点的填充计算

    • 渲染到缓冲区

  • 时钟信号

    • 垂直同步信号V-Sync

    • 水平同步信号H-Sync

  • iOS设备双缓冲机制

    • 显示系统通常会引入两个帧缓冲区

二、 双缓冲机制

  • 有前帧缓存、后帧缓存,即GPU会预先渲染好一帧放入一个缓冲区内(前帧缓存),让视频控制器读取

  • 当下一帧渲染好后,GPU会直接把视频控制器的指针指向第二个缓冲器(后帧缓存)

  • 当你视频控制器已经读完一帧,准备读下一帧的时候

    • GPU会等待显示器的VSync信号发出后,前帧缓存和后帧缓存会瞬间切换

    • 后帧缓存会变成新的前帧缓存,同时旧的前帧缓存会变成新的后帧缓存

2.1 卡顿产生的原因

如果在一个VSync时间内CPUGPU没有将处理完成的数据提交到帧缓冲区,那一帧就会被丢弃

按照60fps刷新率,每隔16ms就会又一次VSync信号

2.2 卡顿优化-CPU

  • 尽量用轻量级的对象,比如用不到事件处理的地方,可以考虑使用CALayer取代UIView

  • 不要频繁地调用UIView的相关属性,比如frame、bounds、transform等属性,尽量减少不必要的修改

  • 尽量提前计算好布局,在有需要时一次性调整对应的属性,不要多次修改属性

  • Autolayout会比直接设置frame消耗更多的CPU资源

  • 图片的size最好刚好跟UIImageView的size保持一致

  • 控制一下线程的最大并发数量

  • 尽量把耗时的操作放到子线程

2.3 卡顿优化-GPU

  • 尽量避免短时间内大量图片的显示,尽可能将多张图片合成一张进行显示

  • 尽量减少视图数量和层次

  • 减少透明的视图(alpha<1),不透明的就设置opaque为YES

  • 尽量避免出现离屏渲染

2.4 卡顿检测

  • YYFPSLabel

    • 基于 DisplayLink

    • 每次++,少了就掉帧了

  • Runloop

    • 主线程Runloop 中创建一个 Observer

    • 监听事务

    • 子线程 开启计时

    • 两次事务的间隔超过一定的时间就标识有事务比较耗时

  • 微信的 Matrix

    • 也是基于 Runloop 的,更全面

  • 滴滴 Doraemon

    • 主线程发不断发信号

三、 图片加载的过程

3.1 imageNamed

使用后会有缓存,适合重复使用的场景

When searching the asset catalog, this method prefers an asset containing a symbol image over an asset with the same name containing a bitmap image. Because symbol images are supported only in iOS 13 and later, you may include both types of assets in the same asset catalog. The system automatically falls back to the bitmap image on earlier versions of iOS. You cannot use this method to load system symbol images; use the init(systemName:) method instead.

This method checks the system caches for an image object with the specified name and returns the variant of that image that is best suited for the main screen. If a matching image object is not already in the cache, this method creates the image from an available asset catalog or loads it from disk. The system may purge cached image data at any time to free up memory. Purging occurs only for images that are in the cache but are not currently being used.

In iOS 9 and later, this method is thread safe.

3.2 imageWithContentsOfFile

不会创建缓存,适合于低频使用的资源图

This method does not cache the image object.

3.3 具体过程

  1. UIImage赋值给UIImageView

  2. 一个隐式的CATransaction捕捉到了UIImageView图层树的变化

  3. 主线程下个RunLoop到来时,CoreAnimation提交了这个Transaction

    • 这个过程可能会对图片进行Copy操作,而受图片是否字节对齐等因素的影响,这个Copy操作可能包含一下部分或全部操作:

    • 分配内存缓冲区,用于管理文件IO解压缩操作

    • 将图片数据从磁盘读取到内存

    • 将图片数据解码成未压缩的位图格式,这是个非常耗时的CPU操作

    • CPU计算好图片Frame,对图片解压后,交给GPU进行渲染

    • CoreAnimationCALayer用位图数据渲染UIImageView

  4. 渲染流程

    • GPU获取图片坐标

    • 将坐标交给顶点着色器

      • 顶点计算

    • 将图片光栅化

      • 获取图片对应屏幕上的像素点

    • 片元着色器计算

      • 计算每个像素点最终显示的颜色值

    • 从帧缓存区中渲染到屏幕上

四、 为什么需要解压缩

4.1 位图 JPG PNG

  • 位图就是一个像素数组,包含了每个像素点的信息。

  • JPGPNG都是压缩的位图

    • JPG有损压缩

    • PNG无损压缩,支持alpha通道

4.2 获取原始图片数据

UIImage *image = [UIImage imageNamed:@"text.png"];
CFDataRef rawData = CGDataProviderCopyData(CGImageGetDataProvider(image.CGImage));

五、 界面优化

5.1 预排版

提前进行相关计算,例如在 Cell 复用之前就通过数据模型对相关 Layout 信息进行计算。否则每次复用都进行计算就很浪费性能了。

5.2 预解码

系统默认会在主线程对图片进行解压,但该操作是个非常耗时的操作。所以现在就出现了对图片进行异步解压的方案。核心方法是CGBitmapContextCreate

SDWebImage可以在sd_decompressedImageWithImage处断点调试过程

下面是我在我的个人项目 梦见账本 中的账户封面设置中用做的优化:

public struct SwiftyImageLoader {
    public static let colorSpace: CGColorSpace = CGColorSpaceCreateDeviceRGB()
    
    public static func imageRefContainsAlpha(imageRef: CGImage) -> Bool {
        switch imageRef.alphaInfo {
        case .none, .noneSkipFirst, .noneSkipLast:
            return false
        default:
            return true
        }
    }
}

extension UIImageView {
    func asyncSet(newImage: UIImage?) {
        guard let image = newImage else {
            self.image = nil
            return
        }
        
        DispatchQueue.global().async { [weak self] in
            guard let imageRef = image.cgImage else {
                self?.setImageInMainQueue(image: image)
                return
            }

    //        let bytesPerPixel: Int = 4
            let bitsPerComponent: Int = 8
            
            // device color space
            let colorspaceRef = SwiftyImageLoader.colorSpace
            let hasAlpha = SwiftyImageLoader.imageRefContainsAlpha(imageRef: imageRef)
            
            // iOS display alpha info (BRGA8888/BGRX8888)
            // 位移枚举可以这样写
            let bitmapInfo: CGBitmapInfo = CGBitmapInfo(rawValue: hasAlpha
                                                            ? CGImageAlphaInfo.premultipliedFirst.rawValue
                                                            : CGImageAlphaInfo.noneSkipFirst.rawValue)
                .union(.byteOrder32Little)
            
            let width = imageRef.width
            let height = imageRef.height
            
            // kCGImageAlphaNone is not supported in CGBitmapContextCreate.
            // Since the original image here has no alpha info, use kCGImageAlphaNoneSkipLast
            // to create bitmap graphics contexts without alpha info.
            
            guard
                let context: CGContext = CGContext.init(data: nil, width: width, height: height, bitsPerComponent: bitsPerComponent, bytesPerRow: 0, space: colorspaceRef, bitmapInfo: bitmapInfo.rawValue)
            else {
                self?.setImageInMainQueue(image: image)
                return
            }
            
            // Draw the image into the context and retrieve the new bitmap image without alpha
    //        CGContextDrawImage
            context.draw(imageRef, in: CGRect(origin: .zero, size: CGSize(width: width, height: height)))
            
            guard
                let imageRefWithoutAlpha = context.makeImage()
            else {
                self?.setImageInMainQueue(image: image)
                return
            }
            let imageWithoutAlpha = UIImage(cgImage: imageRefWithoutAlpha, scale: image.scale, orientation: image.imageOrientation)
            self?.setImageInMainQueue(image: imageWithoutAlpha)
            //  CGContextRelease(context) Swift 会自动去管理了,不用手动去释放了
        }
    }
    
    private func setImageInMainQueue(image: UIImage) {
        DispatchQueue.main.async { [weak self] in
            self?.image = image
        }
    }
}

5.3 按需加载

一般我们卡顿主要是因为列表有大量的图片,我们可以选择在滚动的时候采用展示展位图,滚动停止的时候再展示相关图片的方式来提高性能。

当然这样对于用户的浏览体验来说会有一些影响。

5.4 异步渲染

UIView 和 CALayer 的关系

Graver

美团开源Graver框架:用“雕刻”诠释iOS端UI界面的高效渲染

从 Graver 源码再来看异步渲染

Graver 好像是借鉴了 YYAsyncLayer 有争议,后来取消开源了。

Last updated