解决Fresco加载GIF时GIF播放闪烁的问题

本文讲述了 Fresco 加载 GIF 时 GIF 播放闪烁的问题的处理流程。

最近在做需求,需求中有一个功能是显示 GIF,我们项目中目前在用的是 Fresco,版本是:2.3.0

测试过程中发现了一个 BUG:加载某些特定的表情时,表情会不停的闪烁。

我感到很纳闷儿。查不出为什么。让视觉改了好几次切图,也无法修复。视觉最后也不知道为什么,也没辙了。遂决定先抛弃 Fresco,改用 Android 系统提供的 GIF 解析方案试试。不试不知道,一试吓一跳:使用 Android 自带的方案显示会闪烁的 GIF。GIF 就不闪了。这意味着 Fresco 的解析 GIF 的逻辑有问题。Android 系统的实现方案如下,

  • 高版本用 ImageDecoder + AnimatedImageDrawable
  • 低版本用 Movie + 自定义 View

下面是使用 Android 自带方法实现的部分关键代码。

public class Test {
    public void test {
        // SDK >= 28
        if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) {
            testByDrawable();
        } else {
            // 低版本使用 Movie + 自定义 View
            startParseGif();
        }
    }
    // 高版本测试方法类
    public void testByDrawable() {
        AnimatedImageDrawable decodedAnimation = ImageDecoder
            .decodeDrawable(ImageDecoder.createSource(new File(gifPath)));
        // 给 ImageView 设置图像
        iv.setImageDrawable(decodedAnimation);

        btn_start.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                if (decodedAnimation instanceof AnimatedImageDrawable) {
                    /**
                     * 设置重复次数
                     */
                    decodedAnimation.repeatCount = 0;
                    // 开始动画前,首帧会优先展示
                    ((AnimatedImageDrawable)decodedAnimation).start();
                }
            }
        });
        btn_stop.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                if (decodedAnimation instanceof AnimatedImageDrawable) {
                    /**
                     * 设置重复次数
                     */
                    decodedAnimation.repeatCount = 0;
                    // 停止动画后,首帧会优先展示
                    ((AnimatedImageDrawable)decodedAnimation).stop();
                }
            }
        });
    }

    /**
     * 关键方法,方法节选自自定义 View。设置gif图资源
     */
    public void startParseGif() {
        byte[] bytes = getGiftBytes();
        mMovie = Movie.decodeByteArray(bytes, 0, bytes.length);
        requestLayout();
    }

    /**
     * 将gif图片转换成byte[]
     * @return byte[]
     */
    private byte[] getGiftBytes() {
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        InputStream is = new FileInputStream(gifPath);
        byte[] b = new byte[1024];
        int len;
        try {
            while ((len = is.read(b, 0, 1024)) != -1) {
                baos.write(b, 0, len);
            }
            baos.flush();
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            if (is != null) {
                try {
                    is.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
        return baos.toByteArray();
    }
    /**
     * 关键方法,方法节选自自定义 View。设置gif图资源
     */
    @Override
    protected void onDraw(Canvas canvas) {
        if (mMovie != null) {
            if (!mPaused) {
                updateAnimationTime();
                drawMovieFrame(canvas);
                invalidateView();
            } else {
                drawMovieFrame(canvas);
            }
        }
    }

    /**
     * 更新当前显示进度
     */
    private void updateAnimationTime() {
        long now = android.os.SystemClock.uptimeMillis();
        // 如果第一帧,记录起始时间
        if (mMovieStart == 0) {
            mMovieStart = now;
        }
        // 取出动画的时长
        int dur = mMovie.duration();
        if (dur == 0) {
            dur = DEFAULT_MOVIE_DURATION;
        }
        // 算出需要显示第几帧
        mCurrentAnimationTime = (int) ((now - mMovieStart) % dur);
    }
    /**
     * 绘制图片
     *
     * @param canvas 画布
     */
    private void drawMovieFrame(Canvas canvas) {
        // 设置要显示的帧,绘制即可
        mMovie.setTime(mCurrentAnimationTime);
        canvas.save();
        canvas.scale(mScale, mScale);
        mMovie.draw(canvas, mLeft / mScale, mTop / mScale);
        canvas.restore();
    }

    /**
     * 重绘
     */
    private void invalidateView() {
        if (mVisible) {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
                postInvalidateOnAnimation();
            } else {
                invalidate();
            }
        }
    }
}

视觉解决不了这个问题,而 Android 原生的解析又没有问题,那就只有是 Fresco 的解析有问题了。然后怎么办呢?只能去啃 Fresco 源码,看看能不能解决了。

寻找解码位置

在 Fresco 源码中找了一番,最终找到了 ImageDecoder 这个接口,解码图片时,Fresco 会调用这个接口的实现类。其定义如下:

public interface ImageDecoder {
    CloseableImage decode( @Nonnull EncodedImage encodedImage,
        int length, @Nonnull QualityInfo qualityInfo,
        @Nonnull ImageDecodeOptions options);
}

Fresco 提供了 DefaultImageDecoder 这个类,其实现了 ImageDecoder。未指定解码器时,Fresco 默认使用这个类解析图片。其核心代码如下:

public class DefaultImageDecoder implements ImageDecoder {

    private final ImageDecoder mAnimatedGifDecoder;
    private final ImageDecoder mAnimatedWebPDecoder;
    private final PlatformDecoder mPlatformDecoder;
    // 未指定解码器时,默认的解码器
    private final ImageDecoder mDefaultDecoder = new ImageDecoder() {
        @Override
        public CloseableImage decode(EncodedImage encodedImage,
            int length, QualityInfo qualityInfo,
            ImageDecodeOptions options) {
            // 获取图片结构
            ImageFormat imageFormat = encodedImage.getImageFormat();
            if (imageFormat == DefaultImageFormats.JPEG) {
                // JPG
                return decodeJpeg(encodedImage, length, qualityInfo, options);
            } else if (imageFormat == DefaultImageFormats.GIF) {
                // GIF
                return decodeGif(encodedImage, length, qualityInfo, options);
            } else if (imageFormat == DefaultImageFormats.WEBP_ANIMATED) {
                // WebP
                return decodeAnimatedWebp(encodedImage, length, qualityInfo, options);
            } else if (imageFormat == ImageFormat.UNKNOWN) {
                throw new DecodeException("unknown image format", encodedImage);
            }
            // 默认解码成静态图片
            return decodeStaticImage(encodedImage, options);
        }
    };

    /**
     * 在 ImagePipelineFactory 的 ImageDecoderConfig 中指定的不同图片的解码器
     * 项目中目前并未使用这个属性。
     */
    @Nullable private final Map<ImageFormat, ImageDecoder> mCustomDecoders;

    @Override
    public CloseableImage decode(final EncodedImage encodedImage,
        final int length, final QualityInfo qualityInfo,
        final ImageDecodeOptions options) {
        // 最高优先级的解码器
        // 我们在 ImageDecodeOptions 中指定的解码器
        if (options.customImageDecoder != null) {
            return options.customImageDecoder.decode(encodedImage, length, qualityInfo, options);
        }
        // 中优先级的解码器,在 ImagePipelineFactory 中的 ImageDecoderConfig 中指定的,对应图片类型的解码器
        ImageFormat imageFormat = encodedImage.getImageFormat();
        if (imageFormat == null || imageFormat == ImageFormat.UNKNOWN) {
            imageFormat = ImageFormatChecker.getImageFormat_WrapIOException(encodedImage.getInputStream());
            encodedImage.setImageFormat(imageFormat);
        }
        if (mCustomDecoders != null) {
            ImageDecoder decoder = mCustomDecoders.get(imageFormat);
            if (decoder != null) {
                return decoder.decode(encodedImage, length, qualityInfo, options);
            }
        }
        // 默认解码器,最低优先级
        return mDefaultDecoder.decode(encodedImage, length, qualityInfo, options);
    }
}

GIF 为何会闪烁

通过跟踪 decodeGif 这个方法,我最终找到了如下代码:

public class AnimatedFactoryV2Impl implements AnimatedFactory {
    public ImageDecoder getGifDecoder(final Config bitmapConfig) {
        return new ImageDecoder() {
            public CloseableImage decode(EncodedImage encodedImage, int length, 
                QualityInfo qualityInfo, ImageDecodeOptions options) {
                // 默认的 GIF 的解码位置
                return AnimatedFactoryV2Impl.this.getAnimatedImageFactory()
                    .decodeGif(encodedImage, options, bitmapConfig);
            }
        };
    }

    public ImageDecoder getWebPDecoder(final Config bitmapConfig) {
        return new ImageDecoder() {
            public CloseableImage decode(EncodedImage encodedImage, int length, 
                QualityInfo qualityInfo, ImageDecodeOptions options) {
                // 默认的 Webp 的解码位置
                return AnimatedFactoryV2Impl.this.getAnimatedImageFactory()
                    .decodeWebP(encodedImage, options, bitmapConfig);
            }
        };
    }
}

点击进入,找到实现的位置。如下:

public class AnimatedImageFactoryImpl implements AnimatedImageFactory {   
    public CloseableImage decodeGif(final EncodedImage encodedImage,
        final ImageDecodeOptions options, final Bitmap.Config bitmapConfig) {
        if (sGifAnimatedImageDecoder == null) {
            throw new UnsupportedOperationException("To encode animated gif please add the dependency " 
                + "to the animated-gif module");
        }
        // 未解码的数据
        final CloseableReference<PooledByteBuffer> bytesRef = encodedImage.getByteBufferRef();
        Preconditions.checkNotNull(bytesRef);
        try {
            final PooledByteBuffer input = bytesRef.get();
            AnimatedImage gifImage;
            // 这两个解码方法,最终都走到了 Native 层。没法继续追踪了。
            if (input.getByteBuffer() != null) {
                gifImage = sGifAnimatedImageDecoder
                    .decodeFromByteBuffer(input.getByteBuffer(), options);
            } else {
                gifImage = sGifAnimatedImageDecoder.decodeFromNativeMemory(
                    input.getNativePtr(), input.size(), options);
            }
            return getCloseableImage(options, gifImage, bitmapConfig);
        } finally {
            CloseableReference.closeSafely(bytesRef);
        }
    }
}

走到最后,发现 Fresco 默认使用的是 Native 层的方法解码。没法继续追踪了。不过还好,范围已经够窄了。去百度了一下。最终明白了Fresco 默认是借助 giflib 库在 Native 层进行解码。这说明 Fresco 自带的 giflib 库在解码 GIF 时不可靠,可能会出现解码出来的 GIF 数据有异常的情况。

解决 GIF 闪烁

需要更换方式。更换啥呢,有点没思路了。最后经大佬指点,发现项目中还有一个 Fresco 库没引入(lite库)。死马当活马医,引入试试。好家伙,引入了之后,看了下里面包括的类,瞬间燃起了希望。里面**有个关键类 GifDecoder。看了下类说明,发现这个类是基于 Android 自带的 Movie 类解码的。**Movie 类不正好是上面验证的没问题的方案吗?

/** 
 * A simple Gif decoder that uses Android's {@link Movie} class to decode Gif images.
 * 翻译:使用 Android 的 Movie 解码 Gif。
 */
public class GifDecoder implements ImageDecoder {
    // 代码省略
}

接着就试试是否可以解决 GIF 闪烁的问题。在 ImageRequest 中传入自定义解码器。使用代码如下:

ImageRequest request = ImageRequestBuilder.newBuilderWithSource(Uri.parse(filePath))
    .setResizeOptions(new ResizeOptions(width, height))
    .setProgressiveRenderingEnabled(true)
    .setRotationOptions(RotationOptions.autoRotate())
    .setImageDecodeOptions(ImageDecodeOptions.newBuilder()
         // 设置自定义的解码器
         .setCustomImageDecoder(new GifDecoder())
         // 优先加载GIF的第一帧
         .setDecodePreviewFrame(true)
         .build())
    .build();

编译,运行,Bingo,GIF 不闪了。

可能 Fresco 维护者在使用时遇到了和我一样的问题,所以才特意提供的这个包吧。

解决静态图片解析失败的问题

上面的方法虽然解决了 GIF 闪烁的问题,但是又产生了新的问题。原本显示 OK 的 JPG,无法显示了。

从上面的代码中,我明白了以下几点:

  • 默认情况下,Fresco 解码图片,最终会走到 DefaultImageDecoder 这个类。在这个类中进行解码操作。
  • DefaultImageDecoder 这个类中实现了默认的解码方式,默认解码会根据图片类型不同而采取不同的解码方式。也就是说,当我们在代码中没有指定解码器时,解码器默认会判断图片的类型,然后解码数据进行展示。JPG 显示成 JPG,GIF 显示成 GIF。
  • Fresco 为我们准备了三种不同优先级的 ImageDecoder。我们在 ImageDecodeOptions 中指定了解码器,那么所有的图片都会被这个解码器处理。如果我们仅仅指定了 GIF 的解码器,那么传入 JPG 图片时,是解析不了的。这就是导致静态图片解析失败的原因。
  • Fresco 中可以指定三种不同级别的图片解码器。其中以每次请求的解码器优先级最高(第一种方式),其次是在初始化 Fresco 时,传入的每种图片格式的解码器(第二种方式),此配置会对全局生效。默认解码器的优先级最低。

要解决图片解码失败的问题。可以用两种方式设置解码器。第二种相比于第一种,虽然设置起来方便,但是对全局生效,侵入性较强。第一种虽然比较麻烦,但是侵入性是完全可以接受的。此次解决问题,考虑了下现有项目的结构,决定采用第一种方案。

在使用第一种方案的思路下,理了一下解码器传入的流程,发现如果传GIF解码器进入默认解码器的话,流程太长,需要传入的东西太多。于是决定使用反射拿到自定义的解码器的默认实现,并替换其中的 GIF 解码流程。代码如下:

import android.text.TextUtils;

import com.facebook.animated.giflite.GifDecoder;
import com.facebook.drawee.backends.pipeline.Fresco;
import com.facebook.imageformat.DefaultImageFormats;
import com.facebook.imageformat.ImageFormat;
import com.facebook.imageformat.ImageFormatChecker;
import com.facebook.imagepipeline.common.ImageDecodeOptions;
import com.facebook.imagepipeline.core.ImagePipelineFactory;
import com.facebook.imagepipeline.decoder.DefaultImageDecoder;
import com.facebook.imagepipeline.decoder.ImageDecoder;
import com.facebook.imagepipeline.image.CloseableImage;
import com.facebook.imagepipeline.image.EncodedImage;
import com.facebook.imagepipeline.image.QualityInfo;

import java.lang.reflect.Field;

public class CompatibleGifImageDecoder implements ImageDecoder {
    private static final String TAG = "CompatibleGifImageDecoder";
    /**
     * 非 GIF 类型图片的解码器
     * */
    private ImageDecoder defaultDecoder;
    /**
     * GIF 的解码器
     * */
    private GifDecoder mGifDecoder;

    @Override
    public CloseableImage decode(EncodedImage encodedImage, int length, 
        QualityInfo qualityInfo, ImageDecodeOptions options) {
        // 拿到图片的类型,参考自 DefaultImageDecoder 源码
        ImageFormat imageFormat = encodedImage.getImageFormat();
        if (imageFormat == null || imageFormat == ImageFormat.UNKNOWN) {
            imageFormat = ImageFormatChecker.getImageFormat_WrapIOException(encodedImage.getInputStream());
            encodedImage.setImageFormat(imageFormat);
        }
        if(TextUtils.equals(DefaultImageFormats.GIF.getName(), imageFormat.getName())) {
            // GIF
            if(mGifDecoder == null) {
                mGifDecoder = new GifDecoder();
            }
            return mGifDecoder.decode(encodedImage, length, qualityInfo, options);
        } else {
            // 其他图片格式的解码器
            if(defaultDecoder != null) {
                return defaultDecoder.decode(encodedImage, length, qualityInfo, options);
            }
            // long startTime = System.nanoTime();
            // 反射拿默认解码器
            ImagePipelineFactory factory = Fresco.getImagePipelineFactory();
            Class clz = ImagePipelineFactory.class;
            try {
                // 拿到 DefaultImageDecoder 实例
                Field field = clz.getDeclaredField("mImageDecoder");
                field.setAccessible(true);
                DefaultImageDecoder defaultImageDecoder = (DefaultImageDecoder)field.get(factory);
                // 通过 DefaultImageDecoder 实例拿到类中默认的实现
                clz = DefaultImageDecoder.class;
                field = clz.getDeclaredField("mDefaultDecoder");
                field.setAccessible(true);
                defaultDecoder = (ImageDecoder)field.get(defaultImageDecoder);
                // Log.d(TAG, "拿值耗时:" + (System.nanoTime() - startTime));
                if (defaultDecoder != null) {
                    return defaultDecoder.decode(encodedImage, length, qualityInfo, options);
                }
                return null;
            } catch (Exception e) {
                e.printStackTrace();
                return null;
            }
        }
    }
}

然后在加载图片时传入,就解决了只能显示 GIF 的问题:

ImageRequest request = ImageRequestBuilder.newBuilderWithSource(Uri.parse(gifPath))
    .setRotationOptions(RotationOptions.autoRotate())
    .setImageDecodeOptions(ImageDecodeOptions.newBuilder()
                           // 设置解码器
                           .setCustomImageDecoder(new CompatibleGifImageDecoder())
                           // 优先加载GIF的第一帧
                           .setDecodePreviewFrame(true)
                           .build())
    .build();

关于反射的耗时问题

大家都说反射挺耗时的,在使用反射时,我也额外关注了下反射的耗时问题。

// 上述代码中的这两个方法,计算耗时,单位为纳秒
long startTime = System.nanoTime();
// 反射代码省略
Log.d(TAG, "拿值耗时:" + (System.nanoTime() - startTime));

经过测算。上面的代码拿值耗时为:

  • 小米10青春版:首次拿值耗时最大在 60000 纳秒左右,后续拿值耗时在 20000 纳秒左右
  • 5.0 系统的渣机,首次拿值耗时最大在 270000 纳秒左右,后续拿值耗时在 70000 纳秒左右

1 ms = 1,000,000 ns,结论:上述反射代码的全机型耗时,应该在 1ms 以下。属于可接受的范围。

解决 Release 版本图片失效的问题

上面的解决方案,在 Debug 版本上测试,是 OK 的,但是编了 Release 版本后,就失效。**大概率是混淆的问题。**看了错误日志,的确是报了找不到 DefaultImageDecoder 类的错误。于是在混淆配置文件加入以下内容。

-keep class com.facebook.imagepipeline.decoder.**{*;}

再次编译,测试 OK。

以上便是 Fresco 显示部分 GIF 闪烁问题的详细解决过程。