Swift 项目总结 08 GIF 图片加载优化

2,176 阅读6分钟

一、问题出现

在公司项目中,需要显示一些网络 GIF 图片,使用的是 Kingfisher 第三方图片缓存库进行加载图片,一般情况下挺好的,但有时候会出现内存暴增,一开始以为是没有对图片缓存进行释放导致,后来测试发现是因为某个 GIF 帧数过高导致的,一个 1MB 大小但帧数有 150 帧的 GIF 图片,采用 Kingfisher 加载到内存中需要占用至少 300 MB 以上的内存,多加载几张这样的 GIF 内存直接爆炸,所以需要进行 GIF 图片加载进行优化。

二、问题思考

为什么会导致这样的内存暴增呢?

因为 Kingfisher 在加载 GIF 图的时候,会把 GIF 图的所有帧图片数据都加载到内存进行显示,导致内存暴增。

降低内存消耗,提高 CPU 消耗

去网上找第三方 GIF 图加载优化库,发现了SwiftGifYLGIFImage-Swift 这两个框架,我看了一下 YLGIFImage-Swift 框架里面的实现,是通过动态加载动画帧的形式来优化的。

动态加载帧原理:

  1. 一开始不加载所有图片帧,只加载少量的帧图片
  2. 在动画执行过程中利用定时器不断进行加载帧图片
  3. 释放已执行完动画的帧图片内存
  4. 内存消耗降低,这样的代价就是会导致 CPU 的使用提高

因为项目代码使用到的是 Swift3.2,YLGIFImage-Swift 第三方库更新比较慢,所以对该框架手动进行了一些调整和优化。

三、源代码解析和优化

String+MD5.swift 文件如下: 【需要桥接 OC 头文件 <CommonCrypto/CommonDigest.h>

// String+MD5.swift
import Foundation
extension String {
    /// 字符串 MD5 加密
    var encodeMD5: String? {
        guard let str = cString(using: String.Encoding.utf8) else { return nil }
        let strLen = CC_LONG(lengthOfBytes(using: String.Encoding.utf8))
        let digestLen = Int(CC_MD5_DIGEST_LENGTH)
        let result = UnsafeMutablePointer<CUnsignedChar>.allocate(capacity: digestLen)
        // MD5 加密
        CC_MD5(str, strLen, result)
        // 把结果打印输出成 16 进制字符串
        let hash = NSMutableString()
        for i in 0..<digestLen {
            hash.appendFormat("%02x", result[I])
        }
        result.deallocate(capacity: digestLen)
        return String(format: hash as String)
    }
}

GIFImage.swift 文件如下:

// GIFImage.swift
import UIKit
import ImageIO
import MobileCoreServices

class GIFImage {
    /// 内部读取图片帧队列
    fileprivate lazy var readFrameQueue: DispatchQueue = DispatchQueue(label: "image.gif.readFrameQueue", qos: .background)
    /// 图片资源数据
    fileprivate var cgImageSource: CGImageSource?
    /// 总动画时长
    var totalDuration: TimeInterval = 0.0
    /// 每一帧对应的动画时长
    var frameDurations: [Int: TimeInterval] = [:]
    /// 每一帧对应的图片
    var frameImages: [Int: UIImage] = [:]
    /// 总图片数
    var frameTotalCount: Int = 0
    /// 兼容之前的 UIImage 使用
    var image: UIImage?

    /// 全局配置
    struct GlobalSetting {
        /// 配置预加载帧的数量
        static var prefetchNumber: Int = 10
        static var minFrameDuration: TimeInterval = 0.01
    }

    /// 兼容 UIImage named 调用
    convenience init?(named name: String!) {
        guard let path = Bundle.main.path(forResource: name, ofType: ".gif") else { return nil }
        guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else { return nil }
        self.init(data: data)
    }

    /// 兼容 UIImage contentsOfFile 调用
    convenience init?(contentsOfFile path: String) {
        guard let url = URL(string: path) else { return nil }
        guard let data = try? Data(contentsOf: url) else { return nil }
        self.init(data: data)
    }
    
    /// 兼容 UIImage contentsOf 调用
    convenience init?(contentsOf url: URL) {
        guard let data = try? Data(contentsOf: url) else { return nil }
        self.init(data: data)
    }

    /// 兼容 UIImage data 调用
    convenience init?(data: Data) {
        self.init(data: data, scale: 1.0)
    }
    
    /// 根据二进制数据初始化【核心初始化方法】
    init?(data: Data, scale: CGFloat) {
        guard let cgImageSource = CGImageSourceCreateWithData(data as CFData, nil) else { return }
        self.cgImageSource = cgImageSource
        if GIFImage.isCGImageSourceContainAnimatedGIF(cgImageSource: cgImageSource) {
            initGIFSource(cgImageSource: cgImageSource)
        } else {
            image = UIImage(data: data, scale: scale)
        }
    }
    
    /// 判断图片数据源包含 GIF 信息
    fileprivate class func isCGImageSourceContainAnimatedGIF(cgImageSource: CGImageSource) -> Bool {
        guard let type = CGImageSourceGetType(cgImageSource) else { return false }
        let isGIF = UTTypeConformsTo(type, kUTTypeGIF)
        let imgCount = CGImageSourceGetCount(cgImageSource)
        return isGIF && imgCount > 1
    }
    
    /// 获取图片数据源的第 index 帧图片的动画时间
    fileprivate class func getCGImageSourceGifFrameDelay(imageSource: CGImageSource, index: Int) -> TimeInterval {
        var delay = 0.0
        guard let imgProperties: NSDictionary = CGImageSourceCopyPropertiesAtIndex(imageSource, index, nil) else { return delay }
        // 获取该帧图片的属性字典
        if let property = imgProperties[kCGImagePropertyGIFDictionary as String] as? NSDictionary {
            // 获取该帧图片的动画时长
            if let unclampedDelayTime = property[kCGImagePropertyGIFUnclampedDelayTime as String] as? NSNumber {
                delay = unclampedDelayTime.doubleValue
                if delay <= 0, let delayTime = property[kCGImagePropertyGIFDelayTime as String] as? NSNumber {
                    delay = delayTime.doubleValue
                }
            }
        }
        return delay
    }
    
    /// 根据图片数据源初始化,设置动画总时长、总帧数等属性
    fileprivate func initGIFSource(cgImageSource: CGImageSource) {
        let numOfFrames = CGImageSourceGetCount(cgImageSource)
        frameTotalCount = numOfFrames
        for index in 0..<numOfFrames {
            // 获取每一帧的动画时长
            let frameDuration = GIFImage.getCGImageSourceGifFrameDelay(imageSource: cgImageSource, index: index)
            self.frameDurations[index] = max(GlobalSetting.minFrameDuration, frameDuration)
            self.totalDuration += frameDuration
            // 一开始初始化预加载一定数量的图片,而不是全部图片
            if index < GlobalSetting.prefetchNumber {
                if let cgimage = CGImageSourceCreateImageAtIndex(cgImageSource, index, nil) {
                    let image: UIImage = UIImage(cgImage: cgimage)
                    if index == 0 {
                        self.image = image
                    }
                    self.frameImages[index] = image
                }
            }
        }
    }

    /// 获取某一帧图片
    func getFrame(index: Int) -> UIImage? {
        guard index < frameTotalCount else { return nil }
        // 取当前帧图片
        let currentImage = self.frameImages[index] ?? self.image
        // 如果总帧数大于预加载数,需要加载后面未加载的帧图片
        if frameTotalCount > GlobalSetting.prefetchNumber {
            // 清除当前帧图片缓存数据,空出内存
            if index != 0 {
                self.frameImages[index] = nil
            }
            // 加载后面帧图片到内存
            for i in 1...GlobalSetting.prefetchNumber {
                let idx = (i + index) % frameTotalCount
                if self.frameImages[idx] == nil {
                    // 默认加载第一张帧图片为占位,防止多次加载
                    self.frameImages[idx] = self.frameImages[0]
                    self.readFrameQueue.async { [weak self] in
                        guard let strongSelf = self, let cgImageSource = strongSelf.cgImageSource else { return }
                        guard let cgImage = CGImageSourceCreateImageAtIndex(cgImageSource, idx, nil) else { return }
                        strongSelf.frameImages[idx] = UIImage(cgImage: cgImage)
                    }
                }
            }
        }
        return currentImage
    }
}

BasicGIFImageView.swift 文件如下:

// BasicGIFImageView.swift
import UIKit
import QuartzCore

class BasicGIFImageView: UIImageView {
    /// 后台下载图片队列
    fileprivate lazy var downloadImageQueue: DispatchQueue = DispatchQueue(label: "image.gif.downloadImageQueue", qos: .background)
    /// 累加器,用于计算一个定时循环中的可用动画时间
    fileprivate var accumulator: TimeInterval = 0.0
    /// 当前正在显示的图片帧索引
    fileprivate var currentFrameIndex: Int = 0
    /// 当前正在显示的图片
    fileprivate var currentFrame: UIImage?
    /// 动画图片存储属性
    fileprivate var animatedImage: GIFImage?
    /// 定时器
    fileprivate var displayLink: CADisplayLink!
    /// 当前将要显示的 GIF 图片资源路径
    fileprivate var gifUrl: URL?
  
    /// 重载初始化,初始化定时器
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        setupDisplayLink()
    }
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        setupDisplayLink()
    }
    
    override init(image: UIImage?) {
        super.init(image: image)
        setupDisplayLink()
    }
    
    override init(image: UIImage?, highlightedImage: UIImage!) {
        super.init(image: image, highlightedImage: highlightedImage)
        setupDisplayLink()
    }
    
    /// 当设置该属性时,将不显示 GIF 动效
    override var image: UIImage? {
        get {
            if let animatedImage = self.animatedImage {
                return animatedImage.getFrame(index: 0)
            } else {
                return super.image
            }
        }
        set {
            if image === newValue {
                return
            }
            super.image = newValue
            self.gifImage = nil
        }
    }
    
    /// 设置 GIF 图片
    var gifImage: GIFImage? {
        get {
            return self.animatedImage
        }
        set {
            if animatedImage === newValue {
                return
            }
            self.stopAnimating()
            self.currentFrameIndex = 0
            self.accumulator = 0.0
            if let newAnimatedImage = newValue {
                self.animatedImage = newAnimatedImage
                if let currentImage = newAnimatedImage.getFrame(index: 0) {
                    super.image = currentImage
                    self.currentFrame = currentImage
                }
                self.startAnimating()
            } else {
                self.animatedImage = nil
            }
            self.layer.setNeedsDisplay()
        }
        
    }
    
    /// 当显示 GIF 时,不处理高亮状态
    override var isHighlighted: Bool {
        get {
            return super.isHighlighted
        }
        set {
            if self.animatedImage == nil {
                super.isHighlighted = newValue
            }
        }
    }
    
    /// 获取是否正在动画
    override var isAnimating: Bool {
        if self.animatedImage != nil {
            return !self.displayLink.isPaused
        } else {
            return super.isAnimating
        }
    }
    
    /// 开启定时器
    override func startAnimating() {
        if self.animatedImage != nil {
            self.displayLink.isPaused = false
        } else {
            super.startAnimating()
        }
    }
    
    /// 暂停定时器
    override func stopAnimating() {
        if self.animatedImage != nil {
            self.displayLink.isPaused = true
        } else {
            super.stopAnimating()
        }
    }
    
    /// 当前显示内容为 GIF 当前帧图片
    override func display(_ layer: CALayer) {
        if self.animatedImage != nil {
            if let frame = self.currentFrame {
                layer.contents = frame.cgImage
            }
        }
    }
    
    /// 初始化定时器
    fileprivate func setupDisplayLink() {
        displayLink = CADisplayLink(target: self, selector: #selector(BasicGIFImageView.changeKeyFrame))
        self.displayLink.add(to: RunLoop.main, forMode: .commonModes)
        self.displayLink.isPaused = true
    }
    
    /// 动态改变图片动画帧
    @objc fileprivate func changeKeyFrame() {
        if let animatedImage = self.animatedImage {
            guard self.currentFrameIndex < animatedImage.frameTotalCount else { return }
            self.accumulator += min(1.0, displayLink.duration)
            var frameDuration = animatedImage.frameDurations[self.currentFrameIndex] ?? displayLink.duration
            while self.accumulator >= frameDuration {
                self.accumulator -= frameDuration
                self.currentFrameIndex += 1
                if self.currentFrameIndex >= animatedImage.frameTotalCount {
                    self.currentFrameIndex = 0
                }
                if let currentImage = animatedImage.getFrame(index: self.currentFrameIndex) {
                    self.currentFrame = currentImage
                }
                self.layer.setNeedsDisplay()
                if let newFrameDuration = animatedImage.frameDurations[self.currentFrameIndex] {
                    frameDuration = min(displayLink.duration, newFrameDuration)
                }
            }
        } else {
            self.stopAnimating()
        }
    }
    
    /// 显示本地 GIF 图片
    func showLocalGIF(name: String?) {
        guard let name = name else { return }
        self.gifImage = GIFImage(named: name)
    }
    
    /// 根据 urlStr 显示网络 GIF 图片
    func showNetworkGIF(urlStr: String?) {
        guard let urlStr = urlStr else { return }
        guard let url = URL(string: urlStr) else { return }
        showNetworkGIF(url: url)
    }
    
    /// 根据 url 显示网络 GIF 图片
    func showNetworkGIF(url: URL) {
        guard let fileName = url.absoluteString.encodeMD5, let directoryPath = NSSearchPathForDirectoriesInDomains(.cachesDirectory, .userDomainMask, true).first else { return }
        let filePath = (directoryPath as NSString).appendingPathComponent("\(fileName).gif") as String
        let fileUrl = URL(fileURLWithPath: filePath)
        self.gifUrl = fileUrl
        // 后台下载网络图片或者加载本地缓存图片
        self.downloadImageQueue.async { [weak self] in
            if FileManager.default.fileExists(atPath: filePath) { // 本地缓存
                let gifImage = GIFImage(contentsOf: fileUrl)
                DispatchQueue.main.async { [weak self] in
                    if let strongSelf = self, strongSelf.gifUrl == fileUrl {
                        strongSelf.gifImage = gifImage
                    }
                }
            } else { // 网络加载
                let task = URLSession.shared.dataTask(with: url, completionHandler: { (data, _, _) in
                    guard let data = data else { return }
                    do {
                        try data.write(to: fileUrl, options: .atomic)
                    } catch {
                        debugPrint(error)
                    }
                    let gifImage = GIFImage(data: data)
                    DispatchQueue.main.async { [weak self] in
                        if let strongSelf = self, strongSelf.gifUrl == fileUrl {
                            strongSelf.gifImage = gifImage
                        }
                    }
                })
                task.resume()
            }
        }
    }
}

使用如下:

// ViewController.swift
import UIKit
class ViewController: UIViewController {
    @IBOutlet weak var networkImageView: BasicGIFImageView!
    @IBOutlet weak var localImageView: BasicGIFImageView!
 
    override func viewDidLoad() {
        super.viewDidLoad()
        // 加载网络 GIF 图片
        let testUrlStr = "https://images.ifanr.cn/wp-content/uploads/2018/05/2018-05-09-17_22_48.gif"
        networkImageView.showNetworkGIF(urlStr: testUrlStr)
        // 加载本地 GIF 图片
        localImageView.showLocalGIF(name: "test")
    }
}

Demo 源代码在这:GIFImageLoadDemo

有什么问题可以在下方评论区提出,写得不好可以提出你的意见,我会合理采纳的,O(∩_∩)O哈哈~,求关注求赞