基于GLSurfaceView的视频播放器偶现无画面的问题分析

一、 问题背景

博主所在项目中,涉及到视频动画播放功能,其实现方案采用的是bilibili开源项目ijkplayer播放器+GLSurfaceView+自定义渲染器:

  • ijkplayer提供视频解码能力,回调帧数据
  • 自定义Renderer实现shader操作,对帧画面修改
  • GLSurfaceView作为画布,进行展示

整个视频动画播放流程如下:

在这里插入图片描述

图1.1 视频动画播放流程

在长达近一年时间里,会偶现视频播放无画面的问题,具体表现为:视频动画开始播放到结束期间,没有任何帧画面。

该问题到了博主手里有半年时间,受限于对视频解码、OpenGL等技术领域知识体系的匮乏,尽管每隔一段时间把该问题捞出来分析一天,但每次都不了了之。并且也认为自己搞不定这个问题,无从下手。

这周趁着需求空档期,有些时间,决定调整思路,再系统地分析一遍这个问题。


二、 逐步排查

2.1 增加log,复现问题

  • SurfaceTexture#OnFrameAvailableListeneronFrameAvailable回调中增加日志,正常情况下每一帧都会回调该方法。
  • 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回调仅首帧打印了一次。

扫描二维码关注公众号,回复: 15299853 查看本文章

根据日志,在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,执行consumerDisconnect方法后置为true
  • 对于已废弃的BufferQueue,从IGraphicBufferProducer接口调过来时都会返回NO_INIT错误

IGraphicBufferConsumer中有两处consumerDisconnect的调用:

在这里插入图片描述

在这里插入图片描述

前者跨进程调用给后者的IBinder,然后后者在进程内调用,这是因为native渲染流程位于一个与应用进程独立的进程。

从现在开始倒推分析,均位于应用进程。

在这里插入图片描述

ConsumerBaseabandonLocked方法被SurfaceTexture覆写,这在头文件中有声明:

在这里插入图片描述
在这里插入图片描述

看到SurfaceTexture的native类,不禁想到Bitmap也是这样设计,Java层只是一个壳,封装一些基本的API,本质上是通过JNI调用native方法,核心逻辑全部位于Native层的同名类中。

abandonLocked方法又是由abandon调用,abandonSurfaceTexture的JNI调用:

在这里插入图片描述

回到Java层的SurfaceTexture

在这里插入图片描述

  • 用于释放缓冲区资源,将SurfaceTexture置为abandoned状态且不可逆转
  • 当处于abandoned状态,调用IGraphicBufferProducer接口的任何方法都会返回NO_INIT错误,即错误码-19
  • 调用后会释放这个SurfaceTexture关联的所有缓冲,如果有客户端或OpenGL ES通过纹理的方式引用这些缓冲,则继续保留
  • 当不再使用该SurfaceTexture时,需要调用这个方法,避免后续资源分配受阻

这和前面看到的BufferQueueCore中对mIsAbandoned字段的描述基本上是一回事。

由此可知,以上释放资源的步骤主要流程如下:

在这里插入图片描述

图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环境,其中便有回调RendereronSurfaceCreated方法。

而线程启动的地方有两处:

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进行初始化和准备。

无论是RendereronSurfaceCreated回调还是Player的onPrepare回调,都去调用同一个校验方法,当SurfaceTexture创建好且Player准备就绪时,设置Surface并开始播放。需要注意的是,onSurfaceCreated的回调位于子线程,需要切换到主线程。


五、 反思总结

最终,博主采用了方案二来解决这个“祖传bug”。整个问题从系统分析到找到原因耗时不到一天,回顾过去的几个月,其实都是在做无用功。这个问题的整个处理过程,也颇有反思:

  • 对于不熟悉的技术领域,应当尽可能一边快速学习一边分析问题,如果不迈出第一步,则永远无法拓宽技术栈
  • 不轻易否定自己,尤其是在没有系统思考和查阅检索的情况下,这是逃避问题不负责任的表现
  • 当问题卡壳时,借助图形辅助手段,梳理流程和思路,找准问题核心原因,避免在错误的方向上浪费时间精力

路漫漫其修远兮,这也算是职业生涯的成长过程吧。

猜你喜欢

转载自blog.csdn.net/zy13608089849/article/details/116243009