Swift 項(xiàng)目總結(jié) 08 - GIF 圖片加載優(yōu)化

一、問(wèn)題出現(xiàn)

在公司項(xiàng)目中,需要顯示一些網(wǎng)絡(luò) GIF 圖片,使用的是 Kingfisher 第三方圖片緩存庫(kù)進(jìn)行加載圖片,一般情況下挺好的,但有時(shí)候會(huì)出現(xiàn)內(nèi)存暴增,一開(kāi)始以為是沒(méi)有對(duì)圖片緩存進(jìn)行釋放導(dǎo)致,后來(lái)測(cè)試發(fā)現(xiàn)是因?yàn)槟硞€(gè) GIF 幀數(shù)過(guò)高導(dǎo)致的,一個(gè) 1MB 大小但幀數(shù)有 150 幀的 GIF 圖片,采用 Kingfisher 加載到內(nèi)存中需要占用至少 300 MB 以上的內(nèi)存,多加載幾張這樣的 GIF 內(nèi)存直接爆炸,所以需要進(jìn)行 GIF 圖片加載進(jìn)行優(yōu)化。

二、問(wèn)題思考

為什么會(huì)導(dǎo)致這樣的內(nèi)存暴增呢?

因?yàn)?Kingfisher 在加載 GIF 圖的時(shí)候,會(huì)把 GIF 圖的所有幀圖片數(shù)據(jù)都加載到內(nèi)存進(jìn)行顯示,導(dǎo)致內(nèi)存暴增。

降低內(nèi)存消耗,提高 CPU 消耗

去網(wǎng)上找第三方 GIF 圖加載優(yōu)化庫(kù),發(fā)現(xiàn)了SwiftGifYLGIFImage-Swift 這兩個(gè)框架,我看了一下 YLGIFImage-Swift 框架里面的實(shí)現(xiàn),是通過(guò)動(dòng)態(tài)加載動(dòng)畫(huà)幀的形式來(lái)優(yōu)化的。

動(dòng)態(tài)加載幀原理:

  1. 一開(kāi)始不加載所有圖片幀,只加載少量的幀圖片
  2. 在動(dòng)畫(huà)執(zhí)行過(guò)程中利用定時(shí)器不斷進(jìn)行加載幀圖片
  3. 釋放已執(zhí)行完動(dòng)畫(huà)的幀圖片內(nèi)存
  4. 內(nèi)存消耗降低,這樣的代價(jià)就是會(huì)導(dǎo)致 CPU 的使用提高

因?yàn)轫?xiàng)目代碼使用到的是 Swift3.2,YLGIFImage-Swift 第三方庫(kù)更新比較慢,所以對(duì)該框架手動(dòng)進(jìn)行了一些調(diào)整和優(yōu)化。

三、源代碼解析和優(yōu)化

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)
        // 把結(jié)果打印輸出成 16 進(jìn)制字符串
        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 {
    /// 內(nèi)部讀取圖片幀隊(duì)列
    fileprivate lazy var readFrameQueue: DispatchQueue = DispatchQueue(label: "image.gif.readFrameQueue", qos: .background)
    /// 圖片資源數(shù)據(jù)
    fileprivate var cgImageSource: CGImageSource?
    /// 總動(dòng)畫(huà)時(shí)長(zhǎng)
    var totalDuration: TimeInterval = 0.0
    /// 每一幀對(duì)應(yīng)的動(dòng)畫(huà)時(shí)長(zhǎng)
    var frameDurations: [Int: TimeInterval] = [:]
    /// 每一幀對(duì)應(yīng)的圖片
    var frameImages: [Int: UIImage] = [:]
    /// 總圖片數(shù)
    var frameTotalCount: Int = 0
    /// 兼容之前的 UIImage 使用
    var image: UIImage?

    /// 全局配置
    struct GlobalSetting {
        /// 配置預(yù)加載幀的數(shù)量
        static var prefetchNumber: Int = 10
        static var minFrameDuration: TimeInterval = 0.01
    }

    /// 兼容 UIImage named 調(diào)用
    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 調(diào)用
    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 調(diào)用
    convenience init?(contentsOf url: URL) {
        guard let data = try? Data(contentsOf: url) else { return nil }
        self.init(data: data)
    }

    /// 兼容 UIImage data 調(diào)用
    convenience init?(data: Data) {
        self.init(data: data, scale: 1.0)
    }
    
    /// 根據(jù)二進(jìn)制數(shù)據(jù)初始化【核心初始化方法】
    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)
        }
    }
    
    /// 判斷圖片數(shù)據(jù)源包含 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
    }
    
    /// 獲取圖片數(shù)據(jù)源的第 index 幀圖片的動(dòng)畫(huà)時(shí)間
    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 {
            // 獲取該幀圖片的動(dòng)畫(huà)時(shí)長(zhǎng)
            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
    }
    
    /// 根據(jù)圖片數(shù)據(jù)源初始化,設(shè)置動(dòng)畫(huà)總時(shí)長(zhǎng)、總幀數(shù)等屬性
    fileprivate func initGIFSource(cgImageSource: CGImageSource) {
        let numOfFrames = CGImageSourceGetCount(cgImageSource)
        frameTotalCount = numOfFrames
        for index in 0..<numOfFrames {
            // 獲取每一幀的動(dòng)畫(huà)時(shí)長(zhǎng)
            let frameDuration = GIFImage.getCGImageSourceGifFrameDelay(imageSource: cgImageSource, index: index)
            self.frameDurations[index] = max(GlobalSetting.minFrameDuration, frameDuration)
            self.totalDuration += frameDuration
            // 一開(kāi)始初始化預(yù)加載一定數(shù)量的圖片,而不是全部圖片
            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 }
        // 取當(dāng)前幀圖片
        let currentImage = self.frameImages[index] ?? self.image
        // 如果總幀數(shù)大于預(yù)加載數(shù),需要加載后面未加載的幀圖片
        if frameTotalCount > GlobalSetting.prefetchNumber {
            // 清除當(dāng)前幀圖片緩存數(shù)據(jù),空出內(nèi)存
            if index != 0 {
                self.frameImages[index] = nil
            }
            // 加載后面幀圖片到內(nèi)存
            for i in 1...GlobalSetting.prefetchNumber {
                let idx = (i + index) % frameTotalCount
                if self.frameImages[idx] == nil {
                    // 默認(rèn)加載第一張幀圖片為占位,防止多次加載
                    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 {
    /// 后臺(tái)下載圖片隊(duì)列
    fileprivate lazy var downloadImageQueue: DispatchQueue = DispatchQueue(label: "image.gif.downloadImageQueue", qos: .background)
    /// 累加器,用于計(jì)算一個(gè)定時(shí)循環(huán)中的可用動(dòng)畫(huà)時(shí)間
    fileprivate var accumulator: TimeInterval = 0.0
    /// 當(dāng)前正在顯示的圖片幀索引
    fileprivate var currentFrameIndex: Int = 0
    /// 當(dāng)前正在顯示的圖片
    fileprivate var currentFrame: UIImage?
    /// 動(dòng)畫(huà)圖片存儲(chǔ)屬性
    fileprivate var animatedImage: GIFImage?
    /// 定時(shí)器
    fileprivate var displayLink: CADisplayLink!
    /// 當(dāng)前將要顯示的 GIF 圖片資源路徑
    fileprivate var gifUrl: URL?
  
    /// 重載初始化,初始化定時(shí)器
    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()
    }
    
    /// 當(dāng)設(shè)置該屬性時(shí),將不顯示 GIF 動(dòng)效
    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
        }
    }
    
    /// 設(shè)置 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()
        }
        
    }
    
    /// 當(dāng)顯示 GIF 時(shí),不處理高亮狀態(tài)
    override var isHighlighted: Bool {
        get {
            return super.isHighlighted
        }
        set {
            if self.animatedImage == nil {
                super.isHighlighted = newValue
            }
        }
    }
    
    /// 獲取是否正在動(dòng)畫(huà)
    override var isAnimating: Bool {
        if self.animatedImage != nil {
            return !self.displayLink.isPaused
        } else {
            return super.isAnimating
        }
    }
    
    /// 開(kāi)啟定時(shí)器
    override func startAnimating() {
        if self.animatedImage != nil {
            self.displayLink.isPaused = false
        } else {
            super.startAnimating()
        }
    }
    
    /// 暫停定時(shí)器
    override func stopAnimating() {
        if self.animatedImage != nil {
            self.displayLink.isPaused = true
        } else {
            super.stopAnimating()
        }
    }
    
    /// 當(dāng)前顯示內(nèi)容為 GIF 當(dāng)前幀圖片
    override func display(_ layer: CALayer) {
        if self.animatedImage != nil {
            if let frame = self.currentFrame {
                layer.contents = frame.cgImage
            }
        }
    }
    
    /// 初始化定時(shí)器
    fileprivate func setupDisplayLink() {
        displayLink = CADisplayLink(target: self, selector: #selector(BasicGIFImageView.changeKeyFrame))
        self.displayLink.add(to: RunLoop.main, forMode: .commonModes)
        self.displayLink.isPaused = true
    }
    
    /// 動(dòng)態(tài)改變圖片動(dòng)畫(huà)幀
    @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)
    }
    
    /// 根據(jù) urlStr 顯示網(wǎng)絡(luò) GIF 圖片
    func showNetworkGIF(urlStr: String?) {
        guard let urlStr = urlStr else { return }
        guard let url = URL(string: urlStr) else { return }
        showNetworkGIF(url: url)
    }
    
    /// 根據(jù) url 顯示網(wǎng)絡(luò) 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
        // 后臺(tái)下載網(wǎng)絡(luò)圖片或者加載本地緩存圖片
        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 { // 網(wǎng)絡(luò)加載
                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()
        // 加載網(wǎng)絡(luò) 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

有什么問(wèn)題可以在下方評(píng)論區(qū)提出,寫(xiě)得不好可以提出你的意見(jiàn),我會(huì)合理采納的,O(∩_∩)O哈哈~,求關(guān)注求贊

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • 發(fā)現(xiàn) 關(guān)注 消息 iOS 第三方庫(kù)、插件、知名博客總結(jié) 作者大灰狼的小綿羊哥哥關(guān)注 2017.06.26 09:4...
    肇東周閱讀 15,083評(píng)論 4 61
  • 使用python對(duì)制定文件夾下制定后綴的文件進(jìn)行遍歷. 主要用到的庫(kù) os os.path.exists(path...
    ciantian閱讀 2,156評(píng)論 0 1
  • 1.九九第九天。 王老師說(shuō)昨晚下雨了,車(chē)窗上有泥濘斑駁的證據(jù)。 天很陰,空氣有點(diǎn)濕濕的。 該和冬天告別了。 2.啄...
    高小花0218閱讀 377評(píng)論 0 0

友情鏈接更多精彩內(nèi)容