MediaCodec 全链路深度剖析(七):SurfaceFlinger、HWC 与 Android 显示管线
系列导读:第六篇把「容器 GraphicBuffer」和「管道 BufferQueue」讲完了,MediaCodec 解码出来的 YUV 已经躺在 SurfaceFlinger 的一个 slot 里、fence 也挂好了。接下来的问题是——这块 Buffer 是怎么变成屏幕上一帧图像的。这一篇把 Producer 之后到像素扫描出去之前的完整链路拆开:Surface / ANativeWindow 是最后一公里的封装,SurfaceFlinger 是每个 VSYNC 醒来的调度器,HWC/DPU 是真正把像素送出去的硬件,VSYNC + Choreographer 是整套系统的节拍器,SurfaceTexture 是通往 GPU 特效的桥。讲完这一篇,MediaCodec 一帧从入队到上屏的因果链就闭合了。
一、Surface / ANativeWindow:Producer 侧的最后一公里
第六篇讲过 BufferQueue 的接口是IGraphicBufferProducer / IGraphicBufferConsumer,两个 Binder 接口。直接用它们写代码相当难受——参数长、状态多、还要自己管跨进程句柄。所以libgui在 Producer 侧封了一层Surface,暴露 C++ 类和一套 C 结构体ANativeWindow。
一句话理解三者关系:
IGraphicBufferProducer (Binder 接口,跨进程) |
Surface同时继承ANativeObjectBase<ANativeWindow, Surface, RefBase>——同一个 C++ 对象既能当 C++ 类用(RefBase 智能指针),也能当 C 结构体用(EGL/Vulkan 那套 API)。这是 Android 图形栈里少见的”一个对象两种视图”设计。
Surface 干的三件事
第一件事,把 BufferQueue 的接口翻译成ANativeWindow函数指针:
struct ANativeWindow { |
好处是纯 C 接口没有 C++ ABI 依赖,EGL、Vulkan、MediaCodec 的 JNI 都直接用这套 API。任何调用最终都会走进Surface::hook_dequeueBuffer这一族静态函数,再转到实例方法上——“函数指针散射到 C++ 方法”是Surface一层最典型的实现套路。
第二件事,把 Producer 端琐碎的元信息 setter 集中管理:
surface->setBuffersDimensions(1920, 1080); |
这些字段最终都会打包进第六篇讲过的QueueBufferInput,跟着queueBuffer一起发到 Consumer 侧。dataspace 传给 SurfaceFlinger 决定要不要做色彩转换,usage 传给 Gralloc 决定分配到哪个 heap,transform 传给 HWC 决定硬件旋转还是 GPU 旋转——这些字段在下面几节都会再次出现。
第三件事,eglSwapBuffers背后就是它。GL 渲染管线里,一次eglSwapBuffers本质是这样一段:
EGLBoolean eglSwapBuffers(EGLDisplay dpy, EGLSurface eglSurface) { |
GL 渲染的输出等于 BufferQueue 的 Producer,只是 EGL 把这层封得看不出来。这个视角特别关键,MediaCodec.createInputSurface()就是把 encoder 塞成 BufferQueue 的 Consumer,再把 Producer 端 Surface 递给你,让你用 GL 去eglSwapBuffers喂帧给编码器。整个”GL 渲染 → 编码”零拷贝路径的接缝就在这里。
MediaCodec 的两种 Surface 用法
跟 MediaCodec 打交道,Surface 出现在两个场景,方向刚好相反:
解码输出侧——codec.configure(format, surface, null, 0)。此时 codec 是 Producer,你传进去的 surface 背后的 BufferQueue 的 Consumer 是 SurfaceFlinger(SurfaceView 场景)或 SurfaceTexture(TextureView / 特效场景)。第五篇讲过CCodecBufferChannel::renderOutputBuffer,就是把 vendor 解码产物在协议层queueBuffer一次。
编码输入侧——val inputSurface = encoder.createInputSurface()。此时 encoder 是 Consumer,你自己变成 Producer。中间夹的这个 BufferQueue 由GraphicBufferSource同时扮演 Consumer 和 encoder 的输入侧适配器:
// MediaCodec.cpp 简化,示意 createInputSurface 内部结构 |
也就是:编码器 Input Surface 只是把第六篇那套 BufferQueue 掉了个方向使——GL 渲染是 Producer,encoder 是 Consumer。
二、SurfaceFlinger:每个 VSYNC 醒来做的四件事
Buffer 在 BufferQueue 里QUEUED之后,就等 SurfaceFlinger 来 latch。SurfaceFlinger 是一个独立进程(system_server外,PID 通常写作sf),进程主线程是一个跑Looper的合成循环,由 VSYNC 信号驱动。
先给一张全景,接下来按顺序拆四步:
flowchart TB
VS["VSYNC-sf 事件到达"]
A["Phase 1: latchBuffer
拉每个 Layer 的最新一帧"]
B["Phase 2: prepare(HWC)
询问 HWC 每层怎么合"]
C["Phase 3: composition
DEVICE 归 HWC / CLIENT 归 GPU"]
D["Phase 4: presentDisplay
提交给显示硬件"]
NEXT["等下一次 VSYNC"]
VS --> A --> B --> C --> D --> NEXT
Phase 1: latchBuffer——拉最新一帧
SurfaceFlinger 里每个可见的窗口都对应一个Layer对象,Layer 内部持有一个 BufferQueue Consumer 端。合成循环开始时,每个 Layer 都会调一次latchBuffer:
status_t Layer::latchBuffer(bool& recomputeVisibleRegions, nsecs_t latchTime) { |
三行关键判断读出来:
acquireBuffer只拿一帧:即便 BufferQueue 里堆了三块QUEUED,SF 一次只拉一块。异步模式下拉的是最新那块,中间那块被直接 drop——这就是”SurfaceFlinger 掉帧”的机制原点,跟 App 端渲染慢是两码事。- acquire fence 只是记下来,不 wait:SF 到真正让 GPU/HWC 读 buffer 时才让硬件命令流去等这个 fence,CPU 这条线全程不阻塞。
- releaseBuffer 传回 releaseFence:Producer 下一次
dequeueBuffer要复写这个 slot 时会等这个 fence signal——这条链在第六篇讲过,两端 fence 加起来构成完整的握手。
Phase 2: prepare——把 layer 集合交给 HWC 决策
在讲这一步之前,先把两个后面反复出现的词定义清楚。SurfaceFlinger 每一层都要走两条合成路径中的一条:
- DEVICE(硬件合成):这层 buffer 交给 HWC,由 DPU 硬件流水线上的一个 overlay plane 直接读、直接扫描到显示接口。SF 和 GPU 完全不参与像素搬运。是”最省电、最低延迟”的路径,视频播放追求的就是这条。
- CLIENT(GPU 合成):DPU 的 overlay plane 收不下这一层(原因下一节展开),SF 只能自己动手——用 GPU 把这层画到一块中间 buffer(叫 Client Target)里,最后再把 Client Target 当作一层交给 HWC。多一次 GPU pass,功耗和延迟都会抬。
每层归 DEVICE 还是 CLIENT,不是 SF 单方面决定的,是 SF 提议 + HWC 拍板——这就是 Phase 2 干的事。
latch 完所有 Layer 后,SF 把当前这一轮的 layer 集合交给 HWC HAL:每个 Layer 的buffer、acquireFence、transform、crop、dataspace、hdrMetadata一并 setLayer 到 HWC,然后调validateDisplay:
for (Layer* layer : layers) { |
validateDisplay是一次协商——SF 说”我想让这几层都走 DEVICE”,HWC 回一句”这几层我能收,那几层你自己搞”。返回值里带的ChangedCompositionTypes就是 HWC 改判为CLIENT的那些 layer。这一步之后,每个 Layer 的最终 composition 类型才定下来。
Phase 3: composition——两条路径分工
Composition 分两条路走:
DEVICE 层:SF 什么都不做,等下一步的presentDisplay让 HWC 直接搬过去。
CLIENT 层:SF 用RenderEngine(底层是 GLES 或 Vulkan)把这些层合到一块中间GraphicBuffer——叫 Client Target。合完之后 Client Target 也当作一个 layer 交给 HWC:
renderEngine->drawLayers(clientLayers, clientTarget, displayViewport); |
这就是”GPU 合成”和”HWC 合成”这两个说法的底层含义——同一次 VSYNC 里,一部分 layer 走硬件路径,一部分走 GPU 路径,最后由 HWC 把两条路的结果贴在一起。视频播放最理想的场景是零 CLIENT 层,全部 DEVICE,GPU 完全不参与。
Phase 4: present——提交并回传 fence
最后一步很短:
hwcDisplay->presentDisplay(&presentFence); |
presentFence是 DPU 真正把这一帧扫描到显示接口时 signal 的 fence。它会通过 BufferQueue 反向传给 Producer——MediaCodec 解码出来的这一帧到底什么时候上屏,Producer 侧靠这条 fence 才知道。A/V 同步校准的时间戳源头就在这里(后面文章会用到)。
三、HWC / DPU:为什么视频能做到低功耗
上一节讲了 SF 会把 layer 交给 HWC 决策,这一节讲 HWC 到底是什么,凭什么能”不走 GPU 就把画面送出去”。
HWC(Hardware Composer)是一层 HAL,Android 13 之后接口从 HIDL 换到了 AIDL:
android.hardware.graphics.composer3 // AIDL, Android 13+ |
HAL 之下真正干活的硬件叫 DPU(Display Processing Unit)——手机 SoC 里独立于 CPU/GPU 的一块显示模块。它的核心能力是多个 overlay plane 直接读 DMA-BUF、硬件混合、扫描输出:
flowchart TB
subgraph DPU["DPU 硬件流水线"]
P1["Plane 1
Layer: 状态栏 (RGBA)"]
P2["Plane 2
Layer: App UI (RGBA)"]
P3["Plane 3
Layer: 视频 (NV12/P010)"]
P4["Plane 4
Layer: 导航栏 (RGBA)"]
BLEND["Alpha 混合 + 色彩转换 + 缩放"]
P1 --> BLEND
P2 --> BLEND
P3 --> BLEND
P4 --> BLEND
end
OUT["MIPI DSI / eDP / HDMI / DP"]
BLEND --> OUT
overlay plane 数量因 SoC 而异,中高端 SoC 大约 4-8 个。每个 plane 读一块GraphicBuffer——就是第六篇讲的那个 dma-buf 视图,MediaCodec 输出的 YUV buffer 直接就能塞进去。
视频层为什么典型是 DEVICE 合成
一个典型视频播放场景的 layer 栈从底到上排列:
Z=0 墙纸 (DEVICE) |
这五层刚好占满 5 个 overlay plane,全 DEVICE 合成。整条播放链路的画面数据流是:
VPU 硬件解码 |
GPU、CPU 全程不参与像素搬运——VPU 写完这块 YUV,DPU 直接把它扫到屏幕。第六篇里那句”MediaCodec 输出走HW_TEXTURE | HW_COMPOSER“到这里才闭合含义:HW_COMPOSER就是给 HWC 用的钥匙。这条零拷贝路径决定了 1080p30 视频稳定播放时功耗能压到 100-200 mW 量级——手机厂商标榜的”低功耗视频播放”,能耗曲线上那条平线基本靠这条通路撑起来。
四、VSYNC 与 Choreographer:整套系统的节拍器
前面三节都在描述”每一次 VSYNC 里发生什么”。这一节讲 VSYNC 本身——它从哪里来、怎么分发到 App 和 SF、以及为什么 Android 4.1 之后卡顿明显减少。
VSYNC 的来源
屏幕硬件每 16.67 ms(60 Hz)扫描完一帧就触发一次硬件中断,Linux DRM 驱动收到中断,通过 event fd 通知 SurfaceFlinger。SF 内部维护一个DispSync对象,用来对硬件 VSYNC 做节奏预测——即使硬件 VSYNC 因为省电临时关掉,DispSync也能凭历史时刻软推下一次 VSYNC:
DPU 硬件 vblank 中断 |
分成两路广播的原因是让 App 比 SF 早若干毫秒开始绘制,这样 SF 醒来时 App 已经queueBuffer完了,SF 立刻能 latch 到最新一帧。如果 Offset 设为 0(同时醒来),App 画完的帧必须干等一个周期,到下一个信号来时才能被 SurfaceFlinger 消费,产生 33.2ms(2帧) 的巨大肉眼延迟;
VSYNC-app 与 VSYNC-sf 的 offset
两路 VSYNC 相对硬件 VSYNC 各有一个 offset(负值,即提前触发):
sequenceDiagram
participant HW as 硬件 VSYNC
participant APP as App (Choreographer)
participant SF as SurfaceFlinger
HW->>APP: VSYNC-app (offset_app 提前, 比如 -2ms)
Note over APP: 触发 doFrame → 测量/布局/绘制/eglSwapBuffers
APP->>APP: buffer QUEUED
HW->>SF: VSYNC-sf (offset_sf 提前, 比如 -1ms)
Note over SF: onMessageRefresh → latchBuffer → present
HW->>HW: 硬件扫描出这一帧
各家厂商的 offset 都是自己调优的,Pixel 系上大约是vsyncPhaseOffsetNs=1000000(SF 提前 1 ms)、appVsyncPhaseOffsetNs=2000000(App 提前 2 ms)量级。这两个数字直接影响端到端延迟:offset 拉大能让流水线更宽松,代价是从用户输入到上屏多一帧延迟。
Choreographer 是 App 侧入口
App 侧不直接监听 VSYNC,是通过Choreographer——它内部有个DisplayEventReceiver挂在 fd socket 上,SF 那边把 VSYNC-app 事件写过来:
Choreographer.getInstance().postFrameCallback(new Choreographer.FrameCallback() { |
Choreographer每次收到 VSYNC-app 会按固定顺序处理四类回调:INPUT(分发触摸事件)→ANIMATION(推进属性动画)→TRAVERSAL(ViewRootImpl.performTraversals触发测量/布局/绘制)→COMMIT。这条流水线就是 UI 帧渲染的骨架——任何一步耗时超 16 ms,这一帧就 miss VSYNC,用户感知为卡顿。
视频播放的 VSYNC 用法
写视频播放器时最容易踩的一个坑:直接按 PTS 用Thread.sleep控制帧节奏。这个写法在 60Hz 屏、30fps 视频上偶尔看着还行,一遇到 24fps 或者动态刷新率就崩:
// 反例:睡眠不对齐 VSYNC |
问题在releaseOutputBuffer(idx, true)只是把 buffer 递给 SF 的 BufferQueue,具体上哪一次 VSYNC 由 SF 决定,App 完全不可控。正确姿势是把预期上屏时刻直接告诉 MediaCodec:
// 正确:把渲染目标时刻告诉 codec |
MediaCodec 内部会把desiredRenderTimeNs写进QueueBufferInput.timestamp(第六篇讲过这个字段)——SF 在 latch 时会挑timestamp最接近当前 VSYNC 时刻的那一帧显示。这条通路是 A/V 同步与 VRR(可变刷新率)联动的关键,具体实现细节留到后面的 A/V 同步专题。
五、SurfaceTexture:把视频帧变成 GL 纹理
前面四节把”视频帧到屏幕”这条主路讲完了。但很多真实场景里视频不是直接上屏——播放器要加滤镜、加水印、做美颜、录屏。这些场景的公共前提是把视频帧当成 GL 纹理来采样。这一步靠SurfaceTexture。
定位:一种特殊的 Consumer
SurfaceTexture本质是 BufferQueue 的一种 Consumer,跟 SurfaceFlinger 的 Layer 平级——同样从 BufferQueue 里acquireBuffer。区别在于它拿到 buffer 之后不做合成、也不送屏,而是把这块GraphicBuffer绑到一个 GL 纹理上,供你自己写的 shader 采样:
MediaCodec 解码器 (Producer) |
这条通路的价值是提供了”视频 + 特效”这个能力——不用把 YUV 拷到 CPU 转 RGB 再传回 GL,一切在 GPU 侧完成。
updateTexImage 的两次关键 EGL 调用
updateTexImage是SurfaceTexture的核心方法,代码骨架看下面这几行:
status_t SurfaceTexture::updateTexImage() { |
关键在步骤 2 和步骤 3——eglCreateImageKHR + glEGLImageTargetTexture2DOES是 Android 图形栈里”跨子系统零拷贝”的通用模式。GraphicBuffer 底层的 dma-buf 没有被拷贝,只是多了一个 GL 侧的采样入口。VPU 写过的那块物理内存,GPU 直接就能读。
为什么要用 GL_TEXTURE_EXTERNAL_OES
普通GL_TEXTURE_2D要求内容是 GL 标准像素格式(RGBA 之类)。SurfaceTexture 底下经常是 NV12、P010——这些是 YUV 家族的存储格式,GL 标准里不认。GL_TEXTURE_EXTERNAL_OES是 Khronos 的扩展,允许采样任意硬件像素格式,GPU 驱动内部自动做 YUV 到 RGB 的转换。
对应到 shader 上有两处必须改:
|
SurfaceView vs TextureView vs 手动管 GL
同一个 codec 输出 Surface,接的 Consumer 不同,性能特征天差地别:
- SurfaceView:Consumer 是 SurfaceFlinger,视频层是独立 Layer。走 HWC DEVICE 合成的可能性最大,功耗最低、延迟最短,代价是不能参与 View 动画(旋转、alpha、平移都不作用于视频画面)。
- TextureView:Consumer 是内嵌的 SurfaceTexture,视频帧被合并进 App 的硬件绘制树。可以随便做 View 动画,代价是必然走 CLIENT 合成(多一次 GPU pass),功耗和延迟都比 SurfaceView 高一档,一般多 1-2 帧延迟。
- 手动管 EGL Context + SurfaceTexture:Consumer 是自己写的类,视频帧作为一个 GL 纹理供你随意加特效,最终再输出到 SurfaceView 或 encoder 的 Input Surface。灵活度最高,实现复杂度也最高。
选型判断:纯播放选 SurfaceView;需要视频跟着 View 动画一起变换选 TextureView;做特效或需要边播边录,只能选第三种。
第三种方案的完整拓扑是这样的:
flowchart LR
DEC["MediaCodec 解码器"]
ST["SurfaceTexture
OES 纹理"]
FX["自定义 GL Shader
滤镜/水印/美颜"]
DISP_SURF["SurfaceView 的
EGLSurface"]
ENC_SURF["Encoder InputSurface
的 EGLSurface"]
ENC["MediaCodec 编码器"]
MP4[("MP4 输出")]
DEC -- queueBuffer --> ST
ST -- updateTexImage --> FX
FX -- eglSwapBuffers --> DISP_SURF
FX -- eglSwapBuffers --> ENC_SURF
ENC_SURF --> ENC --> MP4
同一个 GL Context 里挂两个 EGLSurface,分别向显示和编码器输出——这是 Android 上”边播边录 + 特效”的标准骨架。
六、闭环:MediaCodec 一帧的完整命运
到这里把一帧 YUV 从 vendor 进程解出来到显示到屏幕的完整因果链拉通看一遍:
sequenceDiagram
participant VPU as VPU 硬件
participant Vendor as vendor 进程
(Codec2 HAL)
participant App as App 进程
(MediaCodec / BufferChannel)
participant BQ as BufferQueue
(SF 进程内)
participant SF as SurfaceFlinger 主线程
participant HWC as HWC HAL / DPU
participant Screen as 显示屏
Note over VPU,Vendor: 第五篇讲的 vendor 侧
VPU->>Vendor: 解码完成,YUV 写入 dma-buf
VPU->>Vendor: 触发 acquire fence
Note over Vendor,App: 第五 + 第六篇讲的跨进程
Vendor->>App: workDone (C2Work 回调)
App->>App: renderOutputBuffer(pts, renderTimeNs)
Note over App,BQ: 本文第一节
App->>BQ: Surface::queueBuffer
(带 timestamp/dataspace/fence)
BQ->>BQ: slot 状态 DEQUEUED → QUEUED
BQ->>SF: onFrameAvailable
Note over SF: 本文第二节
SF->>BQ: latchBuffer (下一次 VSYNC-sf 触发)
BQ->>SF: BufferItem (带 acquire fence)
SF->>HWC: setLayer + validateDisplay
HWC->>SF: composition = DEVICE
Note over SF,HWC: 本文第三节
SF->>HWC: presentDisplay
HWC->>HWC: DPU 直接读 dma-buf
(等 acquire fence signal)
HWC->>Screen: 扫描到 MIPI/eDP/HDMI
HWC->>SF: presentFence
SF->>App: 回传 releaseFence + presentFence
按流水线角度重述整条路径的一句话:
VPU 解码写出的这块 dma-buf,在 vendor 进程、App 进程、SF 进程里各有一份 GraphicBuffer 视图,物理内存只有一份(第六篇的物理底座)。App 进程走Surface::queueBuffer把 slot 交给 SF 的 BufferQueue(本文第一节)。SF 每收到一次 VSYNC-sf 醒来一次,latchBuffer拿到最新帧,validateDisplay跟 HWC 协商 composition 类型(本文第二节)。视频层多数场景是 DEVICE,DPU 硬件流水线直接读那块 dma-buf 扫描到显示接口(本文第三节)。整条链上,acquire fence 保证 DPU 不会读到还没写完的 buffer,release fence 保证 VPU 不会复写还没扫完的 buffer——CPU 全程只在协议层做转手,一字节像素都没搬。
VSYNC 是这条流水线的节拍器(本文第四节),SurfaceTexture 是从这条流水线抽出一个中间点做特效的接口(本文第五节)。
收束
- MediaCodec 的输出端不神秘。它就是一个 BufferQueue Producer,通过
Surface封装暴露成ANativeWindow。渲染到屏幕靠 SF+HWC,加特效靠 SurfaceTexture,编码新一路视频靠createInputSurface——三条路共用同一个 BufferQueue 抽象。 - SurfaceFlinger 每次 VSYNC 只做四件事:latch、prepare、composition、present。搞不清卡在哪一步就
dumpsys SurfaceFlinger --latency看时间点,别猜。 - HWC 是”能不用 GPU 就不用 GPU”的裁判员。DPU 有几个 overlay plane、DPU 支不支持某种 dataspace,直接决定视频功耗曲线的形状。
- VSYNC-app 和 VSYNC-sf 的 offset 是端到端延迟的直接来源。播放器控帧不要
Thread.sleep,用releaseOutputBuffer(idx, renderTimeNs)把预期上屏时刻交给 SF。 - SurfaceTexture 是特效路径的入口——理解它 = 理解
eglCreateImageKHR + glEGLImageTargetTexture2DOES这两行调用做了什么。
到这里图形管线(GraphicBuffer + BufferQueue + Surface + SurfaceFlinger + HWC + VSYNC + SurfaceTexture)全部拆完,MediaCodec 的输出端已经完全打通。系列后续两篇转向 GPU 特效链路和编码回程,都会不断用到这一篇里定义的 Surface / SurfaceTexture / VSYNC 三个接口。