Photos框架之GIF相关

前言

好久没有写 Photos 框架这个系列了,其实也没有多少要写了,总共还有两篇,一篇就是这次的 GIF,下一篇再讲讲 Live Photo。

说起 GIF,可能很多人认为 iPhone 的系统图库并不支持 GIF 图片。其实不是的,你将一张网站上的 GIF 图片从 Safari 浏览器保存到系统图库,这张 GIF 依然是一张 GIF 图片,只是它在系统图库中不能动而已。同样地,从别的 App 上保存到系统图库,只要 App 没有在保存到图库的过程中动手脚(例如压缩,或者只保存第一帧),保存的姿势正确的话,存在系统图库中的 GIF 图片依然是原始的 GIF 数据,只要将它放在能支持 GIF 动起来的地方,它就能动。

要测试保存的 GIF 依然是一张 GIF 图片很简单。假如你的手机配置了邮箱的话,在图库找到你的 GIF 图片,点击左下角“箭头朝上”的那个图标,然后点击邮箱,你的 GIF 图片就会被添加到邮件的正文中,你就能看到 GIF 在动了。邮箱之所以支持,是因为它将该 GIF 图片添加到了 WebView 上面,而 WebView 是支持让 GIF 动的。

将 GIF 图片从系统图库保存到沙盒

现在假设我们的系统图库中保存了一些 GIF 图片,怎么样将它保存到沙盒中呢?
我们之前讲过了将普通的图片、视频保存到沙盒的正确姿势。在这里同样适合 GIF 图片,因为那种方式,就是将二进制 Data 数据直接从原先系统图库的存储路径复制到了我们沙盒自己的路径下面。具体的做法,可以参考前面的文章,这里就不重复贴代码了。
之前我们还说了,保存普通图片到沙盒还有一种不合理的方式,先加载到内存,然后再转换成 Data 二进制形式保存。虽然它不合理,但对于普通照片来说,效果还是有。但是对于此处的 GIF 来说,就没有效果了,使用那种方式,只会将 GIF 图片的第一帧保存到沙盒中,那样的话,图片到时候就动不起来了。

从沙盒获取 GIF 图片

假设我们已经将很多 GIF 图片保存到了沙盒中,现在我们要将它们取出来,并且显示出来,也就是加载到界面上,让它们动起来。首先要做的就是,从沙盒中取出来。

对于普通图片,我么知道可以用如下方法将沙盒中的图片加载到内存中,得到的是 UIImage 对象:

1
2
public init?(contentsOfFile path: String)
// 例子:leg image = UIImage(contentsOfFile: path)

但是对于 GIF 图片,可不能直接这么做,直接这么做的话,只会得到 GIF 的第一帧图片。我们需要获取 GIF 图片完整的二进制 Data 数据。所以我们用 NSData 的如下的方法来获取,它得到的是 NSData 对象,它依然是包含了 GIF 图片的完整数据,有了它,我们就可以做之后的事情:

1
2
public init?(contentsOfFile path: String)
// 例子:let imageData = NSData(contentsOfFile: path)

这里还有一个问题,我怎么确定我当前路径对应的图片到底是普通图片还是 GIF 图片呢?
这个办法就比较多,一个很简单的做法就是保存到沙盒的时候,给图片的命名做上标记,比如说,给所有的 GIF 图片的文件名称都加上统一前缀 “GIF-“。还有一个办法就是根据文件的后缀来区分,“.GIF”后缀就是 GIF 图片,其他如 “.JPEG” 或者 “.PNG” 就是其他图片。当然这个不是百分之百正确的,后缀和文件名一样,是可以改的。我一张 GIF 的图片,一样可以用上 “.PNG” 的后缀,但只要你保存的时候正确识别了类型然后加上了正确的后缀,然后还是你自己来把它取出来,那你通过后缀来区分,一般也是没有问题的,我就是如此做的。苹果区分图片类型,有它自己的方式,它是给图片的资源对象 PHAssetResource 加上了一个 uniformTypeIdentifier 的属性。通过这个属性告诉我们, 我们通过 Photos 框架从图库获取到的图片是什么类型的,对于 GIF 图片,这个值为 com.compuserve.gif。当然这个属性只是在我们从图库获取照片的时候用到,我们自己从沙盒获取图片的时候就没有这个属性来参考了。你当然可以在将图片保存到沙盒的时候,给图片数据写上自己的标识属性,但那比较麻烦。所以我是选择了一种比较廉价的方式,就是用后缀区分,反正后缀都是我保存到沙盒的时候确认过之后设置的。
但是后来,我在看 SDWebImage 源码的时候,学到了一招怎么根据图片的二进制 Data 数据中来区分到底是什么类型的图片。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 返回图片格式
+ (SDImageFormat)sd_imageFormatForImageData:(nullable NSData *)data {
if (!data) {
return SDImageFormatUndefined;
}

// 根据图片Data数据的第一位字节来判断是什么类型的图片
uint8_t c;
[data getBytes:&c length:1];
switch (c) {
case 0xFF:
return SDImageFormatJPEG;
case 0x89:
return SDImageFormatPNG;
case 0x47:
return SDImageFormatGIF;
case 0x49:
case 0x4D:
return SDImageFormatTIFF;
case 0x52:
// R as RIFF for WEBP
if (data.length < 12) {
return SDImageFormatUndefined;
}

NSString *testString = [[NSString alloc] initWithData:[data subdataWithRange:NSMakeRange(0, 12)] encoding:NSASCIIStringEncoding];
if ([testString hasPrefix:@"RIFF"] && [testString hasSuffix:@"WEBP"]) {
return SDImageFormatWebP;
}
}
return SDImageFormatUndefined;
}

上面这段 OC 代码是 SDWebImage 用来区分图片类型的方法,可以看到,不同类型的图片的二进制数据的第一个字节是不同的,而且是确定的。例如 GIF 图片的二进制数据的第一个字节就是 0x47,PNG 就是 0x89
所以,针对我们这种情况,我们也可以从沙盒中使用 NSData 的方法统统先获取到图片的 NSData 对象,得到它的二进制数据,然后再根据这个 NSData 对象判断类型,再区分到底是普通图片还是 GIF 图片,再做不同处理。

让 GIF 图片动起来

接下来,假设我们得到了 GIF 图片对应的 NSData 对象,这个对象包含了完整的 GIF 二进制数据,那么理论上,它就可以动起来,所以,我们该怎么让它动起来呢?

我就从一个第三方库 SwiftGif 来介绍一下 GIF 如何动起来。SwiftGif 的核心代码就是 UIImage+Gif.swift 这个文件,而里面也只有区区 200 行代码,所以很适合用来理解和学习。

我写把完整的代码贴出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
//
// Gif.swift
// SwiftGif
//
// Created by Arne Bahlo on 07.06.14.
// Copyright (c) 2014 Arne Bahlo. All rights reserved.
//
import UIKit
import ImageIO

extension UIImageView {

public func loadGif(name: String) {
DispatchQueue.global().async {
let image = UIImage.gif(name: name)
DispatchQueue.main.async {
self.image = image
}
}
}

}

extension UIImage {

public class func gif(data: Data) -> UIImage? {
// Create source from data
guard let source = CGImageSourceCreateWithData(data as CFData, nil) else {
print("SwiftGif: Source for the image does not exist")
return nil
}

return UIImage.animatedImageWithSource(source)
}

public class func gif(url: String) -> UIImage? {
// Validate URL
guard let bundleURL = URL(string: url) else {
print("SwiftGif: This image named \"\(url)\" does not exist")
return nil
}

// Validate data
guard let imageData = try? Data(contentsOf: bundleURL) else {
print("SwiftGif: Cannot turn image named \"\(url)\" into NSData")
return nil
}

return gif(data: imageData)
}

public class func gif(name: String) -> UIImage? {
// Check for existance of gif
guard let bundleURL = Bundle.main
.url(forResource: name, withExtension: "gif") else {
print("SwiftGif: This image named \"\(name)\" does not exist")
return nil
}

// Validate data
guard let imageData = try? Data(contentsOf: bundleURL) else {
print("SwiftGif: Cannot turn image named \"\(name)\" into NSData")
return nil
}

return gif(data: imageData)
}

internal class func delayForImageAtIndex(_ index: Int, source: CGImageSource!) -> Double {
var delay = 0.1

// Get dictionaries
let cfProperties = CGImageSourceCopyPropertiesAtIndex(source, index, nil)
let gifPropertiesPointer = UnsafeMutablePointer<UnsafeRawPointer?>.allocate(capacity: 0)
if CFDictionaryGetValueIfPresent(cfProperties, Unmanaged.passUnretained(kCGImagePropertyGIFDictionary).toOpaque(), gifPropertiesPointer) == false {
return delay
}

let gifProperties:CFDictionary = unsafeBitCast(gifPropertiesPointer.pointee, to: CFDictionary.self)

// Get delay time
var delayObject: AnyObject = unsafeBitCast(
CFDictionaryGetValue(gifProperties,
Unmanaged.passUnretained(kCGImagePropertyGIFUnclampedDelayTime).toOpaque()),
to: AnyObject.self)
if delayObject.doubleValue == 0 {
delayObject = unsafeBitCast(CFDictionaryGetValue(gifProperties,
Unmanaged.passUnretained(kCGImagePropertyGIFDelayTime).toOpaque()), to: AnyObject.self)
}

delay = delayObject as? Double ?? 0

if delay < 0.1 {
delay = 0.1 // Make sure they're not too fast
}

return delay
}

internal class func gcdForPair(_ a: Int?, _ b: Int?) -> Int {
var a = a
var b = b
// Check if one of them is nil
if b == nil || a == nil {
if b != nil {
return b!
} else if a != nil {
return a!
} else {
return 0
}
}

// Swap for modulo
if a! < b! {
let c = a
a = b
b = c
}

// Get greatest common divisor
var rest: Int
while true {
rest = a! % b!

if rest == 0 {
return b! // Found it
} else {
a = b
b = rest
}
}
}

internal class func gcdForArray(_ array: Array<Int>) -> Int {
if array.isEmpty {
return 1
}

var gcd = array[0]

for val in array {
gcd = UIImage.gcdForPair(val, gcd)
}

return gcd
}

internal class func animatedImageWithSource(_ source: CGImageSource) -> UIImage? {
let count = CGImageSourceGetCount(source)
var images = [CGImage]()
var delays = [Int]()

// Fill arrays
for i in 0..<count {
// Add image
if let image = CGImageSourceCreateImageAtIndex(source, i, nil) {
images.append(image)
}

// At it's delay in cs
let delaySeconds = UIImage.delayForImageAtIndex(Int(i),
source: source)
delays.append(Int(delaySeconds * 1000.0)) // Seconds to ms
}

// Calculate full duration
let duration: Int = {
var sum = 0

for val: Int in delays {
sum += val
}

return sum
}()

// Get frames
let gcd = gcdForArray(delays)
var frames = [UIImage]()

var frame: UIImage
var frameCount: Int
for i in 0..<count {
frame = UIImage(cgImage: images[Int(i)])
frameCount = Int(delays[Int(i)] / gcd)

for _ in 0..<frameCount {
frames.append(frame)
}
}

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

return animation
}

}

我们可以看到这些支持 GIF 的代码主要是放在 UIImage 和 UIImageView 的 extension 中。最主要的是 UIImage 的 extension。

UIImageView 的 extension 中添加了一个方法:

1
public func loadGif(name: String)

主要用来在并行队列中异步加载 GIF 图片,里面还是调用了 UIImage 的 extension 方法。

而 UIImage 的 extension 有三个 public 方法,供外部调用:

1
2
3
public class func gif(data: Data) -> UIImage?
public class func gif(url: String) -> UIImage?
public class func gif(name: String) -> UIImage?

它们分别用来支持 GIF 的三种传入方式,最终,它们调用的还是内部的这个方法:

1
internal class func animatedImageWithSource(_ source: CGImageSource) -> UIImage?

显而易见,这就是我们的主角。

这里主要用到了 ImageIO 这个框架。在 func gif(data: Data) 方法中,首先创建了一个 CGImageSource 对象,然后将这个对象传入了上面这个方法中。

1
2
let source = CGImageSourceCreateWithData(data as CFData, nil)
return UIImage.animatedImageWithSource(source)

func animatedImageWithSource(_ source: CGImageSource) 方法中,首先先根据传入的CGImageSource 对象获取到 GIF 图片对应的图片帧数(count),相应每帧的图片(images),以及每一帧的时间(delays)。

获取图片帧数和对应的图片比较简单,分别调用了下面这两个方法:

1
2
let count = CGImageSourceGetCount(source)
let image = CGImageSourceCreateImageAtIndex(source, i, nil)

而获取每一帧时间稍微麻烦点,因此作者封装了这样一个方法:

1
internal class func delayForImageAtIndex(_ index: Int, source: CGImageSource!) -> Double

这个方法内部看起来比较乱,那是因为涉及到了很多指针操作,而 Swift 不像 OC 那样可以直接兼容 C 语言,所以它定义了很多和 C 语言指针交互的方法。如果这段代码用 OC 来写,会简洁很多。这个方法主要也是通过 ImageIO 框架获得对应 CGImageSource 对象的属性列表,然后取出里面的 kCGImagePropertyGIFUnclampedDelayTime 对应的值。

在得到了 GIF 图片的图片帧数、对应每帧的图片、以及每一帧的时间之后,接下来就是怎么使用这些数据和来让图片动起来。

UIImage 有一些类方法,例如下面这个:

1
2
@available(iOS 5.0, *)
open class func animatedImage(with images: [UIImage], duration: TimeInterval) -> UIImage?

通过传入一个 UIImage 的数组,以及持续时间,系统就会自动为我们循环地播放这些图片。
GIF 本身也是有一帧帧图片组成的,将 GIF 每一帧的图片传入给这个方法,再加上时间,那不就可以播放 GIF 图片了。

确实是这样子,但这里有一个很关键的地方,GIF 每一帧的图片对应的播放时长是不同的,但是传入给 UIImage 上面那个方法的时候,只是传入了总的播放时长,那样子的话,UIImage 在播放的时候,一定会把时间平均分配给每一帧,这样子的话,最后动起来的 GIF 与我们想要的效果肯定不一样。

这里作者想了一个很巧妙地办法,首先得到之前获得的每一帧图片时间的最大公约数(GCD),然后通过当前帧数对应时间与刚才的最大公约数的比值,得到当前这一帧的图像在传入 UIImage 的时候的张数。简单来说,就是将时间转化成了图片数量,最后达到的效果是一样的。
举个例子来说,本来能控制每帧时长的情况下,如果该 GIF 图片有2帧,第一帧持续 0.2 秒,第二帧支持 0.4 秒。假如我们直接将两帧图片以及总共的时长 0.6 秒传入给 UIImage,那么最后播放的时候,两帧图片每一帧播放的时长都会变成 0.3 秒。这显然会让动起来的图片看起来很奇怪。现在的做法是,得到两个时间的最大公约数,这个例子中就是 0.2,那么我们传入 UIImage 的时候,第一帧传入 1 张,第二帧则传入 2 张,那么总共传了 3 张图片给 UIImage,虽然现在总时长还是 0.6 秒,分配给每张图片的时长是平均下来的 0.2 秒,但由于原先的第二帧,我们传入了两张,所以最后的效果就好像这一帧图片播放了 0.4 秒一样。
这一步的具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Get frames
let gcd = gcdForArray(delays)
var frames = [UIImage]()

var frame: UIImage
var frameCount: Int
for i in 0..<count {
frame = UIImage(cgImage: images[Int(i)])
frameCount = Int(delays[Int(i)] / gcd)

for _ in 0..<frameCount {
frames.append(frame)
}
}

上面还有一点要注意的是,原先我们获取的每一帧图片都是 CGImage 对象,还要转成 UIImage 对象。然后将它们传给 UIImage:

1
2
let animation = UIImage.animatedImage(with: frames,
duration: Double(duration) / 1000.0)

这个 animation 对象依然是 UIImage 格式的,最后将其返回即可。

回到我们的例子上面,我们之前得到了 GIF 图片的 NSData 对象,现在只要调用 public class func gif(data: Data) -> UIImage?方法,就可以得到可以动的 UIImage 对象了,然后将它用到你想要显示的地方即可。

后记

说起来今天说的 GIF 与 Photos 框架关系并不是很大,唯一用到了 Photos 框架的地方,就是从系统图库获取 GIF 图片,这个和之前普通图片的操作方式是一样的。其实保存沙盒中的 GIF 图片到系统图库的方法与普通图片也是一样,之前也都讲过了,所以也没细说。

下一次,就说一下我想要说的关于 Photos 框架的最后一个东西:Live Photo