用了两年GLSurfaceView,我一直觉得它是 Android 平台上最省心的封装。直到我开始接视频滤镜、多路画面混合和自定义渲染管线,才发现它的封装也成了天花板。那段时间我花了不少力气把GLSurfaceView从核心链路里摘掉,自己接管 EGL 环境、渲染线程和 Surface 生命周期,折腾下来最大的感受是:这套流程不复杂,但链路上的细节非常多。
这篇内容就围绕一件事展开:不依赖GLSurfaceView,如何正确地把 OpenGL ES 渲染到屏幕上。我会把 EGL 初始化、线程模型、窗口绑定、生命周期管理这些关键节点逐一拆开讲,同时把我在实践中踩过的坑直接整理出来,适合已经会用基础 OpenGL ES、但对底层机制好奇的 Android 开发者,也适合正在设计自研渲染引擎、想做离屏渲染的同学参考。
1. 理解:GLSurfaceView 到底帮你省了什么
1.1 一个类办了四件事:EGL 管理、渲染线程、循环调度、生命周期绑定
很多初学者会以为GLSurfaceView只是继承SurfaceView后多了一个渲染回调,实际上它内部做的事情远超想象。我第一次读它的源码时,印象最深的是它把 OpenGL ES 在 Android 上运行的四个关键环节全部做了封装。
第一件事是 EGL 环境的创建。EGL 是 OpenGL ES 和本地窗口系统之间的桥梁,负责创建显示连接(Display)、绘制表面(Surface)和渲染上下文(Context)。GLSurfaceView在后台悄悄完成了eglGetDisplay、eglInitialize、eglChooseConfig、eglCreateContext这一整套流程,你几乎感知不到它的存在。
第二件事是渲染线程的调度。它内部维护了一个专门的渲染线程,不需要你在主线程做任何同步操作,GLSurfaceView 会在合适的时机把渲染任务派发下去,避免阻塞 UI 线程。
第三件事是渲染循环的驱动。只要设置过RENDERMODE_CONTINUOUSLY或RENDERMODE_WHEN_DIRTY,它就会自动决定什么时候调用onDrawFrame,是否需要连续渲染,还是只在requestRender时才刷新。
第四件事,也是GLSurfaceView真正棘手的地方,是生命周期管理。Surface 被创建、销毁,窗口尺寸发生变化,界面进入后台再回来,这些状态切换它都帮你处理好了。我们自己动手时,这一步最容易出问题。
1.2 拆掉封装之后:这四件事就是你要手写的清单
有了上面这个认知,所谓“不用 GLSurfaceView 渲染图像”,本质上做一次职责转移。你从被动调用者变成主动管理者,需要自己完成下面这张清单:
- 自己调用 EGL 相关 API,完成 Display、Config、Context、Surface 的初始化。
- 自己创建并维护渲染线程,处理好与主线程之间的状态同步。
- 自己监听窗口系统的变化,比如 Surface 的创建尺寸更新销毁,并在变化时重建 EGLSurface。
- 自己管理渲染循环的启停和帧率控制。
- 自己保证 Context 在合适的线程内被访问,并在生命周期切换时释放或保留资源。
听上去像是工作量增加了,但好处非常明显。你不再受GLSurfaceView内部状态机的限制,可以自由决定渲染上下文和线程的绑定关系,比如在同一个 EGLContext 之间共享纹理、同时渲染多个窗口,甚至可以把渲染结果直接输出到MediaCodec的输入 Surface 上,这些在视频工程里都是刚需。
我在实际项目中频繁使用的场景是视频处理管线:相机采集的帧先经过 OpenGL 滤镜处理,处理结果既要上屏预览,又要通过MediaCodec编码。这种情况如果还依赖GLSurfaceView,你会发现很难把中间渲染结果截取出来,或者再送入第二个消费方,而自己管理 EGL 环境之后,一条渲染管线可以绑定到任意 EGLSurface,代码结构反而更加清晰。
2. 核心原理:EGL 环境怎么一步步搭起来
2.1 Display/Surface/Context:三个概念先对齐
在写初始化代码之前,我建议先把 EGL 的三个核心概念彻底搞清楚。它们之间的关系有点像“显示器、画布、画笔”的组合。
EGLDisplay 连接到底层显示系统,在 Android 上通常传入EGL_DEFAULT_DISPLAY获取默认显示设备,它代表一个访问底层图形硬件的句柄。EGLSurface 是一块可绘制的区域,由本地窗口系统创建,通常对应一个Surface或SurfaceTexture,图像最终会被提交到这里。EGLContext 保存 OpenGL ES 的完整状态,包括顶点数组、着色器对象、纹理绑定等,它是渲染操作生效的前提。
三者必须配套使用。同一时刻只能有一个线程通过eglMakeCurrent绑定一组 Display、Surface、Context,切换线程时也要切换绑定的组合,这一点在自定义渲染线程时尤其关键。
2.2 代码实现:EGL14 初始化完整流程
空谈概念没有意义,直接看初始化代码。Android 上 Java 层有android.opengl.EGL14这个类,可以不用写任何 C/C++ 代码就完成整个 EGL 初始化,非常适合快速验证流程。
public boolean init(Surface surface) { // 1. 获取默认 Display mEglDisplay = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY); if (mEglDisplay == EGL14.EGL_NO_DISPLAY) { Log.e(TAG, "eglGetDisplay failed"); return false; } // 2. 初始化 Display 版本信息 int[] version = new int[2]; if (!EGL14.eglInitialize(mEglDisplay, version, 0, version, 1)) { Log.e(TAG, "eglInitialize failed"); return false; } // 3. 选择配置 int[] configAttribs = new int[] { EGL14.EGL_RENDERABLE_TYPE, EGL14.EGL_OPENGL_ES2_BIT, EGL14.EGL_SURFACE_TYPE, EGL14.EGL_WINDOW_BIT, EGL14.EGL_RED_SIZE, 8, EGL14.EGL_GREEN_SIZE, 8, EGL14.EGL_BLUE_SIZE, 8, EGL14.EGL_ALPHA_SIZE, 8, EGL14.EGL_DEPTH_SIZE, 16, EGL14.EGL_NONE }; EGLConfig[] configs = new EGLConfig[1]; int[] numConfigs = new int[1]; if (!EGL14.eglChooseConfig(mEglDisplay, configAttribs, 0, configs, 0, 1, numConfigs, 0) || numConfigs[0] <= 0) { Log.e(TAG, "eglChooseConfig failed"); return false; } mEglConfig = configs[0]; // 4. 创建 Context int[] contextAttribs = new int[] { EGL14.EGL_CONTEXT_CLIENT_VERSION, 2, EGL14.EGL_NONE }; mEglContext = EGL14.eglCreateContext(mEglDisplay, mEglConfig, EGL14.EGL_NO_CONTEXT, contextAttribs, 0); if (mEglContext == EGL14.EGL_NO_CONTEXT) { Log.e(TAG, "eglCreateContext failed"); return false; } // 5. 创建 Window Surface int[] surfaceAttribs = new int[] { EGL14.EGL_NONE }; mEglSurface = EGL14.eglCreateWindowSurface(mEglDisplay, mEglConfig, surface, surfaceAttribs, 0); if (mEglSurface == EGL14.EGL_NO_SURFACE) { Log.e(TAG, "eglCreateWindowSurface failed"); return false; } // 6. 绑定当前上下文 if (!EGL14.eglMakeCurrent(mEglDisplay, mEglSurface, mEglSurface, mEglContext)) { Log.e(TAG, "eglMakeCurrent failed"); return false; } return true; }这段代码有一个容易忽略的细节:EGL_CONTEXT_CLIENT_VERSION必须显式设置为 2,否则部分设备会默认创建 OpenGL ES 1.x 的上下文,导致之后调用 2.0 的接口直接报错。另外创建 Window Surface 时传入的不是EGLDisplay也不是EGLContext,而是 Android 的Surface对象本身,它本质上是ANativeWindow的 Java 层封装。
2.3 初始化成功却黑屏?先查这几个配置项
初始化代码运行正常,但屏幕上就是一片黑,这是我被问得最多的问题之一。大部分情况不是 EGL 初始化失败,而是配置项不满足当前窗口系统的要求。
最经常出问题的配置是EGL_SURFACE_TYPE。如果只指定了EGL_WINDOW_BIT,意味着你要创建的是窗口表面,对应屏幕上可见的窗口。但如果你后续想用eglCreatePbufferSurface做离屏渲染,就必须把相应的位也加进去,否则创建时会直接失败。
还有一个隐蔽的问题来自EGL_RENDERABLE_TYPE。某些设备上,默认配置可能同时匹配 OpenGL ES 2.0 和 3.0,但因为代码里没有设置EGL_CONTEXT_CLIENT_VERSION为 3,实际创建的是 2.0 上下文,而你后续可能使用了 3.0 的着色器语法。这通常不会在初始化时报错,而是在第一次绘制时,着色器编译阶段抛出错误。排查这种问题,先确认你的着色器版本和 EGL 上下文版本完全对齐。
EGL_DEPTH_SIZE和EGL_STENCIL_SIZE也是常见坑。如果你的渲染场景需要深度测试,但配置的深度位数不满足要求,eglChooseConfig可能匹配到完全不支持深度的配置,导致深度缓冲无效。这个问题的表象非常迷惑:画面能显示,但物体之间的遮挡关系完全不对。所以配置里要么不写深度相关项,要么写一个明确的值,不要依赖驱动默认行为。
3. 渲染线程与窗口绑定:自驱动帧循环
3.1 线程内部结构:平台无关的渲染 Loop 模板
EGL 环境搭建完成之后,渲染循环也要自己驱动。这里给一个可以直接用的线程模板,核心思路是把初始化、循环、释放封装在同一个线程内,避免跨线程调用 EGL 接口造成的崩溃。
public class RenderThread extends Thread { private Surface mSurface; private volatile boolean mRunning = false; private EGLDisplay mEglDisplay = EGL14.EGL_NO_DISPLAY; private EGLContext mEglContext = EGL14.EGL_NO_CONTEXT; private EGLSurface mEglSurface = EGL14.EGL_NO_SURFACE; private static final long FRAME_INTERVAL_MS = 16; public RenderThread(Surface surface) { mSurface = surface; } public void requestStop() { mRunning = false; interrupt(); } @Override public void run() { if (!init(mSurface)) { return; } mRunning = true; while (mRunning && !isInterrupted()) { long start = System.nanoTime(); drawFrame(); EGL14.eglSwapBuffers(mEglDisplay, mEglSurface); long costUs = (System.nanoTime() - start) / 1000; long sleepMs = FRAME_INTERVAL_MS - costUs / 1000; if (sleepMs > 0) { try { Thread.sleep(sleepMs); } catch (InterruptedException e) { break; } } } release(); } private void drawFrame() { // 到这里已经有一个可用的 GL 上下文,可以执行 glClear、绑定纹理、绘制三角形等操作 GLES20.glClearColor(0f, 0f, 0f, 1f); GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT); } }这个模板虽然简单,但结构上是完整的。init只负责创建 EGL 环境,drawFrame只负责绘制,release只负责释放资源。线程启动之后不会和主线程产生 EGL 接口上的竞争,因为所有调用都发生在同一个线程内部。volatile boolean和interrupt共同保证停止指令能够及时传递,避免线程卡死在 sleep。
3.2 绑定到 View 系统:三种画面载体怎么选
渲染线程准备好了,画面最终显示在哪还需要仔细考虑。Android 上常见的三种方案,底层逻辑完全不同,很多人在这里容易选错。
第一种是SurfaceView,它本身提供一个独立于 View 层级之外的 Surface,优点是可以直接与 EGL 创建 Window Surface 对接,性能最高,适合游戏、相机预览这类对延迟敏感的实时渲染场景。第二种是TextureView,它把内容合成到 View 层级内部,可以使用普通的 View 属性(比如旋转、缩放),但合成开销比SurfaceView大一些。第三种是GLSurfaceView,内部已经做了完整封装,我们这里既然要拆掉它,通常不再选它作为载体。
我的建议是:实时预览用SurfaceView,因为它与 EGL 兼容性最好,不需要额外处理 View 合成。如果需要在画面之上叠加复杂的 UI 动效,同时希望渲染内容参与 View 动画,再考虑TextureView。
使用SurfaceView时,要给SurfaceHolder添加回调,确保在 Surface 可用之后才启动渲染线程:
SurfaceHolder holder = surfaceView.getHolder(); holder.addCallback(new SurfaceHolder.Callback() { @Override public void surfaceCreated(SurfaceHolder holder) { mRenderThread = new RenderThread(holder.getSurface()); mRenderThread.start(); } @Override public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) { // 渲染线程里更新视口,见第 4 节 } @Override public void surfaceDestroyed(SurfaceHolder holder) { if (mRenderThread != null) { mRenderThread.requestStop(); try { mRenderThread.join(); } catch (InterruptedException e) { // ignore } mRenderThread = null; } } });这里有一个很重要的小细节:surfaceCreated被调用时,holder.getSurface()一定不为空,但此时窗口尺寸可能还没有就绪,所以glViewport不要放在这里调用。等surfaceChanged回调给出具体宽高后再设置视口,否则画面上可能出现拉伸或只显示局部的问题。
3.3 帧率控制:不锁死也不空转
很多开发者习惯在渲染循环里直接写一个while(true),每帧调用eglSwapBuffers之后不做任何等待。这样做会出现两个问题。第一,帧率完全失控,可能跑到 200 帧每秒,白白消耗 GPU 和 CPU;第二,与显示器刷新率不同步,出现画面撕裂。
最简单的帧率控制方法就是按固定间隔 sleep。上面的模板使用 16 毫秒作为间隔,对应约 60 帧每秒的上限。这个方案在入门阶段完全够用,但它不是最优解,因为 Android 设备的实际刷新率可能是 90Hz、120Hz,固定帧间隔无法精确对齐。
进阶做法是通过Choreographer获取下一帧的 vsync 时间,然后让渲染线程等待到那个时间点再继续执行。实现思路是主线程注册Choreographer.FrameCallback,在回调中向渲染线程发送信号,渲染线程收到后执行一帧绘制。这种方式既能让渲染节奏跟随显示刷新率,又不会造成无意义的空转。
如果你的渲染数据来自相机或视频解码器,建议采用按需渲染而不是持续渲染。也就是每一帧数据到达时触发一次渲染,否则在静止场景下依然满帧渲染,耗电量和发热都会明显上升。我在做视频预览功能时,最开始就用了持续渲染,结果手机发烫严重,后来改成“来了新帧再绘制”,效果立竿见影。
4. 生命周期管理:旋转、后台、销毁全流程
4.1 Surface 重建时的状态处理
Android 生命周期中,Surface 不是在onCreate时必然可用的。比如屏幕旋转后 Activity 重建,旧 Surface 会被销毁,新 Surface 会被创建。如果渲染线程还在使用旧 Surface,就会触发EGL_BAD_SURFACE之类的错误。
处理方式需要区分两个阶段。Surface 被销毁但线程还活着时,不能立刻销毁 EGLContext,因为纹理等 GPU 资源可能还需要保留。正确顺序是先解绑当前上下文,再销毁旧的 EGLSurface,保存 EGLContext 和 EGLDisplay,等待新的 Surface 到来后,重新创建一个新的 EGLSurface 并与原有 Context 绑定。
public void onSurfaceDestroyed() { if (mEglDisplay != EGL14.EGL_NO_DISPLAY && mEglSurface != EGL14.EGL_NO_SURFACE) { EGL14.eglMakeCurrent(mEglDisplay, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_CONTEXT); EGL14.eglDestroySurface(mEglDisplay, mEglSurface); mEglSurface = EGL14.EGL_NO_SURFACE; } } public void onSurfaceCreated(Surface surface) { if (mEglDisplay == EGL14.EGL_NO_DISPLAY || mEglContext == EGL14.EGL_NO_CONTEXT) { init(surface); } else { mEglSurface = EGL14.eglCreateWindowSurface(mEglDisplay, mEglConfig, surface, new int[] { EGL14.EGL_NONE }, 0); EGL14.eglMakeCurrent(mEglDisplay, mEglSurface, mEglSurface, mEglContext); } }这套逻辑保证了 Surface 重建时 Context 不会丢失。对于纹理这种 GPU 资源,只要 Context 还在,纹理就能继续使用。我在第一次写这套代码时不明白为什么不能直接销毁 Context,结果每次旋转屏幕后屏幕都黑掉,后来才发现纹理确实还在,但 Context 已经重建,旧纹理全部失效了。
4.2 EGL 上下文释放的顺序问题
应用进入后台或页面销毁时,需要彻底释放 EGL 环境。这里的顺序如果搞反,部分驱动会直接产生 native crash。
释放顺序固定为三步:第一步解绑上下文,调用eglMakeCurrent传入EGL_NO_SURFACE和EGL_NO_CONTEXT;第二步销毁 Surface;第三步销毁 Context 和 Display。要注意的是,Context 的销毁必须发生在eglMakeCurrent解绑之后,否则销毁动作会被忽略,后续还可能造成资源泄漏。
public void release() { if (mEglDisplay != EGL14.EGL_NO_DISPLAY) { EGL14.eglMakeCurrent(mEglDisplay, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_CONTEXT); if (mEglSurface != EGL14.EGL_NO_SURFACE) { EGL14.eglDestroySurface(mEglDisplay, mEglSurface); mEglSurface = EGL14.EGL_NO_SURFACE; } if (mEglContext != EGL14.EGL_NO_CONTEXT) { EGL14.eglDestroyContext(mEglDisplay, mEglContext); mEglContext = EGL14.EGL_NO_CONTEXT; } EGL14.eglTerminate(mEglDisplay); mEglDisplay = EGL14.EGL_NO_DISPLAY; } }另一个很多人忽略的问题是渲染线程的停止顺序。一定要先让渲染线程退出,再在 UI 线程释放 EGL 资源,否则可能出现线程还在eglSwapBuffers,资源已经被释放,导致空指针或驱动崩溃。所以我通常把requestStop()和join()放在主线程,确保线程完全退出后再调用release。
4.3 从一个链路延伸到离屏渲染
说到生命周期,顺便提一个不绑定窗口的经典场景:离屏渲染。前面的流程里,eglCreateWindowSurface需要传入一个窗口 Surface,但如果我不想直接上屏,而是想把渲染结果写进 Bitmap 或者编码成视频,怎么办?
答案是eglCreatePbufferSurface。它创建的 Surface 不关联任何窗口,驱动会在内存中开辟一块像素缓冲,所有绘制操作都在离屏像素缓冲中完成。初始化代码几乎一样,唯一区别是把配置里的EGL_SURFACE_TYPE加上EGL_PBUFFER_BIT,然后调用eglCreatePbufferSurface替代eglCreateWindowSurface。
离屏渲染最常见的工程应用是视频滤镜。相机采集到的一帧SurfaceTexture纹理,经过 OpenGL 绘制到离屏缓冲,再用glReadPixels读回 CPU,或者直接把像素缓冲交给MediaCodec编码。如果使用MediaCodec的输入 Surface,还可以把离屏渲染目标直接设为MediaCodec的 Surface,省去一次 CPU 拷贝,效率提升非常明显。
这部分内容值得单独展开,但核心原理仍然是 EGL 环境的管理:Context 不变,Surface 换成离屏类型,渲染目标就完全由你控制了。
5. 问题战场:黑屏、上下文丢失与画面撕裂
5.1 常见问题速查表
自己管理 EGL 环境之后,问题排查的难度比GLSurfaceView时代高了一个等级。我把平时最常踩的问题整理成一个速查表,方便你快速定位。
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 初始化失败 | 缺少EGL_RENDERABLE_TYPE或EGL_SURFACE_TYPE配置 | 对照eglChooseConfig的配置逐项检查 |
| 创建 Window Surface 失败 | Surface 已被销毁、不在 UI 线程、传入 null | 确认 Surface 可用时间点 |
| 绘制后黑屏 | 视口未设置、着色器编译失败、上下文版本不匹配 | 调用glGetError逐步定位 |
| 屏幕旋转后黑屏 | Surface 重建后仍使用旧 EGLSurface | 遵循解绑、销毁、重建的流程 |
| 程序退后台崩溃 | 渲染线程未停止就释放资源 | 先 join 线程,再 release EGL |
| 画面撕裂 | 帧率与刷新率不对齐 | 接入 Choreographer,启用缓冲交换控制 |
| 纹理数据错乱 | 多线程访问同一个 Context | 保证所有 GL 操作集中在渲染线程 |
5.2 上下文丢失后的恢复策略
EGL Context 存在丢失的可能,尤其在应用被系统回收、驱动重置或者显存不足时。这个问题在GLSurfaceView里也有,只是因为它把回调暴露成onSurfaceCreated的重新创建,你感知不到底层发生了什么。自己管理 EGL 之后,必须实现一个完整的恢复策略。
我采用的状态机设计是:渲染线程内部维护EGLContext 创建成功、EGLContext 失效、等待重建三种状态。当检测到eglSwapBuffers返回EGL_CONTEXT_LOST时,立即停止绘制并清理旧资源,标记为失效状态,然后在下一帧循环中重新执行创建流程。要注意的是,所有纹理、帧缓冲、着色器对象都绑定在旧 Context 上,Context 丢失后这些资源全部失效,需要重新创建。
大多数开发者会在这里掉以轻心。我试过只重新创建 Context 而不重建纹理,结果画面恢复后所有贴图都变成一片白。正确做法是设计一个GLResourceManager,统一管理纹理和着色器的生命周期,Context 重建时通过回调通知资源层全部重新加载。
5.3 多线程同步那些容易被忽视的细节
最后一个高频坑来自多线程同步。渲染线程和 UI 线程之间需要通信,比如传一个滤镜参数、通知 Surface 尺寸变化,如果只用一个普通变量,很容易出现“值改了但渲染线程看不到”或者“读到的值是半个状态”的问题。
最安全的方案是使用volatile标注跨线程传递的简单状态,比如布尔开关、整数参数;对于复杂对象,使用Handler把数据切换到渲染线程再更新。还有一个杀手级方案是SurfaceTexture配合OnFrameAvailableListener,新帧到达时由渲染线程的 Handler 收到消息,再执行取帧和绘制操作,这套模式在相机预览中非常稳定。
另外要特别警惕eglMakeCurrent的线程切换代价。频繁在两个线程之间切换 EGL 上下文绑定,会带来肉眼可见的掉帧。几乎不需要跨线程访问 GL 资源的场景,最好把渲染和资源加载全部固定在一个线程中,绑定一次 Context 后就不要反复切换。
6. 一些实际使用后的个人建议
拆掉GLSurfaceView这件事,我觉得最大的收获不是“性能变好了多少”,而是终于弄懂了 Android 图形栈里每一层究竟在做什么。你开始理解为什么有时候黑屏,为什么 Context 不能随便跨线程,为什么旋转屏幕后纹理突然消失,这些知识在你排查其他图形问题时同样能复用。
如果你当前的需求只是把一张纹理显示到屏幕上,也没有复杂的滤镜、多路合成需求,那继续用GLSurfaceView完全没有问题。但如果你正在做相机采集、视频编辑、多窗口渲染,或者想把渲染能力下沉到 Native 层,我非常建议自己手写一次这套流程。把 EGL 初始化、线程管理、Surface 重建这三块啃下来,再回头看GLSurfaceView的源码,会突然觉得它不再是一个黑盒。
最后分享一个我实践中的小技巧:在渲染线程内部维护一个简单的帧耗时统计,每隔一秒打印一次平均帧耗时。这个日志在刚接入 OpenGL ES 时可能看不出价值,但当你优化复杂场景时,它是定位掉帧和 GPU 过载的最直接依据。我在所有自研渲染模块里都保留了这个统计,排查问题时帮了大忙。