Flutter之Image加载内存共享的实现

3,924 阅读5分钟

Flutter加载图片与Glide一文中通过Glide来实现了文件在磁盘中的缓存,但Flutter加载图片、gifwebp等文件还是通过Image.file来实现的,也就因此导致了一些问题。如下。

  1. 文件(图片、gifwebp)的加载无法做到在FlutterAndroid间的内存共享,从而增加内存消耗。
  2. 通过FlutterImage来加载gif、动态webp等动画时,存在内存抖动,尤其是在列表中加载这些动画时。

先来看问题一。根据闲鱼Flutter图片框架架构演进(超详细)一文,得知可以通过Texture来实现内存在Android/iOSFlutter间的复用,所以也就可以通过Texture来解决问题一。

1、Texture的使用

Flutter中,Texture的使用特别简单,基本上仅需要传递一个textureId即可。代码实现如下。

Texture(
  filterQuality: FilterQuality.none,
  textureId: _textureId,
)

这里的textureId是由Android/iOS端通过PlatformChannel传递过来的,至于Texture的宽高是由父Widget指定的。代码实现如下。

SizedBox(
  width: _width,
  height: _height,
  child: Texture(
    filterQuality: FilterQuality.none,
    textureId: _textureId,
  ),
)

最后为了防止Texture的自动拉伸导致图片变形,所以需要通过如下方式来使用Texture

  Widget _getWidget() {
    return Container(
      width: widget.width,//widget的宽
      height: widget.height,//widget的高
      child: Center(
        child: SizedBox(
          width: _width,//image的真实宽
          height: _height,//image的真实高
          child: Texture(
            filterQuality: FilterQuality.none,
            textureId: _textureId,
          ),
        ),
      ),
    );
  }
}

2、Image的加载

Android端,本文还是以Glide为例来加载图片,当然也可以把Glide换成其他的图片加载库。

要想把图片通过TextureFlutter中展示出来,需要经历如下三个步骤。

首先是SurfaceTextureEntry对象的创建,该对象中的id方法返回的就是FlutterTexture所需要的textureId

   private fun createSurfaceTextureEntry() {
       if (surfaceTextureEntry == null) {
           //缓存的engine对象
           val engine = FlutterEngineCache.getInstance().get("xxx")
           surfaceTextureEntry = engine!!.renderer.createSurfaceTexture()
       }
   }

其次就是通过Glide来加载图片,想必该过程对于Android开发者非常熟悉了。

    private fun loadDrawable(url: String, size: DoubleArray?) {
        if (size == null || size.size < 2) return
        val width = size[0]
        val height = size[1]
        createSurfaceTextureEntry()

        val requestManager = Glide.with(context)
        var builder: RequestBuilder<Drawable>

        builder = requestManager.load(url).override(width.toInt(), height.toInt())
        
        //当调用dontAnimate后,gif仅会显示第一帧
        if (!isGif(url)) {
            builder = builder.dontAnimate()
        }
        loadFuture = builder.listener(object : RequestListener<Drawable> {
            override fun onLoadFailed(e: GlideException?, model: Any?, target: Target<Drawable>?, isFirstResource: Boolean): Boolean {
                mainHandler.post {
                    result?.error("", "", "")
                    //todo 图片加载失败还未处理
                }
                return false
            }

            override fun onResourceReady(resource: Drawable?, model: Any?, target: Target<Drawable>?, dataSource: DataSource?, isFirstResource: Boolean): Boolean {
                Log.i(TAG, "url:" + model.toString() + "加载成果...")
                // todo Glide加载gif还有优化的空间,例如采用giflib来实现解码,暂时先就用glide默认gif加载方式
                if (resource is GifDrawable /*|| resource instanceof WebpDrawable*/) {
                    drawAnimatedDrawables(resource, calculateSize(resource.getIntrinsicWidth(), resource.getIntrinsicHeight(), width, height))
                } else if (resource is BitmapDrawable) {//图片与静态webp的处理逻辑
                    drawDrawable(resource, calculateSize(resource.getIntrinsicWidth(), resource.getIntrinsicHeight(), width, height))
                }

                return false
            }
        }).submit()
    }

最后就是将BitmapDrawable展示在Flutter中。

    private fun drawDrawable(resource: Drawable, size: IntArray?) {
        if (size == null || size.size < 2) return
        if (surfaceTextureEntry != null) {
            val jsonMap = HashMap<String, String>(4)
            //Texture需要的textureId
            jsonMap["id"] = surfaceTextureEntry!!.id().toString()
            //图片的真实宽度
            jsonMap["width"] = size[0].toString()
            //图片的真实高度
            jsonMap["height"] = size[1].toString()
            val json = JSONObject(jsonMap as Map<String, String>).toString()
            mainHandler.post { result?.success(json) }
            val rect = Rect(0, 0, size[0], size[1])
            val surfaceTexture = surfaceTextureEntry!!.surfaceTexture()
            surfaceTexture.setDefaultBufferSize(size[0], size[1])
            val surface = Surface(surfaceTexture)
            resource.bounds = rect
            val canvas = surface.lockCanvas(rect)
            resource.draw(canvas)//图片的绘制
            surface.unlockCanvasAndPost(canvas)
            surface.release()

        } else {
            notImplemented()
        }
    }

经过上面三个步骤,就成功实现了图片与静态webp加载时在FlutterAndroid间的内存共享。

3、其他文件的支持

再来看对gif的支持,它与图片的加载流程基本一致,但最终的处理流程稍有不同。

    private fun drawAnimatedDrawables(resource: Drawable, size: IntArray?) {
        if (size == null || size.size < 2) return

        if (surfaceTextureEntry != null) {
            val jsonMap = HashMap<String, String>(4)
            //Texture需要的textureId
            jsonMap["id"] = surfaceTextureEntry!!.id().toString() + ""
            //图片的真实宽度
            jsonMap["width"] = size[0].toString()
            //图片的真实高度
            jsonMap["height"] = size[1].toString()

            val jsonObject = JSONObject(jsonMap as Map<String, String>)
            val json = jsonObject.toString()
            mainHandler.post { result?.success(json) }
            val rect = Rect(0, 0, size[0], size[1])

            val surfaceTexture = surfaceTextureEntry!!.surfaceTexture()
            surfaceTexture.setDefaultBufferSize(size[0], size[1])

            val surface = Surface(surfaceTexture)
            resource.bounds = rect
            if (drawableCallback == null) {
                //由于Drawable中是一个弱引用来持有callback,所以必须得有一个强引用来持有callback,从而避免callback在使用时被gc回收
                drawableCallback = DrawableCallback(surface, rect)
            }
            resource.callback = drawableCallback
            startDrawableAnim(resource)
            this.surface = surface
            this.resource = resource
        } else {
            notImplemented()
        }
    }

由于gif需要不停的绘制,所有这里利用了Drawablecallback属性,所以其具体绘制代码在DrawableCallback中。

    private inner class DrawableCallback internal constructor(private val surface: Surface?, private val rect: Rect) : Drawable.Callback {

        @Volatile
        private var isLock = false

        // 将drawable绘制到canvas中
        private fun draw(who: Drawable) {
            if (who.callback == null) return
            val canvas = surface!!.lockCanvas(rect)
            //            canvas.save();
            //清除画布
            canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
            //绘制图像
            who.draw(canvas)
            //            canvas.restore();
            surface.unlockCanvasAndPost(canvas)
        }

        override fun invalidateDrawable(who: Drawable) {
            if (who is GifDrawable) {
                if (surface == null || !surface.isValid || !who.isRunning) return
            }


            //在profile或release下,如果不加isLock判断,下面代码可能会报错
            if (isLock) return
            isLock = true
            draw(who)
            isLock = false
        }

        override fun scheduleDrawable(who: Drawable, what: Runnable, `when`: Long) {
            Log.i(TAG, "loadGif-scheduleDrawable")
        }

        override fun unscheduleDrawable(who: Drawable, what: Runnable) {
            Log.i(TAG, "loadGif-unscheduleDrawable")
        }
    }

通过上面的代码,实现了gifAndroidFlutter间的内存共享。

由于Glide不支持动态webp的加载,所以需要借助第三方库来实现,比如webpdecoder。通过webpdecoder加载动态webp返回的是一个继承自GifDrawableWebpDrawable。所以通过webpdecoder来加载动态webp的流程及代码实现与gif一样。

4、总结

通过上面的内容,实现了图片、gifwebp加载时内存在AndroidFlutter间的共享,也顺便解决了本文开始时所说的内存抖动问题。

由于将绘制交给Android端,所以FlutterTexture仅发挥展示的作用。这也导致了需要手动来控制生命周期,特别是加载gif、动态webp及在列表中使用上述方案时来加载图片、gifwebp时。比如在列表中,当gif及动态webp滑出可视区域后需要停止加载,当重新出现在可视区域时需要重新开始加载;当快速滑动时,未加载成功或还未下载成功时,需要停止;当退出Activity时需要进行资源的释放等等。

如果是iOS设备,也是可以通过Texture来实现内存的共享。

Google提供的video_player就是使用Texture来实现的视频播放,它是一个不错的学习例子。