记一次GIF的问题的排查与探索

1,615 阅读6分钟

前言

某天收到业务方反馈,一张GIF活动图在网页端无法播放,在 APP 侧是正常的。

业务方表示上一次配置是正常的,并给出了两次配置的图片链接。

虽然提供的信息不算多,不过已经足够定位和解决问题了。

图片

原始图片这里就不放了,看完文章可以找任意 GIF 图片练手。

分析

从链接来看,两张图链接的后缀都是以 jpg 结尾的,但 GIF 的判断并不由其后缀决定(此处若不理解可搜索 图片文件格式),先查看一下两张图片的文件头:

可以看到,虽然图片后缀是 jpg ,但两张图都是典型的 GIF 图。 GIFHeader 部分最常见的就是 47 49 46 38 38 61 ,即 GIF89a ,也有 GIF87a ,前者是后者的增强版本,我们所熟悉的透明通道和动画都是前者才具备的能力,另外还有很重要的一点, GIF89a 加入了 Application Extension ,这部分在后面的分析会用到。

既然都是 GIF 图,那么为什么有问题呢?打开 Console将新的图片替换到网页端,发现并不是不能播放,而是动画极短且只播放了一次,所以被业务方误认为是完全没有动画。

那么范围就大大缩小了,如果有过使用 Photoshop 编辑 GIF 的经验,那么一定知道在导出的时候,是可以选择 GIF 的循环次数的,但是因为我没有安装类似软件 ,所以还是用老办法检查一下:

NETSCAPE 就是曾经大名鼎鼎的网景公司,最为人所熟知的大概是其网景浏览器在当年的浏览器大战中被 IE 打败的故事了。在上个世纪90年代, 网景公司提出了 Netscape Looping Application Extension ,也就是上文提到的 GIF89a 中定义的 Application Extension 的一个非官方实现,也是流传至今最常见的实现,其加入了对动画循环次数的声明,在此我引用一张图来介绍其结构:

(图片来源见此处)

图片介绍的很清晰明了,因此我们对照命令行中的结果,可以看出无限循环的图片的 Loop Count 数值为0,而仅循环一次的图片连相关的 Block 都没有。

解决

至此,解决方案出来了,告知业务方重新导出 GIF 图,并设置循环次数为“无限”,问题解决。

探索

不过我仍有困惑,为什么 APP 上没问题呢?

翻源码看看吧。

Android开源图片框架中,以 GlidePicasso 最为知名,下文以 Glide 4.7版本为例。

我们知道, Glide 默认就支持 GIF 的显示,使用 Glide 进行图片加载的时候,其最基础版本只需要短短几行就可以完成快速应用,如下所示 :

    Glide.with(context)
            .load(url)
            .into(mImageView)

通常来说,一个比较合适的看源码的方法,是从对外暴露的方法一层层往内查看调用链。不过本文前一部分已经简单介绍过 GIF 的结构,我们可以顺着这个思路来定向查找。

从上文的介绍可以得知,最新配置的 GIF 图片缺少了描述循环次数的 Block ,所以合理怀疑 ,是否 Glide框架并没有去读取相关信息呢?

直接搜索关键词 GifDecoder ,发现是个 interface,观察发现其中有几个相关方法如下:

      @Deprecated
      int getLoopCount();
    
      int getNetscapeLoopCount();
    
      int getTotalIterationCount();

接着搜索其接口实现,发现仅有一个实现类 StandardGifDecoder,其方法实现如下:

      @Deprecated
      @Override
      public int getLoopCount() {
        if (header.loopCount == GifHeader.NETSCAPE_LOOP_COUNT_DOES_NOT_EXIST) {
          return 1;
        }
        return header.loopCount;
      }
    
      @Override
      public int getNetscapeLoopCount() {
        return header.loopCount;
      }
    
      @Override
      public int getTotalIterationCount() {
        if (header.loopCount == GifHeader.NETSCAPE_LOOP_COUNT_DOES_NOT_EXIST) {
          return 1;
        }
        if (header.loopCount == GifHeader.NETSCAPE_LOOP_COUNT_FOREVER) {
          return TOTAL_ITERATION_COUNT_FOREVER;
        }
        return header.loopCount + 1;
      }

接着查找 header.loopCount 的赋值位置,我们会找到如下函数:

      /**
       * Reads Netscape extension to obtain iteration count.
       */
      private void readNetscapeExt() {
        do {
          readBlock();
          if (block[0] == 1) {
            // Loop count sub-block.
            int b1 = ((int) block[1]) & MASK_INT_LOWEST_BYTE;
            int b2 = ((int) block[2]) & MASK_INT_LOWEST_BYTE;
            header.loopCount = (b2 << 8) | b1;
          }
        } while ((blockSize > 0) && !err());
      }

接着查找该函数被调用的位置,并且简单追溯到略外层的类,会发现是在 GifHeaderParser 类中被调用:

      @NonNull
      public GifHeader parseHeader() {
        if (rawData == null) {
          throw new IllegalStateException("You must call setData() before parseHeader()");
        }
        if (err()) {
          return header;
        }
    
        readHeader();
        if (!err()) {
          readContents();  // <-此函数内调用了 readNetscapeExt()
          if (header.frameCount < 0) {
            header.status = STATUS_FORMAT_ERROR;
          }
        }
    
        return header;
      }

该方法主要有两个类调用了它,其中一个就是上文提到的 StandardGifDecoder :

      @GifDecodeStatus
      public synchronized int read(@Nullable byte[] data) {
        this.header = getHeaderParser().setData(data).parseHeader();
        if (data != null) {
          setData(header, data);
        }
    
        return status;
      }

至此,我们可以得出阶段性的结论, StandardGifDecoder的确有获取了 GIF 的循环次数。

回顾文章开头介绍的 GIF 结构,显然,我们一开始的猜测是错误的, Glide 依然是读取了相关 Block 中的循环次数的。

既然如此,那么为什么在APP上使用 Glide会无限循环播放 GIF 呢,不妨猜测,会不会是获取了循环次数,但默认没有消费它呢?

我们依次查找上文提到的这三个函数的调用者

    @Deprecated
    int getLoopCount();
    
    int getNetscapeLoopCount();
    
    int getTotalIterationCount();

经过简单筛选,会发现,仅有最后一个方法会在 GlideFrameLoader 中被调用:

      int getLoopCount() {
        return gifDecoder.getTotalIterationCount();
      }

继续追溯,会发现在 GifDrawable 中调用了该函数:

      // Public API.
      @SuppressWarnings("WeakerAccess")
      public void setLoopCount(int loopCount) {
        if (loopCount <= 0 && loopCount != LOOP_FOREVER && loopCount != LOOP_INTRINSIC) {
          throw new IllegalArgumentException("Loop count must be greater than 0, or equal to "
              + "GlideDrawable.LOOP_FOREVER, or equal to GlideDrawable.LOOP_INTRINSIC");
        }
    
        if (loopCount == LOOP_INTRINSIC) {
          int intrinsicCount = state.frameLoader.getLoopCount();
          maxLoopCount =
              (intrinsicCount == TOTAL_ITERATION_COUNT_FOREVER) ? LOOP_FOREVER : intrinsicCount;
        } else {
          maxLoopCount = loopCount;
        }
      }

而该方法,默认并不会被调用。继续检查该类,会发现 maxLoopCount 变量默认值为 LOOP_FOREVER ,也就是无限循环播放,而该变量只在该方法里被重新赋值。

至此,我们可以基本确认, Glide 读取了图片信息里的循环次数但默认不消费它。当然,实际上距离立下结论还有其他的确认步骤需要完成,因为主要的部分已经解析完,其他的就简单列举一部分,不再赘述。

  1. parseHeader() 函数有两个类调用了它,另一个未提到的类是 ByteBufferGifDecoder,该类也被 StreamGifDecoder 类使用,可以在 Glide 类中搜索到相关代码。
  2. StreamGifDecoder 中的 handles方法,包含了两处关于 GIF 的逻辑,一是 GifOptions ,可以通过该类的方法设置禁止 GIF 播放,二是判断文件是否为 GIF ,默认处理逻辑在 DefaultImageHeaderParser 类中,也是通过判断文件头是否为 0x474946来处理的。
  3. 有关 Glide 如何将图片地址转化为 Drawable 并且选择对应的 Decoder ,可以搜索 Glide 源码解析,本文就不展开了。

应用

了解完原理之后,如果我们想让 Glide播放 GIF的时候,图片内设置了几次就播放几次,要怎么修改呢?

从上文可以得知,最简单的办法就是调用 setLoopCount(int loopCount),例如可以通过 listener获取到 GifDrawable 等,可以根据具体场景来选择。

用代码来描述的话,就是这样的:

    	// 方法1
    	GlideApp.with(context)
                .load(url)
                .into(object : DrawableImageViewTarget(mImageView) {
                    override fun onResourceReady(resource: Drawable, transition: Transition<in Drawable>?) {
    		               if (resource is GifDrawable) {
    		                   resource.setLoopCount(LOOP_INTRINSIC)
    		               }
    		               super.onResourceReady(resource, transition)
    		           }
                })
    
    	//方法2
    	GlideApp.with(context)
    	        .load(url)
    	        .listener(object : RequestListener<Drawable> {
    	            override fun onResourceReady(resource: Drawable?, model: Any?, target: Target<Drawable>?, dataSource: DataSource?, isFirstResource: Boolean): Boolean {
    	                if (resource is GifDrawable) {
    	                    resource.setLoopCount(LOOP_INTRINSIC)
    	                }
    	                return false
    	            }
    	
    	            override fun onLoadFailed(e: GlideException?, model: Any?, target: Target<Drawable>?, isFirstResource: Boolean): Boolean {
    	                e?.printStackTrace()
    	                return false
    	            }
    	        })
    	        .into(mImageView)

更多

在绝大多数场景下,我们引用 GIF 格式的图片,是为了获得相对视频来说更轻量级的动图效果,那么在部分场景下,是否有其他替代方案呢?

例如 SVGLottieWebPAPNG ,相比之下它们的优缺点又是如何呢?有机会再写吧。