万万没想到——flutter这样外接纹理

前言

记得在13年做群视频通话的时候,多路视频渲染成为了端上一个非常大的性能瓶颈。原因是每一路画面的高速上屏(PresentRenderBuffer or  SwapBuffer 就是讲渲染缓冲区的渲染结果呈现到屏幕上)操作,消耗了非常多的CPU和GPU资源。

那时候的解法是将绘制和上屏进行分离,将多路画面抽象到一个绘制树中,对其进行遍历绘制,绘制完成以后统一做上屏操作,并且每一路画面不再单独触发上屏,而是统一由Vsync信号触发,这样极大的节约了性能开销。

那时候甚至想过将整个UI界面都由OpenGL进行渲染,这样还可以进一步减少界面内诸如:声音频谱,呼吸效果等动画的性能开销。但由于各种条件限制,最终没有去践行这个想法。 

万万没想到的是这种全界面OpenGL渲染思路还可以拿来做跨平台。

Flutter渲染框架

下图为Flutter的一个简单的渲染框架:

 Layer Tree:这个是dart runtime输出的一个树状数据结构,树上的每一个叶子节点,代表了一个界面元素(Button,Image等等)。

Skia:这个是谷歌的一个跨平台渲染框架,从目前IOS和anrdroid来看,SKIA底层最终都是调用OpenGL绘制。Vulkan支持还不太好,Metal还不支持。

Shell:这里的Shell特指平台特性(Platform)的那一部分,包含IOS和Android平台相关的实现,包括EAGLContext管理、上屏的操作以及后面将会重点介绍的外接纹理实现等等。

从图中可以看出,当Runtime完成Layout输出一个Layertree以后,在管线中会遍历Layertree的每一个叶子节点,每一个叶子节点最终会调用Skia引擎完成界面元素的绘制,在遍历完成后,在调用glPresentRenderBuffer(IOS)或者glSwapBuffer(Android)按完成上屏操作。

基于这个基本原理,Flutter在Native和Flutter Engine上实现了UI的隔离,书写UI代码时不用再关心平台实现从而实现了跨平台。

问题

正所谓凡事有利必有弊,Flutter在与Native隔离的同时,也在Flutter Engine和Native之间竖立了一座大山,Flutter想要获取一些Native侧的高内存占用图像(摄像头帧、视频帧、相册图片等等)会变得困难重重。传统的如RN,Weex等通过桥接NativeAPI可以直接获取这些数据,但是Flutter从基本原理上就决定了无法直接获取到这些数据,而Flutter定义的channel机制,从本质上说是提供了一个消息传送机制,用于图像等数据的传输必然引起内存和CPU的巨大消耗。

解法

为此,Flutter提供了一种特殊的机制:外接纹理(ps:纹理Texture可以理解为GPU内代表图像数据的一个对象)

上图是前文提到的LayerTree的一个简单架构图,每一个叶子节点代表了dart代码排版的一个控件,可以看到最后有一个TextureLayer节点,这个节点对应的是Flutter里的Texture控件(ps.这里的Texture和GPU的Texture不一样,这个是Flutter的控件)。当在Flutter里创建出一个Texture控件时,代表的是在这个控件上显示的数据,需要由Native提供。

以下是IOS端的TextureLayer节点的最终绘制代码(android类似,但是纹理获取方式略有不同),整体过程可以分为三步

  1. 调用external_texture copyPixelBuffer,获取CVPixelBuffer

  2. CVOpenGLESTextureCacheCreateTextureFromImage创建OpenGL的Texture(这个是真的Texture)

  3. 将OpenGL Texture封装成SKImage,调用Skia的DrawImage完成绘制。

void IOSExternalTextureGL::Paint(SkCanvas& canvas, const SkRect& bounds) {
  if (!cache_ref_) {
    CVOpenGLESTextureCacheRef cache;
    CVReturn err = CVOpenGLESTextureCacheCreate(kCFAllocatorDefault, NULL,
                                                [EAGLContext currentContext], NULL, &cache);
    if (err == noErr) {
      cache_ref_.Reset(cache);
    } else {
      FXL_LOG(WARNING) << "Failed to create GLES texture cache: " << err;
      return;
    }
  }
  fml::CFRef bufferRef;
  bufferRef.Reset([external_texture_ copyPixelBuffer]);
  if (bufferRef != nullptr) {
    CVOpenGLESTextureRef texture;
    CVReturn err = CVOpenGLESTextureCacheCreateTextureFromImage(
        kCFAllocatorDefault, cache_ref_, bufferRef, nullptr, GL_TEXTURE_2D, GL_RGBA,
        static_cast<int>(CVPixelBufferGetWidth(bufferRef)),
        static_cast<int>(CVPixelBufferGetHeight(bufferRef)), GL_BGRA, GL_UNSIGNED_BYTE, 0,
        &texture);
    texture_ref_.Reset(texture);
    if (err != noErr) {
      FXL_LOG(WARNING) << "Could not create texture from pixel buffer: " << err;
      return;
    }
  }
  if (!texture_ref_) {
    return;
  }
  GrGLTextureInfo textureInfo = {CVOpenGLESTextureGetTarget(texture_ref_),
                                 CVOpenGLESTextureGetName(texture_ref_), GL_RGBA8_OES};
  GrBackendTexture backendTexture(bounds.width(), bounds.height(), GrMipMapped::kNo, textureInfo);
  sk_sp image =
      SkImage::MakeFromTexture(canvas.getGrContext(), backendTexture, kTopLeft_GrSurfaceOrigin,
                               kRGBA_8888_SkColorType, kPremul_SkAlphaType, nullptr);
  if (image) {
    canvas.drawImage(image, bounds.x(), bounds.y());
  }
}

最核心的在于这个external_texture_对象,它是哪里来的呢?

void PlatformViewIOS::RegisterExternalTexture(int64_t texture_id,NSObject*texture) {
  RegisterTexture(std::make_shared(texture_id,texture));
}

可以看到,当Native侧调用RegisterExternalTexture前,需要创建一个实现了FlutterTexture这个protocol的对象,而这个对象最终就是赋值给这个external_texture_。这个external_texture_就是Flutter和Native之间的一座桥梁,在渲染时可以通过他源源不断的获取到当前所要展示的图像数据。

如图,通过外接纹理的方式,实际上Flutter和Native传输的数据载体就是PixelBuffer,Native端的数据源(摄像头、播放器等)将数据写入PixelBuffer,Flutter拿到PixelBuffer以后转成OpenGLES Texture,交由Skia绘制。

至此,Flutter就可以容易的绘制出一切Native端想要绘制的数据,除了摄像头播放器等动态图像数据,诸如图片的展示也提供了Image控件之外的另一种可能(尤其对于Native端已经有大型图片加载库诸如SDWebImage等,如果要在Flutter端用dart写一份也是非常耗时耗力的)。

优化

上述的整套流程,看似完美解决了Flutter展示Native端大数据的问题,但是许多现实情况是这样:

如图工程实践中视频图像数据的处理,为了性能考虑,通常都会在Native端使用GPU处理,而Flutter端定义的接口为copyPixelBuffer,所以整个数据流程就要经过:GPU->CPU->GPU的流程。而熟悉GPU处理的同学应该都知道,CPU和GPU的内存交换是所有操作里面最耗时的操作,一来一回,通常消耗的时间,比整个管道处理的时间都要长。

既然Skia渲染的引擎需要的是GPU Texture,而Native数据处理输出的就是GPU Texture,那能不能直接就用这个Texture呢?答案是肯定的,但是有个条件:EAGLContext的资源共享(这里的Context,也就是上下文,用来管理当前GL环境,可以保证不同环境下的资源的隔离)。

这里我们首先需要介绍下Flutter的线程结构:

如图所示,Flutter通常情况下会创建4个Runner,这里的TaskRunner类似于IOS的GCD,是以队列的方式执行任务的一种机制,通常情况下(一个Runner会对应一个线程,而Platform Runner会在跑在主线程),这里和本文相关的有三个Runner:GPU Runner、IORunner、Platform Runner。

  • GPU Runner:负责GPU的渲染相关操作。

  • IO Runner:负责资源的加载操作。

  • Platform Runner:运行在main thread上,负责所有Native与Flutter Engine的交互。

通常情况下一个使用OpenGL的APP线程设计都会有一个线程负责加载资源(图片到纹理),一个线程负责渲染的方式。但是经常会发现为了能够让加载线程创建出来的纹理,能够在渲染线程使用,两个线程会共用一个EAGLContext。但是从规范上来说这样使用是不安全的,多线程访问同一对象加锁的的话不可避免会影响性能,代码处理不好甚至会引起死锁。因此Flutter在EAGLContext的使用上使用了另一种机制:两个线程各自使用自己的EAGLContext,彼此通过ShareGroup(android为shareContext)来共享纹理数据。(这里需要提一下的是:虽然两个Context的使用者分别是GPU 和IO Runner,但是现有Flutter的逻辑下两个Context都是在Platform Runner下创建的,这里不知道是Flutter是出于什么考虑,但是因为这个设计给我们带来很大的困扰,后面会说到。)

对于Native侧使用OpenGL的模块,也会在自己���线程下面创建出自己线程对应的Context,为了能够让这个Context下创建出来的Texture,能够输送给Flutter 端,并交由Skia完成绘制,我们在Flutter创建内部的两个Context时,将他们的ShareGroup透出,然后在Native侧保存好这个ShareGroup,当Native创建Context时,都会使用这个ShareGroup进行创建。这样就实现了Native和Flutter之间的纹理共享。

通过这种方式来做external_texture有两个好处:

  • 第一:节省CPU时间,从我们测试上看,android机型上一帧720P的RGBA格式的视频,从GPU读取到CPU大概需要5ms左右,从CPU在送到GPU又需要5ms左右,哪怕引入了PBO,也还是有5ms左右的耗时,这对于高帧率场景显然是不能接受的。

  • 第二:节省CPU内存,显而易见数据都在GPU中传递,对于图片场景尤其适用(因为可能同一时间会有很多图片需要展示)。

后语

至此,我们介绍完了Flutter外接纹理的基本原理,以及优化策略。但是可能大家会有疑惑,既然直接用Texture作为外接纹理这么好,为什么谷歌要用Pixelbuffer?这里又回到了那个命题,凡事有利必有弊,使用Texture,必然需要将ShareGroup透出,也就是相当于将Flutter的GL环境开放了,如果外部的OpenGL操作不当(OpenGL的对象对于CPU而言就是一个数字,一个Texture或者FrameBuffer我们断点看到的就是一个GLUint,如果环境隔离,我们随便操作deleteTexture,deleteFrameBuffer不会影响别的环境下的对象,但是如果环境打通,这些操作很可能会影响Flutter自己的Context下的对象),所以作为一个框架的设计者,保证框架的封闭完整性才是首要。

我们在开发过程中,碰到一个诡异的问题,定位了很久发现就是因为我们在主线程没有setCurrentContext的情况下,调用了glDeleteFrameBuffer,从而误删了Flutter的FrameBuffer,导致flutter 渲染时crash。所以建议如果采用这种方案的同学,Native端的GL相关操作务必至少遵从以下一点:

  1. 尽量不要在主线程做GL操作,

  2. 在有GL操作的函数调用前,要加上setCurrentContext。

还有一点就是本文大多数逻辑都是以IOS端为范例进行陈述,Android整体原理是一致的,但是具体实现上稍有不同,Android端Flutter自带的外接纹理是用SurfaceTexture实现,其机理其实也是CPU内存到GPU内存的拷贝,Android OpenGL没有ShareGroup这个概念,用的是shareContext,也就是直接把Context传出去。并且Shell层Android的GL实现是基于C++的,所以Context是一个C++对象,要将这个C++对象和AndroidNative端的java Context对象进行共享,需要在jni层这样调用:

static jobject GetContext(JNIEnv* env,
                          jobject jcaller,
                          jlong shell_holder)
 
{
    jclass eglcontextClassLocal = env->FindClass("android/opengl/EGLContext");
    jmethodID eglcontextConstructor = env->GetMethodID(eglcontextClassLocal, """(J)V");

    void * cxt = ANDROID_SHELL_HOLDER->GetPlatformView()->GetContext();

    if((EGLContext)cxt == EGL_NO_CONTEXT)
    {
        return env->NewObject(eglcontextClassLocal, eglcontextConstructor, reinterpret_cast(EGL_NO_CONTEXT));
    }

    return env->NewObject(eglcontextClassLocal, eglcontextConstructor, reinterpret_cast(cxt));
}

发表评论