MediaCodec 全链路深度剖析(八):总结从码流到屏幕—Android 视频播放全链路
系列导读:这是 MediaCodec 全链路系列 Part I 的收束篇。前七篇按分层顺序拆过一次——从 Kotlin API 到 Native 消息、从 CCodec 到 Codec2、从 GraphicBuffer 到 SurfaceFlinger。分层拆解的代价是每篇只讲一段管道,读完之后端到端的因果链容易散。这一篇把 01 到 07 拉回一张全景图,只讲主线,不再下钻实现细节。读完这一篇,一句话复述”从码流字节到像素点亮”该说清楚哪些环节,就能自己填出来。
一、回到
系列第一篇的问题:点下播放键之后,一帧从 MP4 里的字节到面板上一个像素,中间到底发生了什么?当时给的答案是三个数字——3 次跨进程、7 次关键跳跃、4 种硬件协同。
前七篇每一篇拆的都是这条链路上的某一段:
- 01、02 定位 MediaCodec 的身份和 Java 侧状态机;
- 03 讲 Native 侧
AMessage+ALooper怎么把同步 API 支起来; - 04 讲 MediaCodec 和 CCodec 的分工,
BufferChannel是做什么的; - 05 讲
C2Work跨进程到 vendor,BufferPool 的引用计数; - 06 讲 GraphicBuffer 与 BufferQueue 这套零拷贝底座;
- 07 讲 Surface、SurfaceFlinger、HWC、VSYNC 这一段,一直到 DPU 扫描出去。
每一篇是”一段管道”,Part I 收束就是把七段管道拼成一张地图,让 01 那道题能被完整回答。
二、一帧的完整生命周期
先给那张全景图。这是整条主线唯一一张时序图,横跨三个进程、六个软件层、五种硬件角色。
sequenceDiagram
autonumber
participant App as App 进程
(Kotlin/Java)
participant Frw as App 进程
(Native Framework)
participant Med as mediaserver / codec 进程
(CCodec + Codec2 client)
participant Ven as vendor 进程
(Codec2 HAL + VPU 驱动)
participant Sf as SurfaceFlinger 进程
participant Hw as HWC / DPU (硬件)
App->>Frw: queueInputBuffer(index, ...)
Frw->>Frw: JNI 进入 JMediaCodec
PostAndAwaitResponse 挂起
Frw->>Med: Binder: kWhatQueueInputBuffer
Med->>Med: CCodecBufferChannel
组装 C2Work
Med->>Ven: HIDL/AIDL: queue(work)
Ven->>Ven: VPU 硬解 → 写入 dma-buf
产出 GraphicBuffer(slot)
Ven-->>Med: onWorkDone(work)
output buffer index
Med-->>Frw: kWhatDrainThisBuffer
Frw-->>App: onOutputBufferAvailable(index)
App->>Frw: releaseOutputBuffer(index, renderTimeNs)
Frw->>Sf: Surface::queueBuffer
(通过 IGraphicBufferProducer)
Note over Sf,Hw: VSYNC-sf 到达
Sf->>Sf: latchBuffer / prepare
validateDisplay
Sf->>Hw: presentDisplay(layer 集合)
Hw->>Hw: DPU 直接读 dma-buf
overlay 扫描到面板
Hw-->>Sf: presentFence signal
Sf-->>Ven: releaseFence signal
slot 归还
这张图上每一根箭头都对应前七篇的一节内容,不打算再复述。它的作用是提供一个可以对着 trace 去核对的骨架——Perfetto 里能看到的每一条MediaCodec::onQueueInputBuffer、C2Work::process、SurfaceFlinger::onMessageReceived都能落到这张图上的某一步。
有三条正交的线索沿着这张图并行流动。分开看能看清各自的规律,混在一起看就是一团乱:控制流、数据流、同步流。下面三节各拆一条。
三、控制流:命令怎么走完六层
控制流是”一次调用是怎么被路由到硬件寄存器”的路径。App 侧看到的是同步的方法调用,底下六层每一层都有自己的边界。
按调用发生的顺序排列,一条queueInputBuffer的路径是这样:
Kotlin: codec.queueInputBuffer(idx, 0, size, ptsUs, 0) |
有两个边界值得单独记住,因为它们最容易被误当成”魔法”:
同步 API 的假象由PostAndAwaitResponse维持。 03 里讲过,App 线程在这一步挂起,等AReplyToken被 signal 才继续。上层看起来是同步方法,底下是异步 Looper——从 Java 一直到 CCodec 都在跑异步,同步语义只在最外层薄薄一层。理解这一点之后,”为什么 configure 卡了 200ms 会导致 UI 卡顿”就不需要额外解释。
跨进程只发生在两个位置。 一个是 App 进程 → mediaserver(或 codec 进程),走的是 MediaCodec 的 Binder 接口;另一个是 mediaserver → vendor 进程,走的是 Codec2 的 HIDL/AIDL 接口。加上后面 App → SurfaceFlinger 的queueBuffer,01 那句”3 次跨进程”到这里就落地了。
控制流方向的另一半——回程——不是复用去程通道,而是走 MediaCodec 的回调 AMessage:kWhatDrainThisBuffer往上冒到 Java 层的onOutputBufferAvailable。这也是 03 里独立讲过的那条 looper 单向消息,不再重复。
四、数据流:那块 YUV 到底住在哪儿
控制流拆完之后,数据流反而更容易讲,因为整条链上真正搬像素的机会只有一次——没有。这是 06 讲清楚过的核心结论:稳态播放下 CPU 一字节像素都不搬。
原因在于所有进程看到的都是同一块 dma-buf 的不同视图:
物理内存: 1 块 dma-buf (由 Gralloc 分配) |
同一块物理页,四个进程里四个不同的 fd 数值,因为 fd 是每个进程各自的文件描述符表里的索引,Binder 传递时会做 fd 复制。这也是为什么 05 里那种”跨进程零拷贝”的说法在物理层面站得住脚——搬的不是像素,是引用。
VPU 把解码结果直接写进这块 dma-buf。之后 vendor → mediaserver → App → SF 的四次”传递”,传的都是slot 编号 + fence fd。DPU 最终来读的时候,读到的还是 VPU 当初写入的那块物理内存。从头到尾这块内存只被写一次、读一次,中间没有任何拷贝。
有一个例外场景,07 里点过一次:如果 HWC 判定视频层不能走 DEVICE composition(比如 dataspace 不支持、overlay plane 已经用完),会 fallback 到 CLIENT composition,SF 会用 GPU 把这一层合到 framebuffer 上——那一次 GPU 会真读像素、写到另一块 buffer。不过这属于异常路径,主链路上不发生。
一句话记住数据流:主链路只有 VPU 一次写、DPU 一次读;GPU 只在 fallback 时才碰像素。
五、同步流:三条 fence 链条串起硬件流水线
前面两条线索讲完,还剩最后一个问题:VPU 什么时候真的写完?DPU 什么时候可以安全去读?如果 CPU 全程不搬像素、也不忙等,靠什么保证时序不错乱?
答案是三条 fence 链条。它们贯穿全链路,是 06、07 反复出现但没有集中过的一个骨架:
| fence 类型 | 谁 signal | 谁 wait | 保证的事情 |
|---|---|---|---|
| acquire fence | VPU(写完 buffer 时) | DPU/GPU(读之前) | 消费方不会读到尚未写完的 buffer |
| release fence | DPU(扫描完成时) | VPU(复用 slot 之前) | 生产方不会复写尚未扫完的 buffer |
| present fence | 显示硬件(面板真正刷新时) | SF / App(用于统计和调帧) | 端到端时延闭环,播放器可校准 |
三条 fence 都不是 CPU 忙等——都是内核级的 sync_file,被硬件 signal,被驱动 poll。这带来一个直觉上不太自然的结论:这条流水线上真正让”硬件流水线”名副其实的,不是芯片能力,是 fence 机制。VPU、GPU、DPU 三块芯片可以完全异步跑,CPU 只在协议层转手 fd,硬件之间的顺序关系全部由 fence 表达。
把这条同步线单独拎出来看,才能理解为什么 07 反复强调”CPU 全程不 wait”——不是不需要等,而是等的动作被下沉到硬件里去了。
六、五种硬件在这条链路里各干什么
前七篇没有一次把五种硬件放在同一张图上讲过。到收束这里补上,作为整个 Part I 的另一张核心图。
flowchart LR
subgraph 稳态主链路
direction LR
VPU["VPU
(硬件解码器)"] -->|dma-buf| DPU["DPU
(HWC overlay)"]
DPU -->|MIPI/eDP| Panel["显示面板"]
end
subgraph 辅助与旁路
direction TB
CPU["CPU
控制流 + fence 转手"]
GPU["GPU
特效 / SF fallback"]
NPU["NPU
超分 / 插帧 (opt-in)"]
end
CPU -.协议层调度.-> VPU
CPU -.协议层调度.-> DPU
GPU -.CLIENT composition 兜底.-> DPU
GPU -.App 侧 shader 特效.-> DPU
NPU -.可选中间处理.-> DPU
style VPU fill:#FFF9C4,stroke:#F9A825
style DPU fill:#FFF9C4,stroke:#F9A825
style Panel fill:#FFF9C4,stroke:#F9A825
style CPU fill:#E3F2FD,stroke:#1976D2
style GPU fill:#F3E5F5,stroke:#7B1FA2
style NPU fill:#F3E5F5,stroke:#7B1FA2
图上颜色区分了两组:黄色是稳态主链路——VPU 到 DPU 到面板,视频播放稳定运行时数据流只经过这三块硬件。紫色和蓝色是辅助——CPU 只在协议层做调度、GPU 只在特效或 fallback 时介入、NPU 是可选旁路。
每一块硬件的职责用一句话钉死:
- CPU:只搬 fd 和消息,不搬像素。稳态每帧的 CPU 占用几乎全部来自 Binder、AMessage、fence 转手。
- VPU:真正的解码工人。产物直接落在 dma-buf 上,不经过 CPU 内存。
- GPU:三种角色——App 侧做滤镜和 shader 特效(走 SurfaceTexture)、SF 做色彩空间转换和图层混合(部分设备上)、CLIENT composition 兜底。视频稳态不走 GPU 路径。
- DPU / HWC:视频稳态的最后一公里。overlay plane 直读 dma-buf,扫描到面板,硬件级完成 YUV → RGB 转换和 scaling。走 CLIENT 是异常。
- NPU:目前是 opt-in 的可选处理,比如超分、插帧。不在主链路上。
这张图值得记住的一句话:主链路稳态下只有 VPU 到 DPU 两颗芯片在动,中间那些复杂结构是为了让这两颗芯片不需要认识彼此就能配合。这句话概括了整个下行链路的工程价值。
七、六个边界的备忘录
站在这张全景图前,读者最容易忘的是两个域之间到底怎么衔接。前七篇每一篇的重心是”域内的事”,域和域之间的边界只是一笔带过。这里把六个边界列成备忘录:
| 边界 | 前一域交出的东西 | 后一域怎么接 | 章节 |
|---|---|---|---|
| Java ↔ JNI | MediaCodec.java一次方法调用 |
JMediaCodec翻成 Native 指针 + AMessage |
02 → 03 |
| 同步 API ↔ 异步 Looper | 上层同步阻塞调用 | PostAndAwaitResponse +AReplyToken挂起 |
03 |
| MediaCodec ↔ CCodec | 抽象接口CodecBase |
CCodecBufferChannel落到 Codec2 语义 |
04 |
| CCodec ↔ HAL | 一个C2Work对象 |
HIDL/AIDL 送到 vendor 进程 | 05 |
| Codec2 ↔ 图形栈 | vendor 从 BUFFERQUEUE pool 拿的 GraphicBuffer | 那个 dma-buf fd 就是 SF 侧 slot 的物理内存 | 05 → 06 |
| Surface ↔ SurfaceFlinger | Producer 侧queueBuffer塞进 slot |
Consumer 侧latchBuffer拉走,进入 SF 四步 |
06 → 07 |
六个边界串起来就是一条完整的下行链路。每一行的”章节”列指向的是可以回查细节的位置——这一节不再展开任何一个。
八、把 01 那道题的答卷交出去
回到开篇那三个数字,现在可以填得更实:
| 维度 | 数字 | 落到哪 |
|---|---|---|
| 像素拷贝 | 0 次 | 主链路稳态;CLIENT fallback 时 GPU 会读一次 |
| 跨进程 | 3 次 | App ↔ mediaserver ↔ vendor ↔ SurfaceFlinger |
| 硬件协同 | 4 到 5 种 | CPU + VPU + DPU 是主链路;GPU 视场景介入;NPU opt-in |
| 关键跳跃 | 7 次 | Java → JNI → CCodec → Codec2 client → Codec2 HAL → BufferQueue → SF/HWC |
| 同步机制 | 3 条 fence 链 | acquire / release / present |
数字对得上 01 的开篇。这不是巧合——这几个数字是 01 起笔时倒推 07 结尾的落点得到的,Part I 就是为了把这条推导过程铺满。