Flutter 多引擎渲染,外接纹理实践

4,083 阅读7分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第4天,点击查看活动详情

Flutter 多引擎系列: 《Flutter 多引擎渲染,在稿定 App 的实践》 等等,专栏里可查看

挺多读者评论上都对 multiple-flutters Flutter 多引擎渲染有兴趣,希望能有更多的资料可供参考。

笔者后续大概有两个方向的文章,一是继续介绍我们在 Flutter 多引擎渲染里做了些什么,踩了哪些坑,二是从零开始讲解如何实现 Flutter 多引擎方案。

本篇还是介绍做了些什么。

前言

在 Flutter add to App 混合开发中,资源在 Native 和 Flutter 重复加载,导致内存 double 的性能问题属于司空见惯的现象了。

当然,这个是有“成熟”的解决方案的,各大厂在 Flutter 单引擎时代中,也都是推荐用 Texture 外接纹理的方式来缓解内存压力。

理论上,多引擎应该比单引擎更需要外接纹理方案,毕竟在多引擎的机制下,FlutterEngine 和 FlutterEngine 之间也是不共享资源的,更容易导致内存浪费的问题。

那在 Flutter 多引擎上我们也能用 Texture 外接纹理吗?

答案当然是可以,但还是有一些使用上的不同。

方案

先看一下 Texture 在 Flutter 上是如何使用的,其实很简单,只要有 textureId 即可显示

Texture(textureId: textureId)

那 textureId 怎么来的呢?以前一般是特定的 channel 返回特定场景的 textureId。比如视频播放,画布渲染等。

但在 Flutter 多引擎组件化的思路上,我们希望这个能力是通用的,不局限于场景,对 native 开发调用者来说不再关心 textureId 这件事,对 Flutter 组件开发者来说,也不再关心是 textureId 的来源,拿来即渲染即可。

定义

- name: TestImage
  options:
    note: GUI 图像外接纹理测试
    autolayout: true
  init:
    - { name: imageList, type: List<Image>, note: 图像列表 }
  properties:
    - { name: "image", type: Image, note: 图像 }

如上图所示,我们新增了一种自定义 Image 对象的声明类型,它在 iOS 里对标 UIImage,在 Android 里对标 Bitmap

那组件在 Native 使用上,就如下方式:

iOS

    FGUITestImage *image = [[FGUITestImage alloc] initWithMaker:^(FGUIImageInitConfig * _Nonnull make) {
        UIImage *test1 = [GDVEResource imageNamed:@"video_canvas_bg_blur_ gaussian_selected"];
        UIImage *test2 = [GDVEResource imageNamed:@"video_menu_background_normal"];
        UIImage *test3 = [GDVEResource imageNamed:@"video_canvas_bg_blur_none_normal"];
        UIImage *test4 = [GDVEResource imageNamed:@"video_template_main_track_add"];
        make.imageList = @[test1, test2, test3, test4];
    } hostVC:self];
    image.image = [GDVEResource imageNamed:@"video_template_video_track_icon_image"];
    [self.view addSubview:image.view];
    [image.view mas_makeConstraints:^(MASConstraintMaker *make) {
        make.height.equalTo(@500);
        make.center.width.equalTo(self.view);
    }];

Android

val view1 = findViewById<FGUIImage>(R.id.test_image)
view1.let {
    it.init(supportFragmentManager)
    var image = BitmapFactory.decodeResource(getResources(),
        R.drawable.bg_clear_guide_2
    )
     it.setImage(image)
}

可以看到,对 native 说就是传自身的对象即可,没有多余的开发成本。

实现

那如何做到的呢,原理也十分简单,大概分为2个部分:

Image 模型转换

有看过笔者前几篇文章的同学,应该对模型转换就比较熟悉了,用于抹平各端类型差异,且提供 model 而不是 map 的确定出入参。

Image 比较特殊一点,毕竟在 Flutter 侧只需要 textureId,那其实我们是构建一个抽象的图片对象(宽高用于 Flutter 约束图片大小,这个很重要,可以看测试结论)。


/// 图像外接纹理
class GDImageTexture {
  /// 纹理 ID
  int? textureId;

  /// 图像宽度
  double? width;

  /// 图像高度
  double? height;
  
  GDImageTexture(Map? map) : super() {
    if (map == null) {
      return;
    }
    textureId = map["textureId"] as int?;
    width = map["width"] as double?;
    height = map["height"] as double?;
  }
    
  ...
}

那剩下的工作就是在传输过程中,将 UIImageBitmap 转换成如上对象即可。

iOS:

/// 「通用」获取 FGUIComponentImage 对象
- (NSDictionary *)fetchComponentImage:(UIImage *)image {
    // FGUIComponentImageTexture 就是 Texture 实现
    FGUIComponentImageTexture *imageTexture = [[FGUIComponentImageTexture alloc] initWithImage:image]; 
    [self.imageTextures addObject:imageTexture];
    int64_t textureId = [[self.registrar textures] registerTexture:imageTexture];
    return @{
        @"textureId": @(textureId),
        @"width": @(image.size.width),
        @"height": @(image.size.height)
    };
}

...

Android:

/**
 * 「通用」获取 FGUIComponentImage 对象
 */
private fun fetchComponentImage(@NonNull image: Bitmap): Map<String, Any> {
    var surfaceEntry = textureRegistry.createSurfaceTexture()
    surfaceEntryList.add(surfaceEntry)
    var textureId = surfaceEntry.id()
    var surface = Surface(surfaceEntry.surfaceTexture().apply {
        setDefaultBufferSize(image.width, image.height)
    })
    var rect = Rect(0, 0, image.width, image.height)
    val canvas = surface.lockCanvas(rect)
    canvas.drawBitmap(image, rect, rect, null)
    image.recycle()
    surface.unlockCanvasAndPost(canvas)
    var result = mutableMapOf<String, Any>()
    result["textureId"] = textureId
    result["width"] = image.width.toFloat()
    result["height"] = image.height.toFloat()
    return result
}

如上所示,提供一个工具转换方法,在传输过程中还是用 map,在 Flutter 侧转换成 GDImageTexture 模型即可,当然这一切都是用 FGUIComponentAPI 进行的自动生成,对开发者来说直接定义 yaml 文件即可。

实现 Texture

然后我们来看一下外接纹理如何实现的,这个其实跟单引擎用的也没什么差别,简单的放一下双端代码。

iOS:


static uint32_t bitmapInfoWithPixelFormatType(OSType inputPixelFormat, bool hasAlpha) {
    if (inputPixelFormat == kCVPixelFormatType_32BGRA) {
        uint32_t bitmapInfo = kCGImageAlphaPremultipliedFirst | kCGBitmapByteOrder32Host;
        if (!hasAlpha) {
            bitmapInfo = kCGImageAlphaNoneSkipFirst | kCGBitmapByteOrder32Host;
        }
        return bitmapInfo;
    } else if (inputPixelFormat == kCVPixelFormatType_32ARGB) {
        uint32_t bitmapInfo = kCGImageAlphaPremultipliedFirst | kCGBitmapByteOrder32Big;
        return bitmapInfo;
    } else {
        NSLog(@"不支持此格式");
        return 0;
    }
}

BOOL CGImageRefContainsAlpha(CGImageRef imageRef) {
    if (!imageRef) {
        return NO;
    }
    CGImageAlphaInfo alphaInfo = CGImageGetAlphaInfo(imageRef);
    BOOL hasAlpha = !(alphaInfo == kCGImageAlphaNone ||
                      alphaInfo == kCGImageAlphaNoneSkipFirst ||
                      alphaInfo == kCGImageAlphaNoneSkipLast);
    return hasAlpha;
}

@interface FGUIComponentImageTexture ()

@property (nonatomic, strong) UIImage *image;

@end

@implementation FGUIComponentImageTexture

- (instancetype)initWithImage:(UIImage *)image {
    self = [super init];
    if (self) {
        self.image = image;
    }
    return self;
}

- (CVPixelBufferRef)copyPixelBuffer {
    return [self pixelBufferRefFromUIImage:self.image];
}

- (void)dispose {}

- (CVPixelBufferRef)pixelBufferRefFromUIImage:(UIImage *)image {
    if (!image) {
        GDAssert(0);
        return nil;
    }
    CGImageRef imageRef = [image CGImage];
    
    CGFloat frameWidth = CGImageGetWidth(imageRef);
    CGFloat frameHeight = CGImageGetHeight(imageRef);
    
    BOOL hasAlpha = CGImageRefContainsAlpha(imageRef);
    CFDictionaryRef empty = CFDictionaryCreate(kCFAllocatorDefault, NULL, NULL, 0, &kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks);
    
    NSDictionary *options = [NSDictionary dictionaryWithObjectsAndKeys:
                             [NSNumber numberWithBool:YES], kCVPixelBufferCGImageCompatibilityKey,
                             [NSNumber numberWithBool:YES], kCVPixelBufferCGBitmapContextCompatibilityKey,
                             empty, kCVPixelBufferIOSurfacePropertiesKey,
                             nil];
    
    CVPixelBufferRef pxbuffer = NULL;
    CVReturn status = CVPixelBufferCreate(kCFAllocatorDefault, frameWidth, frameHeight, kCVPixelFormatType_32BGRA, (__bridge CFDictionaryRef) options, &pxbuffer);
    NSParameterAssert(status == kCVReturnSuccess && pxbuffer != NULL);
    
    CVPixelBufferLockBaseAddress(pxbuffer, 0);
    void *pxdata = CVPixelBufferGetBaseAddress(pxbuffer);
    NSParameterAssert(pxdata != NULL);
    
    CGColorSpaceRef rgbColorSpace = CGColorSpaceCreateDeviceRGB();
    
    uint32_t bitmapInfo = bitmapInfoWithPixelFormatType(kCVPixelFormatType_32BGRA, (bool)hasAlpha);
    CGContextRef context = CGBitmapContextCreate(pxdata, frameWidth, frameHeight, 8, CVPixelBufferGetBytesPerRow(pxbuffer), rgbColorSpace, bitmapInfo);
    NSParameterAssert(context);
    
    CGContextConcatCTM(context, CGAffineTransformIdentity);
    CGContextDrawImage(context, CGRectMake(0, 0, frameWidth, frameHeight), imageRef);
    
    CGColorSpaceRelease(rgbColorSpace);
    CGContextRelease(context);
    CVPixelBufferUnlockBaseAddress(pxbuffer, 0);
    
    return pxbuffer;
}

@end

Android:

@Keep
class FGUIImageTexturePlugin(engine: FlutterEngine) {
    private var textureRegistry: TextureRegistry
    private var surfaceEntryList: MutableList<TextureRegistry.SurfaceTextureEntry>

    init {
        var pluginRegistryField = engine.javaClass.getDeclaredField("pluginRegistry")
        pluginRegistryField.isAccessible = true
        val pluginRegistry = pluginRegistryField.get(engine)
        var bindingField = pluginRegistry.javaClass.getDeclaredField("pluginBinding")
        bindingField.isAccessible = true
        var binding = bindingField.get(pluginRegistry) as FlutterPlugin.FlutterPluginBinding
        surfaceEntryList = mutableListOf()
        textureRegistry = binding.textureRegistry
    }

    fun destroy() {
        for (surfaceEntry in surfaceEntryList) {
            surfaceEntry.release()
        }
    }
    ...
}

测试

展示

先看下双端展示效果

image2022-5-20_11-38-33.png image2022-5-20_17-57-15.png

过程

这里罗列一些内存测试过程,没兴趣的同学可以直接看结论。

背景

由于图像外接纹理方案无法脱离 Native 环境,直接使用 Web 测试,所以单独做了一个 Example 来验证效果是否符合预期

测试环境:Debug + Flutter_Release(2.10.5)

测试设备:iPhoneX

测试专注:内存占用

步骤

  • 新建空白项目,引用关键 pod

  • 新增首页页,启动 flutter 引擎,观测内存情况(这里直接加载一个 FGUISwitch)

  • 跳转图像测试页,加载 FGUIImage 测试 FlutterView, 分别记录同时传入1、2、3张图片的内存消耗情况

  • 跳转新页面,观测内存释放情况

  • 返回图像测试页,观测内存加载情况

  • 放置多个 FGUIImage,观测内存加载情况

  • 加载同一个 Image, 观测内存加载情况

记录

(截图略,主要是懒)

  • 初始化:内存占用10.5MB

  • 加载 Flutter 引擎:内存占用36.7MB

  • 单个 FlutterView 加载一张图片(绘制 300*300 pt):内存占用49.9MB

  • 使用 UIImageView 加载同一张图片(绘制 300*300 pt):内存占用39.8MB

  • 同时加载 UIImageView 和 FlutterView,同一个图片内存:内存占用 52.9MB

  • 加载两个 UIImageView,同一张图片:内存占用 42.3MB

  • 加载两个 FlutterView,同一张图片:内存占用 61MB

  • 加载一个 FlutterView,2张不同的图片:内存占用 47.5MB

  • 加载一个 FlutterView,3张不同的图片:内存占用 47.5MB (相同的原因是因为外部高度设置为 300,第三张图片没有绘制)

  • 加载一个 FlutterView,3张不同的图片(300 * 500 pt):内存占用 73.8MB (以上就基本说明 Flutter 外接纹理内存占用跟绘制宽高强有强相关)

  • 再打开二级 VC,加载新的 FlutterView,加载1张图片:60.2MB

  • 关闭二级 VC:47.1MB (二级页面内存可完全释放)

  • 关闭当前 VC:40.5MB (内存只释放了7M,不能完全释放,原因是 IOSurface 未释放,且没有手动释放的方式,只有整个 EngineGroup 进程释放后才会完全释放)

结论

Flutter 外接纹理.png

感想

多引擎外接纹理笔者这里还并没用于实际项目,现在只用来做跨端 UI 组件,还没有遇到需要的场景,而且不利于 Web 转化。但方案确实是可行的。

这里顺便说一说,笔者在开发时喜欢用结果反推的方式,先确定要做一个什么样的,再往那个方向补过程,就和上述方案一样,先写出最终的“定义”是什么样,然后想办法补全实现。这也算是一种 “OKR”?[手动狗头]

如果对你开发学习上有丝丝作用,请点个赞[开心] ~