看啥推荐读物
专栏名称: IAM四十二
Android开发工程师
今天看啥  ›  专栏  ›  IAM四十二

简单实现 GIF 图的倒序播放

IAM四十二  · 掘金  · android  · 2019-07-07 15:33
阅读 27

简单实现 GIF 图的倒序播放

前言

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

比如下面这个

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

再来一个

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

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

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

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

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

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

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 加载每一帧图片的实现)

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 这个对象实现的。这里最关键的一句就是

gifDecoder.advance();
复制代码

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

  /**
   * Move the animation frame counter forward.
   */
  void advance();
复制代码

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

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

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

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 这样的三方库完成。

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 倒放

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

    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

        GifFactory.getReverseRes(mContext,source)
                .subscribe { 
                    Glide.with(mContext).load(it).into(reversed)
                }
复制代码

掘金 GIF 图上传限制为 5MB,图有点被压缩了,感谢的可以拉源码尝试一下效果

是的,就是这么简单,提供原始 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 反转实现的核心,因此对方法耗时简单做了下记录。


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 👀)图片二次编码还是有些性能差距的。

虽然,此次的实现转换较慢,但也算是一次不错的尝试吧。如果对最后一个步骤,有什么更优雅的方式,可以缩短 GIF 合成时间,可以提 PR 到 GitHub ,所有的观点讨论都是非常欢迎的。

源码

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

参考文档

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




原文地址:访问原文地址
快照地址: 访问文章快照