最新消息:欢迎访问Android开发中文站!商务联系QQ:1304524325

GIF面面观

开发进阶 loading 74浏览 0评论

GIF格式

GIF(Graphics Interchange Format,图形交换格式)是由CompuServe公司开发的图形文件格式,关于GIF的资料很多,本文会强调补充一些重要知识点。

GIF文件由三部分构成:文件头(File Header)、GIF数据流(GIF Data Stream)和文件终结器(Trailer),如下所示:

GIF结构

其中文件头占用6个字节,表示GIF标识符,一般为GIF87a或者GIF89a 。 文件终结器占用1个字节,固定值为0x3B

GIF是基于全局(局部)颜色列表的,每个像素存储的是该像素颜色值对应的全局(局部)颜色列表的索引值(0~255),然后经过LZW算法编码压缩后生成编码流,存储在图像块中。

GIF数据流

逻辑屏幕标识符

GIF数据流则包含了主要内容,首先是逻辑屏幕标识符,占用7个字节,如下所示:

逻辑屏幕标识符

这里的逻辑屏幕宽高就是GIF的完整宽高,背景色则是全局颜色表的索引值 第5个字节各个Bit的含义如下所示:

  1. m : 全局颜色列表标志(Global Color Table Flag),当置位时表示有全局颜色列表,此时pixel值才有意义。
  2. cr : 颜色深度(Color ResoluTion),cr+1确定图象的颜色深度。
  3. s : 分类标志(Sort Flag),当置位时表示全局颜色列表使用分类排列。
  4. pixel : 全局颜色列表大小,2 << pixel表示全局颜色列表的Size

全局颜色列表

紧跟在逻辑屏幕列表后面的是全局颜色列表,共占用2 << pixel * 3个字节,每个颜色由3个字节组成,分别是R、G、B颜色分,如下所示:

颜色列表

上述的逻辑屏幕标识符和全局颜色列表都是全局的,一个GIF文件只会存在一个。接下来的每个图像块则对应一个GIF帧。

图像块

图形控制扩展

GIF89a版本中存在图形控制扩展,占用8个字节,一般放在一个图象块(图象标识符)或图形文本扩展块的前面,用来控制紧跟在它后面的第一个图象或文本块的渲染方式,如下所示:

图形控制扩展

其中,第一个字节0x21是GIF扩展块标识; 第二个字节0xF9标识这是一个图形控制扩展块; 延迟时间的单位是10ms,表示当前帧的展示时间; 透明色索引指定了解码当前帧时,需要先把索引对应的全局(局部)颜色表中的颜色修改为透明色,然后解码当前帧后,再恢复成原来的颜色。 第4个字节各个Bit的含义如下所示:

  1. i : 用户输入标志(Use Input Flag),可以是回车键、鼠标点击等事件,可以和延迟时间一起使用,在设置的延迟时间内,有用户输入事件则马上切换下一帧,否则等到延迟时间到达。
  2. t : 透明颜色标志(Transparent Color Flag),置位时表示当前帧使用透明色,与透明色索引搭配使用。

处置方法(Disposal Method,非常重要!!!),表示渲染当前帧时,如何处理前一帧(根据前一帧的Disposal Method处理前一帧,而不是根据当前帧的Disposal Method处理前一帧),有以下取值:

  1. Unspecified(0) (Nothing) : 绘制一个完整大小的、不透明的GIF帧来替换上一帧,就算连续的两帧只在局部上有细微的差异,每一帧依然是完整独立的绘制,如下所示:
    Unspecified
  2. Do Not Dispose(1) (Leave As Is) 1: 未被当前帧覆盖的前一帧像素将继续显示,这种方式常用于对GIF动画进行优化,当前帧只需在上一帧的基础上做局部刷新,上一帧中没有被当前帧覆盖的像素区域将继续展示。这种方式既能节省内存,也能提高解码速度,如下所示:
    Do Not Dispose
  3. Restore to Background(2): 绘制当前帧之前,会先把前一帧的绘制区域恢复成背景色,这种方式常用于优化很多帧背景相同的情况,上一帧的背景色能通过当前帧的透明区域显示,如下所示:
    Restore to Background
  4. Restore to Previous(3) : 绘制当前帧时,会先恢复到最近一个设置为UnspecifiedDo not Dispose的帧,然后再将当前帧叠加到上面,这种方式性能比较差,已经被慢慢废弃,如下所示:
    Restore to Previous

最重要的理解Disposal Method的处理方式:根据前一帧的Disposal Method处理前一帧,而不是根据当前帧的Disposal Method处理前一帧。 比如:某GIF有有A、B两帧,A帧Disposal Method为Restore to Background、B帧Disposal Method为Do Not Dispose,那么绘制B帧时,因为A帧Disposal Method为Restore to Background,所以需要先把A帧的绘制区域恢复成背景色,然后再绘制B帧。

图像标识符

图像标识符是一个图像块的开端,占用10个字节,第一个字节固定为0x2C,用于标识图像标识符。图像标识符定义了当前帧的偏移量、宽高等属性,如下所示:

图像标识符

其中,第2~9字节定义了当前帧实际的偏移量和宽高,怎么理解那? 上面逻辑屏幕标识符中的宽高是GIF的完整宽高,但是因为Disposal Method的存在,当前帧可能是残差帧,也就是真实宽高是小于GIF完整宽高的,所以也就需要一个相对于GIF完整宽高的X和Y偏移量,可以参考上面Do Not Dispose的示意图。

第10字节各个Bit的含义如下所示:

  1. m : 局部颜色列表标志(Local Color Table Flag),当置位时表示当前帧有局部颜色列表,此时后面的pixel值才有意义。局部颜色列表紧跟在图像标识符后面,仅供当前帧使用;否则使用全局颜色列表,忽略pixel值。
  2. i : 交织标志(Interlace Flag),置位时紧跟在局部颜色列表后面的帧图象数据使用交织方式排列,否则使用顺序排列。
  3. s : 分类标志(Sort Flag),置位时表示紧跟着的局部颜色列表分类排列。
  4. r : 保留字段,目前初始化为0
  5. pixel : 局部颜色列表大小,2 << pixel就表示局部颜色列表的Size
局部颜色列表

若图像标识符第10字节的m Bit置位了,则这里存在局部颜色列表,共占用2 << pixel * 3个字节,每个颜色由3个字节组成,分别是R、G、B颜色分量,即与全局颜色列表的存储方式相同。只不过局部颜色列表仅供当前帧使用,解码完当前帧后,需要恢复全局颜色列表。

基于颜色列表的图像数据

首先需要明确的是图像数据存储的是经过LZW压缩算法压缩后的编码数据,由两部分组成:

  • LZW编码长度(LZW Minimum Code Size)
  • 图象数据(Image Data)

LZW压缩算法有三个重要对象:数据流、编码流和编译表。 编码时,数据流是输入对象(图象的光栅化数据序列),编码流就是输出对象(经过压缩运算的编码数据);解码时,编码流则是输入对象,数据流是输出对象;而编译表则是在编码和解码时都须要用借助的对象,而图像块存储的就是编码流。

解码时,首先从图像块中取出LZW编码流,然后通过LZW算法解码成数据流(像素索引值序列),再结合全局(局部)颜色列表,就还原出了一帧图像的像素数据。

关于LZW算法,本文不做详细介绍,可以查看LZW算法

其他扩展块

除了图形控制扩展用于控制图像块的渲染方式之外,还有其他一些扩展块,例如:

  • 注释扩展(Comment Extension):用来记录图形、版权等任何非图形和控制的纯文本数据,注释扩展并不影响图象数据流的处理,解码器完全可以忽略它。可以存放在数据流的任何位置,推荐放在数据流的开始或结尾。
  • 图形文本扩展(Plain Text Extension):用来绘制简单的文本图像,由控制绘制的参数和用来绘制的纯文本数据组成。图形文本扩展块也属于图像块,可以在它前面定义图形控制扩展控制它的渲染形式(与普通图像块类似)。因此,在统计GIF帧数时,图形文本扩展块也会当做一帧进行统计。
  • 应用程序扩展(Application Extension):用于应用程序定义自己的扩展信息。一般情况下,包含了GIF的循环播放次数。关于GIF元数据的解析,可以参考Fresco的GifMetadataStreamDecoder

Fresco解析GIF

Fresco支持对GIF和Webp等动图的解码和渲染,在Fresco V1.11.0版本上,解码后的动图会被封装成AnimatedDrawable2,调用AnimatedDrawable2.start()方法就开始播放了。

针对GIF,Fresco实现了两种解码方式:一种是Native解码,主要是借助giflib库在Native层进行解码,另外一种是通过系统Movie类进行解码。默认情况下,是通过giflib来解码的。 若想要通过Movie进行解码,则需要引入Fresco animated-gif-lite库,同时指定解码器为GifDecoder,如下所示:

Fresco.newDraweeControllerBuilder().setImageRequest(
ImageRequestBuilder.newBuilderWithSource(imageUri)
                .setImageDecodeOptions(
                ImageDecodeOptions.newBuilder().setCustomImageDecoder(GifDecoder(true)).build()).build()
)

其中,创建GifDecoder时,若参数为true,则表示通过GifMetadataMovieDecoder简单解析GIF元数据,比较粗糙,比如:GIF播放次数固定为无限循环;否则若参数为false,则表示通过GifMetadataStreamDecoder详细解析GIF元数据。

我们先看一下Fresco加载图片的流程:

  1. ImagePipeline获取图片时,会根据不同的请求(获取解码图片:fetchDecodedImage,或者获取未解码图片:fetchEncodedImage)生成不同的Producer Sequence,其实就是一个Producer链条,每个Producer只负责整个链条中的一环,例如:NetworkFetchProducer负责下载图片,DecodeProducer负责解码图片等。
  2. ImagePipeline获取到Producer Sequence后,会基于它创建CloseableProducerToDataSourceAdapter,即DataSource,同时触发Producer Sequence整个链条的生产。Producer Sequence生产出结果后,会通过DataSource回调给订阅者DataSubscriber,如果是 ImagePipeline.fetchEncodedImage,那么订阅者拿到的就是CloseableReference<PooledByteBuffer>,即未解码的字节池;如果是ImagePipeline.fetchDecodedImage,那么订阅者拿到的是CloseableReference<CloseableImage>,即解码后的图片数据。
  3. 正常情况下,AbstractDraweeController会拿到解码后的图片数据:CloseableReference<CloseableImage>,然后会把它封装成Drawable(静图封装成BitmapDrawable或者OrientedDrawable;动图则封装成AnimatedDrawable2),交给DraweeHierarchyDraweeHierarchy内部是Drawable层级数组,根据DraweeView的状态展示不同的Drawable。

然后,我们看一下ImagePipeline.fetchDecodedImage从网络获取图片时的整个Producer Sequence,如下所示:

  1. NetworkFetchProducer : 负责从网络下载图片数据,内部持有NetworkFetcher,负责使用不同的Http框架去实现下载逻辑,例如:HttpUrlConnectionNetworkFetcher、OkHttpNetworkFetcher、VolleyNetworkFetcher等。
  2. WebpTranscodeProducer : 因为不是所有Android平台都支持WebP,具体可以参考WebpTranscodeProducer.shouldTranscode方法,所以对于不支持WebP的平台,需要转换成jpg/png。其中无损或者带透明度的WebP(DefaultImageFormats.WEBP_LOSSLESS和DefaultImageFormats.WEBP_EXTENDED_WITH_ALPHA),需要转换成PNG,具体方法是先把WebP解码成RGBA,然后再把RGBA编码成PNG;简单或者扩展的WebP(DefaultImageFormats.WEBP_SIMPLE和DefaultImageFormats.WEBP_EXTENDED),需要转换成JPEG。具体方法是先把WebP解码成RGB,然后再把RGB编码成JPEG。
  3. PartialDiskCacheProducer : 解下来的三个是磁盘缓存EncodedImage相关
  4. DiskCacheWriteProducer
  5. DiskCacheReadProducer
  6. EncodedMemoryCacheProducer : 未解码数据的内存缓存
  7. EncodedCacheKeyMultiplexProducer
  8. AddImageTransformMetaDataProducer
  9. ResizeAndRotateProducer : 负责采样和图片旋转
  10. DecodeProducer : 上述的Producer都是基于EncodedImage,DecodeProducer会把EncodedImage解码成CloseableReference
  11. BitmapMemoryCacheProducer : 接下来的两个是内存Bitmap缓存相关
  12. BitmapMemoryCacheKeyMultiplexProducer
  13. ThreadHandoffProducer : 负责切换线程
  14. BitmapMemoryCacheGetProducer
  15. PostprocessorProducer
  16. PostprocessedBitmapMemoryCacheProducer
  17. BitmapPrepareProducer

从下往上,依次持有引用;从上往下,依次返回数据。

OK,下面我们聚焦下GIF相关的逻辑,从DecodeProducer跟下去,会发现 AnimatedImageFactoryImpl.decodeGif负责把未解码数据EncodedImage解码成GifImage,GifImage就代表一个GIF,这里只会解析出GIF的元数据,不会真正解码GIF帧,等到真正展示时,才会按需解码;AnimatedImageFactoryImpl.decodeWebP负责把未解码数据EncodedImage解码成WebPImage,与GIF类似;若是使用了自定义解码器GifDecoder,则解码出的就是MovieAnimatedImage。上述三个XXXImage,都是AnimatedImage的子类,提供了动图相关的所有操作。

那怎么获取每一帧图像那?首先通过AnimatedImage.getFrame获取AnimatedImageFrame(三个子类分别是:GifFrame、WebPFrame、MovieFrame,与上述的XXXImage相对应),然后通过AnimatedImageFrame.renderFrame把GIF帧渲染到给定的Bitmap上。

这里通过GifImageMovieFrame渲染到Bitmap时,存在很大差异。MovieFrame是通过MovieDrawer类来实现的(借助于系统类Movie),所以它渲染出来的GIF帧就是完整帧,也就是已经根据Disposal Method处理了各种残差帧逻辑,相对比较简单。而GifImage则是借助于第三方库giflib实现,通过GifImage.renderFrame获取的Bitmap是残差帧,需要自己处理Disposal Method策略。

下面,我们看下Fresco是怎么展示GIF,以及怎么处理Disposal Method的,整个调用流程是:AnimatedDrawable2.draw -> AnimationBackendDelegate.drawFrame -> BitmapAnimationBackend.drawFrame -> BitmapAnimationBackend.drawFrameOrFallback -> BitmapAnimationBackend.renderFrameInBitmap -> AnimatedDrawableBackendFrameRenderer.renderFrame -> AnimatedImageCompositor.renderFrame -> AnimatedDrawableBackendImpl.renderFrame -> AnimatedDrawableBackendImpl.renderImageDoesNotSupportScaling -> AnimatedImageFrame.renderFrame -> Native通过giflib解码。

下面,我们重点看下几个关键方法: BitmapAnimationBackend.drawFrameOrFallback:负责把某GIF完整帧渲染到给定的Canvas上,首先从缓存中查找当前完整帧;没有的话,继续查找可重用的Bitmap,然后先把当前帧绘制到Bitmap上,再把Bitmap渲染到Canvas;若没有可重用的Bitmap,则创建新Bitmap,然后先把当前帧绘制到Bitmap上,再把Bitmap渲染到Canvas;最后实在都不行的话,则返回前一帧数据。

AnimatedImageCompositor.renderFrame:负责把指定的GIF帧渲染到给定的完整尺寸的Bitmap上,需要处理各种Dispose MethodblendOperation(GIF都是进行透明度混合),关键代码如下所示:

// 生成GIF给定索引的一个完整帧
public void renderFrame(int frameNumber, Bitmap bitmap) {
    Canvas canvas = new Canvas(bitmap);
    canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.SRC); // 清空Bitmap

    // If blending is required, prepare the canvas with the nearest cached frame.
    int nextIndex;
    if (!isKeyFrame(frameNumber)) {
      // Blending is required. nextIndex points to the next index to render onto the canvas. nextIndex是需要重新绘制的帧起始索引
      nextIndex = prepareCanvasWithClosestCachedFrame(frameNumber - 1, canvas);
    } else {
      // Blending isn't required. Start at the frame we're trying to render.
      nextIndex = frameNumber;
    }

    // Iterate from nextIndex to the frame number just preceding the one we're trying to render 从nextIndex开始一帧一阵向Canvas恢复帧数据
    // and composite them in order according to the Disposal Method.
    for (int index = nextIndex; index < frameNumber; index++) {
      AnimatedDrawableFrameInfo frameInfo = mAnimatedDrawableBackend.getFrameInfo(index);
      DisposalMethod disposalMethod = frameInfo.disposalMethod;
      if (disposalMethod == DisposalMethod.DISPOSE_TO_PREVIOUS) {
        continue;
      }
      // 不需要进行透明像素混合,则把指定帧的绘制区域用透明像素提前覆盖
      if (frameInfo.blendOperation == BlendOperation.NO_BLEND) {
        disposeToBackground(canvas, frameInfo);
      }
      // 具体的绘制某一帧
      mAnimatedDrawableBackend.renderFrame(index, canvas);
      // 向外回调某帧的Bitmap
      mCallback.onIntermediateResult(index, bitmap);
      // disposalMethod表示对当前帧的处理策略,这里为绘制下一帧做好准备:用背景色覆盖当前帧的绘制区域
      if (disposalMethod == DisposalMethod.DISPOSE_TO_BACKGROUND) { 
        disposeToBackground(canvas, frameInfo);
      }
    }

    AnimatedDrawableFrameInfo frameInfo = mAnimatedDrawableBackend.getFrameInfo(frameNumber);
    // 默认的绘制会进行像素混合,但是这里frameNumber帧不需要进行混合,那就需要把覆盖区域清除掉
    if (frameInfo.blendOperation == BlendOperation.NO_BLEND) { 
      disposeToBackground(canvas, frameInfo);
    }
    // Finally, we render the current frame. We don't dispose it.
    mAnimatedDrawableBackend.renderFrame(frameNumber, canvas);

上述代码,首先是找出生成frameNumber帧Bitmap时(详情可参考AnimatedImageCompositor.prepareCanvasWithClosestCachedFrame),需要从哪一帧开始重新绘制,然后就是处理各帧的Dispose Method,主要就是针对Restore to Background模式的帧,提前用背景色替换已绘制区域。

关键帧:当前帧是完整尺寸帧,并且当前帧的透明像素不需要跟前面的帧进行混合,即透明像素也会覆盖前面的像素;或者前一帧是完整尺寸帧,并且前一帧的Disposal Method为Restore to Background

AnimatedDrawableBackendImpl.renderImageDoesNotSupportScaling:负责把残差帧绘制到完整尺寸帧的指定位置,代码如下所示:

  private void renderImageDoesNotSupportScaling(Canvas canvas, AnimatedImageFrame frame) {
    // 获取残差帧的宽高和起始偏移量
    int frameWidth = frame.getWidth();
    int frameHeight = frame.getHeight();
    int xOffset = frame.getXOffset();
    int yOffset = frame.getYOffset();
    synchronized (this) {
      prepareTempBitmapForThisSize(frameWidth, frameHeight);
      // 把残差帧绘制到临时的Bitmap上
      frame.renderFrame(frameWidth, frameHeight, mTempBitmap);

      // Temporary bitmap can be bigger than frame, so we should draw only rendered area of bitmap
      mRenderSrcRect.set(0, 0, frameWidth, frameHeight);
      mRenderDstRect.set(0, 0, frameWidth, frameHeight);

      // 通过Canvas的位移变换把GIF残差帧绘制到指定位置
      canvas.save();
      canvas.translate(xOffset, yOffset); 
      canvas.drawBitmap(mTempBitmap, mRenderSrcRect, mRenderDstRect, null);
      canvas.restore();
    }
  }

AnimatedImageFrame.renderFrame则负责把GIF残差帧绘制到指定的Bitmap上(生成真实尺寸的残差帧),主要是在Native层通过giflib完成的,详情可参考gif.cpp

至此,Fresco对GIF的支持就介绍完了,不得不说,Fresco真是图片处理的一座宝库!!!

参考文档

  1. GIF格式图片详细解析
  2. GIF Disposal Method
  3. AnimatedGifs
  4. Android源码阅读—GIF解码
  5. GIF官方文档
  6. Fresco源码

作者:ltlovezh
链接:https://juejin.im/post/5c617312f265da2dd638d378
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

转载请注明:Android开发中文站 » GIF面面观

您必须 登录 才能发表评论!