解決 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 閃爍問題的詳細解決過程。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM