最近在做需求,需求中有一個功能是顯示 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 閃爍問題的詳細解決過程。
