本公众号内容均为本号转发,已尽可能注明出处。因未能核实来源或转发内容图片有权利瑕疵的,请及时联系本号,本号会第一时间进行修改或删除。 QQ : 3442093904
前言
记得在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类似,但是纹理获取方式略有不同),整体过程可以分为三步
调用external_texture copyPixelBuffer,获取CVPixelBuffer
CVOpenGLESTextureCacheCreateTextureFromImage创建OpenGL的Texture(这个是真的Texture)
将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相关操作务必至少遵从以下一点:
尽量不要在主线程做GL操作,
在有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));
}
联系我们
如果对文本的内容有疑问或指正,欢迎告知我们。
闲鱼技术团队是一只短小精悍的工程技术团队。我们不仅关注于业务问题的有效解决,同时我们在推动打破技术栈分工限制(android/iOS/Html5/Server 编程模型和语言的统一)、计算机视觉技术在移动终端上的前沿实践工作。作为闲鱼技术团队的软件工程师,您有机会去展示您所有的才能和勇气,在整个产品的演进和用户问题解决中证明技术发展是改变生活方式的动力。
作者:闲鱼技术-炉军
链接:https://juejin.im/post/5b7b9051e51d45388b6aeceb
- The End -