在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获取到持有对应Texture的ImageTextureView对象,并执行它的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,所以直接将它的configureBounds和onDraw函数拷贝出来直接用就可以了。
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中有images和duration来记录动图的所有帧和动图播放总时长。但是更新绘制,需要自己来实现。这里采用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的Drawable或UIImage,且Native的图片加载框架,肯定也会有内存缓存,那么Flutter层多个相同纹理,很可能使用的是Native中同一个Drawable或UIImage对象。那么如果停止播放,势必会导致Flutter层使用这个纹理的地方,都停止了动图的播放,就出问题了。
这里采用最简单的处理办法:
- 当这个动图Texture mount时,触发播放;
- 当这个动图Texture全都unmount时(引用计数为0时),触发停止播放;
因此,Flutter和Native的交互通道,新增两个方法:
void setVisible(int textureId);
void setInVisible(int textureId);
Native对接这两个方法,然后进行start和stop即可。