天天看點

基于GLSurfaceView的視訊播放器偶現無畫面的問題分析

文章目錄

    • 一、 問題背景
    • 二、 逐漸排查
      • 2.1 增加log,複現問題
      • 2.2 檢視ijkplayer源碼
      • 2.3 檢視AOSP源碼
    • 三、 分析原因
      • 3.1 Renderer回調onSurfaceCreated
      • 3.2 Player回調onPrepared
      • 3.3 總結
    • 四、 解決方案
      • 4.1 串行
      • 4.2 并行
    • 五、 反思總結

一、 問題背景

部落客所在項目中,涉及到視訊動畫播放功能,其實作方案采用的是bilibili開源項目ijkplayer播放器+GLSurfaceView+自定義渲染器:

  • ijkplayer提供視訊解碼能力,回調幀資料
  • 自定義Renderer實作shader操作,對幀畫面修改
  • GLSurfaceView作為畫布,進行展示

整個視訊動畫播放流程如下:

基于GLSurfaceView的視訊播放器偶現無畫面的問題分析

圖1.1 視訊動畫播放流程

在長達近一年時間裡,會偶現視訊播放無畫面的問題,具體表現為:視訊動畫開始播放到結束期間,沒有任何幀畫面。

該問題到了部落客手裡有半年時間,受限于對視訊解碼、OpenGL等技術領域知識體系的匮乏,盡管每隔一段時間把該問題撈出來分析一天,但每次都不了了之。并且也認為自己搞不定這個問題,無從下手。

這周趁着需求空檔期,有些時間,決定調整思路,再系統地分析一遍這個問題。

二、 逐漸排查

2.1 增加log,複現問題

  • 在SurfaceTexture#OnFrameAvailableListener的

    onFrameAvailable

    回調中增加日志,正常情況下每一幀都會回調該方法。
  • ijkplayer提供了外部注入日志列印的能力,通過IjkLogConfig.setIjkLog設定一個接收日志的對象,加上自己的TAG。

在測試環境下不停送禮觸發禮物視訊動畫,壓測上百次後,複現出該問題,抓取日志,發現其中大量如下異常資訊:

04-25 21:21:20.568 E/BufferQueueProducer(16697): [SurfaceTexture-1-16697-76] query: BufferQueue has been abandoned
04-25 21:21:20.568 E/BufferQueueProducer(16697): [SurfaceTexture-1-16697-76] query: BufferQueue has been abandoned
04-25 21:21:20.568 E/BufferQueueProducer(16697): [SurfaceTexture-1-16697-76] dequeueBuffer: BufferQueue has been abandoned
04-25 21:21:20.568 E/Surface (16697): dequeueBuffer failed (No such device)
04-25 21:21:20.568 E/IJKMEDIA(16697): SDL_Android_NativeWindow_display_l: ANativeWindow_lock: failed -19
04-25 21:21:20.579 E/IJKMEDIA(16697): SDL_AMediaCodecJava_dequeueInputBuffer return -1
04-25 21:21:20.580 E/IJKMEDIA(16697): SDL_AMediaCodec_dequeueInputBuffer 1 fail
04-25 21:21:20.580 I/IJKMEDIA(16697): SDL_AMediaCodec_dequeueInputBuffer 1 fail
04-25 21:21:20.583 E/IJKMEDIA(16697): av_read_frame error = -541478725
           

列印頻率符合每幀列印一次,而

onFrameAvailable

回調僅首幀列印了一次。

根據日志,在native層解碼器從緩沖隊列出隊資料時,發生了異常,錯誤碼

-19

,是以,先從ijkplayer源碼開始分析錯誤碼具體含義。

2.2 檢視ijkplayer源碼

在ijkplayer的Android源碼中,全局搜尋SDL庫的方法

SDL_Android_NativeWindow_display_l

,任選一個CPU平台,這裡以

arm64

為例:

int SDL_Android_NativeWindow_display_l(ANativeWindow *native_window, SDL_VoutOverlay *overlay)
{
    int retval;
    ...
    ANativeWindow_Buffer out_buffer;
    retval = ANativeWindow_lock(native_window, &out_buffer, NULL);
    if (retval < 0) {
        ALOGE("SDL_Android_NativeWindow_display_l: ANativeWindow_lock: failed %d", retval);
        return retval;
    }
    ...
    retval = ANativeWindow_unlockAndPost(native_window);
    if (retval < 0) {
        ALOGE("SDL_Android_NativeWindow_display_l: ANativeWindow_unlockAndPost: failed %d", retval);
        return retval;
    }

    return render_ret;
}
           

報錯日志即上面這一行代碼所輸出,看到成對出現的lock和unlock,第一反應是canvas繪制時的操作步驟,結合這裡的方法名,推測這裡也是要執行繪制相關操作。

全局搜尋未找到

ANativeWindow_lock

這個方法,是以前往AOSP中查找。

2.3 檢視AOSP源碼

以Android Q為例,在ANativeWindow中找到:

基于GLSurfaceView的視訊播放器偶現無畫面的問題分析

其調用具體實作位于Surface中:

基于GLSurfaceView的視訊播放器偶現無畫面的問題分析

繼續追蹤調用鍊:

基于GLSurfaceView的視訊播放器偶現無畫面的問題分析

這裡列印的log符合前面複現問題時的日志。雖然不懂native層渲染邏輯具體實作和原理,但從類名和方法名來看,這裡應該是要從緩沖隊列中出隊幀資料,繼續向下追蹤:

基于GLSurfaceView的視訊播放器偶現無畫面的問題分析

該方法中有兩處給

result

指派的地方,後面一處在小于0時會列印錯誤級别的日志,而本地複現日志中沒有對應記錄,是以錯誤碼

-19

就是這裡傳回的。

在BufferQueueProducer中:

基于GLSurfaceView的視訊播放器偶現無畫面的問題分析

在源碼中,

NO_INIT

的值定義為

-ENODEV

,而

ENODEV

正好等于19。

現在需要分析的是:

mCore->mIsAbandoned

在什麼時候為true。

基于GLSurfaceView的視訊播放器偶現無畫面的問題分析

與Producer相對應,在BufferQueueConsumer中找到了答案,位于

disconnect

方法中,這個方法也有對應的

connect

方法。

BufferQueueCore中對

mIsAbandoned

的聲明如下:

基于GLSurfaceView的視訊播放器偶現無畫面的問題分析
  • 表明從IGraphicBufferProducer接口入隊到BufferQueue中的圖像緩沖,不會再被消費
  • 初始值為false,執行

    consumerDisconnect

    方法後置為true
  • 對于已廢棄的BufferQueue,從IGraphicBufferProducer接口調過來時都會傳回

    NO_INIT

    錯誤

在IGraphicBufferConsumer中有兩處

consumerDisconnect

的調用:

基于GLSurfaceView的視訊播放器偶現無畫面的問題分析

基于GLSurfaceView的視訊播放器偶現無畫面的問題分析

前者跨程序調用給後者的IBinder,然後後者在程序内調用,這是因為native渲染流程位于一個與應用程序獨立的程序。

從現在開始倒推分析,均位于應用程序。

基于GLSurfaceView的視訊播放器偶現無畫面的問題分析

ConsumerBase的

abandonLocked

方法被SurfaceTexture覆寫,這在頭檔案中有聲明:

基于GLSurfaceView的視訊播放器偶現無畫面的問題分析
基于GLSurfaceView的視訊播放器偶現無畫面的問題分析

看到SurfaceTexture的native類,不禁想到Bitmap也是這樣設計,Java層隻是一個殼,封裝一些基本的API,本質上是通過JNI調用native方法,核心邏輯全部位于Native層的同名類中。

abandonLocked

方法又是由

abandon

調用,

abandon

由SurfaceTexture的JNI調用:

基于GLSurfaceView的視訊播放器偶現無畫面的問題分析

回到Java層的SurfaceTexture:

基于GLSurfaceView的視訊播放器偶現無畫面的問題分析
  • 用于釋放緩沖區資源,将SurfaceTexture置為

    abandoned

    狀态且不可逆轉
  • 當處于

    abandoned

    狀态,調用IGraphicBufferProducer接口的任何方法都會傳回

    NO_INIT

    錯誤,即錯誤碼

    -19

  • 調用後會釋放這個SurfaceTexture關聯的所有緩沖,如果有用戶端或OpenGL ES通過紋理的方式引用這些緩沖,則繼續保留
  • 當不再使用該SurfaceTexture時,需要調用這個方法,避免後續資源配置設定受阻

這和前面看到的BufferQueueCore中對

mIsAbandoned

字段的描述基本上是一回事。

由此可知,以上釋放資源的步驟主要流程如下:

基于GLSurfaceView的視訊播放器偶現無畫面的問題分析

圖2.3.1 視訊動畫釋放資源主要流程

三、 分析原因

根據前面的分析,出現無畫面問題的原因是,使用了一個已經釋放資源的SurfaceTexture,進而導緻緩沖區出隊幀資料時報錯。

回過頭來看前面的視訊動畫播放流程3,Player播放有兩個前置條件:

  • 播放器準備就緒(初始化環境資源等):由播放器異步回調

    onPrepared

    ,主線程
  • 設定Surface:由Renderer回調

    onSurfaceCreated

    時建立的SurfaceTexture,再建立出Surface,GL子線程

以上兩個條件位于兩個不同的線程,如果未做線程同步校驗,那麼無法保證在條件一播放器準備就緒時,條件二新的Surface已經建立好,如果每次視訊動畫執行結束後未将舊的變量置空,就會導緻使用上一次釋放過的對象傳給Player,從日志中,也證明了出現問題時使用的舊的SurfaceTexture對象。

那麼,為什麼絕大部分情況下都能正常播放,僅僅偶現無畫面的問題呢?這得從兩個條件的回調時機着手分析。

3.1 Renderer回調onSurfaceCreated

在GLSurfaceView中,定義了靜态内部類GLThread,其

run

方法執行的核心邏輯為

guardedRun

方法:

private void guardedRun() throws InterruptedException {
    mHaveEglContext = false;
    ...
    boolean createEglContext = false;
    boolean askedToReleaseEglContext = false;
    ...
    while(true) {
        synchronized (sGLThreadManager) {
            while(true) {
                ...
                // If we don't have an EGL context, try to acquire one.
                if (! mHaveEglContext) {
                    if (askedToReleaseEglContext) {
                        askedToReleaseEglContext = false;
                    } else {
                        try {
                            mEglHelper.start();
                        } catch (RuntimeException t) {
                            sGLThreadManager.releaseEglContextLocked(this);
                            throw t;
                        }
                        mHaveEglContext = true;
                        createEglContext = true;

                        sGLThreadManager.notifyAll();
                    }
                }
                ...
            }
        }
        ...
        if (createEglContext) {
            if (LOG_RENDERER) {
                Log.w("GLThread", "onSurfaceCreated");
            }
            GLSurfaceView view = mGLSurfaceViewWeakRef.get();
            if (view != null) {
                try {
                    Trace.traceBegin(Trace.TRACE_TAG_VIEW, "onSurfaceCreated");
                    view.mRenderer.onSurfaceCreated(gl, mEglHelper.mEglConfig);
                } finally {
                    Trace.traceEnd(Trace.TRACE_TAG_VIEW);
                }
            }
            createEglContext = false;
        }
        ...
    }
    ...
}

private Renderer mRenderer;
           

内層死循環設定辨別位,跳出循環後,會建立Egl環境,其中便有回調Renderer的

onSurfaceCreated

方法。

而線程啟動的地方有兩處:

public void setRenderer(Renderer renderer) {
    ...
    mRenderer = renderer;
    mGLThread = new GLThread(mThisWeakRef);
    mGLThread.start();
}

@Override
protected void onAttachedToWindow() {
    super.onAttachedToWindow();
    if (LOG_ATTACH_DETACH) {
        Log.d(TAG, "onAttachedToWindow reattach =" + mDetached);
    }
    if (mDetached && (mRenderer != null)) {
        int renderMode = RENDERMODE_CONTINUOUSLY;
        if (mGLThread != null) {
            renderMode = mGLThread.getRenderMode();
        }
        mGLThread = new GLThread(mThisWeakRef);
        if (renderMode != RENDERMODE_CONTINUOUSLY) {
            mGLThread.setRenderMode(renderMode);
        }
        mGLThread.start();
    }
    mDetached = false;
}
           
  • 首次設定Renderer時
  • GLSurfaceView使用過後從視窗移除,後續複用添加到視窗時

對于回調

onSurfaceCreated

的耗時點,前者等于建立線程到線程真正開始執行這段時間,取決于系統目前配置設定資源以及CPU配置設定時間片的耗時,通常很短;後者等于将GLSurfaceView添加到視窗的耗時加上前者的耗時,而添加到視窗的耗時,在主線程流暢的情況下,會在調用

addView

後的下一幀添加到視窗,也就是一個

VSYNC

信号的間隔時長,但在丢幀的情況下,即

VSYNC

信号到來時,無法及時響應Choreographer中的

doFrame

操作,周遊View樹,回調新View的

onAttachedToWindow

,是以耗時會成倍增加。

3.2 Player回調onPrepared

以原生的MediaPlayer為例(IjkMediaPlayer類似),播放器準備操作的大緻流程如下:

基于GLSurfaceView的視訊播放器偶現無畫面的問題分析

圖3.2.1 播放器準備操作大緻流程

Native層具體操作不作詳細闡述。經多次測試,這個耗時大緻在20ms——150ms之間浮動,大于一個

VSYNC

信号間隔16.7ms(60Hz重新整理率下)。

3.3 總結

從以上兩點分析可知,在播放視訊動畫前的準備階段,如果主線程沒有卡頓問題,則通常都能正常播放。而對于丢幀的情景,該問題複現機率理論上會顯著提高,讀者可以通過主線程執行耗時任務模拟卡頓來證明。

四、 解決方案

該問題本質上是一個多線程環境下的時序問題,解決方法有兩種,分别進行說明。

4.1 串行

Player的播放依賴于Surface,那麼在Surface建立完畢後才開始執行Player的準備操作:

基于GLSurfaceView的視訊播放器偶現無畫面的問題分析

圖4.1.1 視訊播放串行準備流程

對于GLSurfaceView提前添加或預設添加到布局的場景下,如果較早設定了Renderer,則可以較早地建立SurfaceTexture,那麼無需關注該時機問題,隻需要在場景觸發播放視訊時,正常設定資源和監聽、準備、開始播放。

但對于僅在需要時才将GLSurfaceView添加到視窗,即節約系統資源的場景下,必須關注該時機問題,那麼串行将導緻視訊動畫真正渲染上屏的首幀時間,被延後一到多個

VSYNC

信号周期。

4.2 并行

為了兼顧“節約系統資源”、“縮短首幀耗時”,可以通過多線程并行+同步校驗的方式:

基于GLSurfaceView的視訊播放器偶現無畫面的問題分析

圖4.2.1 視訊播放并行準備流程

GLSurfaceView在需要播放視訊時調用

addView

添加到視窗,在動畫結束後調用

removeView

及時從視窗移除。在

addView

同時間對Player進行初始化和準備。

無論是Renderer的

onSurfaceCreated

回調還是Player的

onPrepare

回調,都去調用同一個校驗方法,當SurfaceTexture建立好且Player準備就緒時,設定Surface并開始播放。需要注意的是,

onSurfaceCreated

的回調位于子線程,需要切換到主線程。

五、 反思總結

最終,部落客采用了方案二來解決這個“祖傳bug”。整個問題從系統分析到找到原因耗時不到一天,回顧過去的幾個月,其實都是在做無用功。這個問題的整個處理過程,也頗有反思:

  • 對于不熟悉的技術領域,應當盡可能一邊快速學習一邊分析問題,如果不邁出第一步,則永遠無法拓寬技術棧
  • 不輕易否定自己,尤其是在沒有系統思考和查閱檢索的情況下,這是逃避問題不負責任的表現
  • 當問題卡殼時,借助圖形輔助手段,梳理流程和思路,找準問題核心原因,避免在錯誤的方向上浪費時間精力

路漫漫其修遠兮,這也算是職業生涯的成長過程吧。