iOS界面渲染与优化(三) - 图像在渲染中的优化

1,923 阅读12分钟

图像的基础

图像的bitmap的格式(RGBA, BGR24...), 图像的压缩编码格式(jpg, png, webp, GIF...), 视频帧常见格式(YUV系列)等等. 建议参考Apple 官方文档.

也可以简单参考: iOS开发:图片格式与性能优化 - 简书 (jianshu.com)

在UIImageView中展示图片

通常情况下我们使用图片常见的两种场景, 从Bundle资源中读取, 从Network下载, 创建UIImage, 然后展示在UIImageView中. 会经历如下过程: 加载、解码、渲染。

bitmap1.png

在我们将图片存储在磁盘或者从网络数据下载的图片通常是压缩格式, 比如常用的jpg和png, 我们通过如下语句去创建一个UIImage:

    UIImage *image = [UIImage imageWithContentsOfFile:xxx];// PNG或JPG图像的路径
    UIImage *image = [UIImage imageWithData:xxx]; // 这里data是普通的未解码(压缩)的数据

如果需要展示到界面上, 需要经过解压缩转化成bitmap图像(RGB位图等), 具体过程如下.

bitmap2.png

简单来说就是将普通的二进制数据 (存储在dataBuffer 数据) 转化成 RGB的数据(存储在ImageBuffer), 这个被称为图像的解码decode, 它有如下特点:

  1. decode解码过程是一个耗时过程, 并且是在CPU中完成的. 在我们前面文章里面提到的CA Transaction中的第三部prepare中完成!!!
  2. 解码以后的RGB图占用的内存大小只与bitmap的像素格式(RGB32, RGB23, Gray8 ...)和图片宽高有关, 常见bitmap大小: 每个像素点大小 * width * height, 而与原来的压缩格式PNG, JPG大小无关.

注意只有在该UIImageView引起layer-tree中展示, 才会触发CATransaction, 从而导致图片的解码操作!!!

我们在图片展示过程中常见的问题:

  1. CATransaction机制导致的未解码的图片在渲染到界面时 prepare过程的被CPU解码
    1. CPU耗时, 而且是主线程!
  2. 图片太大(分辨率 width *height), 而实际在屏幕上展示的 UIImageView的Size很小, 这样会导致图片会被完全解码以后, 然后进行缩放, 最后渲染到UIImageView上!
    1. 图片会被完全解码!!! 比如1000 * 1000 的图, 放在100 *100 的UIImageView上, 完全没有必要将图片完全解码, 可能只需要重采样为100 * 100的图即可!
    2. 内存消耗, RGB32的图在内存的大小是 4MB!!! 实际只需要100 * 100 的图!!!
    3. 可能渲染格式导致内存消耗, 例如图像的格式是sRGB, 但是我们使用场景只需要8bit 单一颜色通道的图片, 这样会导致无畏的内存占用与CPU消耗.
  3. 图片从文件系统读取到内存中的普通文件IO的耗时

因此在设置UIImageView.image的时候最好保证UIImage中的图片需要保证

  1. 已经解码过的图像
  2. 并且图片的size与UIImageView.size保持一致!!!
  3. 以上两个步骤的图片的预处理操作尽量放到子线程中去完成

Apple推荐的优化方式 -- downSample

如果UIImageView.size 比我们需要展示的image.size要小, 可以用downSample, 参考WWDC2018 Image and Graphics Best Practices

func downsample(imageAt imageURL: URL, to pointSize: CGSize, scale: CGFloat) -> UIImage {
  let imageSourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary
  let imageSource = CGImageSourceCreateWithURL(imageURL as CFURL, imageSourceOptions)!
  let maxDimensionInPixels = max(pointSize.width, pointSize.height) * scale
  let downsampleOptions = [kCGImageSourceCreateThumbnailFromImageAlways: true,
  kCGImageSourceShouldCacheImmediately: true,
  kCGImageSourceCreateThumbnailWithTransform: true,
  kCGImageSourceThumbnailMaxPixelSize: maxDimensionInPixels] as CFDictionary
  let downsampledImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, downsampleOptions)!
  return UIImage(cgImage: downsampledImage)
}

使用CGraphics API生成的缩略图, 并且产生的CGImageRef内容已经是解压过的!!!

其他强制图像解码并绘制缩略图的方式

  1. UIKit的API : UIGraphicsBeginImageContextWithOptions & UIImage -drawInRect:
extension UIImage {
    //UIKit
    func resizeUI(size: CGSize) -> UIImage? {
        let hasAlpha = false
        let scale: CGFloat = 0.0 // Automatically use scale factor of main screen
        
        /**
         创建一个图片类型的上下文。调用UIGraphicsBeginImageContextWithOptions函数就可获得用来处理图片的图形上下文。利用该上下文,你就可以在其上进行绘图,并生成图片
         size:表示所要创建的图片的尺寸
         opaque:表示这个图层是否完全透明,如果图形完全不用透明最好设置为YES以优化位图的存储,这样可以让图层在渲染的时候效率更高
         scale:指定生成图片的缩放因子,这个缩放因子与UIImage的scale属性所指的含义是一致的。传入0则表示让图片的缩放因子根据屏幕的分辨率而变化,所以我们得到的图片不管是在单分辨率还是视网膜屏上看起来都会很好
         */
        UIGraphicsBeginImageContextWithOptions(size, !hasAlpha, scale)
        self.draw(in: CGRect(origin: .zero, size: size))
        let resizedImage = UIGraphicsGetImageFromCurrentImageContext()
        UIGraphicsEndImageContext()
        return resizedImage!
    }
}

UIKit处理大分辨率图片时, 往往容易出现OOM, 原因是-[UIImage drawInRect:]在绘制时, 先解码图片, 再生成原始分辨率大小的bitmap, 这是很耗内存的。解决方法是使用更低层的ImageIO接口, 避免中间bitmap产生。

  1. CoreGraphic的API: CGBitmapContextCreate & CGContextDrawImage
extension UIImage {
    
    //CoreGraphics
    func resizeCG(size:CGSize) -> UIImage? {
        guard  let cgImage = self.cgImage else { return nil }
        let bitsPerComponent = cgImage.bitsPerComponent
        let bytesPerRow = cgImage.bytesPerRow
        let colorSpace = cgImage.colorSpace
        let bitmapInfo = cgImage.bitmapInfo
        guard let context = CGContext(data: nil,
                                      width: Int(size.width),
                                      height: Int(size.height),
                                      bitsPerComponent: bitsPerComponent,
                                      bytesPerRow: bytesPerRow,
                                      space: colorSpace!,
                                      bitmapInfo: bitmapInfo.rawValue) else {
            return nil
        }
        context.interpolationQuality = .high
        context.draw(cgImage, in: CGRect(origin: .zero, size: size))
        let resizedImage = context.makeImage().flatMap {
            UIImage(cgImage: $0)
        }
        return resizedImage
    }
}
  1. ImageIO 的CGImageSourceCreateThumbnailAtIndex
extension UIImage {
    //ImageIO
    func resizeIO(size:CGSize) -> UIImage? {
        guard let data = UIImagePNGRepresentation(self) else { return nil }
        let maxPixelSize = max(size.width, size.height)
        //let imageSource = CGImageSourceCreateWithURL(url, nil)
        guard let imageSource = CGImageSourceCreateWithData(data as CFData, nil) else { return nil }
        //kCGImageSourceThumbnailMaxPixelSize为生成缩略图的大小。当设置为800,如果图片本身大于800*600,则生成后图片大小为800*600,如果源图片为700*500,则生成图片为800*500
        let options: [NSString: Any] = [
            kCGImageSourceThumbnailMaxPixelSize: maxPixelSize,
            kCGImageSourceCreateThumbnailFromImageAlways: true
        ]
        let resizedImage = CGImageSourceCreateImageAtIndex(imageSource, 0, options as CFDictionary).flatMap{
            UIImage(cgImage: $0)
        }
        return resizedImage
    }
}

Image I / O是一个功能强大但鲜为人知的用于处理图像的框架。 独立于Core Graphics,它可以在许多不同格式之间读取和写入,访问照片元数据以及执行常见的图像处理操作。这个库提供了该平台上最快的图像编码器和解码器,具有先进的缓存机制,甚至可以逐步加载图像.

还有其他方法来处理, 比如 CIImage和 vImage处理, 这里可以参考文章iOS的5种图片缩略技术以及性能探讨 - 简书 (jianshu.com)

实际上,在苹果官方在 Performance Best Practices section of the Core Image Programming Guide 部分中特别推荐使用Core Graphics或ImageIO功能预先裁剪或缩小图像。

个人对图像优化的小结

对于一个需要展示在UI的图片, 注意如下优化即可:

  1. 异步下载/从磁盘读取图片文件, 此时图片是JPG/PNG
  2. 预处理图片 - 异步子线程进行, 如果image.size与UIImageVIew.size不同, 需要创建图片的缩略图. 优先使用ImageIO的API, 其次使用CoreGraphicAPI进行处理
  3. 像素对齐: UIimageView.size的宽高尽量取整数, 显示时使用point作为单位(不要用pixel, 有scale) , 可以用 Misaligned Image debug判断是否需要优化 )
  4. 如果图像很大, 可以使用mmap,避免mmcpy。具体可以参考FastImageCache的实现
  5. 在子线程解码, 参考ImageIO 以及 CoreGraphic API(iOS10以上UIGraphicsImageRenderer 代替原来的CoreGraphic API操作), 房子 prepare过程中的image_create操作
  6. 字节对齐: 保证图像内容字节对齐, 防止 prepare 过程中的 image_copy发生!!! 参考 FastImageCache
  7. 针对UIImage的两个方法: imageNamedimageWithContentOfFile在业务中根据场景进行选择!

以上优化都是为了优化CPU耗时!!! 或者减少主线程的计算压力!!!

另外ImageIO在图片实践中还能有如下使用场景:

加载:在decode前插入创建缩略图的过程. 即对image进行预处理, 采用缩小解码后Image Buffer的size的方式, 减少内存的占用 缩放:ImageIO 能够在不产生dirty memory的情况下读取到图片尺寸和元信息, 其内存损耗等于缩减后的图片尺寸产生的内存占用

附1 -- FastImageCache中的极致优化

iOS高效图片 IO 框架是如何炼成的_iOS_开发-CSDN博客

iOS图片加载速度极限优化—FastImageCache解析 « bang’s blog (cnbang.net)

当我们使用图片存储的时候,难免会涉及到文件IO,GPU渲染等问题,文章注重从计算机操作系统方面深入浅析地讲解如何优化图片IO的速度,提高 iOS 中 UIImageView 的渲染效率和内存优化,梳理如下:

iOS从磁盘加载一张图片,使用UIImageVIew显示在屏幕上,需要经过以下步骤:

  1. 从磁盘拷贝数据到内核缓冲区
  2. 从内核缓冲区复制数据到用户空间
  3. 生成UIImageView,把图像数据赋值给UIImageView
  4. 如果图像数据为未解码的PNG/JPG,解码为位图数据
  5. CATransaction捕获到UIImageView layer树的变化
  6. 主线程Runloop提交CATransaction,开始进行图像渲染
    • 6.1 如果数据没有字节对齐,Core Animation会再拷贝一份数据,进行字节对齐。
    • 6.2 GPU处理位图数据,进行渲染。

FastImageCache分别优化了2,4,6.1三个步骤:

  1. 使用mmap内存映射,省去了上述第2步数据从内核空间拷贝到用户空间的操作。
  2. 缓存解码后的位图数据到磁盘,下次从磁盘读取时省去第4步解码的操作。
  3. 生成字节对齐的数据,防止上述第6.1步CoreAnimation在渲染时再拷贝一份数据。

ps: 个人觉得FastImageCache与Path自己的业务耦合太紧. 我们尽量参考SDWebImage和YYImage会比较好

附2 - 像素对齐与字节对齐在图片优化的解释

像素对齐

像素对齐就是视图上像素和屏幕上的物理像素完美对齐。

混合的时候, 假设的情况是多个layer是在每个像素都完全对齐的情况下来进行计算的, 如果像素不对齐的情况下,GPU需要进行Anti-aliasing反抗锯齿计算,GPU的负担就会加重。像素对齐的情况下,我们只需要把所有layer上的单个像素进行混合计算即可。

那么什么原因造成像素不对齐?主要有两点:

  1. 图片大小和UIImageView大小不符合2倍3倍关系时,如一张12x12二倍,18x18三倍的图,UIimageView的size为6x6才符合像素对齐。
  2. 边缘像素不对齐,即起始坐标不是整数,可以使用CGRectIntegral()方法去除小数位。 这两点都有可能造成像素不对齐。如果想获得更好的图形性能,作为开发者要尽可能得避免这两种情况。
字节对齐:

字节对齐是对基本数据类型的地址做了一些限制,即某种数据类型对象的地址必须是其值的整数倍。例如,处理器从内存中读取一个8个字节的数据,那么数据地址必须是8的整数倍。

对齐是为了提高读取的性能。因为处理器读取内存中的数据不是一个一个字节读取的,而是一块一块读取的一般叫做cache lines。如果一个不对齐的数据放在了2个数据块中,那么处理器可能要执行两次内存访问。当这种不对齐的数据非常多的时候,就会影响到读取性能了。这样可能会牺牲一些储存空间,但是对提升了内存的性能,对现代计算机来说是更好的选择。

在iOS中,如果这个图像的数据没有字节对齐,那么Core Animation会自动拷贝一份数据做对齐处理。这里我们可以提前做好字节对齐。在方法CGBitmapContextCreate(void * __nullable data, size_t width, size_t height, size_t bitsPerComponent, size_t bytesPerRow, CGColorSpaceRef __nullable space, uint32_t bitmapInfo)中,有一个参数bytesPerRow,意思是指定要使用的位图每行内存的字节数,ARMv7架构的处理器的cache lines是32byte,A9处理器的是64byte,这里我们要使bytesPerRow为64的整数倍。具体可以参考官方文档Quartz 2D Programming GuideWWDC 2012 Session 238 "iOS App Performance: Graphics and Animations"。字节对齐,在一般情况下,感觉对性能的影响很小,没必要的情况不要过早优化。

参考文章:

WWDC2011 121: understanding uikit rendering

WWDC2012 211: building concurrent user interfaces on ios

WWDC2012 235: iOS App Performance: Responsiveness

WWDC2012 242: iOS App Performance: Memory

WWDC 2012: iOS App Performance: Graphics and Animations

WWDC 2014 -Advanced Graphics and Animations for iOS Apps

WWDC2018 Image and Graphics Best Practices

WWDC2018 iOS Memory Deep Dive

iOS 视图---动画渲染机制探究 - CocoaChina_一站式开发者成长社区

iOS Rendering 渲染全解析(长文干货) (juejin.cn)

bang神强文-iOS图片加载速度极限优化—FastImageCache解析

绘制像素到屏幕上(Getting Pixels onto the Screen译文)

离屏渲染(Offscreen Render)

为iOS设计:图形和性能

iOS性能优化系列篇之“列表流畅度优化”

iOS图像显示原理和卡顿优化

iOS 性能优化总结

ios-rounded-corner

落影 - iOS性能优化——图片加载和处理 (iOS性能优化——图片加载和处理 - 云+社区 - 腾讯云 (tencent.com))

iOS离屏渲染优化(附DEMO)

[[转]iOS 事件处理机制与图像渲染过程](www.cnblogs.com/linganxiong…)

iOS 2D Graphic(1)—— Concept 基本概念和原理

iOS中的图片使用方式、内存对比和最佳实践(juejin.cn/post/684490…)

iOS界面渲染流程分析 - 云+社区 - 腾讯云 (tencent.com)

iOS高效图片 IO 框架是如何炼成的iOS开发-CSDN博客

iOS图片内存管理和性能优化 - 简书 (jianshu.com)

iOS的5种图片缩略技术以及性能探讨 - 简书 (jianshu.com)

JHBlog/加载大图的优化算法.md at master · SunshineBrother/JHBlog (github.com)

探讨iOS 中图片的解压缩到渲染过程 - 简书 (jianshu.com)

[iOS 图形性能优化 (juejin.cn)](