0%

简单实现 GIF 图的倒序播放

前言

经常在网上看到一些有意思的 GIF 图,有些 GIF 图倒放之后,甚至变得更有意思,简直是每日的快乐源泉;

比如下面这个

正放的时候很搞笑,很悲催;倒放的时候居然很炫酷,简直比段誉的凌波微步还牛逼,有没有一种盖世神功已练成的感觉 😎😎😎😎😎,是不是可以和绿巨人一战 😀😀。

再来一个

小男生的快乐与悲伤居然如此简单,嘤嘤婴 😊😊😊😊

🤣🤣🤣🤣🤣🤣🤣🤣,真是能让人笑上三天三夜。下面就来看看如何实现 GIF 图的倒放。

以下所有实现细节源码已同步至 GitHub 仓库,可参考最后源码

GIF 是怎么播放的,如何把GIF倒序?

想要倒放 GIF 图,首先了解一下 GIF 的原理;这里建议看看这篇来自腾讯手Q团队的文章浓缩的才是精华:浅析GIF格式图片的存储和压缩。总的来说,GIF 和图通图片最大的不同点就是它是由许多帧组成的。既然如此我们很容易想到,从 GIF 里把所有帧拿出来,然后倒序组合这些帧,然后在合成一张 GIF 不就可以了吗?

是的,道理就是就么简单。如果你现在去 Google GIF 倒序的实现,会看到很多 Python 的实现版本,类似如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import os
import sys
from PIL import Image, ImageSequence

path = sys.path[0] # 设置路径 -- 系统当前路径
dirs = os.listdir(path) # 获取该路径下的文件
for i in dirs: # 循环读取所有文件
if os.path.splitext(i)[1] == ".gif": # 筛选gif文件
print(i) # 输出所有的gif文件名
#将gif倒放保存
with Image.open(i) as im:
if im.is_animated:
frames = [f.copy() for f in ImageSequence.Iterator(im)]
frames.reverse() # 内置列表倒序
frames[0].save('./save/reverse_'+i+'.gif',save_all=True, append_images=frames[1:])# 保存

不得不说,Python 的各种三方库的确很强大,几行代码就实现了 GIF 倒序的功能。但是作为一个稍微有点追求的人,难道就到此为止了吗?下次如果有个好玩的 GIF 图片,如果想看倒序图,难道还要打开电脑用用上述脚本转一次吗?

尤其是作为一个 Android 开发者,这种事情用手机不也能做吗?为了每日的快乐源泉,就算天崩地裂,海枯石烂也要做出来(其实好像也不是很难o(╯□╰)o)

好了,不吹牛逼了,下面来看看怎么实现。

GIF 倒放的实现

上面已经说过了,要实现 GIF 的倒序需要做三件事

  • 从 GIF 图里把每一帧摘出来,组成序列
  • 把序列逆序
  • 用逆序后的每一帧再重新生成一张新的 GIF 图

上面两步,对集合逆序不是什么难事,主要看看如何实现第一步和第三步。

从 GIF 图里把每一帧抠出来

这个听起来很复杂,做起来好像也挺难,Android 没有提供类似的 API 可以做这件事,平时加载图片用的三方库 Glide,Fresco 等貌似也没有提供可以做类似事情的接口。但其实我们稍微深入看一下三方库是实现 GIF 播放的代码,就会找到突破口,这里以 Glide 为例,假设你研究过 Glide 的源码(如果没有看过,也不要紧,可以略过这段,直接看实现

GifFrameLoader.loadNextFrame

在 GifFrameLoader 的 loadNextFrame 实现中(我们可以猜测到这就是 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
private void loadNextFrame() {
if (!isRunning || isLoadPending) {
return;
}
if (startFromFirstFrame) {
Preconditions.checkArgument(
pendingTarget == null, "Pending target must be null when starting from the first frame");
gifDecoder.resetFrameIndex();
startFromFirstFrame = false;
}
if (pendingTarget != null) {
DelayTarget temp = pendingTarget;
pendingTarget = null;
onFrameReady(temp);
return;
}
isLoadPending = true;
// Get the delay before incrementing the pointer because the delay indicates the amount of time
// we want to spend on the current frame.
int delay = gifDecoder.getNextDelay();
long targetTime = SystemClock.uptimeMillis() + delay;

gifDecoder.advance();
next = new DelayTarget(handler, gifDecoder.getCurrentFrameIndex(), targetTime);
requestBuilder.apply(signatureOf(getFrameSignature())).load(gifDecoder).into(next);
}

可以看到具体的实现是由 gifDecoder 这个对象实现的。这里最关键的一句就是

1
gifDecoder.advance();

我们可以看看这个方法的定义

1
2
3
4
/**
* Move the animation frame counter forward.
*/
void advance();

就是跳转到下一帧的意思。

好了,至此我们知道如果可以获取到 GifDeCoder 和 GifFrameLoader 的实例,那么就可以手动控制和获取 GIF 图里每一帧了。但是,我们回过去看 Glide 提供的 API 发现,我们没有办法直接获取 GifFrameLoader 和 GifDeCoder,因为在源码里这些变量都是 private 的。🤦‍🤦‍🤦‍ ,难道这就走到了死胡同吗?不然,前人曾说过,编程领域的任何问题都可以通过添加一个中间层实现。我们这里的中间层就是 反射。使用反射可以获取就可以访问 GifFrameLoader 和 GifDeCoder 了;那么后续的实现就变得简单了。

获取每一帧图片并保存在集合中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Glide.with(mContext).asGif().listener(object :RequestListener<GifDrawable>{

...fail stuff...

override fun onResourceReady(resource: GifDrawable, model: Any?, target: Target<GifDrawable>?, dataSource: DataSource?, isFirstResource: Boolean): Boolean {
val frames = ArrayList<ResFrame>()
val decoder = getGifDecoder(resource)
if (decoder != null) {

for (i in 0..resource.frameCount) {
val bitmap = decoder.nextFrame
val path = IOTool.saveBitmap2Box(context, bitmap, "pic_$i")
val frame = ResFrame(decoder.getDelay(i), path)
frames.add(frame)
decoder.advance()
}
}
return false
}

}).load(originalUrl).into(original)

这里的实现很简单,监听 GIF 的加载过程,加载成功后得到一个 GifDrawable 的实例 resource ,通过这个实例用反射的方式(具体实现可参考源码,非常简单)获取到了 GifDecode 的实例,有了这个实例就可以获取每一帧了,这里还需要记录一下每一帧播放时间间隔,返回的每一个帧就是一个 Bitmap ,我们把这些 Bitmap 保存在应用的安装目录下,然后用一个列表记录下所有帧的信息,包含当前帧的延迟时间和当前帧对应的 Bitmap 的存储路径。

每一帧的集合序列有了,序列反转一行代码的事情,剩下的就是用这个序列生成新的 GIF 图片了。

用帧序列再次生成图片

用已有的图片组成和一个新的图片,这个并不是什么难事,网上已经有很多实现了。甚至包括 GIF 的再次生成,也可以借助 GifMaker 这样的三方库完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private fun genGifByFrames(context: Context, frames: List<ResFrame>): String {

val os = ByteArrayOutputStream()
val encoder = AnimatedGifEncoder()
encoder.start(os)
encoder.setRepeat(0)
for (value in frames) {
val bitmap = BitmapFactory.decodeFile(value.path)
encoder.setDelay(value.delay)
encoder.addFrame(bitmap)
}
encoder.finish()

val path = IOTool.saveStreamToSDCard("test", os)
IOTool.notifySystemGallay(context, path)

return path
}

借助 AnimatedGifEncoder 非常简单把之前保存的序列再次拼接成了一张新的 GIF 图。

GIF 倒放

把上述三个步骤简单整理一下

1
2
3
4
5
6
7
8
9
10
11
12
13
private fun reverseRes(context: Context, resource: GifDrawable?): String {
if (resource == null) {
return ""
}
// 获取所有帧信息集合
val frames = getResourceFrames(resource, context)

// 逆序集合
reverse(frames)

// 生成新的 GIF 图片
return genGifByFrames(context, frames)
}

需要注意的是,这三步操作都是涉及到 UI 的耗时操作,因此这里简单用 RxJava 做了一次封装。然后就可以愉快的使用了。

demo

1
2
3
4
GifFactory.getReverseRes(mContext,source)
.subscribe {
Glide.with(mContext).load(it).into(reversed)
}

是的,就是这么简单,提供原始 GIF 资源的路径,即可返回实现倒序的 GIF 图。

总结

不得不说,Glide 的内部实现非常强大,对移动端图片加载的各种场景做了非常复杂的考虑和设计,因此也导致它的源码非常的难于阅读。但是,如果仅仅从某个的出发,比如缓存、网络、图片解码和编码的角度出发,脱离整个流程,去看局部还是有收获的。

回到上述 GIF 倒序的步骤,总的来说有以下几个关键步骤

  1. Glide 根据 URL 加载 GIF 图片,同时监听加载过程
  2. 通过 GifDrawable 反射获取到 GifDecoder
  3. 通过 GifDecoder 获取所有帧(包含保存这些帧 Bitmap)
  4. 反转帧序列 frames
  5. 通过 frame 再次生成 GIF 图片

上述步骤中 1 和 4 的执行速度是基本上是线性的,也是无法再过多干预的。而步骤 2,3,5 也是 GIF 反转实现的核心,因此对方法耗时简单做了下记录。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

GIF 图 size = 8.9M

E/GifFactory: 方法: getGifDecoder 耗时 0.001000 second
E/GifFactory: 方法: getResourceFrames 耗时 1.489000 second
E/GifFactory: 方法: genGifByFrames 耗时 9.397000 second

GIF 图 size = 11.9M

E/GifFactory: 方法: getGifDecoder 耗时 0.000000 second
E/GifFactory: 方法: getResourceFrames 耗时 1.074000 second
E/GifFactory: 方法: genGifByFrames 耗时 9.559000 second

GIF 图 size = 3.3M

E/GifFactory: 方法: getGifDecoder 耗时 0.001000 second
E/GifFactory: 方法: getResourceFrames 耗时 0.437000 second
E/GifFactory: 方法: genGifByFrames 耗时 2.907000 second

GIF 图 size = 8.1M

E/GifFactory: 方法: getGifDecoder 耗时 0.000000 second
E/GifFactory: 方法: getResourceFrames 耗时 0.854000 second
E/GifFactory: 方法: genGifByFrames 耗时 6.416000 second

可以看到,虽然我们获取 GifDecoder 的过程使用了反射,但其实这比不是性能瓶颈;获取所有帧信息的方法 getResourceFrames 耗时,也是和 GIF 图的大小有关,基本上是一个可接受的值。但是通过帧序列再次生成 GIF 图的方法执行时间就有点恐怖了,即便我的测试机是 kirin(麒麟)960 ,运行内存有 6G 😳😳。

但是同样的图片在 PC 上用 Python 脚本基本上是毫秒级完成。所以纯粹用 java 实现(AnimatedGifEncoder 是 java 写的,不算 kotlin 👀)图片二次编码还是有些性能差距的。

虽然,此次的实现转换较慢,但也算是一次不错的尝试吧。

源码

本文所有实现细节源码已同步至 GitHub 仓库 AndroidAnimationExercise, 可以参考 ReverseGifActivity

参考文档

浓缩的才是精华:浅析GIF格式图片的存储和压缩

加个鸡腿呗.