使用 Glide,如何将 GifDrawable 的每一帧作为 Bitmap 遍历?

Using Glide, how can I go over each frame of GifDrawable, as Bitmap?

背景

在动态壁纸中,我有一个 Canvas 实例,我希望将 GIF/WEBP 内容绘制到其中,它是通过 Glide 加载的。

我希望用 Glide 来做的原因是,它比我过去为同样的事情找到的解决方案提供了一些优势 ( , repository here) :

  1. 电影的使用限制我只能使用 GIF。使用 Glide 我还可以支持 WEBP 动画
  2. 电影的使用似乎效率低下,因为它没有告诉我帧之间等待的时间,所以我必须选择我想尝试使用的 FPS。它也在 Android P.
  3. 上被弃用
  4. Glide 或许能够简化各种缩放的处理。
  5. Glide 可能不会像原始代码那样崩溃,并且可能提供更好的机制控制。

问题

Glide 似乎已优化为仅适用于正常 UI(视图)。它有一些基本功能,但对于我正在尝试做的事情来说,最重要的功能似乎是私有的。

我发现了什么

我用官方Glide library (v 3.8.0) for GIF loading, and GlideWebpDecoder加载WEBP(同版本)

加载其中每一个的基本调用如下:

动图:

    GlideApp.with(this).asGif()
            .load("https://res.cloudinary.com/demo/image/upload/bored_animation.gif")
            .into(object : SimpleTarget<GifDrawable>() {
                override fun onResourceReady(resource: GifDrawable, transition: Transition<in GifDrawable>?) {
                    //example of usage:
                    imageView.setImageDrawable(resource)
                    resource.start()
                }
            })

WEBP:

        GlideApp.with(this).asDrawable()
                .load("https://res.cloudinary.com/demo/image/upload/fl_awebp/bored_animation.webp")
//                .optionalTransform(WebpDrawable::class.java, WebpDrawableTransformation(CircleCrop()))
                .into(object : SimpleTarget<Drawable>() {
                    override fun onResourceReady(resource: Drawable, transition: Transition<in Drawable>?) {
                        //example of usage:
                        imageView.setImageDrawable(resource)
                        if (resource is Animatable) {
                            (resource as Animatable).start()
                        }
                    }
                })

现在,记住我并没有真正的 ImageView,而我只有一个 Canvas,我通过 surfaceHolder.lockCanvas() 调用获得它。

                    resource.callback = object : Drawable.Callback {
                        override fun invalidateDrawable(who: Drawable) {
                            Log.d("AppLog", "frame ${resource.frameIndex}/${resource.frameCount}")
                        }

                    }

但是,当我尝试获取用于当前帧的位图时,我找不到正确的函数。

我试过这个例子(这只是一个例子,看看它是否可以与 canvas 一起工作):

    val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888)
    val canvas = Canvas(bitmap)

    ...
    resource.draw(canvas)

但是它好像没有把内容绘制到位图中,我想是因为它的draw函数有这几行代码:

  @Override
  public void draw(@NonNull Canvas canvas) {
    if (isRecycled) {
      return;
    }

    if (applyGravity) {
      Gravity.apply(GRAVITY, getIntrinsicWidth(), getIntrinsicHeight(), getBounds(), getDestRect());
      applyGravity = false;
    }

    Bitmap currentFrame = state.frameLoader.getCurrentFrame();
    canvas.drawBitmap(currentFrame, null, getDestRect(), getPaint());
  }

然而 getDestRect() returns 一个 0 大小的矩形,我找不到如何修改它:它也是私有的,我没有看到任何改变它的东西。

问题

  1. 假设我得到了我想使用的 Drawable (GIF/WEBP),我怎样才能得到它可以产生的每一帧(而不仅仅是第一帧),并绘制它进入canvas(当然,帧之间的时间量合适)?

  2. 我能否也以某种方式设置缩放类型,就像在 ImageView 上一样(center-crop、fit-center、center-inside...)?

  3. 是否有更好的选择?也许假设我有一个 GIF/WEBP 动画文件,Glide 是否允许我只使用它的解码器? this library 之类的东西?


编辑:

我找到了一个不错的替代库,它允许一帧接一帧地加载 GIF,here。它在逐帧加载方面似乎效率不高,但它是开源的,可以轻松修改以更好地工作。

在 Glide 上做它可能会更好,因为它也支持缩放和 WEBP 加载。

我做了一个 POC (link here) 表明它确实可以逐帧进行,等待它们之间的正确时间。如果有人像我一样成功,但在 Glide(当然是最新版本的 Glide)上,我会接受答案并给予赏金。这是代码:

**GifPlayer.kt ,基于 NsGifPlayer.java **

open class GifPlayer {
    companion object {
        const val ENABLE_CACHING = false
        const val MEM_CACHE_SIZE_PERCENT = 0.8
        fun calculateMemCacheSize(percent: Double): Long {
            if (percent < 0.05f || percent > 0.8f) {
                throw IllegalArgumentException("setMemCacheSizePercent - percent must be " + "between 0.05 and 0.8 (inclusive)")
            }
            val maxMem = Runtime.getRuntime().maxMemory()
//            Log.d("AppLog", "max mem :$maxMem")
            return Math.round(percent * maxMem)
        }
    }

    private val uiHandler = Handler(Looper.getMainLooper())
    private var playerHandlerThread: HandlerThread? = null
    private var playerHandler: Handler? = null
    private val gifDecoder: GifDecoder = GifDecoder()
    private var currentFrame: Int = -1
    var listener: GifListener? = null
    var state: State = State.IDLE
        private set
    private val playRunnable: Runnable
    private val frames = HashMap<Int, AnimationFrame>()
    private var currentUsedMemByCache = 0L

    class AnimationFrame(val bitmap: Bitmap, val duration: Long)

    enum class State {
        IDLE, PAUSED, PLAYING, RECYCLED, ERROR
    }

    interface GifListener {
        fun onGotFrame(bitmap: Bitmap, frame: Int, frameCount: Int)

        fun onError()
    }

    init {
        val memCacheSize = if (ENABLE_CACHING) calculateMemCacheSize(MEM_CACHE_SIZE_PERCENT) else 0L
//        Log.d("AppLog", "memCacheSize:$memCacheSize = ${memCacheSize / 1024L} MB")
        playRunnable = object : Runnable {
            override fun run() {
                val frameCount = gifDecoder.frameCount
                gifDecoder.setCurIndex(currentFrame)
                currentFrame = (currentFrame + 1) % frameCount
                val animationFrame = if (ENABLE_CACHING) frames[currentFrame] else null
                if (animationFrame != null) {
//                    Log.d("AppLog", "cache hit - $currentFrame")
                    val bitmap = animationFrame.bitmap
                    val delay = animationFrame.duration
                    uiHandler.post {
                        listener?.onGotFrame(bitmap, currentFrame, frameCount)
                        if (state == State.PLAYING)
                            playerHandler!!.postDelayed(this, delay)
                    }
                } else {
//                    Log.d("AppLog", "cache miss - $currentFrame fill:${frames.size}/$frameCount")
                    val bitmap = gifDecoder.bitmap
                    val delay = gifDecoder.decodeNextFrame().toLong()
                    if (ENABLE_CACHING) {
                        val bitmapSize = BitmapCompat.getAllocationByteCount(bitmap)
                        if (bitmapSize + currentUsedMemByCache < memCacheSize) {
                            val cacheBitmap = Bitmap.createBitmap(bitmap)
                            frames[currentFrame] = AnimationFrame(cacheBitmap, delay)
                            currentUsedMemByCache += bitmapSize
                        }
                    }
                    uiHandler.post {
                        listener?.onGotFrame(bitmap, currentFrame, frameCount)
                        if (state == State.PLAYING)
                            playerHandler!!.postDelayed(this, delay)
                    }
                }
            }
        }
    }

    @Suppress("unused")
    protected fun finalize() {
        stop()
    }

    @UiThread
    fun start(filePath: String): Boolean {
        if (state != State.IDLE && state != State.ERROR)
            return false
        currentFrame = -1
        state = State.PLAYING
        playerHandlerThread = HandlerThread("GifPlayer")
        playerHandlerThread!!.start()
        val looper = playerHandlerThread!!.looper
        playerHandler = Handler(looper)
        playerHandler!!.post {
            try {
                gifDecoder.load(filePath)
            } catch (e: Exception) {
                uiHandler.post {
                    state = State.ERROR
                    listener?.onError()
                }
                return@post
            }

            val bitmap = gifDecoder.bitmap
            if (bitmap != null) {
                playRunnable.run()
            } else {
                frames.clear()
                gifDecoder.recycle()
                uiHandler.post {
                    state = State.ERROR
                    listener?.onError()
                }
                return@post
            }
        }
        return true
    }

    @UiThread
    fun stop(): Boolean {
        if (state == State.IDLE)
            return false
        state = State.IDLE
        playerHandler!!.removeCallbacks(playRunnable)
        playerHandlerThread!!.quit()
        playerHandlerThread = null
        playerHandler = null
        return true
    }

    @UiThread
    fun pause(): Boolean {
        if (state != State.PLAYING)
            return false
        state = State.PAUSED
        playerHandler?.removeCallbacks(playRunnable)
        return true
    }

    @UiThread
    fun resume(): Boolean {
        if (state != State.PAUSED)
            return false
        state = State.PLAYING
        playerHandler?.removeCallbacks(playRunnable)
        playRunnable.run()
        return true
    }

    @UiThread
    fun toggle(): Boolean {
        when (state) {
            State.PLAYING -> pause()
            State.PAUSED -> resume()
            else -> return false
        }
        return true
    }

}

MainActivity.kt

class MainActivity : AppCompatActivity() {
    private lateinit var player: GifPlayer

    @SuppressLint("StaticFieldLeak")
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        val file = File(this@MainActivity.filesDir, "file.gif")
        object : AsyncTask<Void, Void, Void?>() {

            override fun doInBackground(vararg params: Void?): Void? {
                val inputStream = resources.openRawResource(R.raw.fast)
                if (!file.exists()) {
                    file.parentFile.mkdirs()
                    val outputStream = FileOutputStream(file)
                    val buf = ByteArray(1024)
                    var len: Int
                    while (true) {
                        len = inputStream.read(buf)
                        if (len <= 0)
                            break
                        outputStream.write(buf, 0, len)
                    }
                    inputStream.close()
                    outputStream.close()
                }
                return null
            }

            override fun onPostExecute(result: Void?) {
                super.onPostExecute(result)
                player.setFilePath(file.absolutePath)
                player.start()
            }

        }.execute()

        player = GifPlayer(object : GifPlayer.GifListener {
            override fun onGotFrame(bitmap: Bitmap, frame: Int, frameCount: Int) {
                Log.d("AppLog", "onGotFrame $frame/$frameCount")
                imageView.post {
                    imageView.setImageBitmap(bitmap)
                }
            }

            override fun onError() {
                Log.d("AppLog", "onError")
            }
        })
    }

    override fun onStart() {
        super.onStart()
        player.resume()
    }

    override fun onStop() {
        super.onStop()
        player.pause()
    }

    override fun onDestroy() {
        super.onDestroy()
        player.stop()
    }
}

当我想在 Glide 中加载 gif 时显示预览而不是动画时,我有类似的要求。

我的解决方案是从 GifDrawable 中获取第一帧并将其呈现为整个可绘制对象。可以采用相同的方法来显示其他帧(或导出等)

DrawableRequestBuilder builder = Glide.with(ctx).load(someUrl);
builder.listener(new RequestListener<String, GlideDrawable>() {
    @Override
    public boolean onException(Exception e, String model, Target<GlideDrawable> target, boolean isFirstResource) {
        return false;
    }

    @Override
    public boolean onResourceReady(GlideDrawable resource, String model, Target<GlideDrawable> target, boolean isFromMemoryCache, boolean isFirstResource) {
        if (resource.isAnimated()) {
            target.onResourceReady(new GlideBitmapDrawable(null, ((GifDrawable) resource).getFirstFrame()), null);
        }
        return handled;
    }
});
builder.into(mImageView);

您可以通过直接访问附加到 GifDrawable 的 decoder 来推进动画以获取关键帧或通过索引在回调中获取它们。或者在准备就绪时在可绘制对象上设置 Callback(实际 class 名称)。它将被 onFrameReady 调用(每次为您提供可绘制对象中的当前帧)。 gif 可绘制对象 class 已经管理位图池。

GifDrawable 准备就绪后,使用以下方法循环帧:

GifDrawable gd = (GifDrawable) resource;
Bitmap b = gd.getDecoder().getNextFrame();  

请注意,如果您正在使用解码器,那么您应该从我上面提到的 onResourceReady 回调中真正做到这一点。我之前尝试这样做时遇到间歇性问题。

如果你让解码器运行自动,你可以获得帧的回调

gifDrawable.setCallback(new Drawable.Callback() {
    @Override
    public void invalidateDrawable(@NonNull Drawable who) {
        //NOTE: this method is called each time the GifDrawable updates itself with a new frame
        //who.draw(canvas); //if you already have a canvas
        // //if you really want a bitmap
    }

    @Override public void scheduleDrawable(@NonNull Drawable who, @NonNull Runnable what, long when) { /* ignore */ }
    @Override public void unscheduleDrawable(@NonNull Drawable who, @NonNull Runnable what) { /* ignore */ }
});

当时这是最好的方法。因为已经一年多了,我不能保证现在没有更有效的方法来做到这一点。

我使用的库版本是Glide 3.7.0。最新版本 4.7.+ 中的访问受到限制,但我不确定您需要回到多远才能使用我的方法。

无论如何,我们将使用 Glide 中未记录的方法,我希望 Glide 团队有一天能做到 public。您需要对 Java 反射有一点经验 :) 下面是从 GIF 文件中提取位图的代码台:

ArrayList bitmaps = new ArrayList<>();
Glide.with(AppObj.getContext())
            .asGif()
            .load(GIF_PATH)
            .into(new SimpleTarget<GifDrawable>() {
                @Override
                public void onResourceReady(@NonNull GifDrawable resource, @Nullable Transition<? super GifDrawable> transition) {
                    try {
                        Object GifState = resource.getConstantState();
                        Field frameLoader = GifState.getClass().getDeclaredField("frameLoader");
                        frameLoader.setAccessible(true);
                        Object gifFrameLoader = frameLoader.get(GifState);

                        Field gifDecoder = gifFrameLoader.getClass().getDeclaredField("gifDecoder");
                        gifDecoder.setAccessible(true);
                        StandardGifDecoder standardGifDecoder = (StandardGifDecoder) gifDecoder.get(gifFrameLoader);
                        for (int i = 0; i < standardGifDecoder.getFrameCount(); i++) {
                            standardGifDecoder.advance();
                            bitmaps.add(standardGifDecoder.getNextFrame());
                        }
                    } catch (Exception ex) {
                        ex.printStackTrace();
                    }

                }
            });

}

好的,我找到了 3 种可能的解决方案:

  1. 如果您希望在播放 Drawable 时出现帧,您可以这样做:
private fun testGif() {
    val drawable = GlideApp.with(applicationContext).load(R.raw.test_gif).skipMemoryCache(true)
            .diskCacheStrategy(DiskCacheStrategy.NONE).submit().get() as GifDrawable
    val bitmap = Bitmap.createBitmap(drawable.intrinsicWidth, drawable.intrinsicHeight, Bitmap.Config.ARGB_8888)
    val canvas = Canvas(bitmap)
    drawable.setBounds(0, 0, bitmap.width, bitmap.height)
    drawable.setLoopCount(1)
    val callback = object : CallbackEx() {
        override fun invalidateDrawable(who: Drawable) {
            super.invalidateDrawable(who)
            val gif = who as GifDrawable
            canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
            who.draw(canvas)
            //image is available here on the bitmap object
            Log.d("AppLog", "frameIndex:${gif.frameIndex} frameCount:${gif.frameCount} firstFrame:${gif.firstFrame}")
        }
    }
    drawable.callback = callback
    drawable.start()
}

private fun testWebp() {
    val drawable = GlideApp.with(applicationContext).load(R.raw.test_webp).skipMemoryCache(true)
            .diskCacheStrategy(DiskCacheStrategy.NONE)
            .submit().get() as WebpDrawable
    val bitmap = Bitmap.createBitmap(drawable.intrinsicWidth, drawable.intrinsicHeight, Bitmap.Config.ARGB_8888)
    val canvas = Canvas(bitmap)
    drawable.setBounds(0, 0, bitmap.width, bitmap.height)
    drawable.loopCount = 1
    val callback = object : CallbackEx() {
        override fun invalidateDrawable(who: Drawable) {
            val webp = who as WebpDrawable
            canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
            who.draw(canvas)
            //image is available here on the bitmap object
            Log.d("AppLog", "frameIndex:${webp.frameIndex} frameCount:${webp.frameCount} firstFrame:${webp.firstFrame}")
        }
    }
    drawable.callback = callback
    drawable.start()
}
  1. 如果您对从 Glide 中得到的东西没有问题,您可以这样使用:
private fun testWebp2() {
    val drawable = GlideApp.with(applicationContext).load(R.raw.test_webp).skipMemoryCache(true)
            .diskCacheStrategy(DiskCacheStrategy.NONE)
            .submit().get() as WebpDrawable
    drawable.constantState
    val state = drawable.constantState as Drawable.ConstantState
    val frameLoader: Field = state::class.java.getDeclaredField("frameLoader")
    frameLoader.isAccessible = true
    @Suppress("NULLABILITY_MISMATCH_BASED_ON_JAVA_ANNOTATIONS")
    val webpFrameLoader = frameLoader.get(state) as WebpFrameLoader
    val webpDecoder: Field = webpFrameLoader.javaClass.getDeclaredField("webpDecoder")
    webpDecoder.isAccessible = true
    val standardGifDecoder = webpDecoder.get(webpFrameLoader) as GifDecoder
    Log.d("AppLog", "got ${standardGifDecoder.frameCount} frames:")
    for (i in 0 until standardGifDecoder.frameCount) {
        val delay = standardGifDecoder.nextDelay
        val bitmap = standardGifDecoder.nextFrame
        //image is available here on the bitmap object
        Log.d("AppLog", "${standardGifDecoder.currentFrameIndex} - $delay ${bitmap?.width}x${bitmap?.height}")
        standardGifDecoder.advance()
    }
    Log.d("AppLog", "done")
}

private fun testGif2() {
    val drawable = GlideApp.with(applicationContext).load(R.raw.test_gif).skipMemoryCache(true)
            .diskCacheStrategy(DiskCacheStrategy.NONE).submit().get() as GifDrawable
    val state = drawable.constantState as Drawable.ConstantState
    val frameLoader: Field = state::class.java.getDeclaredField("frameLoader")
    frameLoader.isAccessible = true
    val gifFrameLoader: Any = frameLoader.get(state)
    val gifDecoder: Field = gifFrameLoader.javaClass.getDeclaredField("gifDecoder")
    gifDecoder.isAccessible = true
    val standardGifDecoder = gifDecoder.get(gifFrameLoader) as StandardGifDecoder
    Log.d("AppLog", "got ${standardGifDecoder.frameCount} frames:")
    val parent = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "gifFrames")
    parent.mkdirs()
    for (i in 0 until standardGifDecoder.frameCount) {
        val file = File(parent, "${String.format("%07d", i)}.png")
        val delay = standardGifDecoder.nextDelay
        val bitmap = standardGifDecoder.nextFrame
        if (bitmap == null) {
            Log.d("AppLog", "error getting frame")
            break
        }
        //image is available here on the bitmap object
        Log.d("AppLog", "${standardGifDecoder.currentFrameIndex} - $delay ${bitmap?.width}x${bitmap?.height}")
        standardGifDecoder.advance()
    }
    Log.d("AppLog", "done")
}
  1. 最后,如果您想要更多 low-level 解决方案,您可以这样做:
    private fun testGif3() {
        // found from GifDrawableResource StreamGifDecoder StandardGifDecoder
        val data = resources.openRawResource(R.raw.test_gif).readBytes()
        val byteBuffer = ByteBuffer.wrap(data)
        val glide = GlideApp.get(this)
        val gifBitmapProvider = GifBitmapProvider(glide.bitmapPool,  glide.arrayPool)
        val header = GifHeaderParser().setData(byteBuffer).parseHeader()
        val standardGifDecoder = StandardGifDecoder(gifBitmapProvider, header, byteBuffer, 1)
        //alternative, without getting header and needing sample size:
//        val standardGifDecoder = StandardGifDecoder(gifBitmapProvider)
//        standardGifDecoder.read(data)
        val frameCount = standardGifDecoder.frameCount
        standardGifDecoder.advance()
        for (i in 0 until frameCount) {
            val delay = standardGifDecoder.nextDelay
            val bitmap = standardGifDecoder.nextFrame
            //bitmap ready here
            standardGifDecoder.advance()
        }
    }

    private fun testWebP3() {
        //found from  ByteBufferWebpDecoder  StreamWebpDecoder  WebpDecoder
        val data = resources.openRawResource(R.raw.test_webp).readBytes()
        val cacheStrategy: WebpFrameCacheStrategy? = Options().get(WebpFrameLoader.FRAME_CACHE_STRATEGY)
        val glide = GlideApp.get(this)
        val bitmapPool = glide.bitmapPool
        val arrayPool = glide.arrayPool
        val gifBitmapProvider = GifBitmapProvider(bitmapPool, arrayPool)
        val webpImage = WebpImage.create(data)
        val sampleSize = 1
        val webpDecoder = WebpDecoder(gifBitmapProvider, webpImage, ByteBuffer.wrap(data), sampleSize, cacheStrategy)
        val frameCount = webpDecoder.frameCount
        webpDecoder.advance()
        for (i in 0 until frameCount) {
            val delay = webpDecoder.nextDelay
            val bitmap = webpDecoder.nextFrame
            //bitmap ready here
            webpDecoder.advance()
        }
    }