萬萬沒想到——flutter這樣外接紋理

閒魚技術發表於2019-03-04

作者:閒魚技術-爐軍

前言

記得在13年做群視訊通話的時候,多路視訊渲染成為了端上一個非常大的效能瓶頸。原因是每一路畫面的高速上屏(PresentRenderBuffer or  SwapBuffer 就是講渲染緩衝區的渲染結果呈現到螢幕上)操作,消耗了非常多的CPU和GPU資源。
       那時候的解法是將繪製和上屏進行分離,將多路畫面抽象到一個繪製樹中,對其進行遍歷繪製,繪製完成以後統一做上屏操作,並且每一路畫面不再單獨觸發上屏,而是統一由Vsync訊號觸發,這樣極大的節約了效能開銷。
       那時候甚至想過將整個UI介面都由OpenGL進行渲染,這樣還可以進一步減少介面內諸如:聲音訊譜,呼吸效果等動畫的效能開銷。但由於各種條件限制,最終沒有去踐行這個想法。 
 
萬萬沒想到的是這種全介面OpenGL渲染思路還可以拿來做跨平臺。

Flutter渲染框架

下圖為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內代表影象資料的一個物件)
萬萬沒想到——flutter這樣外接紋理
上圖是前文提到的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<CVPixelBufferRef> 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<SkImage> 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<FlutterTexture>*texture) {
  RegisterTexture(std::make_shared<IOSExternalTextureGL>(texture_id,texture));
}

複製程式碼
可以看到,當Native側呼叫RegisterExternalTexture前,需要建立一個實現了FlutterTexture這個protocol的物件,而這個物件最終就是賦值給這個external_texture_。這個external_texture_就是Flutter和Native之間的一座橋樑,在渲染時可以通過他源源不斷的獲取到當前所要展示的影象資料。
萬萬沒想到——flutter這樣外接紋理
如圖,通過外接紋理的方式,實際上Flutter和Native傳輸的資料載體就是PixelBuffer,Native端的資料來源(攝像頭、播放器等)將資料寫入PixelBuffer,Flutter拿到PixelBuffer以後轉成OpenGLES Texture,交由Skia繪製。
至此,Flutter就可以容易的繪製出一切Native端想要繪製的資料,除了攝像頭播放器等動態影象資料,諸如圖片的展示也提供了Image控制元件之外的另一種可能(尤其對於Native端已經有大型圖片載入庫諸如SDWebImage等,如果要在Flutter端用dart寫一份也是非常耗時耗力的)。

優化

上述的整套流程,看似完美解決了Flutter展示Native端大資料的問題,但是許多現實情況是這樣:
萬萬沒想到——flutter這樣外接紋理
如圖工程實踐中視訊影象資料的處理,為了效能考慮,通常都會在Native端使用GPU處理,而Flutter端定義的介面為copyPixelBuffer,所以整個資料流程就要經過:GPU->CPU->GPU的流程。而熟悉GPU處理的同學應該都知道,CPU和GPU的記憶體交換是所有操作裡面最耗時的操作,一來一回,通常消耗的時間,比整個管道處理的時間都要長。
既然Skia渲染的引擎需要的是GPU Texture,而Native資料處理輸出的就是GPU Texture,那能不能直接就用這個Texture呢?答案是肯定的,但是有個條件:EAGLContext的資源共享(這裡的Context,也就是上下文,用來管理當前GL環境,可以保證不同環境下的資源的隔離)。
這裡我們首先需要介紹下Flutter的執行緒結構:
萬萬沒想到——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之間的紋理共享。
萬萬沒想到——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, "<init>", "(J)V");
    
    void * cxt = ANDROID_SHELL_HOLDER->GetPlatformView()->GetContext();
    
    if((EGLContext)cxt == EGL_NO_CONTEXT)
    {
        return env->NewObject(eglcontextClassLocal, eglcontextConstructor, reinterpret_cast<jlong>(EGL_NO_CONTEXT));
    }
    
    return env->NewObject(eglcontextClassLocal, eglcontextConstructor, reinterpret_cast<jlong>(cxt));
}
複製程式碼
 

聯絡我們

如果對文字的內容有疑問或指正,歡迎告知我們。
閒魚技術團隊是一隻短小精悍的工程技術團隊。我們不僅關注於業務問題的有效解決,同時我們在推動打破技術棧分工限制(android/iOS/Html5/Server 程式設計模型和語言的統一)、計算機視覺技術在移動終端上的前沿實踐工作。作為閒魚技術團隊的軟體工程師,您有機會去展示您所有的才能和勇氣,在整個產品的演進和使用者問題解決中證明技術發展是改變生活方式的動力。
簡歷投遞:guicai.gxy@alibaba-inc.com

相關文章