Swift 玩转gif

Johnette43A 5年前
   <p><img src="https://simg.open-open.com/show/2c8882ab626d9d756d80559213067ebb.png"></p>    <p style="text-align:center">gif study</p>    <p>众所周知,iOS默认是不支持gif类型图片的显示的,但是我们项目中常常是需要显示gif为动态图片。那肿么办?第三方库?是的 ,很多第三方都支持gif , 如果一直只停留在用第三方上,技术难有提高。上版本的 <a href="/misc/goto?guid=4958872058774071441" rel="nofollow,noindex">Kingfisher</a> 也支持gif ,研究了一番,也在网上搜索了一番,稍微了解了下iOS实现gif的显示,在此略做记录。</p>    <p>本篇文章要实现的效果如图:</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/4ee44c79429b68573f19e66c5d151557.gif"></p>    <p style="text-align:center">gif显示效果</p>    <p> </p>    <p style="text-align:center"> </p>    <p>分解gif帧进行显示</p>    <p>我们一般从网络上下载的gif图片其实是将很多帧静态图片循环播放产生的动态效果,那么在iOS中,如果我们想要显示动态图,同样需要先把gif资源解析为一阵一阵的 UIImage 然后设定间隔时长,不断播放即可。思路是不是很简单呢?那么看看如何实现。</p>    <p>分几个步骤:</p>    <ol>     <li> <p>将gif图片转为 NSData 。</p> </li>     <li> <p>根据 NSData 获取 CGImageSource 对象</p> </li>     <li> <p>获取帧数</p> </li>     <li> <p>根据帧数获取每一帧对应的 UIImage 对象和时间间隔</p> </li>     <li> <p>循环播放</p> </li>    </ol>    <p>首先我们需要引入 import ImageIO , 提供了很多对图片操作的函数。</p>    <p>这里我们从网上down了一个gif的图片,其实下载也是一样的 ,我们需要的是 NSData 类型的数据,用 NSURLSession 下载也可以得到 NSData 类型的数据,这里下载的数据如何判断是否为gif呢?</p>    <p>Kingfisher 库中给出了解决方案,每种格式的图片前面几位都是固定的。所以只需要对比就能判断出类型,这里给出 Kingfisher 判断类型的代码。</p>    <pre>  <code class="language-swift">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      }  }</code></pre>    <p>有了这个扩展判断起来就方便很多了。</p>    <p>为了使demo简单,我们直接将gif放在本地沙盒。下载好直接拖进项目就OK了。</p>    <p>这样就可以很容易的得到 NSData 类型的数据</p>    <pre>  <code class="language-swift">let path = NSBundle.mainBundle().pathForResource("xxx", ofType: "gif")  let data = NSData(contentsOfFile: path!)</code></pre>    <p>第一步已经完成啦。</p>    <p>然后通过 CGImageSourceCreateWithData 方法创建一个 CGImageSource 对象 。</p>    <pre>  <code class="language-swift">// 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          }</code></pre>    <p>这里的options是为了显示优化。提前解码,指定类型。</p>    <p>拿到 CGImageSource 对象就可以为所欲为了。</p>    <pre>  <code class="language-swift">// 获取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)      }  }</code></pre>    <p>先获取帧数,然后循环根据帧数获取对应的图片,然后获取没帧间隔时间。累加时间间隔得到总共的时间,把图片存在一个图片数组中。</p>    <p>有了这些参数,我们就可以播放gif了。</p>    <p>界面上随便拖出来一个 UIImageView 然后给以下属性赋值即可。</p>    <pre>  <code class="language-swift">imgV.contentMode = .ScaleAspectFit  imgV.animationImages = images  imgV.animationDuration = gifDuration  imgV.animationRepeatCount = 0 // 无限循环  imgV.startAnimating()</code></pre>    <p>运行项目,发现gif动起来了。</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/73d987bcb477974f3e2861b202777a05.jpg"></p>    <p style="text-align:center">happy...</p>    <p>原来gif也没那么难,哈哈... ...</p>    <p>但是这样你添加一个开始和暂停的按钮</p>    <pre>  <code class="language-swift">@IBAction func start(sender: AnyObject) {      if !imgV.isAnimating() {          imgV.startAnimating()      }  }      @IBAction func stop(sender: AnyObject) {      if imgV.isAnimating() {          imgV.stopAnimating()      }  }</code></pre>    <p>你会发现,暂停时白板,什么图都没有,而且滚动的时候也不会暂停。。。</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/b90afeeaef193c7430fcc9881620d04b.jpg"></p>    <p style="text-align:center">吐血...</p>    <p>这只是个开始,后面的路还很长,坐好继续。</p>    <h2><strong>处理gif的暂停、播放 滑动暂停等</strong></h2>    <p>以下部分基本上算是对 Kingfisher 的一个理解,我们继续。</p>    <p>简单说下思路,要实现暂停在某帧,滑动暂停某帧这个就不能用 UIImageView 的 startAnimating 直接操作了,需要我们自己处理帧和动画,动画在 Kingfisher 中使用 CADisplayLink 处理的,写了一个 UIImageView 的子类 AnimatedImageView ,重写了 startAnimating 、 stopAnimating 等方法。关于 CADisplayLink 不熟悉的,看这篇文章 -CADisplayLink , 需要滑动暂停就把 CADisplayLink 加到 NSDefaultRunLoopMode 模式的runloop下。 关于对帧的处理单独写了一个 Animator . 下面来看看具体实现。</p>    <p><strong>Animator 类处理帧</strong></p>    <p>首先定义一个结构体,里面就有两个属性 UIImage 图像 和 NSTimeInterval 帧之间时间间隔。</p>    <pre>  <code class="language-swift">struct AnimatedFrame {      var image: UIImage?      let duration: NSTimeInterval        static func null() -> AnimatedFrame {          return AnimatedFrame(image: .None, duration: 0.0)      }  }</code></pre>    <p>接着就可以创建一个 Animator 并定义一些需要用的属性</p>    <pre>  <code class="language-swift">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  }</code></pre>    <p>然后是一个队数据操作的方法,因为Kingfiher是处理网络图片的,所以我这边处理方式略不同</p>    <pre>  <code class="language-swift">/**   根据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)  }</code></pre>    <p>这个方法就是前面的根据 NSData 获取 CGImageSource 对象,以备后用。</p>    <p>然后写一个将每一帧转换为我们刚定义的结构体 AnimatedFrame 对象</p>    <pre>  <code class="language-swift">/// 准备某帧 的 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)  }</code></pre>    <p>就是根据 imageSource 获取 CGImage 再转为 UIImage , 然后获取帧间隔时间,构建结构体。 很easy 。没啥说的。</p>    <p>下面还需要一个预备所有帧的方法</p>    <pre>  <code class="language-swift">/**  预备所有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))  //        }    }</code></pre>    <p>这里其实就是得到总帧数然后给 animatedFrames 赋值, Kingfisher 这里使用了readuce,累加的方式 pure 方法是将一个值转成一个单值数组。</p>    <pre>  <code class="language-swift">private func pure<T>(value: T) -> [T] {      return [value]  }</code></pre>    <p>根据下表取帧</p>    <pre>  <code class="language-swift">/**   根据下标获取帧   */  func frameAtIndex(index: Int) -> UIImage? {      return animatedFrames[index].image  }</code></pre>    <p>当前帧和contentMode属性</p>    <pre>  <code class="language-swift">var currentFrame: UIImage? {      return frameAtIndex(currentFrameIndex)  }    var contentMode: UIViewContentMode = .ScaleToFill</code></pre>    <h2><strong>AnimatedImageView-可以播放gif的ImageView</strong></h2>    <p>基本成型,还差一个更新当前帧的方法,暂时不处理,先看去用实现一个继承自 UIImageView 的 AnimatedImageView 并声明几个属性。</p>    <pre>  <code class="language-swift">public class AnimatedImageView : UIImageView {      /// 是否自动播放      public var autoPlayAnimatedImage = true        /// `Animator` 对象 将帧和指定图片存储内存中      private var animator: Animator?        /// displayLink 为懒加载 避免还没有加载好的时候使用了 造成异常      private var displayLinkInitialized: Bool = false    }</code></pre>    <p>这里利用 CADisplayLink 不断执行某个方法,等达到帧之间的间隔时间的时候就去更新 UIImageView 的 layer 的 contens 属性。这个属性需要一个 CGImage 的对象。</p>    <p>为了防止 AnimatedImageView 和 CADisplayLink 之间的循环引用,Kingfisher在 AnimatedImageView 内部写了一个代理类。</p>    <pre>  <code class="language-swift">/// 防止循环引用  class TargetProxy {      private weak var target: AnimatedImageView?        init(target: AnimatedImageView) {          self.target = target      }        @objc func onScreenUpdate() {          target?.updateFrame()      }  }</code></pre>    <p>就是通过 TargetProxy 来调用 AnimatedImageView 中的 updateFrame 方法,大家可以先写一个空方法。</p>    <p>然后创建一个 CADisplayLink 对象,这里使用懒加载。</p>    <pre>  <code class="language-swift">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  }()</code></pre>    <p>用这个 self.displayLinkInitialized 标志 CADisplayLink 已经加载,然后用代理就调用自己的 updateFrame() 方法</p>    <p>在添加个指定RunLoopMode的属性</p>    <pre>  <code class="language-swift">// 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()          }      }  }</code></pre>    <p>Kingfisher 默认是 NSRunLoopCommonModes 滑动不暂停,我这边换成 NSDefaultRunLoopMode 滑动暂停 。</p>    <p>NSRunLoopCommonModes 包含两个模式 UITrackingRunLoopMode 和 NSDefaultRunLoopMode , 其中 UITrackingRunLoopMode 是滑动时候的模式</p>    <p>,如果只在 NSDefaultRunLoopMode 模式下,那滑动模式就不会执行 CADisplayLink 的方法, NSTimer 也可以指定 模式。非本篇重点 ,这里就不细说了</p>    <p>kingfisher 是重写了 image 属性进行 Animator 的初始化和重置的 , 这里为了demo的easy 我们给 AnimatedImageView 新增一个属性,叫 gifData.</p>    <pre>  <code class="language-swift">public var gifData:NSData?{      didSet{          if let gifData = gifData {              animator = nil              animator = Animator()              animator?.createImageSource(gifData)              animator?.prepareFrames()                didMove()              setNeedsDisplay()              layer.setNeedsDisplay()          }      }  }</code></pre>    <p>创建 Animator 对象 ,缓存帧。 这里didMove() 方法是处理自动播放的</p>    <pre>  <code class="language-swift">private func didMove() {      if autoPlayAnimatedImage && animator != nil {          if let _ = superview, _ = window {              startAnimating()          } else {              stopAnimating()          }      }  }</code></pre>    <p>后面会重写 startAnimating 和 stopAnimating .</p>    <p>先来看 CADisplayLink 每次调用的方法 updateFrame() , 这里默认是每秒60次 , 根据屏幕刷新频率。</p>    <p>要实现 updateFrame() 放法首先要在, Animator 中添加一个更新当前帧的方法。上面提到的,现在可以来写了。</p>    <pre>  <code class="language-swift">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  }</code></pre>    <p>传入的 duration 是 displayLink.duration 默认是 1/60 秒,这里先对每次的 duration 进行累加,直到我们的帧间隔时间小于等于它了 才去获取当前帧和增加下标,返回true , 否则一直返回false</p>    <p>然后 AnimatedImageView 中的 updateFrame 方法就是调用那个方法,直到它返回true才进行处理,这里就是调用了 layer.setNeedsDisplay()</p>    <pre>  <code class="language-swift">private func updateFrame() {      if animator?.updateCurrentFrame(displayLink.duration) ?? false {          // 此方法会触发 displayLayer          layer.setNeedsDisplay()      }  }</code></pre>    <p>layer.setNeedsDisplay() 会触发 displayLayer 方法,我们只要重写这个方法,就能处理每帧的显示了。</p>    <pre>  <code class="language-swift">override public func displayLayer(layer: CALayer) {      if let currentFrame = animator?.currentFrame {          layer.contents = currentFrame.CGImage      } else {          layer.contents = image?.CGImage      }  }</code></pre>    <p>搞了这么多,终于到显示了,不容易呀。。。</p>    <p>这里重写了几个方法,都去调用了didMove</p>    <pre>  <code class="language-swift">override public func didMoveToWindow() {      super.didMoveToWindow()      didMove()  }    override public func didMoveToSuperview() {      super.didMoveToSuperview()      didMove()  }</code></pre>    <p>这里gif的暂停是利用了 CADisplayLink 的 paused 属性控制的</p>    <pre>  <code class="language-swift">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      }  }</code></pre>    <p>这里 displayLinkInitialized 判断 CADisplayLink 是否加载好了。</p>    <p>最后记得在对象销毁的时候吧displaylink也停掉</p>    <pre>  <code class="language-swift">deinit {      if displayLinkInitialized {          displayLink.invalidate()      }  }</code></pre>    <p>至此,所有基本功能已经全部OK了,使用也很简单。</p>    <pre>  <code class="language-swift">let path = NSBundle.mainBundle().pathForResource("xxx", ofType: "gif")  let data = NSData(contentsOfFile: path!)  imgV.gifData = data</code></pre>    <p>默认是自动播放,可以手动设置。</p>    <p> </p>    <p> </p>    <p> </p>    <p> </p>    <p>来自:http://www.jianshu.com/p/b60a06bdb375</p>    <p> </p>