iOS
gif

iOSでgifアニメーションを表示する

mixiグループ Advent Calendar 2017の15日目を担当させていただきます、よろしくお願いします。

内容

iOSアプリのローディング表示に、自分はこれまで(このSwift時代にも関わらず)SVProgressHUDで済ませてきました。
ただ直近のプロジェクトの開発で、「gifアニメーションを使ってカッコいいローディング表示にしたい」という要望がやってきましたので、kirualex/SwiftyGifというライブラリを使って実装しました。
リリース最優先のプロジェクトだったためライブラリの内部の実装をほとんど確認していなかったので、年を越す前に見ておこうという内容になります。

kirualex/SwiftyGifのバージョンは3.1.2のもので確認

ライブラリの使い方

kirualex/SwiftyGifの使い方も一応書いておこうと思います。Githubからそのまま借用。インストールなどについては省略。UIImageUIImageViewのextensionとして実装されていることがわかります。

sample.swift
import SwiftyGif

let gifManager = SwiftyGifManager(memoryLimit:20)
let gif = UIImage(gifName: "MyImage.gif")
let imageview = UIImageView(gifImage: gif, manager: gifManager)
imageview.frame = CGRect(x: 0.0, y: 5.0, width: 400.0, height: 200.0)
view.addSubview(imageview)

内部の実装について

gifファイルの情報を取得する

CGImageSourceを介して、gifファイルに関する各種情報が取得できます

画像枚数を取得する

some.swift
if let url = Bundle.main.url(forResource: "some", withExtension: "gif") {
    if let data = try? Data(contentsOf: url) {
        if let imageSource = CGImageSourceCreateWithData(data as CFData, nil) {
            let count = CGImageSourceGetCount(imageSource)
            print(count)
        }
    }
}
120

n番目の画像を取得する

some.swift
let image = CGImageSourceCreateImageAtIndex(imageSource, index, nil)
print(image)
Optional(<CGImage 0x6040001d7070>
    <<CGColorSpace 0x6000000be720> (kCGColorSpaceICCBased; kCGColorSpaceModelRGB; sRGB IEC61966-2.1)>
        width = 175, height = 175, bpc = 8, bpp = 32, row bytes = 700 
        kCGImageAlphaLast | 0 (default byte order) 
        is mask? No, has mask? No, has matte? No, should interpolate? Yes)

n番目の画像情報を取得する

some.swift
let property = CGImageSourceCopyPropertiesAtIndex(imageSource, index, nil)
print(property)
Optional({
    ColorModel = RGB;
    Depth = 8;
    HasAlpha = 1;
    PixelHeight = 175;
    PixelWidth = 175;
    ProfileName = "sRGB IEC61966-2.1";
    "{GIF}" =     {
        DelayTime = "0.1";
        UnclampedDelayTime = "0.03";
    };
})

なので、この時点でそれぽくgifアニメーションを表示することはできます(オプショナルやキャストの正しい扱い方は省略)

some.swift
let dictKey = Unmanaged.passUnretained(kCGImagePropertyGIFDictionary).toOpaque()
let dictValue = CFDictionaryGetValue(property, dictKey)
let dict = unsafeBitCast(dictValue, to:CFDictionary.self)

let unclampedKey = Unmanaged.passUnretained(kCGImagePropertyGIFUnclampedDelayTime).toOpaque()
let unclampedPointer:UnsafeRawPointer? = CFDictionaryGetValue(dict, unclampedKey)
let UnclampedDelayTimeValue = unsafeBitCast(unclampedPointer, to:AnyObject.self)
let interval = UnclampedDelayTimeValue.doubleValue!

Timer.scheduledTimer(withTimeInterval: interval,
                     repeats: true) { (_) in

                        self.imageView.image = UIImage(cgImage: CGImageSourceCreateImageAtIndex(imageSource, self.tempIndex ,nil)!)

                        self.tempIndex += 1
                        if count == self.tempIndex {
                            self.tempIndex = 0
                        }
}

Unmanagedについては全然知らなかったのですが、こちらが参考になります。

取得した情報をストアドプロパティとして保持する

ここはgifアニメーションとは関係ないところです

kirualex/SwiftyGifは先述したように、UIImageUIImageViewのextensionとして実装されているのですが、gifファイルに関する情報を取得したあとそれらをストアドプロパティとして保持するよう実装されています。Swiftのextensionでストアドプロパティをもつ方法に、objc_getAssociatedObjectがあると思いますが、こちらを利用しています。
以下例として、画像枚数を保持するプロパティ。

UIImage+SwiftyGif.swift
let _imageCountKey = malloc(4)

//...

public var imageCount: Int?{
    get {
        return objc_getAssociatedObject(self, _imageCountKey) as? Int
    }
    set {
        objc_setAssociatedObject(self, _imageCountKey, newValue, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN);
    }
}

描画処理

描画処理(フレーム処理)は先ほどの「それぽくgifアニメーションを表示することはできます」ではTimerの更新タイミングで行なっていましたが、kirualex/SwiftyGifではCADisplayLinkを利用しています(CADisplayLinkの更新タイミングは画面のリフレッシュレートと同期しているのでパフォーマンス的によろしい)。

https://developer.apple.com/documentation/quartzcore/cadisplaylink

こちらのドキュメントに、

For example, an application that displays movies might use the timestamp to calculate which video frame will be displayed next.

とあるので、

some.swift
private var timer: CADisplayLink?

override func viewDidLoad() {
    super.viewDidLoad()

    self.timer = CADisplayLink(target: self, selector: #selector(updated(_:)))
    self.timer?.add(to: .main, forMode: RunLoopMode.commonModes)
}

@objc func updated(_: Timer) {
    print(self.timer!.timestamp)
}
68785.021815896
68785.038482563
68785.05514923
68785.071815897
68785.088482564
68785.105149231
68785.121815898
68785.138482565
68785.155149232
68785.171815899
...

このtimestampの前後の差分(0.01666666... = 60fpsであることがわかる)から、例えばインターバルが0.03secのgifアニメーションの何番目のフレームを毎描画処理で表示すべきか、というロジックは書けそうだ(あとで紹介するkaishin/Gifuでは、timestampではなくdurationプロパティを用いてそのように実装されている)。

ただこのkirualex/SwiftyGifでは、CADisplayLinktimestampdurationは見ずに、事前に各描画処理でどのフレームを表示するかをセットアップするよう実装されている。以下そこに該当するコード。特徴的なのは、levelOfIntegrityといううまく訳せないけれど「gifアニメーションの再生精度」みたいなものを指定できること(0~1まで指定でいて、小さく設定すると、フレームスキップ幅が大きくなる)。

fileprivate func calculateFrameDelay(_ delaysArray:[Float],levelOfIntegrity:Float){

    var delays = delaysArray

    //Factors send to CADisplayLink.frameInterval
    let displayRefreshFactors = [60,30,20,15,12,10,6,5,4,3,2,1]

    //maxFramePerSecond,default is 60
    let maxFramePerSecond = displayRefreshFactors[0]

    //frame numbers per second
    let displayRefreshRates = displayRefreshFactors.map{ maxFramePerSecond/$0 }

    //time interval per frame
    let displayRefreshDelayTime = displayRefreshRates.map{ 1.0/Float($0) }

    //caclulate the time when each frame should be displayed at(start at 0)
    for i in delays.indices.dropFirst() { delays[i] += delays[i-1] }

    //find the appropriate Factors then BREAK
    for (i, delayTime) in displayRefreshDelayTime.enumerated() {

        let displayPosition = delays.map { Int($0/delayTime) }

        var framelosecount: Float = 0
        for j in displayPosition.indices.dropFirst() {
            if displayPosition[j] == displayPosition[j-1] {
                framelosecount += 1
            }
        }

        if displayPosition.first == 0 {
            framelosecount += 1
        }

        if framelosecount <= Float(displayPosition.count) * (1.0 - levelOfIntegrity)
            || i == displayRefreshDelayTime.count-1 {

            imageCount = displayPosition.last
            displayRefreshFactor = displayRefreshFactors[i]
            displayOrder = []
            var indexOfold = 0
            var indexOfnew = 1
            while indexOfnew <= imageCount
                && indexOfold < displayPosition.count {
                if indexOfnew <= displayPosition[indexOfold] {
                    displayOrder?.append(indexOfold)
                    indexOfnew += 1
                } else {
                    indexOfold += 1
                }
            }
            break
        }
    }
}

他ライブラリとの比較

以上がキーとなる部分かなと思います。これだけだと物足りない感もあるので、最後に他のgifアニメーションで使えそうなライブラリと簡単に比較してみることにします。

bahlo/SwiftGif

https://github.com/bahlo/SwiftGif

※ バージョンは1.6.1のもので確認

UIImage+Gif.swift
let animation = UIImage.animatedImage(with: frames, duration: Double(duration) / 1000.0)

単純にこのようにしているだけ(参考)。UIImageでコマアニメを表現できるというのを自分は忘れがち :sweat:

kaishin/Gifu

https://github.com/kaishin/Gifu

スター数も現時点(2017/12)では一番多く(kaishin/Gifu1,880で、kirualex/SwiftyGif 717bahlo/SwiftGif747)、これを採用すべきだったのかなと少し後悔。内部的にもかなり抽象化されていて色々応用できそうな気がする。先述したように、描画まわりの処理で

FrameStore.swift
func shouldChangeFrame(with duration: CFTimeInterval, handler: (Bool) -> Void) {
  incrementTimeSinceLastFrameChange(with: duration)

  if currentFrameDuration > timeSinceLastFrameChange {
    handler(false)
  } else {
    resetTimeSinceLastFrameChange()
    incrementCurrentFrameIndex()
    handler(true)
  }
}

といったコードがあり、durationCADisplayLinkdurationがそのまま渡ってくる。よって毎描画処理でそのdurationをみて、次のフレームを表示する・しないの判定をするといった作りになっているようです