mixiグループ Advent Calendar 2017の15日目を担当させていただきます、よろしくお願いします。
内容
iOSアプリのローディング表示に、自分はこれまで(このSwift時代にも関わらず)SVProgressHUDで済ませてきました。
ただ直近のプロジェクトの開発で、**「gifアニメーションを使ってカッコいいローディング表示にしたい」**という要望がやってきましたので、kirualex/SwiftyGifというライブラリを使って実装しました。
リリース最優先のプロジェクトだったためライブラリの内部の実装をほとんど確認していなかったので、年を越す前に見ておこうという内容になります。
※ kirualex/SwiftyGifのバージョンは3.1.2
のもので確認
ライブラリの使い方
kirualex/SwiftyGifの使い方も一応書いておこうと思います。Githubからそのまま借用。インストールなどについては省略。UIImage
やUIImageView
のextensionとして実装されていることがわかります。
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ファイルに関する各種情報が取得できます
画像枚数を取得する
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番目の画像を取得する
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番目の画像情報を取得する
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アニメーションを表示することはできます(オプショナルやキャストの正しい扱い方は省略)
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は先述したように、UIImage
とUIImageView
のextensionとして実装されているのですが、gifファイルに関する情報を取得したあとそれらをストアドプロパティとして保持するよう実装されています。Swiftのextensionでストアドプロパティをもつ方法に、objc_getAssociatedObject
があると思いますが、こちらを利用しています。
以下例として、画像枚数を保持するプロパティ。
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
の更新タイミングは画面のリフレッシュレートと同期しているのでパフォーマンス的によろしい)。
こちらのドキュメントに、
For example, an application that displays movies might use the timestamp to calculate which video frame will be displayed next.
とあるので、
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.03
secのgifアニメーションの何番目のフレームを毎描画処理で表示すべきか、というロジックは書けそうだ(あとで紹介するkaishin/Gifuでは、timestamp
ではなくduration
プロパティを用いてそのように実装されている)。
ただこのkirualex/SwiftyGif
では、CADisplayLink
のtimestamp
やduration
は見ずに、事前に各描画処理でどのフレームを表示するかをセットアップするよう実装されている。以下そこに該当するコード。特徴的なのは、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
※ バージョンは1.6.1
のもので確認
let animation = UIImage.animatedImage(with: frames, duration: Double(duration) / 1000.0)
単純にこのようにしているだけ(参考)。UIImage
でコマアニメを表現できるというのを自分は忘れがち
kaishin/Gifu
スター数も現時点(2017/12)では一番多く(kaishin/Gifu
は1,880
で、kirualex/SwiftyGif
は 717
、bahlo/SwiftGif
は747
)、これを採用すべきだったのかなと少し後悔。内部的にもかなり抽象化されていて色々応用できそうな気がする。先述したように、描画まわりの処理で
func shouldChangeFrame(with duration: CFTimeInterval, handler: (Bool) -> Void) {
incrementTimeSinceLastFrameChange(with: duration)
if currentFrameDuration > timeSinceLastFrameChange {
handler(false)
} else {
resetTimeSinceLastFrameChange()
incrementCurrentFrameIndex()
handler(true)
}
}
といったコードがあり、duration
はCADisplayLink
のduration
がそのまま渡ってくる。よって毎描画処理でそのduration
をみて、次のフレームを表示する・しないの判定をするといった作りになっているようです