FFmpeg macOS 视频播放深度剖析:从解封装到屏幕渲染
1. 视频播放器到底在“播放”什么
先抛一个问题:你在 macOS 上双击一个 MP4,从按下空格到画面出现,中间那零点几秒里,系统到底做了多少件事?很多人以为视频播放就是把文件“读出来、画上去”,实际上这背后是一条非常长的流水线:文件读取、封装解析、压缩数据包提取、音频视频解码、像素格式转换、音画时钟同步、最终渲染上屏。任何一环出问题,你看到的就是黑屏、花屏、卡顿或者声音画面各走各的。
我在 macOS 上用 FFmpeg 做播放器相关开发也有一段时间了,从最早只会用命令行转码,到后来被拉去做一个基于 FFmpeg 的 macOS 原生播放器,才真正把这条链路的每个环节都啃了一遍。这篇文章就沿着“从解封装到屏幕渲染”这条主线,把 FFmpeg 在 macOS 上做视频播放的核心机制、关键 API、常见坑和我的实操经验一次性讲透,适合正准备在 macOS 上写播放器、或者想搞清楚 FFmpeg 内部播放流程的同学参考。
有个容易被忽略的前提先说明:FFmpeg 本身提供的 libavformat、libavcodec、libavfilter、libswscale 这些库,本质上只负责“拿到你要的数据”和“把压缩数据变成原始图像/音频”,它并不负责“把画面画到屏幕上”和“把声音送到扬声器”。最后那一步,需要你自己调用 macOS 的系统框架(如 Metal、AVFoundation、AudioQueue 等)来完成。这意味着如果你想在 macOS 上做一个完整的播放器,你的工作实际上是“用 FFmpeg 处理数据 + 用系统组件做渲染”,两套东西需要拼在一起。
整条数据流的逻辑关系大概是这样的:本地文件或网络流通过 I/O 层读取,进入解封装器(demuxer),解封装器把容器里的音频包和视频包分离开,分别送入解码器,解码器输出原始图像帧(NV12/YUV420P 等)和原始音频采样(PCM),图像帧经过格式转换后变成渲染器能接受的纹理数据,最终通过 Metal/OpenGL 画到窗口上;音频 PCM 则送进音频设备播放。同时,一个全局时钟负责协调音频和视频的播放节奏,避免音画不同步。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 解封装:FFmpeg 读取文件的“第一道闸口”
2.1 解封装不是解码
我见过不少刚入门的同学把“解封装”和“解码”混为一谈,这是第一个必须掰开的概念。所谓封装格式,指的是容器(container),比如 MP4、MKV、AVI、TS,它规定的是“视频流、音频流、字幕流、章节信息怎么组织、怎么排列、怎么索引”,它不关心视频到底是用 H.264 还是 HEVC 压缩的。解封装要做的是在容器里把每一段属于视频的数据包和属于音频的数据包“分拣”出来,送到对应的解码器里去。
FFmpeg 里负责解封装的模块是 libavformat,核心结构体是 AVFormatContext,你可以把它理解成“整个媒体文件的总管家”。一个视频文件打开之后,首先得到一个 AVFormatContext,它内部维护了文件的所有流信息、元数据、时长、比特率等。每一路流用 AVStream 表示,而流里面真正压缩过的数据单元叫做 AVPacket,这才是解封装器输出给解码器的“原材料”。
2.2 打开文件的完整套路
在 macOS 上,无论是读取本地文件还是网络流,用 FFmpeg 打开输入的代码套路几乎是固定的。第一步是分配 AVFormatContext,然后调用 avformat_open_input 打开文件或流地址;第二步是调用 avformat_find_stream_info 探测流信息,这一步会读取一部分数据,解析出每一路流的编码参数;第三步是遍历 streams 数组,找到我们需要的视频流索引和音频流索引。
c复制AVFormatContext *fmt_ctx = NULL;
if (avformat_open_input(&fmt_ctx, filepath, NULL, NULL) < 0) {
// 文件打不开,或者协议不支持
return -1;
}
if (avformat_find_stream_info(fmt_ctx, NULL) < 0) {
// 流信息探测失败
return -1;
}
int video_stream_idx = -1;
int audio_stream_idx = -1;
for (int i = 0; i < fmt_ctx->nb_streams; i++) {
AVCodecParameters *params = fmt_ctx->streams[i]->codecpar;
if (params->codec_type == AVMEDIA_TYPE_VIDEO && video_stream_idx < 0) {
video_stream_idx = i;
} else if (params->codec_type == AVMEDIA_TYPE_AUDIO && audio_stream_idx < 0) {
audio_stream_idx = i;
}
}
这里要单独强调一句:新版 FFmpeg 里不要直接访问 AVStream->codec,要用 AVStream->codecpar 拿编码参数。官方在 FFmpeg 4.0 之前还能用 codec,之后就把这个字段废除了,目的就是让编解码器的参数获取更安全、更清晰。很多老博客里的代码在 macOS 上编译会报错或者行为异常,十有八九就是踩了这个坑。
avformat_find_stream_info 这一步有个容易忽略的点:对于网络流和某些封装不规范的本地文件,它会阻塞较长的时间,因为需要读入足够多的数据才能解析出关键帧信息。实测下来,如果你打开的是一个远程 HTTP 流,这个函数的耗时能到几百毫秒甚至几秒。优化的办法是给 AVFormatContext 设置 probesize 和 max_analyze_duration 参数,限制探测的数据量,但设置太小又可能探测不出准确的编码参数,这个平衡需要按实际场景调。
2.3 不同封装格式的处理差异
在 macOS 上最常见的封装格式是 MP4(比如从相机导出的、从 QuickTime 录屏得到的),其次是 MKV、TS、FLV、MOV。FFmpeg 的 avformat_open_input 会自动探测文件头,不需要你手动指定 demuxer,但对某些特殊格式要格外小心。
比如 TS 流,它的特点是没有文件级别的索引,是一段一段连续打包的结构。如果你的视频是从网络直播录下来的 TS 切片,用 FFmpeg 打开时 find_stream_info 可能只能拿到很少的信息,时长甚至可能是未知的。这时候要做 seek 操作,就需要启用在 TS 里不太可靠的 seek 机制,或者干脆自己做关键帧索引。再比如 FLV,它常用于直播场景,解封装器输出 AVPacket 时的 DTS/PTS 可能会因为 B 帧存在而出现乱序,播放器在处理时需要特别留意。
解封装阶段另一个实际问题:读取本地大文件时,FFmpeg 默认的 I/O 层是直接读文件系统,性能尚可。但如果你的播放器要支持从网络加载,或者需要自定义的鉴权逻辑(比如 URL 里带签名),就需要自己实现 AVIOContext 的回调函数,把 FFmpeg 的读取行为映射到你的网络层上。我在 macOS 上做播放器时,为了绕开系统自带的 HTTP 缓存限制,就自己实现了一套带预读和本地缓存机制的 AVIO 回调,这样在拖动进度条时明显比直接塞 URL 给 FFmpeg 顺滑。
3. 解码:硬解还是软解,这是个选择题
3.1 解码器初始化的关键步骤
解封装拿到 AVPacket 之后,要交给解码器。解码器的核心结构体是 AVCodecContext,初始化流程是:用 AVStream->codecpar 里的编码信息查找到对应的解码器 AVCodec,然后分配 AVCodecContext,再把 codecpar 里的参数拷贝进去,最后 avcodec_open2 打开解码器。
c复制const AVCodec *decoder = avcodec_find_decoder(params->codec_id);
if (!decoder) {
// 没有找到对应的解码器
return -1;
}
AVCodecContext *codec_ctx = avcodec_alloc_context3(decoder);
if (!codec_ctx) {
return -1;
}
avcodec_parameters_to_context(codec_ctx, params);
// 有需要的话在这里设置 codec_ctx->hw_device_ctx、codec_ctx->thread_count
if (avcodec_open2(codec_ctx, decoder, NULL) < 0) {
return -1;
}
这里有个重要的取舍:thread_count 怎么设置。对软解来说,多线程解码能显著提高帧率,尤其是处理 4K/8K 视频时,但线程数并不是越多越好。经验值是在 macOS 上设成 CPU 物理核心数减一比较稳,设置过高会导致线程切换开销反而拖慢解码速度。还有一个 thread_type 参数,可以选 FF_THREAD_FRAME(帧级并行)或 FF_THREAD_SLICE(片级并行)。对 H.264/HEVC 来说,帧级并行更有效,因为多个帧之间天然没有依赖(B 帧除外),可以同时解码多帧;片级并行则是把一个帧切成多个 slice 同时解,主要用于编码侧,解码侧收益一般。
3.2 VideoToolbox 硬解的正确打开方式
macOS 上最大的优势是系统自带的 VideoToolbox 框架,能够调用硬件解码器(H.264/HEVC 都有硬解支持)。FFmpeg 对 VideoToolbox 的封装叫 h264_videotoolbox、hevc_videotoolbox,开发者不需要直接走 VideoToolbox 的 C API,只要在初始化解码器时指定用 VideoToolbox 的 hwaccel,FFmpeg 内部就会自动把解码工作交给硬件。
闲话少说,直接上代码。要用硬解,一般有两种方式。一种是在 avcodec_find_decoder 时直接指定名字:
c复制const AVCodec *decoder = avcodec_find_decoder_by_name("h264_videotoolbox");
另一种是更通用的方式,先找到软解 decoder,再通过 avcodec_get_hw_config 检查是否支持 AV_HWDEVICE_TYPE_VIDEOTOOLBOX,然后创建 AVBufferRef 类型的 hw_device_ctx 挂到 codec_ctx 上。这种方式更规范,因为 FFmpeg 会自动帮你在硬解失败时回退到软解:
c复制AVBufferRef *hw_device_ctx = NULL;
if (av_hwdevice_ctx_create(&hw_device_ctx, AV_HWDEVICE_TYPE_VIDEOTOOLBOX, NULL, NULL, 0) == 0) {
codec_ctx->hw_device_ctx = av_buffer_ref(hw_device_ctx);
}
硬解的输出帧默认是 AV_PIX_FMT_VIDEOTOOLBOX,这是一种硬件纹理格式,不能直接交给 libswscale 做缩放或格式转换。你需要调用 av_hwframe_transfer_data 把数据拷回到 CPU 内存,变成 NV12 或 YUV420P。但我个人的建议是:如果后续渲染要用 Metal/OpenGL,尽量保持数据不拷回 CPU,直接把 AV_PIX_FMT_VIDEOTOOLBOX 的 CVPixelBuffer 拿去做纹理上传,这样能省一次 GPU-CPU 数据回传,性能差一截。
实际开发中还有一种混合策略:默认开硬解,如果发现解码器初始化失败或输出帧率异常,就切换到软解。我写过一个简单的判据:如果硬解初始化失败,或者连续 30 帧的解码耗时超过每帧预算的两倍,就自动降级。这个策略在用户机器配置差异很大的场景下非常实用,因为不同 Mac 对硬解的支持不一致,有些老型号对 HEVC 的硬解支持很糟糕。
3.3 PTS/DTS 与 AVPacket 的生命周期
解码阶段最容易出问题的就是时间戳。PTS(Presentation Time Stamp)是“显示时间”,DTS(Decode Time Stamp)是“解码时间”。在没有 B 帧的编码流里,两者相同;有 B 帧时,解码顺序和显示顺序是错开的。FFmpeg 解封装器输出的 AVPacket,其 pts 字段可能不是以毫秒为单位的,也可能不是从 0 开始的,它表示的数值乘以 time_base 才是真实秒数。
每个 AVStream 都有 time_base,这导致不同流(视频流、音频流)的 PTS 量纲不一样。播放器需要一个统一的时钟系统,把所有时间戳都换算成同一个时间基准。我的做法是统一换算成微秒(microseconds):
c复制int64_t pts_in_us = av_rescale_q(pkt->pts, stream->time_base, AV_TIME_BASE_Q);
AV_TIME_BASE_Q 是 FFmpeg 内置的微秒时间基。换算之后,后续的 av_sync、seek、缓冲都可以在一个单位体系里操作,省去很多 bug。
另外一个容易踩的坑:H.264/H.265 裸流(Annex-B 格式)没有封装层,这时解封装器输出的 AVPacket 里可能没有 PTS,需要自己根据帧率推算。MP4 等封装格式一般都会存 PTS,但有些封装不规范的 MP4 文件的 PTS 起点不是 0,或者存在轻微回退。播放器处理时最好做一次时间戳归零和单调性校正,不然播到一半时间轴跳变,进度条和字幕都会错乱。
4. 格式转换与图像处理:帧数据要变成“渲染器能吃的形态”
4.1 像素格式的麻烦事
解码器输出的图像帧,最常见的格式是 YUV420P 或 NV12。为什么视频不用 RGB 存储?因为人眼对亮度更敏感,对色度相对不敏感,YUV 能把色度信息适当降采样,节省带宽。但 macOS 的显示链路,不管是 OpenGL 还是 Metal,最终上屏都是要 RGB 的。
这里就需要 libswscale 出马。sws_getContext 可以通过设置源像素格式、目标像素格式、缩放尺寸来创建一个转换上下文;sws_scale 执行转换。核心代码大致是这样的:
c复制SwsContext *sws_ctx = sws_getContext(
src_w, src_h, src_pix_fmt, // 输入宽高和格式
dst_w, dst_h, dst_pix_fmt, // 输出宽高和格式
SWS_BILINEAR, NULL, NULL, NULL
);
uint8_t *dst_data[4];
int dst_linesize[4];
av_image_alloc(dst_data, dst_linesize, dst_w, dst_h, dst_pix_fmt, 1);
sws_scale(sws_ctx, src_data, src_linesize, 0, src_h, dst_data, dst_linesize);
这里面有三个坑。第一个是像素格式选错,比如把 NV12 当成 YUV420P 喂给 sws_scale,输出的画面颜色会偏绿或偏紫。第二个是宽高对齐问题,某些格式要求宽高是偶数(YUV420 系列必须偶数),奇数尺寸的视频在转换时会有边缘色带。第三个是转换耗时问题,SWS_BILINEAR 是速度和质量比较平衡的选项,但如果跑到 4K 分辨率还追求实时性,建议直接上 GPU 侧做颜色转换,或者用 vImage 框架。
4.2 硬件帧怎么处理更高效
如果你的解码走的是 VideoToolbox 硬解,解码器输出的是 AV_PIX_FMT_VIDEOTOOLBOX 格式的 AVFrame,里面实际上持有的是一个 CVPixelBuffer 的引用。这时候最优雅的方式是直接把 CVPixelBuffer 交给 Metal/OpenGL 做纹理上传,省掉一次 sws_scale。
但这也带来了新的问题:CVPixelBuffer 可能不是你能直接访问内存的格式,它的颜色空间(Color Space)可能是 BT.709 而不是 BT.601,不做颜色空间转换,渲染出来的画面对比度和饱和度会偏。而且 CVPixelBuffer 的像素格式可能是 kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange 这种双平面格式,和 FFmpeg 的 NV12 是两回事,虽然内存布局相似,但传给 Metal 的时候要按 CVPixelBuffer 的属性去创建纹理,不能直接按 FFmpeg 的 linesize 去猜。
我个人在实际项目里的做法是维护一个 CVPixelBuffer → Metal 纹理的缓存池,避免每帧都创建 Metal 纹理对象。CVPixelBuffer 内容更新后,用 MTLRegion 替换纹理内容,这样一个 4K 帧的纹理更新消耗能控制在 1ms 以内。如果你嫌麻烦,也可以把硬解帧传回内存再走软解那条渲染路径,但那样硬解就白开了,性能折损挺大。
5. 渲染上屏:OpenGL 还是 Metal
5.1 老牌方案 OpenGL 的边界
在 macOS 上,OpenGL 虽然已经被苹果官方标记为 deprecated,但依然能用,而且不少开源播放器(比如 mpv 的默认渲染路径之一)还在用 OpenGL 做视频渲染。它的优点是生态成熟、资料多,FFmpeg 官方示例里就有基于 SDL2 + OpenGL 的播放器,抄起来方便。缺点是苹果不再为 OpenGL 提供性能优化,新硬件的驱动支持力度也在下降,未来如果做跨平台播放器,OpenGL 可能还会存在一段时间,但纯 macOS 新项目真的不建议再入 OpenGL 的坑了。
如果只是用 OpenGL 渲染视频帧,最简单的做法是把 YUV 数据拆成三个纹理(Y 纹理、U 纹理、V 纹理),然后用 shader 做颜色矩阵转换。NV12 则稍微不同,是一个亮度平面加一个交织的 UV 平面。用 OpenGL 渲染 YUV 有个好处,就是颜色转换可以完全交给 GPU,不需要在 CPU 侧跑 sws_scale。但要注意不同视频源的色域(BT.709 和 BT.601)不同,shader 里的矩阵需要动态切换,否则 SDR 视频的颜色会发灰。
5.2 更现代的选择:Metal 渲染管线
如果要做一个 macOS 原生播放器,Metal 是绕不开的正道。Metal 渲染视频帧的大致流程是:创建 MTLTexture,把 YUV 或 CVPixelBuffer 的数据填充进去,然后在 fragment shader 里做 YUV→RGB 颜色转换,最后绘制到一个铺满窗口的 quad(四角形)上。
具体到 CVPixelBuffer 直通 Metal 的场景,苹果提供了一个非常有用的 API:MTLCreateTextureFromCVPixelBuffer 或者更现代的 CVMetalTextureCacheCreateTextureFromImage。这两者可以避免 CPU 拷贝,直接在 GPU 端复用 CVPixelBuffer 里的数据。我在 MacBook Pro M1 Pro 上实测,用 CVMetalTextureCache 做 4K 60fps 视频的纹理上传,大概每帧 0.5ms 左右,性能余量非常充足。
Metal 渲染 YUV 平面时有个细节:YCbCr 的 color matrix 要写成矩阵乘法的形式,并且要注意 pixel format 的 range(video range 是 16-235,full range 是 0-255)。很多人在 Metal 上渲染视频偏灰或者偏白,基本都是 range 没对。正确做法是在 shader 里传一个 matrix 参数,在 CPU 侧根据视频的 color_range、color_space 动态构造,而不是写死一个矩阵。
下面是一个简化版的 Metal fragment shader,用于 NV12 双平面渲染:
metal复制fragment float4 yuv2rgb_fragment(
VertexOut in [[stage_in]],
texture2d<float, access::sample> tex_y [[texture(0)]],
texture2d<float, access::sample> tex_uv [[texture(1)]],
constant float4x4 &colorMatrix [[buffer(0)]]
) {
constexpr sampler s(coord::normalized, address::clamp_to_edge, filter::linear);
float y = tex_y.sample(s, in.texCoord).r;
float2 uv = tex_uv.sample(s, in.texCoord).rg;
float4 ycbcr = float4(y, uv.r, uv.g, 1.0);
return colorMatrix * ycbcr;
}
5.3 渲染与解码的线程模型
一个播放器如果只有单线程,一边解码一边渲染,画面必然卡顿。常见的做法是:一个解码线程持续从解封装器里读包、解码、把 AVFrame 放入帧队列;一个渲染线程从帧队列取帧,做格式转换和上屏;一个音频播放线程负责喂数据给音频设备。这三者之间用队列和锁同步。
在 macOS 上,解码线程可以放到普通后台队列,渲染线程最好有一个与主屏幕刷新率同步的机制。用 Metal 的话可以直接用 CAMetalLayer 和 CVDisplayLink(或者 CADisplayLink 的 iOS 版本,macOS 上一般用 CVDisplayLink)来驱动渲染回调。另一种简单做法是渲染线程自己按帧率 sleep 或等待 vsync,但精度不够,遇到 23.976fps 这种非整数帧率视频容易抖动。
帧队列的长度也需要控制。队列太长,视频起播慢,拖动进度条后反应迟钝;队列太短,网络稍有抖动就卡顿。我的经验是视频帧队列控制在 3-5 帧,音频帧队列控制在 50-100ms 的音频数据量。缓冲策略要根据播放场景区分:本地文件可以激进一点,尽量填满队列;网络流则需要结合码率和网络带宽做动态调整。
6. 时钟同步:让画面和声音“对齐”
6.1 以谁为基准?
音画同步最简单可靠的模式是“以音频时钟为基准”。原因是人耳对声音的抖动比对画面的抖动更敏感,画面偶尔掉帧你未必注意,声音一变调、一顿挫,立刻就发现了。所以播放器通常的做法是:维护一个全局的音频时钟,它表示“当前正在播放的音频数据对应的 PTS”;视频渲染线程根据音频时钟决定是丢帧、追帧还是等待。
用 FFmpeg 获取音频时钟,需要在音频输出回调里记录当前正在播放的音频帧的 PTS。比如你用 AudioQueue 播放,可以在 AudioQueueInputCallback 里拿到 mCurrentTime,再结合音频采样格式换算出对应的 PTS 值。
视频这边,每一帧都有自己的 PTS。渲染线程取到一帧后,计算 frame_pts - audio_clock:
- 如果差值大于阈值(比如 100ms),说明视频太快,需要等待;
- 如果差值小于负阈值(比如 -50ms),说明视频太慢,应该丢帧;
- 否则直接渲染。
阈值不能设得太死,因为视频帧率一低(比如 24fps),相邻帧间隔就有 41ms,太小的阈值会导致频繁的等待/丢帧切换,反而造成抖动。
6.2 无音频流的处理
有些视频没有音轨,这时候不能以音频时钟为基准,只能以系统时钟为基准。做法是记录开始播放的系统时间 start_time,理论上某一帧应该显示的时间是 frame_pts - first_frame_pts + start_time。渲染线程根据当前系统时间和理论显示时间的差距决定是否渲染。
无音频流的视频在拖动进度条后还有个额外问题:你需要重新计算 start_time,否则时间轴会漂移。实际操作时,seek 完成后要把 start_time 更新为 当前系统时间 - 目标帧的 PTS。
7. macOS 特有的播放坑与排查方法
7.1 硬解初始化失败时的黑屏问题
VideoToolbox 硬解并不是所有 iOS/macOS 版本都表现一致。我曾经遇到一台 2019 Intel Mac 上硬解 H.264 完全没问题,硬解 HEVC 却直接黑屏,日志里没有任何报错。排查了半天,才发现是 HEVC 硬解需要系统版本和硬件型号的双重支持,那台机器的 GPU 对 HEVC Main10 的解码支持有缺陷,FFmpeg 解码出来的帧全是坏的。
建议在初始化硬解后做一次“试探性解码”:喂一个很小的 I 帧进去,检查输出的 AVFrame 是否正常。如果输出帧为空或格式异常,立刻切回软解。用 avcodec_send_packet 和 avcodec_receive_frame 的返回值来判断比单纯看是否打开成功要可靠得多。
7.2 macOS 上 CVPixelBuffer 与 Metal 纹理的桥接错误
用 CVMetalTextureCacheCreateTextureFromImage 从 CVPixelBuffer 创建 Metal 纹理时,有个很隐蔽的问题:CVPixelBuffer 的 pixel format 如果是 kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange,对应的 Metal 纹理格式应该是 MTLPixelFormatR8Unorm(Y 平面)和 MTLPixelFormatRG8Unorm(UV 平面)。如果你误用了 MTLPixelFormatRGB8Unorm 或 BGRA,画面会直接花掉。可以在运行时打印 CVPixelBufferGetPixelFormatType 和纹理格式来对照检查。
此外,CVMetalTextureCache 的纹理对象不能长期持有超过 CVPixelBuffer 的生命周期,否则会引起 GPU 资源泄漏。所以我一般在帧渲染完成一个 vsync 周期后立即释放纹理引用,让系统回收。
7.3 内存暴涨问题
解码速度跟不上渲染速度时,帧队列会不断堆积,内存消耗直线上升。这在播放 4K 高码率视频时特别明显。我遇到过一台测试机,播放一个码率 120Mbps 的 4K 文件,播放 5 分钟内存涨了 2GB,最后被系统杀了。
根本原因是我解码线程和渲染线程的消费速度失衡,解码器不断输出、队列不断堆积。解决办法是队列长度加硬限制(视频队列 10 帧封顶),超过上限后解码线程阻塞等待;同时配合丢帧策略,如果渲染线程落后太多,直接丢弃队列里的非关键帧。这样内存能稳定在几十 MB 级别。
7.4 touches 到悬停预览的实现细节
这个和渲染上屏直接相关。macOS 上做播放器时,很多人希望实现类似“鼠标悬停进度条时显示预览帧”的功能。我的方案是:维护一个独立的低分辨率解码通道,在用户拖动进度条时,直接用 av_seek_frame seek 到目标时间附近,解码一个 I 帧显示到小窗口。
这里有个性能提示:seek 之后最好先丢弃旧队列里的所有包和帧,让解码线程重新从新位置开始,否则用户快速拖动进度条时,旧队列里的帧会持续输出,导致预览画面一顿一顿。另外,预览解码建议用单独的解码器实例,不要和主播放线程共用,因为频繁 seek 会打断主线程的正常播放节奏。
8. 一个最小可跑的 macOS 播放器骨架
前面讲了这么多,最后我把一个最小可编译、可跑起来看画面的思路捋一遍。不需要完整贴几千行代码,但关键的几个环节必须串起来。
主流程上,初始化阶段依次做这么几件事:注册 FFmpeg 所有组件、打开输入、找到视频流/音频流、初始化解码器、创建帧队列、启动解码线程、启动渲染线程、启动音频播放。
解码线程的伪代码逻辑:
c复制while (!stop_requested) {
AVPacket *pkt = av_packet_alloc();
if (av_read_frame(fmt_ctx, pkt) < 0) {
break; // EOF
}
if (pkt->stream_index == video_stream_idx) {
avcodec_send_packet(video_codec_ctx, pkt);
AVFrame *frame = av_frame_alloc();
while (avcodec_receive_frame(video_codec_ctx, frame) == 0) {
// 把 frame 推入视频帧队列
frame_queue_push(video_queue, frame);
}
av_frame_free(&frame);
}
av_packet_free(&pkt);
}
渲染线程(假设用 Metal + CVDisplayLink 驱动)在每个 vsync 回调里做三件事:从帧队列取最新帧;根据音频时钟判断是否渲染;如果渲染,就把帧数据转成 Metal 纹理,绘制到当前的 drawable 上。
音频这边,最简单是用 AudioQueue,回调里从音频包队列拿 PCM 数据,填入 AudioQueueBuffer,提交播放。音频时钟的更新可以放在回调里:每提交一个 buffer 时,记录 buffer 起始时间对应的 PTS,这样渲染端就能查到一个相对精确的音频时钟。
上面这套骨架在 macOS 上跑通之后,剩下的就是打磨工作:Seek 逻辑、字幕支持、倍速播放、画面缩放模式、音频设备切换等等。每一项都有不少细节,但核心的数据流水线永远不会变:读包、解封装、解码、同步、渲染。
这套流程我在 macOS 上前后迭代过几个版本,最大的感受是:FFmpeg 提供的能力很强,但真正决定播放器体验的反而是那些“FFmpeg 之外”的东西——线程模型、缓冲策略、GPU 资源管理、系统框架的桥接。希望这篇文章能帮你把这条链路的每个环节都看清,少踩几个我踩过的坑。
