Swift 玩转gif

gif study

众所周知,iOS默认是不支持gif类型图片的显示的,但是我们项目中常常是需要显示gif为动态图片。那肿么办?第三方库?是的 ,很多第三方都支持gif , 如果一直只停留在用第三方上,技术难有提高。上版本的 Kingfisher 也支持gif ,研究了一番,也在网上搜索了一番,稍微了解了下iOS实现gif的显示,在此略做记录。

本篇文章要实现的效果如图:

gif显示效果

可以开始和暂停gif的播放,滑动时停止播放,这个简书也是这么做得,好多app为了滑动时顺畅,停止了gif。

下面要进入正文啦!

期待...

分解gif帧进行显示

我们一般从网络上下载的gif图片其实是将很多帧静态图片循环播放产生的动态效果,那么在iOS中,如果我们想要显示动态图,同样需要先把gif资源解析为一阵一阵的UIImage然后设定间隔时长,不断播放即可。思路是不是很简单呢?那么看看如何实现。

分几个步骤:

  1. 将gif图片转为NSData
  2. 根据NSData获取CGImageSource对象
  3. 获取帧数
  4. 根据帧数获取每一帧对应的UIImage对象和时间间隔
  5. 循环播放

首先我们需要引入import ImageIO , 提供了很多对图片操作的函数。

这里我们从网上down了一个gif的图片,其实下载也是一样的 ,我们需要的是NSData类型的数据,用NSURLSession下载也可以得到NSData类型的数据,这里下载的数据如何判断是否为gif呢?

Kingfisher 库中给出了解决方案,每种格式的图片前面几位都是固定的。所以只需要对比就能判断出类型,这里给出Kingfisher判断类型的代码。

private let pngHeader: [UInt8] = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]
private let jpgHeaderSOI: [UInt8] = [0xFF, 0xD8]
private let jpgHeaderIF: [UInt8] = [0xFF]
private let gifHeader: [UInt8] = [0x47, 0x49, 0x46]

enum ImageFormat {
    case Unknown, PNG, JPEG, GIF
}

extension NSData {
    var kf_imageFormat: ImageFormat {
        var buffer = [UInt8](count: 8, repeatedValue: 0)
        self.getBytes(&buffer, length: 8)
        if buffer == pngHeader {
            return .PNG
        } else if buffer[0] == jpgHeaderSOI[0] &&
            buffer[1] == jpgHeaderSOI[1] &&
            buffer[2] == jpgHeaderIF[0]
        {
            return .JPEG
        } else if buffer[0] == gifHeader[0] &&
            buffer[1] == gifHeader[1] &&
            buffer[2] == gifHeader[2]
        {
            return .GIF
        }
        
        return .Unknown
    }
}

有了这个扩展判断起来就方便很多了。

为了使demo简单,我们直接将gif放在本地沙盒。下载好直接拖进项目就OK了。

这样就可以很容易的得到NSData类型的数据

let path = NSBundle.mainBundle().pathForResource("xxx", ofType: "gif")
let data = NSData(contentsOfFile: path!)

第一步已经完成啦。

然后通过CGImageSourceCreateWithData 方法创建一个CGImageSource 对象 。

// kCGImageSourceShouldCache : 表示是否在存储的时候就解码
// kCGImageSourceTypeIdentifierHint : 指明source type
let options: NSDictionary = [kCGImageSourceShouldCache as String: NSNumber(bool: true), kCGImageSourceTypeIdentifierHint as String: kUTTypeGIF]
guard let imageSource = CGImageSourceCreateWithData(data, options) else {
            return
        }

这里的options是为了显示优化。提前解码,指定类型。

拿到CGImageSource 对象就可以为所欲为了。

// 获取gif帧数
let frameCount = CGImageSourceGetCount(imageSource)
var images = [UIImage]()

var gifDuration = 0.0

for i in 0 ..< frameCount {
    // 获取对应帧的 CGImage
    guard let imageRef = CGImageSourceCreateImageAtIndex(imageSource, i, options) else {
        return
    }
    if frameCount == 1 {
        // 单帧
        gifDuration = Double.infinity
    } else{
        // gif 动画
        // 获取到 gif每帧时间间隔
        guard let properties = CGImageSourceCopyPropertiesAtIndex(imageSource, i, nil) , gifInfo = (properties as NSDictionary)[kCGImagePropertyGIFDictionary as String] as? NSDictionary,
            frameDuration = (gifInfo[kCGImagePropertyGIFDelayTime as String] as? NSNumber) else
        {
            return
        }
//                print(frameDuration)
        gifDuration += frameDuration.doubleValue
        // 获取帧的img
        let  image = UIImage(CGImage: imageRef , scale: UIScreen.mainScreen().scale , orientation: UIImageOrientation.Up)
        // 添加到数组
        images.append(image)
    }
}

先获取帧数,然后循环根据帧数获取对应的图片,然后获取没帧间隔时间。累加时间间隔得到总共的时间,把图片存在一个图片数组中。

有了这些参数,我们就可以播放gif了。

界面上随便拖出来一个 UIImageView 然后给以下属性赋值即可。

imgV.contentMode = .ScaleAspectFit
imgV.animationImages = images
imgV.animationDuration = gifDuration
imgV.animationRepeatCount = 0 // 无限循环
imgV.startAnimating()

运行项目,发现gif动起来了。

happy...

原来gif也没那么难,哈哈... ...

但是这样你添加一个开始和暂停的按钮

@IBAction func start(sender: AnyObject) {
    if !imgV.isAnimating() {
        imgV.startAnimating()
    }
}


@IBAction func stop(sender: AnyObject) {
    if imgV.isAnimating() {
        imgV.stopAnimating()
    }
}

你会发现,暂停时白板,什么图都没有,而且滚动的时候也不会暂停。。。

吐血...

这只是个开始,后面的路还很长,坐好继续。

处理gif的暂停、播放 滑动暂停等


以下部分基本上算是对Kingfisher 的一个理解,我们继续。

简单说下思路,要实现暂停在某帧,滑动暂停某帧这个就不能用UIImageViewstartAnimating直接操作了,需要我们自己处理帧和动画,动画在Kingfisher中使用CADisplayLink处理的,写了一个UIImageView的子类AnimatedImageView,重写了startAnimatingstopAnimating 等方法。关于CADisplayLink不熟悉的,看这篇文章 - CADisplayLink , 需要滑动暂停就把 CADisplayLink 加到 NSDefaultRunLoopMode模式的runloop下。 关于对帧的处理单独写了一个Animator . 下面来看看具体实现。

Animator 类处理帧

首先定义一个结构体,里面就有两个属性UIImage 图像 和 NSTimeInterval 帧之间时间间隔。

struct AnimatedFrame {
    var image: UIImage?
    let duration: NSTimeInterval
    
    static func null() -> AnimatedFrame {
        return AnimatedFrame(image: .None, duration: 0.0)
    }
}

接着就可以创建一个 Animator 并定义一些需要用的属性

class Animator{
    private let maxFrameCount: Int = 100    // 最大帧数
    private var imageSource:CGImageSource!  // imageSource 处理帧相关操作
    private var animatedFrames = [AnimatedFrame]()  //
    private var frameCount = 0  // 帧的数量
    private var currentFrameIndex = 0   // 当前帧下标
    private var currentPreloadIndex = 0 // 当前预缓存帧的下标
    private var timeSinceLastFrameChange: NSTimeInterval = 0.0  // 距离上一帧改变的时间
    /// 循环次数
    private var loopCount = 0
    /// 做大间隔
    private let maxTimeStep: NSTimeInterval = 1.0
}

然后是一个队数据操作的方法,因为Kingfiher是处理网络图片的,所以我这边处理方式略不同

/**
 根据data创建 CGImageSource
 
 - parameter data: gif data
 */
func createImageSource(data:NSData){
    let options: NSDictionary = [kCGImageSourceShouldCache as String: NSNumber(bool: true), kCGImageSourceTypeIdentifierHint as String: kUTTypeGIF]
    imageSource = CGImageSourceCreateWithData(data, options)
}

这个方法就是前面的根据NSData 获取 CGImageSource 对象,以备后用。

然后写一个将每一帧转换为我们刚定义的结构体 AnimatedFrame 对象

 /// 准备某帧 的 frame
func prepareFrame(index: Int) -> AnimatedFrame {
    // 获取对应帧的 CGImage
    guard let imageRef = CGImageSourceCreateImageAtIndex(imageSource, index , nil) else {
        return AnimatedFrame.null()
    }
    // 获取到 gif每帧时间间隔
    guard let properties = CGImageSourceCopyPropertiesAtIndex(imageSource, index , nil) , gifInfo = (properties as NSDictionary)[kCGImagePropertyGIFDictionary as String] as? NSDictionary,
        frameDuration = (gifInfo[kCGImagePropertyGIFDelayTime as String] as? NSNumber) else
    {
        return AnimatedFrame.null()
    }
    
    let image = UIImage(CGImage: imageRef , scale: UIScreen.mainScreen().scale , orientation: UIImageOrientation.Up)
    return AnimatedFrame(image: image, duration: Double(frameDuration) ?? 0.0)
}

就是根据imageSource获取CGImage再转为UIImage , 然后获取帧间隔时间,构建结构体。 很easy 。没啥说的。

下面还需要一个预备所有帧的方法

/**
预备所有frames
*/
func prepareFrames() {
frameCount = CGImageSourceGetCount(imageSource)

if let properties = CGImageSourceCopyProperties(imageSource, nil),
    gifInfo = (properties as NSDictionary)[kCGImagePropertyGIFDictionary as String] as? NSDictionary,
    loopCount = gifInfo[kCGImagePropertyGIFLoopCount as String] as? Int {
    self.loopCount = loopCount
}

// 总共帧数
let frameToProcess = min(frameCount, maxFrameCount)

animatedFrames.reserveCapacity(frameToProcess)

// 相当于累加
animatedFrames = (0..<frameToProcess).reduce([]) { $0 + pure(prepareFrame($1))}

// 上面相当于这个
//        for i in 0..<frameToProcess {
//            animatedFrames.append(prepareFrame(i))
//        }

}

这里其实就是得到总帧数然后给animatedFrames赋值,Kingfisher这里使用了readuce,累加的方式pure 方法是将一个值转成一个单值数组。

private func pure<T>(value: T) -> [T] {
    return [value]
}

根据下表取帧

/**
 根据下标获取帧
 */
func frameAtIndex(index: Int) -> UIImage? {
    return animatedFrames[index].image
}

当前帧和contentMode属性

var currentFrame: UIImage? {
    return frameAtIndex(currentFrameIndex)
}

var contentMode: UIViewContentMode = .ScaleToFill
AnimatedImageView-可以播放gif的ImageView

基本成型,还差一个更新当前帧的方法,暂时不处理,先看去用实现一个继承自UIImageViewAnimatedImageView 并声明几个属性。

public class AnimatedImageView : UIImageView {
    /// 是否自动播放
    public var autoPlayAnimatedImage = true
    
    /// `Animator` 对象 将帧和指定图片存储内存中
    private var animator: Animator?
    
    /// displayLink 为懒加载 避免还没有加载好的时候使用了 造成异常
    private var displayLinkInitialized: Bool = false

}

这里利用 CADisplayLink 不断执行某个方法,等达到帧之间的间隔时间的时候就去更新UIImageViewlayercontens 属性。这个属性需要一个CGImage的对象。

为了防止AnimatedImageViewCADisplayLink 之间的循环引用,Kingfisher在AnimatedImageView 内部写了一个代理类。

 /// 防止循环引用
class TargetProxy {
    private weak var target: AnimatedImageView?
    
    init(target: AnimatedImageView) {
        self.target = target
    }
    
    @objc func onScreenUpdate() {
        target?.updateFrame()
    }
}

就是通过TargetProxy 来调用 AnimatedImageView 中的 updateFrame 方法,大家可以先写一个空方法。

然后创建一个CADisplayLink对象,这里使用懒加载。

private lazy var displayLink: CADisplayLink = {
    self.displayLinkInitialized = true
    let displayLink = CADisplayLink(target: TargetProxy(target: self), selector: #selector(TargetProxy.onScreenUpdate))
    displayLink.addToRunLoop(NSRunLoop.mainRunLoop(), forMode: self.runLoopMode)
    displayLink.paused = true
    return displayLink
}()

用这个self.displayLinkInitialized 标志 CADisplayLink 已经加载,然后用代理就调用自己的 updateFrame()方法

在添加个指定RunLoopMode的属性

// NSRunLoopCommonModes
public var runLoopMode = NSDefaultRunLoopMode {
    willSet {
        if runLoopMode == newValue {
            return
        } else {
            stopAnimating()
            displayLink.removeFromRunLoop(NSRunLoop.mainRunLoop(), forMode: runLoopMode)
            displayLink.addToRunLoop(NSRunLoop.mainRunLoop(), forMode: newValue)
            startAnimating()
        }
    }
}

Kingfisher 默认是NSRunLoopCommonModes 滑动不暂停,我这边换成NSDefaultRunLoopMode 滑动暂停 。

NSRunLoopCommonModes 包含两个模式 UITrackingRunLoopModeNSDefaultRunLoopMode , 其中UITrackingRunLoopMode 是滑动时候的模式
,如果只在 NSDefaultRunLoopMode 模式下,那滑动模式就不会执行CADisplayLink 的方法, NSTimer 也可以指定 模式。非本篇重点 ,这里就不细说了

kingfisher 是重写了 image 属性进行Animator的初始化和重置的 , 这里为了demo的easy 我们给 AnimatedImageView 新增一个属性,叫 gifData.

public var gifData:NSData?{
    didSet{
        if let gifData = gifData {
            animator = nil
            animator = Animator()
            animator?.createImageSource(gifData)
            animator?.prepareFrames()

            didMove()
            setNeedsDisplay()
            layer.setNeedsDisplay()
        }
    }
}

创建Animator对象 ,缓存帧。 这里didMove() 方法是处理自动播放的

private func didMove() {
    if autoPlayAnimatedImage && animator != nil {
        if let _ = superview, _ = window {
            startAnimating()
        } else {
            stopAnimating()
        }
    }
}

后面会重写startAnimatingstopAnimating .

先来看 CADisplayLink 每次调用的方法updateFrame() , 这里默认是每秒60次 , 根据屏幕刷新频率。

要实现updateFrame() 放法首先要在,Animator 中添加一个更新当前帧的方法。上面提到的,现在可以来写了。

func updateCurrentFrame(duration: CFTimeInterval) -> Bool {
    // 计算距离上一帧 改变的时间 每次进来都累加 直到frameDuration  <= timeSinceLastFrameChange 时候才继续走下去
    timeSinceLastFrameChange += min(maxTimeStep, duration)
    guard let frameDuration = animatedFrames[safe: currentFrameIndex]?.duration where frameDuration <= timeSinceLastFrameChange else {
        return false
    }
    // 减掉 我们每帧间隔时间
    timeSinceLastFrameChange -= frameDuration
    let lastFrameIndex = currentFrameIndex
    currentFrameIndex += 1 // 一直累加
    // 这里取了余数
    currentFrameIndex = currentFrameIndex % animatedFrames.count
    
    if animatedFrames.count < frameCount {
        animatedFrames[lastFrameIndex] = prepareFrame(currentPreloadIndex)
        currentPreloadIndex += 1
        currentPreloadIndex = currentPreloadIndex % frameCount
    }
    return true
}

传入的durationdisplayLink.duration 默认是 1/60 秒,这里先对每次的duration进行累加,直到我们的帧间隔时间小于等于它了 才去获取当前帧和增加下标,返回true , 否则一直返回false

然后AnimatedImageView中的 updateFrame 方法就是调用那个方法,直到它返回true才进行处理,这里就是调用了layer.setNeedsDisplay()

private func updateFrame() {
    if animator?.updateCurrentFrame(displayLink.duration) ?? false {
        // 此方法会触发 displayLayer
        layer.setNeedsDisplay()
    }
}

layer.setNeedsDisplay() 会触发 displayLayer 方法,我们只要重写这个方法,就能处理每帧的显示了。

override public func displayLayer(layer: CALayer) {
    if let currentFrame = animator?.currentFrame {
        layer.contents = currentFrame.CGImage
    } else {
        layer.contents = image?.CGImage
    }
}

搞了这么多,终于到显示了,不容易呀。。。

这里重写了几个方法,都去调用了didMove

override public func didMoveToWindow() {
    super.didMoveToWindow()
    didMove()
}

override public func didMoveToSuperview() {
    super.didMoveToSuperview()
    didMove()
}

这里gif的暂停是利用了CADisplayLinkpaused属性控制的

 override public func isAnimating() -> Bool {
    if displayLinkInitialized {
        return !displayLink.paused
    } else {
        return super.isAnimating()
    }
}

/// Starts the animation.
override public func startAnimating() {
    if self.isAnimating() {
        return
    } else {
        displayLink.paused = false
    }
}

/// Stops the animation.
override public func stopAnimating() {
    super.stopAnimating()
    if displayLinkInitialized {
        displayLink.paused = true
    }
}

这里displayLinkInitialized 判断CADisplayLink是否加载好了。

最后记得在对象销毁的时候吧displaylink也停掉

deinit {
    if displayLinkInitialized {
        displayLink.invalidate()
    }
}

至此,所有基本功能已经全部OK了,使用也很简单。

let path = NSBundle.mainBundle().pathForResource("xxx", ofType: "gif")
let data = NSData(contentsOfFile: path!)
imgV.gifData = data

默认是自动播放,可以手动设置。

文章比较长,可能描述的不是很到位,有啥不清楚可以留言交流。

github地址:https://github.com/smalldu/ImageDemo

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

推荐阅读更多精彩内容

  • 发现 关注 消息 iOS 第三方库、插件、知名博客总结 作者大灰狼的小绵羊哥哥关注 2017.06.26 09:4...
    肇东周阅读 11,596评论 4 59
  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 170,544评论 25 707
  • 最近《我的前半生》特别的火,于是我也凑热闹看了几集,正常来说以我的心性我是不会看这种让人绝望的,无事生非的电视。生...
    不小不阅读 414评论 0 0
  • 如果你问一个地地道道的武夷山人:“你们武夷山有什么好玩好吃的?”不论男女老少,是何职业身份都会热情地回答你: “景...
    孝文家茶tea阅读 624评论 0 1
  • 我的心如寂寂死灰,沉在无边的黑暗里,你潇洒的一转身,就已将我与你的情隔在了万丈红尘外。 人说十年磨一剑,而这十年却...
    红尘紫陌阅读 284评论 2 3