很多做 macOS 播放器的人,第一次接触 FFmpeg 都会有点懵。网上的教程要么停留在命令行转码,要么就是调一个 av_read_frame 就以为万事大吉,结果一上真机,画面卡成幻灯片,声音和画面还对不上。我在这条路上也折腾了很长时间,从最初只会用 ffmpeg 命令转格式,到后来在 macOS 上基于 FFmpeg API 从零搭起一个能流畅播放视频的播放器内核,中间踩过的坑、理顺的原理,想一次说清楚。
这篇内容不是 FFmpeg 命令行的使用手册,而是聚焦一条完整的播放链路:解封装(Demux)→ 解码(Decode)→ 像素格式转换(Scale)→ 屏幕渲染(Render),并针对 macOS 这个特定的系统环境,聊清楚每一级到底发生了什么、用什么 API、有哪些容易出问题的细节。适合已经会编译 FFmpeg、能调用基础 API,但还没能把播放器完整跑通的开发者。如果你只是想找一条 ffmpeg -i input.mp4 output.mp4 的命令,这篇帮不上忙;如果你想搞懂播放器底层原理、亲手实现一个播放内核,这篇就是为你准备的。
1. 为什么在 macOS 上自研播放器要选 FFmpeg:一套播放管线的前世今生
打开 macOS 自带的 QuickTime,或者直接用 AVPlayer,大部分场景其实不用自己写播放器。但一旦涉及到自定义协议、私有封装格式、特殊滤镜、低延迟直播、逐帧分析这类需求,系统播放器就完全不够用了。这时候 FFmpeg 几乎是唯一一个能覆盖全流程的成熟开源方案。
1.1 播放器内核不是"解码"这一个动作
很多初学者以为播放器核心就是解码,这是最大的误解。一个完整的播放管线,至少包含五级流水线:
- 协议层:处理 HTTP、RTMP、HLS、本地文件等数据源,负责把数据拉进来。
- 封装层(Demuxer):把容器格式拆开。MP4、MKV、FLV、TS 各有各的拆分规则,输出的是一个个 AVPacket。
- 解码层(Decoder):把压缩编码的 AVPacket 还原成原始像素,H.264、H.265、VP9 等都在这一层处理。
- 图像处理层:像素格式转换(YUV 转 RGB)、缩放、滤镜。
- 渲染层:把最终的图像呈现到窗口上,macOS 上可能走 OpenGL、Core Video 或者 Metal。
FFmpeg 的定位非常清晰:它把前四层做得极其扎实,但最后一层——渲染——它故意不碰。这给开发者留出了极大的自由空间,但也是很多人卡壳的地方。你解码做得再好,渲染线程一抖,画面照样卡。
1.2 FFmpeg 在 macOS 上的独特价值
在 macOS 上选择 FFmpeg 还有一个特殊原因:macOS 自带的 VideoToolbox 虽然支持硬件解码,但它不暴露解封装能力,也不支持绝大多数第三方封装格式和编码格式。FFmpeg 恰好可以在这两层之间搭桥——用 FFmpeg 做解封装和软件解码,把硬件解码的部分交给 VideoToolbox,两者各取所长。这也是 macOS 平台相比 Windows 和 Linux 更典型的组合方案。
另外,FFmpeg 在 macOS 上的编译默认会启用很多第三方库,比如 OpenSSL(用于 HTTPS)、SDL(用于测试播放)、freetype(用于字幕)等。自制播放器时,这些库是否被正确链接,直接影响你能处理多少种视频源。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备:macOS 上装 FFmpeg 与搭建调试工程的两种姿势
动手之前先把环境理清楚。macOS 上获取 FFmpeg 的方式很多,但作为要搞开发的你,不能只满足于 brew install ffmpeg,因为你还需要头文件、静态库以及调试符号。
2.1 快速安装与自定义编译怎么选
如果你只是想先跑通代码,直接走 Homebrew:
bash复制brew install ffmpeg
这个命令会拉取预编译的 FFmpeg 以及一堆依赖,包含常见编码器、协议、滤镜。头文件位于 /opt/homebrew/include/(Apple Silicon)或 /usr/local/include/(Intel)。链接库则在 lib 目录下,pkg-config 可以帮你管理这些路径。
但要注意,Homebrew 默认编译可能没有开启你需要的某些功能,比如特定的硬件加速支持。如果你需要按需裁剪 FFmpeg,或者要引入自定义 patch,就得自己编译。我的建议是,初期调试阶段直接 Homebrew,等确认要做产品化再切换到自定义编译版本,因为 FFmpeg 的 configure 选项非常多,自己编译一开始很容易漏东西。
如果确实需要自己编译,一个比较稳妥的最小配置参考:
bash复制git clone https://git.ffmpeg.org/ffmpeg.git ffmpeg
cd ffmpeg
./configure --prefix=/opt/ffmpeg_custom \
--enable-shared \
--enable-libx264 \
--enable-libx265 \
--enable-videotoolbox \
--enable-audiotoolbox \
--enable-gpl
make -j8
make install
--enable-videotoolbox 和 --enable-audiotoolbox 是 macOS 硬件解码的关键开关,编译出来的 ffmpeg 是带硬解能力的。注意加上 --enable-gpl,因为 libx264 是 GPL 协议,不加的话会报错。
2.2 在 CLion 里配置 FFmpeg 头文件和链接
CLion 是很多人在 macOS 上写 C/C++ 的首选,但导入 FFmpeg 时头文件路径、动态库链接经常让人头大。核心是配置 CMakeLists.txt。
一个能直接跑的 CMake 配置如下:
cmake复制cmake_minimum_required(VERSION 3.20)
project(FFmpegPlayer)
set(CMAKE_C_STANDARD 11)
find_package(PkgConfig REQUIRED)
pkg_check_modules(FFMPEG REQUIRED IMPORTED_TARGET libavformat libavcodec libavutil libswscale)
add_executable(${PROJECT_NAME} main.c)
target_link_libraries(${PROJECT_NAME} PkgConfig::FFMPEG)
这个方式依赖系统里有 pkg-config 文件。Homebrew 安装的 FFmpeg 一般都会带 .pc 文件,所以 pkg_check_modules 能正常找到。如果你的 FFmpeg 是手动编译安装到 /opt/ffmpeg_custom,需要额外设置环境变量:
bash复制export PKG_CONFIG_PATH=/opt/ffmpeg_custom/lib/pkgconfig
如果你不想用 pkg-config,也可以粗暴一点,直接写绝对路径:
cmake复制include_directories(/opt/homebrew/include)
link_directories(/opt/homebrew/lib)
target_link_libraries(${PROJECT_NAME}
avformat
avcodec
avutil
swscale
)
这里有个容易踩的坑:链接顺序。C 语言的链接器对库的依赖顺序敏感,avformat 依赖 avcodec,avcodec 依赖 avutil,所以通常情况下被依赖的库要放在后面。用 target_link_libraries 时顺序写错会在 link 阶段报一堆"undefined reference"。
2.3 第一行能跑起来的代码:初始化与版本校验
环境准备好后,先跑一个最简单的程序验证 FFmpeg 能被正确链接:
c复制#include <stdio.h>
#include <libavutil/log.h>
#include <libavformat/avformat.h>
int main() {
av_log_set_level(AV_LOG_INFO);
printf("FFmpeg version: %s\n", av_version_info());
printf("avformat version: %u\n", avformat_version());
return 0;
}
编译运行后,如果你能看到版本号,说明基础环境没问题。先别急着往下写,确认这一步,可以省掉后面一大部分"为什么链接不过"的排查时间。
3. 解封装阶段:avformat_open_input 打开的究竟是什么
播放器的工作首先从解封装开始。这一步的目标很纯粹:从输入文件(或网络流)中抽出封装层的信息,然后不断读取一个个包含压缩数据的 AVPacket。
3.1 从文件路径到 AVFormatContext 的完整路径
avformat_open_input 是解封装的入口函数,但它的背后做了很多事情。如果传入的是一个文件路径,FFmpeg 会通过文件协议层读取文件头,然后嗅探容器格式。它读取一部分数据,和内部注册的所有 Demuxer 的 read_probe 函数签名比对,选出最匹配的 Demuxer,然后初始化这个 Demuxer 对应的上下文。
c复制AVFormatContext *fmt_ctx = NULL;
AVDictionary *opts = NULL;
// 超时设置,对网络流很有用
av_dict_set(&opts, "timeout", "5000000", 0); // 单位是微秒,5秒
int ret = avformat_open_input(&fmt_ctx, filepath, NULL, &opts);
if (ret < 0) {
char errbuf[128];
av_strerror(ret, errbuf, sizeof(errbuf));
fprintf(stderr, "Could not open source: %s\n", errbuf);
return -1;
}
这里 fmt_ctx 就是一个 AVFormatContext,它内部包含了所有流的信息、元数据、时长、比特率等。注意第三个参数 fmt,如果传 NULL,FFmpeg 会自动探测;如果你明确知道输入是 MP4,也可以显式指定 av_find_input_format("mp4"),这样能跳过探测环节,打开速度更快。但在处理未知来源时,自动探测更稳。
avformat_open_input 只完成了"发现容器"这一步,它并不会读取完整的流信息。紧接着你必须调用 avformat_find_stream_info,让它去读取足够多的数据包,才能得到每个流的编码参数:
c复制ret = avformat_find_stream_info(fmt_ctx, NULL);
if (ret < 0) {
fprintf(stderr, "Could not find stream information\n");
return -1;
}
这一步对于播放器特别重要,因为后面初始化解码器时,AVCodecParameters 里的宽高、像素格式、编码器名等数据都是在这里填上的。如果跳过这步直接去开解码器,大概率会因为参数不完整而失败。
3.2 av_read_frame 的循环与 AVPacket 生命周期
解封装的核心循环其实很简单:
c复制AVPacket *pkt = av_packet_alloc();
while (av_read_frame(fmt_ctx, pkt) >= 0) {
// pkt->stream_index 标识这个包属于哪条流
if (pkt->stream_index == video_stream_index) {
// 送去解码
} else if (pkt->stream_index == audio_stream_index) {
// 另一条解码线程
}
av_packet_unref(pkt);
}
av_packet_free(&pkt);
但这里有一堆细节需要注意。av_read_frame 每次返回一个 AVPacket,但这个包是压缩编码后的数据,不是你可以直接送到声卡或显卡的原始数据。它是 AVPacket,不是 AVFrame。分清包(Packet)和帧(Frame)是播放器开发的第一课。
AVPacket 的内存生命周期同样是新手重灾区。av_read_frame 内部给 pkt 分配了缓冲区,每次循环结束必须用 av_packet_unref 释放引用,否则下一次调用 av_read_frame 会返回 AVERROR(EAGAIN) 或者发生内存泄漏。常见错误是把 av_read_frame 的循环写成:
c复制while (av_read_frame(fmt_ctx, pkt) >= 0) {
// 忘记 av_packet_unref(pkt)
}
跑不了多久内存就爆了。
另一个重要细节是 AVPacket 里的 pts 和 dts 是时间基(time_base)相关的整数,不是秒。同一份数据,用不同的时间基去解释,数值可能差几千倍。至于怎么换算成秒,标准公式是 double seconds = packet->pts * av_q2d(stream->time_base)。后续做音画同步、进度条显示,全靠这个数值。
3.3 时间基换算:音画同步的地基
播放器里的时间概念贯穿所有模块,而 FFmpeg 中时间基换算是一个大坑。每个流都有自己的 time_base,比如视频流常见 1/90000、1/1000,音频流常见 1/44100、1/48000,不同容器给的时间基还可能不一样。
如果你要在播放器统一用"微秒"或"毫秒"来处理同步,就得做换算。FFmpeg 4.x 之后的版本,推荐用一个标准时间基 AV_TIME_BASE,值为 1,000,000(微秒)。换算方法是:
c复制int64_t pts_us = av_rescale_q(pkt->pts, stream->time_base, (AVRational){1, AV_TIME_BASE});
av_rescale_q 是个非常聪明的函数,它在换算过程中会把溢出的风险降到最低。不要自己写 pkt->pts * stream->time_base.num / stream->time_base.den,因为 pts 可能是很大的整数,直接相乘可能溢出 int64。正确姿势永远是 av_rescale_q 或 av_rescale_q_rnd。
3.4 中断回调:别让播放器卡死在网络流上
如果你是播放本地文件,av_read_frame 阻塞的问题不明显。但播放网络流时,一旦服务端无响应,av_read_frame 可能会卡很久。macOS 的播放器要响应窗口关闭,如果解码线程卡死,界面会直接失去响应。
FFmpeg 提供了 AVIOInterruptCB 回调机制。你在 avformat_open_input 之前先注册一个全局中断标志,当用户点击关闭时把标志置为 1,FFmpeg 就会在内部适当的地方中断阻塞调用:
c复制static int interrupt_cb(void *ctx) {
// 检查某个全局变量,如果为 1 则返回 AVERROR_EXIT
return *(int *)ctx;
}
AVIOInterruptCB int_cb = {interrupt_cb, &interrupt_flag};
fmt_ctx->interrupt_callback = int_cb;
注意,这个回调不是一旦置位就立刻生效,它在某些阻塞型 IO 上可能延迟几百毫秒,但总比无限卡死好。实际测试下来,配合超时参数 "timeout" 一起使用,网络流的退出响应基本能控制在 1 秒以内。
4. 解码阶段:send/receive 异步模型与 macOS 硬件加速的接入
解封装拿到的是压缩包,下一步需要解码。现代 FFmpeg 的解码接口是 avcodec_send_packet + avcodec_receive_frame,这和旧版的 avcodec_decode_video2 完全不同。理解这个新模型的本质,能帮你少走很多弯路。
4.1 为什么改成了 send/receive 这种异步模型
新版解码接口把"输入数据"和"输出帧"解耦了。你可以塞进去多个 packet,但可能只取出来 0 个 frame;也可能塞进去 0 个 packet,却能取出好几个 frame。这是因为编解码器内部有缓存。
具体流程是:
c复制AVCodecContext *dec_ctx = avcodec_alloc_context3(codec);
avcodec_parameters_to_context(dec_ctx, codecpar);
avcodec_open2(dec_ctx, codec, NULL);
// 送包
ret = avcodec_send_packet(dec_ctx, pkt);
if (ret < 0 && ret != AVERROR(EAGAIN)) {
// 出错处理
}
// 收帧
while (ret >= 0) {
ret = avcodec_receive_frame(dec_ctx, frame);
if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) {
break;
} else if (ret < 0) {
// 解码出错
}
// 此时 frame 里是一帧完整的原始图像
av_frame_unref(frame);
}
核心逻辑在于 avcodec_send_packet 返回 AVERROR(EAGAIN) 表示解码器内部满了,此时必须先调 avcodec_receive_frame 把已解码帧取走,才能继续塞新包。而 avcodec_receive_frame 返回 AVERROR(EAGAIN) 则相反,表示没有完整帧输出,需要送更多包进来。
这种模型让解码器有了内部缓冲的余地,尤其对 H.264/H.265 这类有参考帧结构的编码格式很重要。如果你还抱着旧接口的思路,一个包对应一个帧,遇到 B 帧的时候画面顺序会彻底乱掉。
4.2 B 帧、解码延迟与显示顺序的重排
视频编码有三种帧:I 帧(关键帧,可独立解码)、P 帧(参考前面的帧)、B 帧(参考前后的帧)。B 帧的存在让解码器必须等后面的帧到达才能输出当前帧,这就是解码延迟的来源。
举个例子,一段 GOP 为 I B B P 的流,解码器收到顺序是 I P B B(P 可能先于 B 到达,因为编码器重排了帧),但解码输出顺序必须是 I B B P。所以 FFmpeg 在解码器内部维护了一个重排序缓存,只有当你持续 send_packet 足够多的包之后,receive_frame 才会开始吐出有序的帧。
理解这点后,播放器的渲染循环就必须处理两个时间点:
- 解码顺序(decode order):packet 被送进解码器的顺序。
- 显示顺序(presentation order):frame 被渲染到屏幕上的顺序。
帧的 best_effort_timestamp 字段就是用来标识显示顺序的时间戳。解码循环里,拿到 frame 后第一时间记录 frame->best_effort_timestamp,后续渲染、音画同步都靠它来判断这一帧应该显示在哪个时刻。
4.3 macOS 上开硬解:videotoolbox 的关键配置
macOS 做播放器,不开硬解,H.265 4K 视频基本没戏。FFmpeg 通过 h264_videotoolbox、hevc_videotoolbox、prores_videotoolbox 这几个解码器接入 Apple 的 VideoToolbox 硬件解码。
要在 FFmpeg 里启用硬解,有两种途径。
第一种是打开解码器时指定硬解解码器名称:
c复制AVCodec *decoder = avcodec_find_decoder_by_name("h264_videotoolbox");
if (!decoder) {
// 降级到软解
decoder = avcodec_find_decoder(AV_CODEC_ID_H264);
}
第二种是在 avcodec_find_decoder 之后,通过 av_dict_set 传递硬件设备上下文。推荐第二种,因为即使查找解码器时用 h264_videotoolbox 失败,也能自然降级到软解。
VideoToolbox 硬解有个特点:它输出的帧格式是 AV_PIX_FMT_VIDEOTOOLBOX,本质上是一个 CVPixelBufferRef,而不是人眼能看到的 RGB 数据。这个 CVPixelBufferRef 可以在解码和渲染两个阶段都保持 GPU 端数据,不用额外拷贝到 CPU,这对性能提升非常关键。
但是硬解也不是万能的。我在实测中发现,H.264 的硬解在隔行扫描(interlaced)内容或者某些非常规分辨率下表现不佳,甚至直接失败。稳妥的做法是在解码器初始化后检查实际的 dec_ctx->pix_fmt,如果发现输出了 AV_PIX_FMT_VIDEOTOOLBOX,就说明硬解成功了;否则就继续走软解路径。
另外,硬解和软解切换时有个隐藏问题:硬解输出的 CVPixelBufferRef 在 OpenGL/Metal 渲染时需要引入 CVOpenGLTextureCache 或 CVMetalTextureCache,这些缓存对象如果不正确创建,渲染线程会频繁卡顿,因为每次都要在 GPU 和 CPU 之间来回拷贝。
5. 像素格式转换:解码输出到渲染之前,为什么非要过 sws_scale 这道桥
解码器输出的帧,拿到的可能是一个 YUV420P 格式的原始数据,而你渲染到屏幕上需要某种特定的像素格式。直接从 YUV 硬画到窗口上,会得到奇怪的色彩和错误的图像,这就需要一层格式转换。
5.1 解码器的输出真的适合直接渲染吗
解码器输出的最常见格式是 YUV 系列(如 AV_PIX_FMT_YUV420P),因为视频编码全部基于 YUV 色彩空间设计。但 macOS 的渲染 API(OpenGL、Metal、Core Image)通常更习惯使用 AV_PIX_FMT_BGRA 或 AV_PIX_FMT_NV12 这类格式。
这里需要分情况讨论:
- 如果用 OpenGL 渲染,最理想的是把视频帧转成
BGRA,然后通过glTexImage2D上传纹理。 - 如果用 Core Video 渲染 +
CVPixelBuffer,那最好保持NV12或YUV420的视频格式直接丢给系统,让 GPU 自己做色彩转换。 - 如果要用 Metal 渲染,可以走
CVMetalTextureCache,把CVPixelBuffer转成 Metal 纹理,灵活度极高。
如果你走的是软解 + OpenGL 的这条路,sws_scale 绝对是核心。示例:
c复制struct 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,
(const uint8_t *const *)frame->data, frame->linesize,
0, src_h,
dst_data, dst_linesize);
sws_scale 做的事情是:把解码出来的 AVFrame 里分散在三个平面的 Y、U、V 数据,打包转换成你在屏幕上要用的 RGB 数据。它会处理色度抽样(比如 4:2:0 转 4:4:4)、色彩空间转换(BT.601 转 BT.709)、缩放(不是所有视频的原始分辨率都刚好等于窗口尺寸)。
5.2 性能省的秘密:一个 SwsContext 复用到底
sws_getContext 的创建开销相当大,因为内部会初始化 LUT 表和向量化指令集。每帧都重新创建,性能会明显下降。正确做法是在视频参数确定后创建一次,渲染过程中循环复用,直到播放结束或视频参数变化。
我在做 macOS 播放器时实测过:每帧创建 SwsContext,4K 60fps 的 H.264 视频,播放帧率会掉到 20fps 以下;复用同一个 SwsContext 后,帧率能稳定在 55fps 以上。这个差距比很多人的直觉大得多。
另外,sws_getContext 的算法参数直接影响画质与性能的平衡。默认的 SWS_BILINEAR 是"中等画质 + 中等性能"的均衡选择。如果你追求画质,可以尝试 SWS_LANCZOS,画面更锐利,但 CPU 占用量会明显上升;如果你是在线直播,对画质要求不高,SWS_FAST_BILINEAR 能大幅降低 CPU 开销。
6. 屏幕渲染:从 AVFrame 到 macOS 窗口的三条路
解封装、解码、格式转换都做完后,最后一环是把图像真正显示到屏幕上。macOS 上做视频渲染有三条主流路线,每条路都有自己的特点。我在不同项目里分别试过,下面重点对比一下。
6.1 路径一:FFmpeg + OpenGL 纹理上传
这是最老牌的组合方案,也是我最早跑通的方案。核心思路是:将解码后的图像数据上传到 OpenGL 纹理,然后通过顶点缓冲区和着色器渲染到窗口。
关键代码片段:
objc复制glGenTextures(1, &textureID);
glBindTexture(GL_TEXTURE_2D, textureID);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA,
width, height, 0,
GL_BGRA, GL_UNSIGNED_BYTE,
pixelData);
这里有几个细节很重要:
- macOS 的窗口坐标原点和 OpenGL 纹理坐标是反的。很多新手第一次渲染出来的视频画面是上下颠倒的,需要在着色的顶点纹理坐标上做翻转,或者用
glTexImage2D时把数据的 row 方向反转。 - macOS 的默认像素缓冲区是 RGBA,但
pixelData从 FFmpeg 转出来的是 BGRA。OpenGL 里要用GL_BGRA来上传,否则颜色通道会错乱,画面会变成蓝色调。 - OpenGL 的上下文创建对线程模式有要求。macOS 上 OpenGL 的上下文不是线程安全的,如果你在解码线程上传纹理,在渲染线程绘制,就需要把两个线程的上下文设为共享,或者干脆用一个线程串行处理。我的做法是:解码和渲染在两个线程,但通过
CGLLockContext加锁,简单有效。
6.2 路径二:Core Video 的 CVPixelBuffer 直通
如果你的解码器用了 VideoToolbox 硬解,输出的 AVFrame->data[0] 其实是一个 CVPixelBufferRef。这种情况下,用 Core Video 直接把它显示出来,效率远高于转成 BGRA 再上传 OpenGL。
步骤是这样的:
- 用
avcodec_receive_frame拿到AVFrame。 - 确认
frame->format == AV_PIX_FMT_VIDEOTOOLBOX,然后通过(CVPixelBufferRef)frame->data[3]拿到实际的 CVPixelBuffer。 - 把
CVPixelBuffer交给AVPlayerLayer或者MTKView渲染。
如果你要继续走 OpenGL 渲染,就要引入 CVOpenGLTextureCache:
objc复制CVReturn err = CVOpenGLTextureCacheCreate(kCFAllocatorDefault,
NULL, cglContext, cglPixelFormat, NULL, &textureCache);
CVOpenGLTextureRef cvTexture;
err = CVOpenGLTextureCacheCreateTextureFromImage(kCFAllocatorDefault,
textureCache, pixelBuffer, NULL, &cvTexture);
GLuint textureID = CVOpenGLTextureGetName(cvTexture);
// 之后 normal 的 GL_TEXTURE_2D 绑定方式即可
关键是这个路径省掉了 sws_scale 的 CPU 拷贝,也省掉了 glTexImage2D 的 CPU 上传,整个视频数据流全程留在 GPU,对性能和功耗都友好得多。
我强烈建议,在 macOS 上做视频播放器,优先走硬解 + CVPixelBuffer 直通渲染路线。
6.3 路径三:Metal 渲染,面向未来的选择
Apple 正在逐步淘汰 OpenGL,这一点不用回避。对于新项目,Metal 是更被推荐的方向。FFmpeg 侧的解码、解封装逻辑完全不变,变化只在渲染层。
Metal 渲染 CVPixelBuffer 的关键是用 CVMetalTextureCache:
objc复制CVMetalTextureCacheCreate(kCFAllocatorDefault,
NULL,
device, // MTLCreateSystemDefaultDevice
NULL,
&textureCache);
CVMetalTextureRef cvTexture;
CVMetalTextureCacheCreateTextureFromImage(kCFAllocatorDefault,
textureCache, pixelBuffer, NULL,
MTLPixelFormatBGRA8Unorm,
width, height,
0, &cvTexture);
id<MTLTexture> texture = CVMetalTextureGetTexture(cvTexture);
拿到 MTLTexture 之后,就进入你熟悉的 Metal 渲染管线:创建渲染管线状态、顶点缓冲、片段着色器,最终绘制到 CAMetalLayer 上。
Metal 的引入门槛比 OpenGL 高,但长期收益明显:更好的资源管理、更低的 API 开销、更新的 GPU 特性支持。如果你的播放器产品要长期维护,建议直接上 Metal。我自己的播放器在切换到 Metal 后,CPU 占用明显下降,4K 视频渲染时的 GPU 占用也比 OpenGL 版更平稳。
6.4 渲染节奏控制:CVDisplayLink 与可行渲染线程模型
渲染层做完,还有一个"什么时候渲染下一帧"的问题。常见错误做法是在一个 while(true) 循环里不断渲染,这样不仅浪费 CPU,而且会撕裂画面。macOS 上做视频播放,正确的节奏通常用 CVDisplayLink 来控制,它的回调频率和显示器的刷新率一致(通常是 60fps 或 120fps)。
CVDisplayLink 的用法大致是:
objc复制CVDisplayLinkCreateWithActiveCGDisplays(&displayLink);
CVDisplayLinkSetOutputHandler(displayLink, ^(CVDisplayLinkRef link,
const CVTimeStamp *now,
const CVTimeStamp *outputTime,
CVOptionFlags flagsIn,
CVOptionFlags *flagsOut) {
// 在子线程中渲染一帧
[self renderFrame];
return kCVReturnSuccess;
});
CVDisplayLinkStart(displayLink);
注意,CVDisplayLink 的回调是在一个独立线程上触发的,不是主线程。窗口尺寸变化、拖拽事件还是要回到主线程处理。并且渲染线程不要和解码线程争抢同一把锁,设计上最好能做到"解码线程写好帧数据,渲染线程读取",通过锁或者无锁队列传递帧索引。
一个可行的线程模型:
- 主线程:UI 事件处理,窗口尺寸变化,控制播放/暂停。
- 解复用线程:
av_read_frame循环,把 packet 分发到不同的流。 - 解码线程:
avcodec_send_packet+avcodec_receive_frame,输出原始帧。 - 渲染线程:CVDisplayLink 触发,读取最新的解码帧,上传纹理,绘制。
这四个线程各司其职,互不阻塞,配合信号量和锁机制,可以做到解码和渲染并行,播放起 4K 视频也基本不会卡顿。
7. macOS 播放器最容易踩的坑:来自实测的排错清单
最后这一部分,我把自己在 macOS 上做 FFmpeg 播放器时遇到过的典型问题整理成一个清单,基本覆盖了从"能出画面"到"流畅播放"之间绝大多数障碍。
7.1 高 DPI(Retina)导致画面模糊或错位
macOS 和 Windows 的最大区别在于 Retina 屏。默认情况下,OpenGL 的视图尺寸是用逻辑像素计算的,但最终渲染目标是物理像素。如果你在 Retina 屏上直接用 glViewport(0, 0, width, height),画面会模糊,因为窗口 800 个逻辑像素对应的是 1600 个物理像素。
解决办法:在初始化 OpenGL 视图时,检测 window.backingScaleFactor,并将 glViewport 设置为逻辑尺寸乘以 scale factor:
objc复制CGFloat scale = self.window.backingScaleFactor;
glViewport(0, 0, (GLsizei)(width * scale), (GLsizei)(height * scale));
同时纹理的大小也要随物理像素走,不然画质会降低。CVPixelBuffer 在 Retina 屏上直通渲染时,同样需要注意 Metal 层的 contentsScale 设置。
7.2 硬解与软解切换时出现内存泄漏
在 macOS 上使用 h264_videotoolbox 硬解时,有一个和 CVPixelBuffer 内存相关的坑。硬解输出的 CVPixelBuffer 是从 VideoToolbox 的缓冲池里复用的,如果你没有及时释放对 CVPixelBuffer 的引用,解码帧池会被耗尽,解码器就会停止输出新帧,表现为画面突然卡住、内存不降。
正确的做法是,拿到帧后立即利用,用完马上 av_frame_unref(frame)。如果要异步渲染,可以将 CVPixelBuffer 用 CVPixelBufferRetain 暂时持有,但必须在渲染结束后 CVPixelBufferRelease,保持引用计数平衡。我在实际项目里就是因为忽略了这个细节,导致长时间播放时内存占用不断上升,最后被系统 kill 掉。
7.3 音画不同步:谁才是时间基准
播放器的音画同步是另一个大坑。很多新手把音频和视频各自解码,然后各自独立渲染,结果开始时还可以,播两三分钟后明显对不上。根本原因是没有一个统一时钟。
在 macOS 上做播放器,一个实际可用的方案是:
- 以音频为主时钟。用 AudioQueue 或 AudioUnit 的回调时间作为基准,视频帧按照
frame->pts和音频时钟比较,若视频晚了则等,若视频早了则丢帧或快进。 - 用系统的
mach_absolute_time或者AVAudioTime获取高精度时间戳。 - 视频渲染不要每帧都睡固定时长,而要根据实际 diff 动态调整,比如提前 10ms 渲染或延后 10ms 渲染。
有一种情况很特殊:视频没有音频流。此时没有主时钟,常见做法是以系统单调时钟为基准,按帧率推导每帧的显示时间点。
7.4 文件里有很多流,如何正确选择视频流和音频流
播放器不能假设第一个视频流就是你要的。正确姿势是遍历 fmt_ctx->streams,根据 stream->codecpar->codec_type 筛选 AVMEDIA_TYPE_VIDEO 和 AVMEDIA_TYPE_AUDIO,并记住对应的 stream_index。
很多 MKV 会有多个视频轨(比如不同语言的导演评论轨),或者一个视频流和一个封面图流。封面图流的 codec_type 也是 AVMEDIA_TYPE_VIDEO,但它通常带有 AV_DISPOSITION_ATTACHED_PIC 标志。播放时应该跳过这种流,否则你会在解码回调里看到一张"视频封面",然后画面卡在第一帧。
我建议在选择视频流时加一个判断:
c复制AVStream *vs;
for (int i = 0; i < fmt_ctx->nb_streams; i++) {
if (fmt_ctx->streams[i]->codecpar->codec_type == AVMEDIA_TYPE_VIDEO &&
!(fmt_ctx->streams[i]->disposition & AV_DISPOSITION_ATTACHED_PIC)) {
vs = fmt_ctx->streams[i];
video_stream_index = i;
break;
}
}
7.5 播放完最后一帧后,解码器还欠你几帧
H.264/H.265 的编码结构决定了在读取到所有 packet 之后,解码器内部可能还有缓存帧没有输出。如果你按 av_read_frame 结束就停止接收帧,视频往往会在最后一秒"戛然而止",少了几个画面。
正确的收尾流程是:在 av_read_frame 返回 AVERROR_EOF 后,调用一次 avcodec_send_packet(dec_ctx, NULL) 来"冲洗"解码器,然后继续循环 avcodec_receive_frame,直到它返回 AVERROR_EOF。这个文档里很容易被忽略的行为,在实际播放时对结尾体验影响很大。
一个通用的 flush 代码段:
c复制if (ret == AVERROR_EOF) {
avcodec_send_packet(dec_ctx, NULL);
while (avcodec_receive_frame(dec_ctx, frame) >= 0) {
// 处理这些"欠帧"
av_frame_unref(frame);
}
break;
}
不只是播放完需要 flush,用户手动 seek 后也需要 flush,因为解码器内部的参考帧缓存已经过时了,不清掉会产生花屏甚至解码错误。
7.6 鼠标拖动进度条后花屏或卡顿
seek 实现并不复杂,但坑很多。标准做法是调用 av_seek_frame 或 avformat_seek_file,seek 完成后把解码器 flush 掉,然后跳过所有非关键帧之前的 packet,等待下一个关键帧。
视频编码里,只有关键帧(I 帧)可以独立解码。如果你 seek 到的位置不在关键帧上,解码器从头解的时候可能会参考上一帧之前的数据,而那个数据已经不在缓存里了,结果就是花屏。FFmpeg 的做法是:seek 后,解码器内部会重新等待下一个关键帧,但一些老版本解码器不会自动跳过不可解的部分,需要你手动丢弃 avcodec_receive_frame 返回的错误帧,直到出现第一个正常帧。
我自己的播放器 seek 处理逻辑是:
av_seek_frame(fmt_ctx, video_stream_index, target_ts, AVSEEK_FLAG_BACKWARD)。- 调用
avcodec_flush_buffers(dec_ctx)清空解码器内部缓存。 - 继续
av_read_frame,但丢弃所有pkt->pts < target_ts的 packet(或者等 IE 即 inter/intra 帧)。 - 直到解码器正常输出第一帧,再恢复播放。
- 音频流同样要 flush 并清空音频输出缓冲。
seek 最怕的是解码线程在 seek 的同时还在处理旧 frame,造成时间戳回退、画面乱跳。设计上最好在 seek 时暂停解码线程和渲染线程,seek 完成后再恢复。
8. 最后再分享几个我自己的实践心得
如果看完上面这些内容,你已经能把一条基础播放链路跑通了,那下一步的关键就是细节打磨。我根据多次重做播放器的经验,提几个常规文档里不会写的内容。
第一个心得:解封装线程和渲染线程之间尽量解耦。很多人一开始图省事,把解封装和解码、渲染写在同一个线程里。这样做的代价是,一次网络抖动或者一次 IO 卡顿,整个播放器都会卡住。用队列把解封装后的 packet 缓存起来,解码线程按需取包,渲染线程独立刷屏,这样就算输入源短暂卡住,画面也能靠缓冲扛过去。
第二个心得:用时间戳而不是帧数来控制画面。帧率可能是 25、30、60,甚至 VFR(可变帧率),按"帧数累加"控制节奏会越走越偏。正确地做法是始终以帧的 pts 作为唯一时间参考,结合系统时钟和音频时钟做同步。VFR 视频在 macOS 上并不少见,尤其屏幕录制生成的视频,固定帧率的方式必出问题。
第三个心得:调试时多用 FFmpeg 自带的日志。av_log_set_level(AV_LOG_DEBUG) 后,你能在控制台看到 FFmpeg 在解码时的详细输出,包的大小、丢包情况、编码器参数、时间戳变化都会有提示。很多看起来神秘的问题,打开 debug 日志后真相都在里面。
最后一个建议,如果你决定在 macOS 上做播放器,不妨从"硬解 + CVPixelBuffer + Metal"这条技术路线起步。虽然一开始学习曲线稍陡,但这是当前 macOS 平台上性能和体验最均衡的方案,也能保证你的代码在未来几年内不被系统 API 的变更淘汰。一步一步来,从打开一个 MP4 开始,到硬解出第一帧,再到流畅渲染 4K 60 帧,这条路走完,你对视频播放的理解会上升一个明显的台阶。
