Android GIF图压缩实践

找了一圈没有什么开箱即用的GIF压缩库,索性自己搞一个。

理论思想

其实GIF的压缩,跟其他格式图片压缩差不多,无非就是降低分辨率,减少颜色值。只不过GIF的特殊性,可以有其他一些方面来进行压缩:

  1. 减少调色盘数量

参考前一篇文章所分析的GIF格式,GIF图有一个颜色表的概念,存放着具体的颜色,而具体的每一帧存的是每个像素点的颜色值在颜色表中的索引。颜色表有全局颜色表,每一帧也有自己的局部颜色表。如果减少颜色数量,那么颜色表大小就会减少。这里用一个工具 gifsicle 来尝试对一张gif图进行压缩看下效果。

原图:

通过命令

gifsicle –colors=64 jr1.gif > jr-color64.gif
gifsicle –colors=2 jr1.gif > jr-color2.gif

减少调色盘大小后效果

调色盘64:

调色盘2:

大小分别压缩到了4.2M 1.6M

可以看到,调整调色盘中颜色数量是效果非常明显的,即使只剩两种颜色,还是不会影响理解GIF图内容的,只剩色彩差异很大,只是将颜色压缩至64,也会显得很明显。

  1. 帧复用

在一些背景不动,只有前景物体在动的GIF,可以只在个别帧保留背景,其他帧只存储相对于这些帧之外有变化的部分。比如下面这张GIF图:

背景始终是蓝色,动的只是前景中的一小部分,用以下命令:

gifsicle -I –cinfo –sinfo –xinfo mm.gif

可以看到:

没有局部颜色表,只有全局颜色表,因为复用的大部分颜色,部分帧的大小就很小了。

  1. 降低每一帧图像的分辨率

这个是最简单直接的方法了,直接解码出所有帧图像,降低分辨率后再重新合成,自然能减少大小。

  1. 抽帧

抽帧也很好理解,就是简单粗暴的从原GIF图中去掉几帧。以这张图为例:

用以下命令

gifsicle -b nyan.gif –delete ‘#1’ ‘#3’ …

去除掉一半的帧后,效果:

大小也从 34K 减小到了 17K,只不过因为没有处理延时,所以有很明显的帧率变化。

实际做法

如前所述,大致有四种方法对GIF做压缩,但是在实际应用当中,各有优缺点。

首先,第一种方法,减少调色盘数,或将局部调色盘去掉统一改为全局调色盘,这样处理虽然对压缩很有效,但是对图像的显示效果影响很大,不能保证对所有场景都适用,且在开发商实现成本较高。

第二种方法,对帧进行复用,效果上没有什么缺点,但是只在特定场景下压缩率较高,并不适用所有图片,且实现上需要依赖一些算法来找到可复用的像素和颜色。

第三种方法,降低分辨率,大体来说是属于较好的方法了,对图像质量的影响也不大。

第四种方法,抽帧,抽帧虽然效果更好,但是有两个问题。一个是抽帧的规则不好定义,抽取间隔过多会导致压缩后图像效果有很明显卡顿掉帧。一个是因为部分GIF图并不是每一帧都有全部图像详细的,可能正如前面所说,他只记录了当前帧发生变化的部分,所以如果保留了这种帧而去掉了完整的帧的话,也会显得很奇怪。

所以最后我的做法是,抽帧+降分辨率。先对原GIF图进行解码,将每一帧的图像信息都进行还原,之后进行一次抽帧,对保留的帧再进行缩放降低分辨率,这样能最大限度的既保证图像质量又能有较高的压缩率。

代码实战

GIF的编解码也是用的Glide,具体可见之前的文章。

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

/**
* 用Glide对GIF文件进行解码
*/
private fun decodeGIFFile(context: Context, filePath: String, inSampleSize: Int = 1): GIFImage? {
try {
val inputStream = FileInputStream(filePath)
val byteBuffer = ByteBuffer.wrap(inputStream.readBytes())
val glide = Glide.get(context)
val provider = GifBitmapProvider(glide.bitmapPool, glide.arrayPool)
val header = GifHeaderParser().setData(byteBuffer).parseHeader()
val decoder = StandardGifDecoder(provider, header, byteBuffer, inSampleSize)
val frameCount = decoder.frameCount
decoder.advance()
val frames = ArrayList<GIFFrame>()
for (i in 0 until frameCount) {
val bitmap = decoder.nextFrame
val delay = decoder.nextDelay

if (bitmap != null) {
frames.add(GIFFrame(bitmap, delay))
}
decoder.advance()
}
inputStream.close()
return GIFImage(frames)
} catch (e: Exception) {
e.printStackTrace()
return null
}
}

private fun encodeGIFFile(gif:GIFImage,targetPath: String){
try {
val bos = ByteArrayOutputStream()
val encoder = AnimatedGifEncoder()
encoder.start(bos)
for (frame in gif.frames) {
encoder.addFrame(frame.image)
encoder.setDelay(frame.delay)
}
encoder.finish()
val outStream = FileOutputStream(File(targetPath))
outStream.write(bos.toByteArray())
outStream.close()
} catch (e: Exception) {
e.printStackTrace()
}
}

data class GIFImage(val frames: List<GIFFrame>){
fun recycle(){
for (frame in frames) {
frame.recycle()
}
}
}
data class GIFFrame(var image: Bitmap, var delay: Int){
fun recycle(){
if (!image.isRecycled) image.recycle()
}
}

有了编解码后,重点就是压缩了:

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
private const val GIF_COMPRESS_SIZE_RATIO = 0.8f
private const val GIF_COMPRESS_SIZE_LIMIT = 500

fun compressGIFImageSync(context: Context, filePath: String, inSampleSize: Int = 1):String {
if (File(filePath).length() < 500) return filePath
val start = System.currentTimeMillis()
val gifImage = decodeGIFFile(context, filePath, inSampleSize)
if (gifImage == null || gifImage.frames.isEmpty()) {
Logging.e("[wtf] gifImage decode null")
return filePath
}


val frameCount = gifImage.frames.size

//抽帧的规则,这我在60帧一下统一每两帧抽取一帧,以免卡顿严重
val gap = if (frameCount < 60) 2
else if (frameCount < 200) 3
else 4

//抽帧时要注意,在去掉一帧的同时,要把这一帧的延时累加到上一个保留帧上,不至于处理会导致压缩后的帧率变化过大
val delays = gifImage.frames.map { it.delay }
val resultFrames = gifImage.frames.filterIndexed { index, frame ->
val isKeyFrame = index % gap == 0
if (isKeyFrame){
val othersDelay = delays.subList(index, minOf(index + gap, frameCount)).fold(0) { acc, delay -> acc + delay }
//累加之后还要注意延时不能太大,这里加200上限
frame.delay = minOf(othersDelay, 200)
//压缩规则目前是宽高500以内时,等比压缩80%;否则将大边压缩至500,小边等比缩放。
var w = frame.image.width
var h = frame.image.height
val maxWidth = GIF_COMPRESS_SIZE_LIMIT
val maxHeight = GIF_COMPRESS_SIZE_LIMIT
val scaleRatio = GIF_COMPRESS_SIZE_RATIO
if (w < maxWidth && h < maxHeight){
w = (scaleRatio * w).toInt()
h = (scaleRatio * h).toInt()
}else {
if (w>h){
h = ((h.toFloat() / w.toFloat()) * maxWidth).toInt()
w = maxWidth
}else{
w = ((w.toFloat() / h.toFloat()) * maxHeight).toInt()
h = maxHeight
}
}
frame.image = Bitmap.createScaledBitmap(frame.image,w,h,true)
}else{
frame.recycle()
}
isKeyFrame
}
val resultGif = GIFImage(resultFrames)
val targetFilePath = Const.generateCachePicPath(context, "compress", "gif")
File(targetFilePath).parentFile?.mkdirs()
encodeGIFFile(resultGif, targetFilePath)
gifImage.recycle()

return targetFilePath
}

总结

以上就是我在Android端GIF压缩的一些实践经验,这个方案还是有很多可以优化的地方,比如可以通过在解码GIF时获取每一帧的 dispose 字段,动态的判断当前帧在抽帧时是否可以被遗弃,或者针对帧复用的情况做特殊处理等,这里我只是先提供了一个简单的压缩方案,有需要可以直接取用或者再进行拓展。大家有什么建议或者问题也欢迎指出,感谢!