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 约束图片大小,这个很重要,可以看测试结论)。

/// 图像外接纹理classGDImageTexture{
  /// 纹理 IDint? textureId;

  /// 图像宽度double? width;

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

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

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 对象
 */privatefunfetchComponentImage(@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;
    } elseif (inputPixelFormat == kCVPixelFormatType_32ARGB) {
        uint32_t bitmapInfo = kCGImageAlphaPremultipliedFirst | kCGBitmapByteOrder32Big;
        return bitmapInfo;
    } else {
        NSLog(@"不支持此格式");
        return0;
    }
}

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

@interfaceFGUIComponentImageTexture ()@property (nonatomic, strong) UIImage *image;

@end@implementationFGUIComponentImageTexture

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

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

- (void)dispose {}

- (CVPixelBufferRef)pixelBufferRefFromUIImage:(UIImage *)image {
    if (!image) {
        GDAssert(0);
        returnnil;
    }
    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()
        }
    }
    ...
}

展示

先看下双端展示效果

过程

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

背景

由于图像外接纹理方案无法脱离 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 进程释放后才会完全释放)

结论

感想

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

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

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
Flutter渲染机制可以从源码角度来理解。Flutter渲染与两个线程直相关,分别是UI线程和GPU线程。UI线程负责执行Dart root isolate代码,并将其转换为Layer tree。 Layer 是 Flutter Framework 中的一个重要概念,它代表了一块矩形区域,可以包含图形、文本、图片等内容。这些 Layer 最终会被提交到 Engine 中进行绘制。 Layer 的工作原理是将所有的绘制操作转化为一系列的绘制指令,然后将这些指令传递给 GPU 线程进行绘制。 Flutter Framework 中的绘制过程经过多个步骤,包括布局、绘制、合成等,最终将所有的 Layer 组合在一起形成最终的界面。通过理解 Flutter渲染原理,开发者可以更清晰地了解应用程序的渲染过程,并进行性能优化。<span class="em">1</span><span class="em">2</span><span class="em">3</span> #### 引用[.reference_title] - *1* [Flutter渲染机制—UI线程](https://download.csdn.net/download/weixin_38550834/15446392)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v93^chatsearchT3_2"}}] [.reference_item style="max-width: 50%"] - *2* *3* [Flutter 画面渲染的全面解析](https://blog.csdn.net/chengjiamei/article/details/107974790)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v93^chatsearchT3_2"}}] [.reference_item style="max-width: 50%"] [ .reference_list ]

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

金戈鐡馬

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值