ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Android图形渲染进阶:从GLSurfaceView到手动EGL环境管理

Android图形渲染进阶:从GLSurfaceView到手动EGL环境管理 用了两年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 过载的最直接依据。我在所有自研渲染模块里都保留了这个统计排查问题时帮了大忙。
返回列表