文章目錄
-
- 一、 問題背景
- 二、 逐漸排查
-
- 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作為畫布,進行展示
整個視訊動畫播放流程如下:

圖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中找到:
其調用具體實作位于Surface中:
繼續追蹤調用鍊:
這裡列印的log符合前面複現問題時的日志。雖然不懂native層渲染邏輯具體實作和原理,但從類名和方法名來看,這裡應該是要從緩沖隊列中出隊幀資料,繼續向下追蹤:
該方法中有兩處給
result
指派的地方,後面一處在小于0時會列印錯誤級别的日志,而本地複現日志中沒有對應記錄,是以錯誤碼
-19
就是這裡傳回的。
在BufferQueueProducer中:
在源碼中,
NO_INIT
的值定義為
-ENODEV
,而
ENODEV
正好等于19。
現在需要分析的是:
mCore->mIsAbandoned
在什麼時候為true。
與Producer相對應,在BufferQueueConsumer中找到了答案,位于
disconnect
方法中,這個方法也有對應的
connect
方法。
BufferQueueCore中對
mIsAbandoned
的聲明如下:
- 表明從IGraphicBufferProducer接口入隊到BufferQueue中的圖像緩沖,不會再被消費
- 初始值為false,執行
方法後置為trueconsumerDisconnect
- 對于已廢棄的BufferQueue,從IGraphicBufferProducer接口調過來時都會傳回
錯誤NO_INIT
在IGraphicBufferConsumer中有兩處
consumerDisconnect
的調用:
和
前者跨程序調用給後者的IBinder,然後後者在程序内調用,這是因為native渲染流程位于一個與應用程序獨立的程序。
從現在開始倒推分析,均位于應用程序。
ConsumerBase的
abandonLocked
方法被SurfaceTexture覆寫,這在頭檔案中有聲明:
看到SurfaceTexture的native類,不禁想到Bitmap也是這樣設計,Java層隻是一個殼,封裝一些基本的API,本質上是通過JNI調用native方法,核心邏輯全部位于Native層的同名類中。
abandonLocked
方法又是由
abandon
調用,
abandon
由SurfaceTexture的JNI調用:
回到Java層的SurfaceTexture:
- 用于釋放緩沖區資源,将SurfaceTexture置為
狀态且不可逆轉abandoned
- 當處于
狀态,調用IGraphicBufferProducer接口的任何方法都會傳回abandoned
錯誤,即錯誤碼NO_INIT
-19
- 調用後會釋放這個SurfaceTexture關聯的所有緩沖,如果有用戶端或OpenGL ES通過紋理的方式引用這些緩沖,則繼續保留
- 當不再使用該SurfaceTexture時,需要調用這個方法,避免後續資源配置設定受阻
這和前面看到的BufferQueueCore中對
mIsAbandoned
字段的描述基本上是一回事。
由此可知,以上釋放資源的步驟主要流程如下:
圖2.3.1 視訊動畫釋放資源主要流程
三、 分析原因
根據前面的分析,出現無畫面問題的原因是,使用了一個已經釋放資源的SurfaceTexture,進而導緻緩沖區出隊幀資料時報錯。
回過頭來看前面的視訊動畫播放流程3,Player播放有兩個前置條件:
- 播放器準備就緒(初始化環境資源等):由播放器異步回調
,主線程onPrepared
- 設定Surface:由Renderer回調
時建立的SurfaceTexture,再建立出Surface,GL子線程onSurfaceCreated
以上兩個條件位于兩個不同的線程,如果未做線程同步校驗,那麼無法保證在條件一播放器準備就緒時,條件二新的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類似),播放器準備操作的大緻流程如下:
圖3.2.1 播放器準備操作大緻流程
Native層具體操作不作詳細闡述。經多次測試,這個耗時大緻在20ms——150ms之間浮動,大于一個
VSYNC
信号間隔16.7ms(60Hz重新整理率下)。
3.3 總結
從以上兩點分析可知,在播放視訊動畫前的準備階段,如果主線程沒有卡頓問題,則通常都能正常播放。而對于丢幀的情景,該問題複現機率理論上會顯著提高,讀者可以通過主線程執行耗時任務模拟卡頓來證明。
四、 解決方案
該問題本質上是一個多線程環境下的時序問題,解決方法有兩種,分别進行說明。
4.1 串行
Player的播放依賴于Surface,那麼在Surface建立完畢後才開始執行Player的準備操作:
圖4.1.1 視訊播放串行準備流程
對于GLSurfaceView提前添加或預設添加到布局的場景下,如果較早設定了Renderer,則可以較早地建立SurfaceTexture,那麼無需關注該時機問題,隻需要在場景觸發播放視訊時,正常設定資源和監聽、準備、開始播放。
但對于僅在需要時才将GLSurfaceView添加到視窗,即節約系統資源的場景下,必須關注該時機問題,那麼串行将導緻視訊動畫真正渲染上屏的首幀時間,被延後一到多個
VSYNC
信号周期。
4.2 并行
為了兼顧“節約系統資源”、“縮短首幀耗時”,可以通過多線程并行+同步校驗的方式:
圖4.2.1 視訊播放并行準備流程
GLSurfaceView在需要播放視訊時調用
addView
添加到視窗,在動畫結束後調用
removeView
及時從視窗移除。在
addView
同時間對Player進行初始化和準備。
無論是Renderer的
onSurfaceCreated
回調還是Player的
onPrepare
回調,都去調用同一個校驗方法,當SurfaceTexture建立好且Player準備就緒時,設定Surface并開始播放。需要注意的是,
onSurfaceCreated
的回調位于子線程,需要切換到主線程。
五、 反思總結
最終,部落客采用了方案二來解決這個“祖傳bug”。整個問題從系統分析到找到原因耗時不到一天,回顧過去的幾個月,其實都是在做無用功。這個問題的整個處理過程,也頗有反思:
- 對于不熟悉的技術領域,應當盡可能一邊快速學習一邊分析問題,如果不邁出第一步,則永遠無法拓寬技術棧
- 不輕易否定自己,尤其是在沒有系統思考和查閱檢索的情況下,這是逃避問題不負責任的表現
- 當問題卡殼時,借助圖形輔助手段,梳理流程和思路,找準問題核心原因,避免在錯誤的方向上浪費時間精力
路漫漫其修遠兮,這也算是職業生涯的成長過程吧。