Flutter-通过Native和外接纹理加载图片

900 阅读11分钟

在Native和Flutter混合开发时,如果Flutter的业务面逐渐增多,且对性能指标有所要求,那么势必会遇到一个问题:使用Flutter加载图片的内存以及缓存文件,和Native环境下出现叠加导致APP的内存占用、存储占用上升的问题。 因此打通Flutter和Native的缓存机制,是非常有必要的。

而在2年前,阿里就已经推出了PowerImage,文章里面也对比了Flutter原生方案和Texture以及FFI的性能指标,只是这个库后面再继续维护了。

所以参考阿里的代码,去掉过度设计,抽象加载过程,重新设计Texture内存管理机制,实现一个低成本、易对接拓展的图片加载框架。

采用Texture纹理方案

虽然默认情况下Texture方案会存在GPU->CPU->GPU的问题,但考虑到目前的设备的整体性能,还是直接使用Texture的方案,基本满足了大部分的场景需要,然后就是管理好Texture的创建、缓存和销毁即可。

使用TextureRegistry来创建SurfaceTexture:

TextureRegistry.SurfaceTextureEntry entry = textureRegistry.createSurfaceTexture();
mTextureId = entry.id();
mSurfaceTexture = entry.surfaceTexture();
mSurface = new Surface(mSurfaceTexture);

有了textureId,Flutter层的TextureWidget就能和Native的Surface进行绑定了:

Texture(textureId: _textureId);

有了Surface就能使用Canvas进行绘制渲染了:

Canvas canvas = mSurface.lockCanvas(null);
...
mSurface.unlockCanvasAndPost(canvas);

这也就是实际将Native加载的图片绘制到Flutter的地方。

然后在不再需要时进行销毁:

mSurfaceTexture.release();
entry.release();

iOS需要注意的是,只有在主线程调用textureRegistry.textureFrameAvailable(textureId),才能使给copyPixelBuffer设置返回的纹理数据生效,Flutter层才能显示出最新的纹理。

Flutter层纹理缓存机制

只要Native层的SurfaceTexture没有被销毁,那么通过textureId就能显示对应的图片,这个也就能算是Flutter层的图片纹理缓存。

这里需要使用2层缓存的设计,一层TextureManager用来管理存在外部引用的Texture,然后使用LruCache来作为没有引用的Texture缓存管理。

当一个Texture被mount时,对应引用计数+1,unmount时计数-1,当计数为0时,意味着外部没有引用,则将这个Texture加入LruCache;当LruCache中缓存的Texture会mount时,则将其从LruCache中移除,并引用计数+1。这就意味着LruCache中保存的Texture一定是外部没有引用的。当LruCache满了,新的Texture加入,老的Texture移除,就需要调用channel,通知Native对移除的Texture进行销毁。

stateDiagram-v2

加载Uri对应的Key --> TextureManager
TextureManager --> 返回textureId,计数加1:找到缓存
TextureManager --> Lrucache:找不到缓存
Lrucache --> 返回textureId,并从Lrucache中移除: 找到缓存
返回textureId,并从Lrucache中移除 --> 将缓存加入TextureManager,计数加1
Lrucache --> 通知Native加载:找不到缓存
通知Native加载 --> 加入TextureManager,计数加1:加载成功
stateDiagram-v2

Texture.dispose --> TextureManager:Key
TextureManager --> 计数减1:找到对应记录
计数减1 --> 从TextureManager移出,加入Lrucache:计数为0

和Native图片加载框架的对接

为了适配各种不同的图片加载框架,因此将图片实际的加载过程抽象出来,由Native对接实现。不需要太多的接口,只需要

static Future<int?> createTexture();
static Future<NImageInfo> loadImage(LoadRequest request);
static void destroyTexture(int textureId)

这里对于Flutter和Native通信的MethodChannel不做说明。

createTexture

没什么好说的,只需要Native创建一个Texture,然后把textureId返回给Flutter即可。

同时,这里定义一个ImageTextureView的类,持有Texture,并且将<textureId, ImageTextureView>的对应关系记录下来,方便后面的流程函数,通过textureId能够方便的拿到对应的ImageTextureView,后面加载逻辑,就都在ImageTextureView中实现。

class ImageTextureView {
    private final TextureRegistry.SurfaceTextureEntry entry;
    private final SurfaceTexture mSurfaceTexture;
    private final Surface mSurface;

    public ImageTextureView(@NonNull TextureRegistry.SurfaceTextureEntry surfaceTextureEntry) {
        entry = surfaceTextureEntry;
        mTextureId = surfaceTextureEntry.id();
        mSurfaceTexture = surfaceTextureEntry.surfaceTexture();
        mSurface = new Surface(mSurfaceTexture);
    }
}

destroyTexture

在Flutter层都不再需要这个纹理内存时,通知Native销毁对应的Texture

public void destroy() {
    try {
        mSurfaceTexture.release();
    } catch (Exception | Error ignore) {
    }
    try {
        entry.release();
    } catch (Exception | Error ignore) {
    }
}

loadImage

将图片加载所需要的参数塞入LoadRequest中,以Map的形式传给Native。

class LoadRequest {
  /// id of the texture created by native
  int textureId;

  /// image uri
  String? uri;

  int width;

  int height;

  BoxFit fit;
}

Native在channel中收到调用后,解析出对应的参数,根据textureId获取到持有对应TextureImageTextureView对象,并执行它的loadImage方法:


class ImageTextureView {
    private final SurfaceTexture mSurfaceTexture;
    
    public void loadImage(LoadRequest loadRequest, MethodChannel.Result result) {
    }
}

然后在有了加载结果之后,将最终加载好的图片的尺寸返回给Flutter。

抽象ImageLoader

为了能够对接各种Native的加载框架,这里将loadImage的实际加载任务通过代理的方式抛出去实现,因此简单定义几个代理接口类:

public class ImageLoader {

    private static ILoaderProxy mProxy;

    public static ILoaderProxy getProxy() {
        return mProxy;
    }

    public static void setProxy(ILoaderProxy proxy) {
        mProxy = proxy;
    }
}
public interface ILoaderProxy<T> {

    T loadImage(Context appCtx, LoadRequest request, ILoadCallback target);

    void cancelLoad(T task);
}
public interface ILoadCallback {

    void onLoadSuccess(Drawable drawable);

    void onLoadFailed(String error);
}

这里在Android上选择Drawable作为加载结果的载体,而iOS上,则使用UIImage。所以只要通过ILoaderProxy实现真正的加载,然后ImageTextureView作为ILoadCallback的实现者,就能打通外部的图片加载框架和ImageTextureView了。

class ImageTextureView implements ILoadCallback {
    private final SurfaceTexture mSurfaceTexture;
    private MethodChannel.Result mResult;
    private Drawable mDrawable;
    
    public void loadImage(LoadRequest loadRequest, MethodChannel.Result result) {
        mResult = result;
        ImageLoader.getProxy().loadImage(mContext, loadRequest, this);
    }
    
    @Override
    public void onLoadSuccess(Drawable result) {
        //接收到图片加载的结果
        mDrawable = result;
    }
    
    @Override
    public void onLoadFailed(String error) {
        //图片加载失败
    }

Android-GlideLoader

Android上以Glide库为示例,对接ILoaderProxy:

public class GlideLoader implements ILoaderProxy<FutureTarget<Drawable>> {

    @Override
    public FutureTarget<Drawable> loadImage(Context appCtx, LoadRequest request,
                                            ILoadCallback target) {
        String uri = request.uri;
        int width = request.width;
        int height = request.height;
        RequestBuilder<Drawable> builder = Glide.with(appCtx).asDrawable()
                .diskCacheStrategy(DiskCacheStrategy.AUTOMATIC).load(uri).addListener(new RequestListener<Drawable>() {
                    @Override
                    public boolean onLoadFailed(@Nullable GlideException e, Object model,
                                                Target<Drawable> t,
                                                boolean isFirstResource) {
                        if (e != null) {
                            target.onLoadFailed(e.getMessage());
                        } else {
                            target.onLoadFailed("load failed.");
                        }
                        return true;
                    }

                    @Override
                    public boolean onResourceReady(Drawable resource, Object model,
                                                   Target<Drawable> t,
                                                   DataSource dataSource,
                                                   boolean isFirstResource) {
                        if (resource instanceof GifDrawable) {
                            ((GifDrawable) resource).start();
                        }
                        target.onLoadSuccess(resource);
                        return true;
                    }
                });
        if (width > 0 && height > 0) {
            return builder.submit(width, height);
        }
        return builder.submit(Target.SIZE_ORIGINAL, FutureTarget.SIZE_ORIGINAL);
    }

    @Override
    public void cancelLoad(FutureTarget<Drawable> task) {
        if (task != null) {
            if (!task.isDone()) {
                task.cancel(true);
            }
        }
    }
}

iOS-SDWebImage

iOS上以SDWebImage为示例:

class SDWebImageLoader: NSObject, ILoaderProxy {
    
    typealias T = SDWebImageCombinedOperation?
    
    func loadImage(from request: LoadRequest, callback: ILoadCallback) -> SDWebImageCombinedOperation? {
        guard let uri = request.uri else {
            callback.onFailure(error: "Missing image URI")
            return nil
        }
        
        // 使用 SDWebImage 加载图片
        return SDWebImageManager.shared.loadImage(with: URL(string: uri), options: [], progress: nil) { (image, data, error, cacheType, finished, url) in
            if let error = error {
                callback.onFailure(error: error.localizedDescription)
            } else {
                guard let image = image else {
                    callback.onFailure(error: "Failed to load image")
                    return
                }
                callback.notifyUIImage(image: image)
                let imageInfo = NImageInfo()
                imageInfo.uri = uri
                imageInfo.imageWidth = Int(image.size.width)
                imageInfo.imageHeight = Int(image.size.height)
                callback.onSuccess(imageInfo: imageInfo)
            }
        }
    }
    
    func cancelLoad(task: Any) {
        (task as! SDWebImageCombinedOperation).cancel()
    }
}

图片的绘制

既然已经拿到了加载结果,那么就需要将其绘制到Surface上。

Android绘制Drawable

在绘制Drawable之前,需要设置SurfaceTexture的BuferSize,可以直接设置为Flutter层Texture Widget的宽高,也就是loadImage传过来的LoadRequest中的宽高:

//mSurfaceW = loadRequest.width;
//mSurfaceH = loadRequest.height;
mSurfaceTexture.setDefaultBufferSize(mSurfaceW, mSurfaceH);

在Canvas上绘制Drawable非常的简单,没有什么特殊处理的话,基本就是下面这样

canvas = mSurface.lockCanvas(null);
if (canvas != null) {
    mPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR));
    canvas.drawPaint(mPaint);
    mPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC));
    drawable.draw(canvas);
    mSurface.unlockCanvasAndPost(canvas);
}

为了充分利用Drawable的特性,以及为了后面对动图的支持,这里让ImageTextureView去实现Drawable.Callback

public interface Callback {

    void invalidateDrawable(@NonNull Drawable who);

    void scheduleDrawable(@NonNull Drawable who, @NonNull Runnable what, long when);

    void unscheduleDrawable(@NonNull Drawable who, @NonNull Runnable what);
}

invalidateDrawable中,实现在Canvas上绘制Drawable。这样的话,也就只需要在onLoadSuccess中调用invalidateDrawable或者drawable.invalidateSelf()触发绘制即可:

    @Override
    public void onLoadSuccess(Drawable result) {
        //接收到图片加载的结果
        mDrawable = result;
        mDrawable.setCallback(this);
        invalidateDrawable(mDrawable); //mDrawable.invalidateSelf();
    }

iOS绘制UIImage

iOS上只要把UIImage转成CVPixelBuffer即可:

func imageToPixelBuffer(uiimage: UIImage) -> CVPixelBuffer? {
    let image = uiimage.cgImage
    let imageWidth = image.width
    let imageHeight = image.height
    
    var pixelBuffer: CVPixelBuffer?
    let options: [String: Any] = [
        kCVPixelBufferIOSurfacePropertiesKey as String: [:],
        kCVPixelBufferCGImageCompatibilityKey as String: false,
        kCVPixelBufferCGBitmapContextCompatibilityKey as String: false
    ]
    
    let status = CVPixelBufferCreate(kCFAllocatorDefault, imageWidth, imageHeight, kCVPixelFormatType_32BGRA, options as CFDictionary, &pixelBuffer)
    
    if status != kCVReturnSuccess {
        return nil
    }
    
    CVPixelBufferLockBaseAddress(pixelBuffer!, CVPixelBufferLockFlags(rawValue: 0))
    let data = CVPixelBufferGetBaseAddress(pixelBuffer!)
    
    let colorSpace = CGColorSpaceCreateDeviceRGB()
    let context = CGContext(data: data, width: imageWidth, height: imageHeight, bitsPerComponent: 8, bytesPerRow: CVPixelBufferGetBytesPerRow(pixelBuffer!), space: colorSpace, bitmapInfo: kCGBitmapByteOrder32Host.rawValue | CGImageAlphaInfo.premultipliedFirst.rawValue)
    
    context?.draw(image, in: CGRect(x: 0, y: 0, width: imageWidth, height: imageHeight))
    
    CVPixelBufferUnlockBaseAddress(pixelBuffer!, CVPixelBufferLockFlags(rawValue: 0))
    
    return pixelBuffer
}

BoxFit

能够将图片绘制到纹理上之后,就该处理正确显示图片效果的问题。也就是将图片的哪一部分区域,绘制到画布的哪个区域里,实际就是Flutter中BoxFit的显示效果。

Android

在Android上处理Fit的效果,主要是依靠canvas.concat(Matrix),这个的具体实现,可以直接偷懒,Android的ImageView,支持各种scaleType,图片载体也是Drawable,所以直接将它的configureBoundsonDraw函数拷贝出来直接用就可以了。

iOS

在iOS上,则是采用转换成CopyPixelBuffer之前,对加载得到的UIImage进行处理,重新生成一个Surface尺寸的UIImage,这个UIImage的效果就是BoxFit后的效果。

private func fitTransform(_ image: UIImage) -> UIImage {
    let viewSize = CGSize(width: self.loadRequest!.width!, height:  self.loadRequest!.height!)
    let imageSize = image.size
    switch self.loadRequest?.fit {
    case .none?:
        // 填充模式:裁剪图像以填充整个视图
        // 创建一个新的图像上下文
        UIGraphicsBeginImageContextWithOptions(CGSize(width: viewSize.width, height: viewSize.height), false, image.scale)
        
        // 绘制图像到新的上下文
        image.draw(in: CGRect(x: (viewSize.width - imageSize.width) / 2, y: (viewSize.height - imageSize.height) / 2, width: imageSize.width, height: imageSize.height))
        
        // 从上下文获取新的图像
        let newImage = UIGraphicsGetImageFromCurrentImageContext()
        
        // 结束图像上下文
        UIGraphicsEndImageContext()
        
        return newImage ?? image
    case .contain:
        // 包含模式:缩放图像以完全显示在视图中
        // 计算缩放比例
        let scaleWidth = viewSize.width / imageSize.width
        let scaleHeight = viewSize.height / imageSize.height
        let scale = min(scaleWidth, scaleHeight)
        
        // 计算缩放后的图像尺寸
        let newWidth = imageSize.width * scale
        let newHeight = imageSize.height * scale
        
        // 创建一个新的图像上下文
        UIGraphicsBeginImageContextWithOptions(CGSize(width: viewSize.width, height: viewSize.height), false, image.scale)
        
        // 绘制图像到新的上下文
        image.draw(in: CGRect(x: (viewSize.width - newWidth) / 2, y: (viewSize.height - newHeight) / 2, width: newWidth, height: newHeight))
        
        // 从上下文获取新的图像
        let newImage = UIGraphicsGetImageFromCurrentImageContext()
        
        // 结束图像上下文
        UIGraphicsEndImageContext()
        
        return newImage ?? image
    case .cover:
        // 覆盖模式:缩放图像以覆盖整个视图,可能会裁剪部分图像
        // 计算缩放比例
        let scaleWidth = viewSize.width / imageSize.width
        let scaleHeight = viewSize.height / imageSize.height
        let scale = max(scaleWidth, scaleHeight)
        
        // 计算裁剪后的图像尺寸
        let newWidth = imageSize.width * scale
        let newHeight = imageSize.height * scale
        
        // 创建一个新的图像上下文
        UIGraphicsBeginImageContextWithOptions(CGSize(width: viewSize.width, height: viewSize.height), false, image.scale)
        
        // 绘制图像到新的上下文
        image.draw(in: CGRect(x: (viewSize.width - newWidth) / 2, y: (viewSize.height - newHeight) / 2, width: newWidth, height: newHeight))
        
        // 从上下文获取新的图像
        let newImage = UIGraphicsGetImageFromCurrentImageContext()
        
        // 结束图像上下文
        UIGraphicsEndImageContext()
        
        return newImage ?? image
    case .fitWidth:
        // 适应宽度模式:缩放图像以适应视图的宽度,高度可能会超出视图
        // 计算缩放比例
        let scaleWidth = viewSize.width / imageSize.width
        let scale = scaleWidth
        
        // 计算缩放后的图像尺寸
        let newWidth = imageSize.width * scale
        let newHeight = imageSize.height * scale
        
        // 创建一个新的图像上下文
        UIGraphicsBeginImageContextWithOptions(CGSize(width: viewSize.width, height: viewSize.height), false, image.scale)
        
        // 绘制图像到新的上下文
        image.draw(in: CGRect(x: (viewSize.width - newWidth) / 2, y: (viewSize.height - newHeight) / 2, width: newWidth, height: newHeight))
        
        // 从上下文获取新的图像
        let newImage = UIGraphicsGetImageFromCurrentImageContext()
        
        // 结束图像上下文
        UIGraphicsEndImageContext()
        
        return newImage ?? image
    case .fitHeight:
        // 适应高度模式:缩放图像以适应视图的高度,宽度可能会超出视图
        // 计算缩放比例
        let scaleHeight = viewSize.height / imageSize.height
        let scale = scaleHeight
        
        // 计算缩放后的图像尺寸
        let newWidth = imageSize.width * scale
        let newHeight = imageSize.height * scale
        
        // 创建一个新的图像上下文
        UIGraphicsBeginImageContextWithOptions(CGSize(width: viewSize.width, height: viewSize.height), false, image.scale)
        
        // 绘制图像到新的上下文
        image.draw(in: CGRect(x: (viewSize.width - newWidth) / 2, y: (viewSize.height - newHeight) / 2, width: newWidth, height: newHeight))
        
        // 从上下文获取新的图像
        let newImage = UIGraphicsGetImageFromCurrentImageContext()
        
        // 结束图像上下文
        UIGraphicsEndImageContext()
        
        return newImage ?? image
    case .scaleDown:
        // 缩小模式:如果图像大于视图,则缩小图像以适应视图
        // 计算缩放比例
        let scaleWidth = viewSize.width / imageSize.width
        let scaleHeight = viewSize.height / imageSize.height
        let scale = min(scaleWidth, scaleHeight)
        
        // 如果缩放比例小于1,则进行缩放
        if scale < 1 {
            // 计算缩放后的图像尺寸
            let newWidth = imageSize.width * scale
            let newHeight = imageSize.height * scale
            
            // 创建一个新的图像上下文
            UIGraphicsBeginImageContextWithOptions(CGSize(width: viewSize.width, height: viewSize.height), false, image.scale)
            
            // 绘制图像到新的上下文
            image.draw(in: CGRect(x: (viewSize.width - newWidth) / 2, y: (viewSize.height - newHeight) / 2, width: newWidth, height: newHeight))
            
            // 从上下文获取新的图像
            let newImage = UIGraphicsGetImageFromCurrentImageContext()
            
            // 结束图像上下文
            UIGraphicsEndImageContext()
            
            return newImage ?? image
        } else {
            // 如果图像小于或等于视图
            // 创建一个新的图像上下文
            UIGraphicsBeginImageContextWithOptions(CGSize(width: viewSize.width, height: viewSize.height), false, image.scale)
            
            // 绘制图像到新的上下文
            image.draw(in: CGRect(x: (viewSize.width - imageSize.width) / 2, y: (viewSize.height - imageSize.height) / 2, width: imageSize.width, height: imageSize.height))
            
            // 从上下文获取新的图像
            let newImage = UIGraphicsGetImageFromCurrentImageContext()
            
            // 结束图像上下文
            UIGraphicsEndImageContext()
            
            return newImage ?? image
        }
    default:
        // 如果 fit 模式是 .fill则直接返回原始图像
        return image
    }
}

对动图的支持

既然绘制都是由Native自己来了,那么对动图的支持其实就是不停的绘制最新的帧到纹理上即可。

Android

在Android上,Native对于动图的Drawable,有子类AnimationDrawable进行支持。因为我们将ImageTextureView绑定作为Drawable.Callback,所以只需要调用AnimationDrawable.start,就能直接支持动图的绘制,不需要做其他处理。

iOS

而在iOS上,UIImage中有imagesduration来记录动图的所有帧和动图播放总时长。但是更新绘制,需要自己来实现。这里采用Timer.scheduledTimer来实现一个定时触发的逻辑即可:

private func showNextFrame() {
    if (self.animatedPlayTimer == nil) {
        self.animatedPlayTimer = Timer.scheduledTimer(withTimeInterval: self.frameInterval!, repeats: true) { timer in
            self.index += 1
            if (self.index >= self.animatedImages!.count) {
                self.index = 0
            }
            let frame = self.animatedImages![self.index]
            let fitImage = self.fitTransform(frame)
            if let cgImage = fitImage.cgImage {
                let p = self.imageToPixelBuffer(image: cgImage)
                self.notifyTextureUpdate(pixelBuffer: p!)
            }
        }
    }
}

private func notifyTextureUpdate(pixelBuffer: CVPixelBuffer) {
    self.pixelBuffer = pixelBuffer
    //判断是否是主线程,如果不是,则切换到主线程调用
    if (Thread.isMainThread) {
        guard let textureId = self.textureId else {
            return
        }
        self.textureRegistry.textureFrameAvailable(textureId)
    } else {
        DispatchQueue.main.async {
            guard let textureId = self.textureId else {
                return
            }
            self.textureRegistry.textureFrameAvailable(textureId)
        }
    }
}
对于动图播放的控制

既然支持的动图,那肯定需要考虑,何时触发动图的播放和停止。因为动图本身的载体还是Native的DrawableUIImage,且Native的图片加载框架,肯定也会有内存缓存,那么Flutter层多个相同纹理,很可能使用的是Native中同一个DrawableUIImage对象。那么如果停止播放,势必会导致Flutter层使用这个纹理的地方,都停止了动图的播放,就出问题了。

这里采用最简单的处理办法:

  1. 当这个动图Texture mount时,触发播放;
  2. 当这个动图Texture全都unmount时(引用计数为0时),触发停止播放;

因此,Flutter和Native的交互通道,新增两个方法:

void setVisible(int textureId);
void setInVisible(int textureId);

Native对接这两个方法,然后进行startstop即可。

Demo效果

single.gif

源码

NImage