Swift 專案總結 08 GIF 圖片載入優化

執著丶執念發表於2018-06-02

Swift 專案總結 08   GIF 圖片載入優化

一、問題出現

在公司專案中,需要顯示一些網路 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哈哈~,求關注求贊

相關文章