iOS - 图片使用优化

2019-10-02

最近在使用动图时,发现一个现象,因为 animatedImage 需要加载大量 image,所以在加载时会造成一定的卡顿。因此我想通过一些测试记录一下 image 在使用过程中的一些优化

open class func animatedImage(with images: [UIImage], duration: TimeInterval) -> UIImage?

1. 图片对包大小的影响

所有图片用同一张图片,大小为 3840 x 2160

图片格式 图片大小 图片放Bundle 包大小 图片放 Assets 包大小 备注
png 22.2 MB 24.5 MB 16.2 MB  
png 5.6 MB 6.3 MB 6.1 MB 关闭 Compress PNG Files - Packaging ,包大小为 5.8 MB,原因
jpg 8.3 MB 8.4 MB 8.4 MB  
jpg 752 KB 912 KB 932 KB  

1.1 结论

  1. 图片大小对包大小是有一定影响的,最好对工程中所有图片进行压缩
  2. png 图片放在 Assets 中,iOS 会对其进行进一步的压缩,可以考虑使用 Assets 管理 png 图片
  3. Building Setting 中的 Compress PNG Files - Packaging 选项关闭可以稍微减少包大小,但是会影响 png 在使用时加载的速度,所以建议保持默认值,开启

2. UIImage 的 init 方法对比

  1. 方法一
public init?(named name: String)

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.

此方法会从系统缓存检查是否具有指定名称的 UIImage 对象。如果缓存中不存在匹配的图像对象,则此方法将从 assets 目录创建图像或从磁盘加载图像。系统可以随时清除缓存的图像数据以释放内存。仅对缓存中但当前未使用的图像进行清除。

  1. 方法二
public init?(contentsOfFile path: String)

This method loads the image data into memory and marks it as purgeable. If the data is purged and needs to be reloaded, the image object loads that data again from the specified path.

此方法将图像数据加载到内存中并将其标记为可清除。如果清除数据并需要重新加载,则图像对象将从指定路径再次加载该数据。

2.1 结论

对于方法两个方法的主要区别就是

  • 方法一:创建的 UIImage 的加载到内存中后,会一直存在内存中,及时持有 UIImage 的对象(如 UIImageView)释放了也不会释放。以及加载到内存中时就是解码后的图片。
  • 方法二:没有对象(如 UIImageView)持有该 UIImage,会从内存中释放,下次使用时会从指定的 path 重新加载。在内存中不是解码后的图片,需要在渲染前额外进行解码操作。

这就造成了两者的使用场景不同

  • 方法一:频繁使用的小图片
  • 方法二:不频繁使用的图片,大图片,不包含在 Bundle 中的图片

2.2 理解误区

同时这里有一个我之前一直理解错误的误区,因为之前看过各种各样的资料,说的不尽相同。也是这次自己编写代码测试过后才重新确定了一些逻辑。

当我们执行以下代码

let image = UIImage(named: "1")

或者

let path = Bundle.main.path(forResource: "1", ofType: "png")!
let image = UIImage(contentsOfFile: path)

此时只是创建了 UIImage 对象,并不会把图片加载到内存(测试过程中未看到使用内存增加)中以及进行图片解码(未造成卡顿)

只有使用到 UIImage 时,即将 image 赋值给 layer 的 contents 或者 imageView 的时候,才会加载 image 到内存以及解码,这个时候才是一些卡顿发生的时间。

我看 iOS Core Animation: Advanced Techniques 在讲如何避免延迟解码带来的卡顿有这样一段话,给我带来很多的干扰

使用 UIImage+imageNamed: 方法避免延时加载。不像 +imageWithContentsOfFile:(和其他别的UIImage 加载方法),这个方法会在加载图片之后立刻进行解码。

我品了品这句话的意思,说的有道理,其实并没什么卵用。因为加载的时机都是将 image 赋值给 layer 的 contents,此时用 +imageNamed: 会解码完存在内存中;+imageWithContentsOfFile 直接存在内存中,然后在渲染前解码。两者比较其实 +imageNamed: 并没有把解码时机提前,卡顿时间应该是一样的,它的优势仅仅在频繁使用时只要解码一次

3. png 、jpg 加载速度测试

测试环境

  • iPhone7 真机

  • iOS 13.1

测试不同大小的 png、jpg 图片加载以及解码的耗时,图片越大加载速度越慢,png 往往比 jpg 大,但是 png 解码速度相比 jpg 又有优势,总耗时要综合考虑两个因素。图片都是有 Mac 上自带的 预览 导出的,计算一百次取平均值

时间计算代码

static func loadImage(contentsOfFile path: String) -> CFAbsoluteTime {
    UIGraphicsBeginImageContext(CGSize(width: 1, height: 1))

    var loadTime: CFAbsoluteTime = 0

    for _ in 0 ..< 100 {
        let tmpTime = CFAbsoluteTimeGetCurrent()
        // load image
        let image = UIImage(contentsOfFile: path)!
        
        // decompress image by drawing it
        image.draw(at: CGPoint.zero)
        
        loadTime += CFAbsoluteTimeGetCurrent() - tmpTime
    }
    
    UIGraphicsEndImageContext()

    return (loadTime / 100)
}

结果

类型 大小 时间 / ms
png 3840 x 2160 (22.2 MB) 111.27
png 1920 x 1080 (4.7 MB) 28.75
png 480 x 270 (243 KB) 2.93
png 160 x 90 (30 KB) 1.22
png 48 x 27 (4KB) 0.80
jpg 3840 x 2160(8.3 MB) 32.54
jpg 1920 x 1080(491KB) 13.82
jpg 480 x 270 (39KB) 9.81
jpg 160 x 90 (12 KB) 1.96
jpg 48 x 27 (8KB) 1.89

3.1 结论

  • 对于大图,可以使用 jpg,不仅体积小,加载速度快
  • 对于小图,可以使用 png,此时 png 和 jpg 大小差异不明显,选择解码速度快的

4. animatedImage 加载优化

实现显示一个 animatedImage 会卡顿的优化,测试用的动图总共 222 张,大小为 500 x 375,总共的大小为 2.8 MB

计算卡顿的方式使用 CADisplayLink,当发生卡顿时,输出总共丢失的帧数

link = CADisplayLink(target: self, selector: #selector(tick(_:)))
link.add(to: RunLoop.main, forMode: RunLoop.Mode.common)

/// 当发生卡顿时,输出丢失的帧数
@objc private func tick(_ link: CADisplayLink) {
    if lastTime == 0 {           // 对lastTime进行初始化
      lastTime = link.timestamp
      return
    }

    let delta = link.timestamp - self.lastTime;  //计算本次刷新和上次更新FPS的时间间隔

    let timeInterval = 1.0 / 60.0

    if delta > timeInterval * 1.5 {
      let lostFrame = delta / timeInterval
      print(String(format: "卡顿的帧数:%.2f", lostFrame))
    }

    self.lastTime = link.timestamp
}

4.1 不做处理

var animatedImage: UIImage = {
    var images: [UIImage] = []
    for i in 0 ... 221 {
      let imagePath = Bundle.main.path(forResource: "\(i)", ofType: "png")!
      let image = UIImage(contentsOfFile: imagePath)!
      images.append(image)
    }

    return UIImage.animatedImage(with: images, duration: 5)!
}()

// 使用
imageView.image = animatedImage

输出的结果如下,前面的 16 帧丢失应该是加载图片造成的,后面的 25 帧丢失应该是图片解码造成的

卡顿的帧数:16.00
卡顿的帧数:25.00

同时查看使用的内存为 17 MB -> 180 MB

4.2 提前一步加载解码图片

我们在使用动图之前提前对图片进行加载和解码

我们解码的方法如下

struct Utils {
  	/// 使用 CGContext,绘制时会自动进行解码
    static func decompress(_ imagePath: String, in size: CGSize) -> UIImage? {
        guard let image = UIImage(contentsOfFile: imagePath) else {
            return nil
        }

        UIGraphicsBeginImageContext(size)
        // decompress image by drawing it
        image.draw(in: CGRect(x: 0, y: 0, width: size.width, height: size.height))
        let resultImage = UIGraphicsGetImageFromCurrentImageContext()
        UIGraphicsEndImageContext()

        return resultImage
    }
    
  	/// 使用 Image IO 创建 UIImage,通过指定 option 使其在创建 image 就进行解压
    static func decompress(_ imagePath: String) -> UIImage? {
        var image: UIImage?
        
        let imageURL = URL(fileURLWithPath: imagePath) as CFURL
        let options = [
        kCGImageSourceCreateThumbnailFromImageAlways: true,
        kCGImageSourceCreateThumbnailWithTransform: true,
        kCGImageSourceShouldCacheImmediately: true,
        kCGImageSourceThumbnailMaxPixelSize: 500
        ] as CFDictionary
        
        if let source: CGImageSource = CGImageSourceCreateWithURL(imageURL, nil),
            let imageRef: CGImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options) {
            image = UIImage(cgImage: imageRef)
        }
        
        return image
    }
}

使用

DispatchQueue.global().async {
    var images: [UIImage] = []
    for i in 0 ... 221 {
        let imagePath = Bundle.main.path(forResource: "\(i)", ofType: "png")!
        if let decompressedImage = Utils.decompress(imagePath, in: CGSize(width: 400, height: 300)) {
            images.append(decompressedImage)
        }
//        if let decompressedImage = Utils.decompress(imagePath) {
//            images.append(decompressedImage)
//        }
    }
            
    self.decompressedImage = UIImage.animatedImage(with: images, duration: 5)!
}

// 使用
imageView.image = self.decompressedImage
  • 使用 CGContext 解码:丢失的帧数为 0,内存变化为 17 MB -> 25MB
  • 使用 ImageIO 解码:丢失的帧数为 1,内存变化为 17 MB -> 24MB

通过对比我们发现使用两种方式解压效果是基本相同的,那么两者的优劣势是怎么呢

  • CGContext 解码:
    • 优势:使用 Core Graphic 创建的 image 在绘制时有优化,绘制更快;可以指定生成的 image 的 size,匹配 imageView 的大小可以提升一定效率;
    • 劣势:使用 Core Graphic 会占用一定的 CPU 资源,对性能有影响
  • ImageIO 解码:
    • 劣势:在使用过程中我发现不一定能解码,多次测试后总结出来只有 CGImageSourceCreateThumbnailAtIndex() 方法配上 options 的 kCGImageSourceCreateThumbnailWithTransform: true 才能解码成功。使用 CGImageSourceCreateImageAtIndex() 方法这些都没办法立刻解码,具体原因我也不清楚了。

5. iOS 13 新增功能

public init?(named name: String)

方法在 iOS 13 中有一句介绍是这么说的

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.

简单来说就是在 iOS 13 之后该方法查找 image 会优先使用 symbol image ,没有的话再使用 bitmap image。iOS 13 之前只会使用 bitmap image

bitmap image 就是我们之前使用的图片,都是位图。我们在使用过程中也知道这种图在放大后会失真。

那什么是 symbol image 呢,简单来说就是放大缩小不会失真的图片,有使用过 iconfont 应该很好理解。并且系统有内置一些 symbol image 给我们使用,可以通过以下方式使用

let image = UIImage(systemName: "multiply.circle.fill")

至于 symbol image 如何制作、使用,可以参考以下文档

Creating Custom Symbol Images for Your App Configuring and Displaying Symbol Images in Your UI

6. 参考资料

iOS 核心动画高级教程 - 图像 IO

iOS图片加载速度极限优化—FastImageCache解析

iOS减包实战:Compress PNG Files作用分析