“解码后的视频帧到 TextureView 动起来” 的数据流、定时显示、与帧回收

128 阅读5分钟

1) 帧是怎么流转到 TextureView 的?

核心参与者与关系

MediaCodec(视频渲染器) →  Surface  →  BufferQueue  →  SurfaceTexture  →  TextureView(GPU采样绘制)
  • MediaCodecVideoRenderer(ExoPlayer) :把解码后的帧交给 Surface

  • Surface:生产者侧把手;往它 queueBuffer 就等于把帧送入 BufferQueue

  • BufferQueue:一个有固定槽位(典型 triple-buffer)的队列,负责生产者/消费者之间的缓冲周转。

  • SurfaceTexture(消费者):从 BufferQueue 取到“最新一帧”,把它暴露为 OES 外部纹理(GL_OES_EGL_image_external)。

  • TextureView:在下一次 VSync 的绘制中对这张外部纹理做一次 GPU 采样并画到 View 区域。

实际上,ExoPlayer 调用 player.setVideoTextureView(textureView) 后,会用 textureView.getSurfaceTexture() 创建 Surface 并把它交给视频渲染器,后续就进入上述链路。


2) 如何保证“在正确的时刻显示正确的一帧”?

时钟与对齐

  • ExoPlayer 以音频时钟为主(没有音频时用系统时钟),计算“某一视频帧的呈现时间戳(PTS)应该何时显示”。

  • 视频渲染器(MediaCodecVideoRenderer)在恰当的时刻把解码输出缓冲“释放”给 Surface:

    • API 23+:MediaCodec.releaseOutputBuffer(index, renderTimestampNs) —— 预约渲染,系统会在更接近 VSync 的时间把帧送去合成;
    • 老版本:releaseOutputBuffer(index, /render=/true),立即可用,随后在合成周期显示。
  • VideoFrameReleaseHelper:ExoPlayer 的帧释放节拍器。根据显示器刷新率 / VSync 相位微调释放时机(必要时还会调用 Surface.setFrameRate()),减少抖动/撕裂。

帧选择与丢弃

  • 如果某帧已经“晚了”(当前时刻 > PTS + 阈值),渲染器会跳过(drop)这帧,转而追上更靠后的关键帧/下一帧;这样能保证“现在看到的是时间上正确的画面”,而不是把过期帧逐个补上导致更大的不同步。

3) TextureView 是怎么“消费最新一帧”的?

  • 生产者不断把帧 queueBuffer 到 BufferQueue

  • SurfaceTexture 收到 onFrameAvailable 事件,表示有新帧可取。

  • TextureView 的一帧绘制(由 Choreographer/RenderThread 驱动)里,会执行:

    1. updateTexImage():取出“最新一帧” 并把它绑定到 OES 外部纹理;
    2. getTransformMatrix():获得纹理采样矩阵(应对裁剪/翻转/旋转);
    3. GPU 绘制:把这张纹理贴到 TextureView 的区域(因此 TextureView 能做圆角、缩放、alpha 等效果)。
  • 如果生产者比 UI 绘制更快,SurfaceTexture 只保留最新帧;中间帧会被跳过(这也是“追实时”的一种自然机制)。


4) 不用的帧如何回收?怎么避免内存泄漏?

图像缓冲的生命周期(无须你手动干预)

  • BufferQueue 的每个槽位里是一个 GraphicBuffer(底层显存/共享内存);帧在“生产者 → 消费者 → 归还”的循环中周转。

  • 当 TextureView 下一次 updateTexImage() 取新帧时,上一帧对应的 buffer 会自动归还到队列可复用;Java 层无需你保存/释放单帧对象。

  • 只要生产者和消费者都正确释放(MediaCodec/Surface/SurfaceTexture/TextureView 生命周期对齐),缓冲就能在 native 层及时复用,不会“越积越多”。

真正会造成泄漏/“bad surface”的是生命周期管理不当:

  • 只保留 Surface 引用,而让它关联的 SurfaceTexture/TextureView 被回收 → 继续写会报 …is abandoned/bad surface;底层可能残留未释放的句柄。

  • 未在合适时机 解绑/释放 播放器与输出 Surface,导致 MediaCodec、OpenGL 纹理等资源悬挂。

防泄漏实务清单

  1. 绑定与解绑
// 绑定
player.setVideoTextureView(textureView)

// TextureView 要销毁时(或退出页面)先解绑再释放
override fun onSurfaceTextureDestroyed(st: SurfaceTexture): Boolean {
    player.setVideoTextureView(null)   // 或 clearVideoTextureView()
    return true // 让系统回收底层 SurfaceTexture 资源
}

override fun onDestroy() {
    player.release()
}
  1. 自己用 Surface(textureView.surfaceTexture) 时,也要在不再使用时 player.setVideoSurface(null) 并 surface.release()。

  2. 保持消费者强引用

    不要只留着 Surface;要保证 TextureView/SurfaceTexture/PlayerView 的生命周期覆盖你的播放周期。

  3. 避免父布局裁剪导致“半帧留存”假象

    使用 animateContentSize / AnimatedVisibility 等布局动画,不要在父容器上硬改 height 把视频裁掉。看似“没回收/只显示一半”的多是布局问题。

  4. 不要把帧转成 Bitmap 常驻内存

    若要截图,截完即用即弃;不要缓存大量帧的 Bitmap,这才是 Java 层真正的内存风险点。

  5. 播放完/切后台时降资源占用

    • 不需要显示时 player.pause() 并可 clearVideoTextureView();
    • 需要保留最后一帧预览,可等页面真正销毁时再解绑。
  6. 监控与调试

    • 关注 ExoPlayer 的 dropped frames / decoder counters 日志;
    • adb shell dumpsys SurfaceFlinger --list/--latency、systrace(gfx/sf)看合成与队列情况;
    • adb shell dumpsys meminfo 观察 native/graphics 内存。

5) 常见问答

  • Q:为什么会“错位/卡顿/抖动”?

    A:通常是 时钟不同步释放时机不对齐 VSync。ExoPlayer 已用 VideoFrameReleaseHelper 对齐;若你自行改了刷新率/显示模式或父布局频繁测量裁剪,会破坏稳定性。

  • Q:暂停后内存会涨吗?

    A:暂停时一帧仍驻留在 SurfaceTexture 的“当前 buffer”上;这不是泄漏。解绑或销毁后会释放/复用。

  • Q:能调 BufferQueue 大小吗?

    A:应用层基本不可控(系统策略);你只能控制是否丢帧(ExoPlayer 的 lateness 策略)和输出帧率(setFrameRate/内容本身帧率)。


小结

  • 正确时刻显示正确帧:ExoPlayer 以音频时钟为主 + 帧释放对齐 VSync(预约渲染/及时丢弃过期帧)。
  • 不需要的帧如何回收:BufferQueue 的获取/归还是自动的;只要按生命周期解绑/释放 Surface/SurfaceTexture/MediaCodec,不会积压。
  • 避免泄漏的关键:管理好绑定/解绑顺序消费者强引用,别缓存大量帧对象,退出时清理干净。