前言
某天收到业务方反馈,一张GIF活动图在网页端无法播放,在 APP 侧是正常的。
业务方表示上一次配置是正常的,并给出了两次配置的图片链接。
虽然提供的信息不算多,不过已经足够定位和解决问题了。
图片
原始图片这里就不放了,看完文章可以找任意 GIF 图片练手。
分析
从链接来看,两张图链接的后缀都是以 jpg 结尾的,但 GIF 的判断并不由其后缀决定(此处若不理解可搜索 图片文件格式),先查看一下两张图片的文件头:
可以看到,虽然图片后缀是 jpg ,但两张图都是典型的 GIF 图。 GIF 的 Header 部分最常见的就是 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开源图片框架中,以 Glide 与 Picasso 最为知名,下文以 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 读取了图片信息里的循环次数但默认不消费它。当然,实际上距离立下结论还有其他的确认步骤需要完成,因为主要的部分已经解析完,其他的就简单列举一部分,不再赘述。
parseHeader()函数有两个类调用了它,另一个未提到的类是ByteBufferGifDecoder,该类也被StreamGifDecoder类使用,可以在Glide类中搜索到相关代码。StreamGifDecoder中的handles方法,包含了两处关于GIF的逻辑,一是GifOptions,可以通过该类的方法设置禁止GIF播放,二是判断文件是否为GIF,默认处理逻辑在DefaultImageHeaderParser类中,也是通过判断文件头是否为0x474946来处理的。- 有关
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 格式的图片,是为了获得相对视频来说更轻量级的动图效果,那么在部分场景下,是否有其他替代方案呢?
例如 SVG、 Lottie 、 WebP、 APNG ,相比之下它们的优缺点又是如何呢?有机会再写吧。