在安卓中压缩GIF的几种方法(附实例代码)

3,942 阅读8分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第1天,点击查看活动详情

前言

最近在划水摸鱼的时候,看到有位大佬发了一篇 GIF 压缩思路的文章。

让我突然想起来,很久以前我在我的项目 隐云图解制作 中就实现了一个动图工具箱,其中一个功能就是压缩GIF。

不过这位大佬只介绍了其中几种使用方法,还有一些方法他没有说到,正好我可以拆解我的项目对此做一个补全。

压缩方法介绍

降低分辨率

和静态图片以及视频一样,GIF文件的尺寸和分辨率呈正相关关系,分辨率越高需要储存的图像信息越多,所以GIF文件大小就会越大。

因此我们可以通过降低GIF的分辨率来减小文件体积,但是实际上并不是所有场景都适用于减少分辨率。

如果是表情包之类的GIF,那么就无所谓,只要还能看见就可以随意减少分辨率;如果是用于固定场景(例如商城头图)则不能随便改分辨率,因为在这些场景下对分辨率有严格要求。

降低颜色深度

由于GIF这个格式已经十分古老了,所以它在今天也还是只支持256色,对于颜色简单的动画来说勉强够用,对于实际拍摄的视频转成的GIF现在的256色都已经有点捉襟见肘了,更别说继续降低颜色位数。

所以这个方法只适用于颜色比较简单的GIF文件。

降低帧率

虽然一般来说,需要帧率达到24人眼看起来才会觉得流畅,但是实际上,GIF的帧率只要在10左右都还是比较流畅的。

并且大多数动图的动画其实并不需要高帧率,因此降低GIF帧率不失为一种减少文件体积的好办法。

更多方法

根据GIF格式的原理,我们还可以使用仅储存变化内容、使用透明度帧、合理应用调色板、借助第三方工具的压缩算法等方法来实现降低GIF文件大小。

压缩效果预览

下面是我使用不同压缩方法压缩后的效果:

压缩方法图像大小图像参数
原图p1.gif5.49mb分辨率: 540x532 ; 帧率: 33FPS ; 颜色深度: 256
降低分辨率p2.gif3.47mb分辨率: 270x266 ; 帧率: 33FPS ; 颜色深度: 256
降低颜色深度p3.gif4.41mb分辨率: 540x532 ; 帧率: 33FPS ; 颜色深度: 128
降低帧率p4.gif6.79mb分辨率: 540x532 ; 帧率: 16FPS ; 颜色深度: 256
Gifsicle无损压缩p5.gif4.69mb分辨率: 540x532 ; 帧率: 33FPS ; 颜色深度: 256

从上面的表格中,可以看出降低分辨率能够大幅减少文件大小。

降低颜色深度虽然也能减少大小,但是图像失真严重。

降低帧率后文件大小不减反增,其实降低帧率应该是可以减小文件大小的,只是因为这里我降低分辨率后没有重新做压缩优化,导致大小反而增加了。(压缩优化即上面提到的仅储存变化内容和透明度,原图已经进行过压缩优化,但是这里我降低帧率后反而把压缩优化全丢失了。)

使用 Gifsicle 无损压缩也能够大幅减少文件大小,并且图像质量几乎没有损失。

其实 Gifsicle 还可以进行有损压缩,虽然名字叫有损压缩,但是实际肉眼几乎看不出来差别。

另外,这里列举的只是单一压缩方法,实际使用时不会只使用一种压缩,而是多种压缩方法混合使用。

压缩方法实现

使用 FFmpeg

对于降低帧率,我们这里使用的是 FFmpeg 来实现,关于怎么在安卓上使用 FFmpeg 可以参考我的这篇文章: 在安卓项目中使用 FFmpeg 实现 GIF 拼接

命令十分简单:

val gifPath = "input.gif"
val savePath = "output.gif"
val frameRate = 12 // 新帧率
val cmd = FFMpegArgumentsBuilder.Builder()
        .setOverride(true)
        .setInput(gifPath)
        .setFrameRate(frameRate)
        .setOutput(savePath)
        .build()
        .cmd
FFmpegKit.executeWithArguments(cmd)

可以看到,我们这里直接使用了 FFmpeg 进行抽帧,而没有做任何的优化处理,这也是为什么在上面的测试中,降低帧率反而会使得文件体积更大。

使用 Gifsicle

对于除帧率外的压缩,我们均使用 Gifsicle 来实现,关于如何在安卓上使用 Gifsicle 可以看我的文章: 在安卓项目中使用gifsicle编辑GIF动图-Android NDK 编译 Gifsicle 为可执行文件

需要注意的是,其实使用 Gifsicle 也可以完成抽帧的需求,但是 Gifsicle 抽帧需要自己计算并明确指定抽出哪些帧,相比于 FFmpeg 会自动计算并删除帧,我们只需要指定最终导出图像需要多少帧即可,所以我偷懒直接使用 FFmpeg 来抽帧了。

虽然 FFmpeg 抽帧后反而会导致体积增大,但是不用担心,接下来我们就会说如何避免这个情况。

Gifsicle 为我们提供了非常多的 GIF 操作命令,对于压缩 GIF 这个需求,我们可以使用:

  1. --resize 更改分辨率
  2. --lossy 有损压缩
  3. --colors 或者 -k 更改颜色位数
  4. -Ox 无损优化压缩

更改分辨率和更改颜色位数不用过多介绍,这里着重介绍一下 Gifsicle 提供的无损压缩(优化)指令:-O1 -O2 -O3;以及有损压缩指令 --lossy 。

无损压缩

无损压缩使用指令 -O[level] 其中的 level 为压缩级别,可以填写1-3,数字越大,压缩效果越强:

-O1 : 仅储存每帧之间变化的部分

-O2 : 仅储存每帧之间变化的部分,并启用透明度。

-O3 : 同时尝试多种优化方式。

无损压缩的原理即通过对比帧与帧之间的图像区别,后面的帧储存的不是完整的图像,而是相对于前面的帧的不同的地方。

例如这张 gif :

pig

解开每帧后实际是这样的:

export

可以看到除了第一帧储存的是完整的图像,后面储存的都只是相对于前一帧有变化的部分。

这对于动图中有大量静态部分的图片压缩效果非常明显,并且对动图质量几乎没有任何影响。

需要注意的是,开启 O3 级别压缩后,因为混合使用了多种优化算法,所以对于某些GIF也可能出现体积不降反增的现象(例如将已优化过后的GIF使用相同指令再优化一次就大概率会使得文件大小增加)

有损压缩

使用 --lossy[=lossiness] 可以对 GIF 进行有损压缩。

其中 lossiness 为压缩值,它是一个整数。

该选项默认值是 20,当值为 200 时就已经是非常大的压缩值了。

但是需要注意的是,由于算法限制,并不是值越大压缩效果越好:

It works best when only little loss is introduced, and due to limitation of the compression algorithm very high loss levels won't give as much gain.

它的实现原理:

GIF's LZW compression is based on a "dictionary" of strings of pixels seen. Normal encoder searches the dictionary for the longest string of pixels that exactly matches pixels in the image. Lossy encoder picks longest string of pixels that's "similar enough" to pixels in the image (plus some magic to hide the distortions with dithering).

简单理解就是通过优化 GIF 的压缩算法,原压缩算法在在编码时需要匹配完全一致的数据,但是 lossy 通过更改为匹配 “足够相似” 的数据来进行压缩。当然,这意味着会造成数据的丢失,表现在图像上就是会产生一些抖动和噪点。

效果如下:

  1. 未压缩 3.3 MB p6.gif
  2. 压缩后 1.2 MB p7.gif

混合多种压缩方法

在介绍完上述压缩方法和参数后,我在项目中实际应用时其实是混合了多种方式来压缩的。

例如,在我提到的这个 GIF工具 功能中,有一个一键压缩至指定大小,或预设大小的功能:

s1.jpg

该功能我在实现时会优先使用无损压缩方法压缩,如果无损压缩后尺寸不能满足则依次使用对质量影响较小的方法尝试压缩,直至尺寸达到预设值:

suspend fun compressGif2Size(
        activity: FragmentActivity?,
        sourcePath: String,
        targetSize: Long,
        resultPath: String,
        gifDrawable: GifDrawable
	): Boolean {
    // ……

    return compressByGifsicleOptimization(gifsicle, sourcePath, resultPath, targetSize, gifDrawable)
}

// 使用 Gifsicle -O3 压缩
private suspend fun compressByGifsicleOptimization(
    gifsicle: File,
    sourcePath: String,
    resultPath: String,
    targetSize: Long,
    gifDrawable: GifDrawable): Boolean {

    val cmd = "$gifsicle -i $sourcePath -O3 -o $resultPath"

    // …… 执行 gifsickle 命令
    
    if (resultFile.length() < targetSize) {
        log2text("compress success!", "d")
        return true
    }

    return compressByReduceFrameRate(sourcePath, resultPath, targetSize, gifDrawable, gifsicle)
}

// 使用 FFmpeg 降低帧率
private suspend fun compressByReduceFrameRate(
    sourcePath: String,
    resultPath: String,
    targetSize: Long,
    gifDrawable: GifDrawable,
    gifsicle: File): Boolean {
    // ……
    while (rate >= CompressGifFrameRateMinValue) {
        var ffmpegCmd = FFMpegArgumentsBuilder.Builder()
            .setOverride(true)
            .setInput(sourcePath)
            .setFrameRate(currentRate.toString())
            .setOutput(resultPath)
            .build(false)
            .cmd
        // …… 执行 FFmpeg 命令
        if (resultFile.length() < targetSize) {
            return true
        }

        // ……
        
        rate--
    }

    return compressByReduceResolution(resultPath, gifDrawable, gifsicle, targetSize)
}

// 使用 Gifsicle 减少分辨率
private suspend fun compressByReduceResolution(
    resultPath: String,
    gifDrawable: GifDrawable,
    gifsicle: File,
    targetSize: Long): Boolean {
    // ……

    while (scale >= minScale) {
        val cmd = "$gifsicle -i $tempOutFile --scale $scale -O3 -o $resultPath"
        // …… 执行 gifsickle 命令
        if (resultFile.length() < targetSize) {
            return true
        }
        // ……
        scale--
    }

    // ……

    return compressByLossy(gifsicle, resultPath, targetSize)
}

// 使用 Gifsicle lossy 压缩
private suspend fun compressByLossy(
    gifsicle: File,
    resultPath: String,
    targetSize: Long): Boolean {
    // ……
    for (i in 20..CompressGifLossyMaxValue step CompressGifLossyStepValue) {
        val cmd = "$gifsicle -i $resultPath --lossy=$i  -O3 -o $resultPath"
        // …… 执行 gifsickle 命令
        if (resultFile.length() < targetSize) {
            return true
        }
        // ……
    }

    // ……

    return compressByReduceColorBit(gifsicle, resultPath, targetSize)
}

// 使用 Gifsicle 减少颜色位数
private suspend fun compressByReduceColorBit(
    gifsicle: File,
    resultPath: String,
    targetSize: Long): Boolean {
    // ……
    for (i in 256 downTo CompressGifMinColorNum step CompressGifMinColorStepValue) {
        val cmd = "$gifsicle -i $resultPath -k $i --lossy=$CompressGifLossyMaxValue -O3 -o $resultPath"
        
        // …… 执行 gifsickle 命令
        
        if (resultFile.length() < targetSize) {
            return true
        }
    }

    // ……
    
    // 所有方法都试过后还是无法满足文件大小要求则认为压缩失败,返回 false
    return false
}

总结

总的来说,为了降低GIF文件的大小,我们有以下几种方法:

s2.jpg

而这些方法都可以使用 Gifsicle 来实现。

其中,除了使用 -Ox 优化外,其他均是有损压缩,或多或少会影响压缩后的动图质量。

参考

  1. Glide库里,藏了一套你心心念念的GIF压缩工具集
  2. 如何正确压缩GIF格式文件?来看京东设计师的总结!
  3. Lossy Gif Compressor