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哈哈~,求关注求赞

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 217,657评论 6 505
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,889评论 3 394
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 164,057评论 0 354
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,509评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,562评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,443评论 1 302
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,251评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,129评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,561评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,779评论 3 335
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,902评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,621评论 5 345
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,220评论 3 328
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,838评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,971评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,025评论 2 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,843评论 2 354

推荐阅读更多精彩内容

  • 发现 关注 消息 iOS 第三方库、插件、知名博客总结 作者大灰狼的小绵羊哥哥关注 2017.06.26 09:4...
    肇东周阅读 12,103评论 4 62
  • 使用python对制定文件夹下制定后缀的文件进行遍历. 主要用到的库 os os.path.exists(path...
    ciantian阅读 2,084评论 0 1
  • 1.九九第九天。 王老师说昨晚下雨了,车窗上有泥泞斑驳的证据。 天很阴,空气有点湿湿的。 该和冬天告别了。 2.啄...
    高小花0218阅读 315评论 0 0