26、性能优化:渲染性能分析(Systrace/Profiler)、减少掉帧、内存优化、GPU过度绘制优化

说到SurfaceView的视频渲染,性能优化这块儿,我估计是很多同学心里最没底的地方。代码写完了,能跑了,但一上真机就卡成PPT。嗯,这事儿我太熟了。早期我做视频播放器的时候,就吃过这个亏——明明逻辑都对,但就是掉帧,用户反馈说“看个视频像在翻连环画”。

所以这一章,咱们就把性能优化这事儿掰开揉碎了讲。说白了,就是三件事:找到瓶颈、干掉掉帧、管好内存。我把自己这些年踩过的坑、用过的工具、总结的方法,全放在这里了。

26.1 渲染性能分析:你得先知道问题在哪儿

优化之前,先得诊断。我个人习惯用两个工具:SystraceGPU Profiler。它们俩配合起来,基本能覆盖90%的渲染问题。

26.1.1 用Systrace抓帧

Systrace是Android自带的性能分析工具。它能告诉你每一帧里,CPU、GPU、UI线程都在干嘛。你想想看,如果一帧超过了16ms,那肯定掉帧了。Systrace能帮你定位到具体是哪个环节超时。

我一般这么用:

// 命令行启动Systrace
python systrace.py -t 10 -o trace.html sched gfx view wm am app

// 或者直接在Android Studio里用Profiler抓取

抓完之后,打开生成的trace.html。你会看到一条条彩色的时间轴。重点关注这几个东西:

  • Choreographer#doFrame:如果这个阶段耗时超过16ms,说明UI线程卡了。
  • SurfaceFlinger:如果这里出现断层,说明GPU提交帧太慢。
  • BufferQueue:如果这里显示“dequeueBuffer”等待,说明SurfaceView的缓冲区被占满了。

重点:我在项目中遇到过,SurfaceView的渲染线程和UI线程互相抢资源。Systrace里一看,两个线程的“锁等待”时间占了整整5ms。后来我把渲染线程的优先级调低了一点,问题就解决了。

26.1.2 GPU Profiler看渲染管线

GPU Profiler能直接看到每一帧的渲染管线耗时。包括:顶点处理、光栅化、片段着色。如果某个阶段特别长,那就要针对性优化。

举个例子,有一次我发现片段着色器耗时异常高。查了半天,发现是SurfaceView的像素格式用了RGBA_8888,但实际视频源是NV12。每次渲染都要做格式转换,GPU累得够呛。改成直接支持NV12的格式后,耗时直接降了一半。

阶段 正常耗时 异常耗时 可能原因
顶点处理 < 2ms > 5ms 顶点数过多、VBO更新频繁
光栅化 < 3ms > 8ms 分辨率过高、Overdraw严重
片段着色 < 4ms > 10ms 着色器复杂、纹理采样过多

26.2 减少掉帧:从16ms到稳稳的幸福

掉帧的本质,就是一帧的渲染时间超过了16ms。为什么会这样?我总结了几种常见情况:

  • CPU/GPU负载不均衡:CPU拼命准备数据,GPU闲着;或者GPU忙死,CPU在等。
  • 缓冲区交换卡顿:SurfaceView的双缓冲或三缓冲机制出了问题。
  • 主线程阻塞:渲染线程被UI操作、垃圾回收等打断。

怎么解决?我一般从这几个方向入手:

26.2.1 使用三缓冲

SurfaceView默认是双缓冲。但如果你发现掉帧是因为“dequeueBuffer”等待,那就试试三缓冲。说白了,就是多一个缓冲区做缓冲池,减少等待。

// 在SurfaceView创建时设置
SurfaceView surfaceView = new SurfaceView(context);
surfaceView.getHolder().setFormat(PixelFormat.RGBA_8888);

// 在渲染线程中,使用三缓冲
EGLConfig config = ...;
EGLContext context = ...;
EGLDisplay display = ...;

// 设置EGL_SWAP_BEHAVIOR为EGL_BUFFER_PRESERVED
int[] attribs = {
    EGL10.EGL_RENDERABLE_TYPE, EGL_OPENGL_ES2_BIT,
    EGL10.EGL_SURFACE_TYPE, EGL_WINDOW_BIT | EGL_SWAP_BEHAVIOR_PRESERVED_BIT,
    EGL10.EGL_BUFFER_SIZE, 32,
    EGL10.EGL_DEPTH_SIZE, 0,
    EGL10.EGL_STENCIL_SIZE, 0,
    EGL10.EGL_NONE
};

小技巧:三缓冲虽然能减少掉帧,但会增加内存占用。如果你的设备内存紧张(比如2GB以下的老机型),建议还是用双缓冲,配合帧率控制。

26.2.2 控制帧率

不是所有场景都需要60fps。比如播放静态PPT或者低帧率视频,强行跑60fps反而浪费性能。我习惯根据视频源的实际帧率来动态调整渲染帧率。

// 动态帧率控制
long targetFrameTime = 1000 / targetFps; // targetFps从视频源获取
long lastFrameTime = 0;

while (isRunning) {
    long currentTime = System.nanoTime() / 1000000;
    long elapsed = currentTime - lastFrameTime;

    if (elapsed < targetFrameTime) {
        // 还没到时间,休眠一会儿
        Thread.sleep(targetFrameTime - elapsed);
        continue;
    }

    // 渲染一帧
    renderFrame();
    eglSwapBuffers(display, surface);

    lastFrameTime = currentTime;
}

26.3 内存优化:别让SurfaceView成为内存黑洞

SurfaceView本身不占太多内存,但它背后关联的图形缓冲区纹理可是吃内存大户。我记得有一次,一个720p的视频,SurfaceView占用了将近200MB内存。查了半天,发现是纹理缓存没释放。

26.3.1 纹理管理

每次渲染一帧,都会生成一个纹理。如果不及时释放,纹理就会越积越多。我一般用纹理池来管理:

// 纹理池示例
class TexturePool {
    private Queue<Integer> availableTextures = new LinkedList<>();
    private static final int MAX_TEXTURES = 3; // 最多保留3个纹理

    public int acquireTexture() {
        if (availableTextures.isEmpty()) {
            // 创建新纹理
            int[] textures = new int[1];
            glGenTextures(1, textures, 0);
            return textures[0];
        }
        return availableTextures.poll();
    }

    public void releaseTexture(int textureId) {
        if (availableTextures.size() < MAX_TEXTURES) {
            availableTextures.offer(textureId);
        } else {
            // 超过上限,直接删除
            glDeleteTextures(1, new int[]{textureId}, 0);
        }
    }
}

26.3.2 缓冲区复用

SurfaceView的渲染循环里,经常需要分配临时缓冲区。比如YUV数据转RGB,每次new一个byte数组,GC就会频繁触发。我建议用对象池或者直接复用全局缓冲区

注意:千万不要在渲染循环里做内存分配。我曾经因为这个原因,导致GC每秒钟触发两三次,掉帧掉到怀疑人生。后来把所有临时对象都改成复用,GC频率降到了每10秒一次。

26.4 GPU过度绘制优化:别让GPU做无用功

过度绘制(Overdraw)就是同一像素被画了多次。在SurfaceView里,常见的情况是:背景层、视频层、UI层叠加在一起,GPU把每一层都画了一遍,但用户只能看到最上面那层。

怎么查过度绘制?打开开发者选项里的“显示GPU过度绘制”,颜色越红,说明越严重。

26.4.1 减少图层

我一般遵循一个原则:能合并的图层就合并。比如视频画面和字幕,如果字幕是直接叠加在视频上的,那就别分成两个图层渲染,直接在片段着色器里合成。

// 片段着色器:视频和字幕一次合成
#version 300 es
precision mediump float;
uniform sampler2D videoTexture;
uniform sampler2D subtitleTexture;
in vec2 vTexCoord;
out vec4 fragColor;

void main() {
    vec4 videoColor = texture(videoTexture, vTexCoord);
    vec4 subtitleColor = texture(subtitleTexture, vTexCoord);

    // 如果字幕像素不透明,就覆盖视频
    if (subtitleColor.a > 0.1) {
        fragColor = subtitleColor;
    } else {
        fragColor = videoColor;
    }
}

26.4.2 裁剪不可见区域

如果SurfaceView有一部分被其他View遮挡了,那就别渲染那部分。用clipRect或者scissor test来裁剪。

// 开启裁剪测试
glEnable(GL_SCISSOR_TEST);
glScissor(x, y, width, height); // 只渲染可见区域

// 渲染...

// 关闭裁剪测试
glDisable(GL_SCISSOR_TEST);

核心思路:GPU每画一个像素,都是有成本的。你想想看,如果屏幕上有30%的区域是看不见的,那GPU就白干了30%的活。裁剪掉这些区域,性能提升立竿见影。

26.5 知识体系总览

说了这么多,我画了一张图,把这一章的核心逻辑串起来。你可以把它当作一个检查清单,每次做性能优化时,对着这张图一步步排查。

SurfaceView 渲染性能优化知识体系 性能分析 • Systrace抓帧 • GPU Profiler • 定位瓶颈 • 线程分析 减少掉帧 • 三缓冲 • 帧率控制 • 负载均衡 • 避免阻塞 内存优化 • 纹理池 • 缓冲区复用 • 减少GC • 对象池 过度绘制优化 • 减少图层 • 裁剪不可见 • 着色器合并 • Scissor Test 优化目标 每一帧 < 16ms | 内存稳定 | GPU无浪费 推荐工具:Systrace + GPU Profiler + 开发者选项过度绘制检测 配合 Perfetto 做深度分析效果更佳

这张图把四个优化方向串在了一起。你从性能分析入手,找到问题后,再针对性地选择减少掉帧内存优化或者过度绘制优化。别想着一次性全搞定,每次只解决一个问题,效果反而更好。

我的习惯:每次优化前,先用Systrace抓一次基线数据。优化完再抓一次,对比前后差异。这样你就能清楚地知道,每一行代码改动到底带来了多少性能提升。

好了,这一章的内容就到这儿。性能优化这事儿,没有银弹。但只要你掌握了这些工具和方法,遇到问题就不会慌。嗯,去试试吧,看看你的SurfaceView能不能跑到60fps稳稳的。