🎬 第八章 FFplay+SDL 2开发音视频流媒体播放器
📄 一句话概括

MFC 移植的实质改动只有一处:窗口不是 SDL 建的。 剩下的全是两个连带问题——消息泵归谁、画面画在窗口的哪一块。

📝 本节和前面几节的关系

08.02 里 SDL 自己建窗口、自己泵消息、自己画。到了 MFC,这三件事的前两件必须交出去。 但「交出去」不等于「不管了」——交出去之后,节拍要靠 MFC 的定时器,退出要靠 WM_CLOSE, 这些都得重写。 06.06 那节讲过「把 SDL 窗口嵌进 MFC」,这一节是把 ffplay 的整条链搬进去, 差别在于还要管同步与队列。

一、矛盾只有一个:窗口归谁

先把这个矛盾说穿,后面全是它的连带后果:

东西 控制台版 MFC 版
窗口 SDL_CreateWindow CWnd 的,SDL 接管
消息泵 SDL_PollEvent MFC 的 OnIdle/定时器
退出信号 SDL_QUIT WM_CLOSE/WM_DESTROY
贴图目标 SDL 的渲染器 同一个渲染器,但目标是别人的窗口

只有第一行是「必须改」的,后三行是改完第一行之后必须跟着改的。

最容易漏的是第三行: 窗口交出去以后,SDL_QUIT 不会自己来了——因为窗口的关闭按钮归 MFC 管。 你在 OnClose 里不通知 SDL 那边,线程就会一直跑着。

二、SDL_CreateWindowFrom:唯一入口

这个函数只有一个参数:一个别人的窗口句柄。 在 MFC 里就是 GetSafeHwnd() 的返回值。

BOOL CPlayView::OnCreate(LPCREATESTRUCT cs) {
    if (CWnd::OnCreate(cs) == -1) return -1;
    m_win = SDL_CreateWindowFrom((void*)GetSafeHwnd());
    if (!m_win) { AfxMessageBox(SDL_GetError()); return -1; }
    m_ren = SDL_CreateRenderer(m_win, -1, SDL_RENDERER_ACCELERATED);
    return 0;
}

注意第一步是 SDL_Init(SDL_INIT_VIDEO) 要先做过,否则这个函数一定返回 NULL。 顺序错了不会报「你没初始化」,只会报一句笼统的错误——这是本函数最气人的地方。

它接管的是什么、不接管的是什么,要分清:

它接管 它不接管
在那块窗口上建渲染上下文 窗口过程(还是 MFC 的)
往那块窗口里贴图 窗口的创建与销毁
按那块窗口的尺寸算缩放 消息的泵送

所以「用 SDL_CreateWindowFrom 就不用管消息了」是一句错话, 消息还是 MFC 泵,SDL 只是把事件队列也插了一脚。

三、反方向:SDL_GetWindowWMInfo

有的时候方向是反的:窗口是先由 SDL 建的,你要把它的句柄交给框架。 比如「先用 SDL 跑通,再把它塞进一个已经存在的 Qt 窗口」——那时得先问 SDL 要那个 HWND。

SDL_GetWindowWMInfo 就是干这个的, 它填一个 SDL_SysWMinfo 结构体,里面按平台有不同的联合体。 Windows 上取 info.win.window,Linux 上取 info.x11.window——同一份代码在不同平台上取不同的字段,这就是「平台相关代码」的典型长相。

Demo 在沙箱里调用它,返回 0,错误是「操作不支持」。 这不是代码写错了,是 dummy 驱动下压根没有「系统窗口」这种东西可以问。 这一步只能老实说「沙箱验不到」,并写清在有屏幕的机器上它应该返回什么。

这种「验不到就明说」的做法,在本课里是有意为之: 把「哪一层真跑过」和「哪一层只是推演」分开标,比给一个看起来完整的假结果有用得多。

四、消息泵归 MFC:三种挂法

这是 MFC 移植里最需要判断的一处,三种做法各有各的代价:

做法 怎么挂 好处 代价
OnIdle 空闲时处理一帧 最简单 空闲才跑,忙时卡
SetTimer(16) 按 WM_TIMER 处理 节拍稳定 精度约 15.6 毫秒
独立线程 线程渲染 + PostMessage 最接近 ffplay 要处理线程安全

为什么定时器精度是 15.6 毫秒:WM_TIMER 的分辨率跟系统时钟节拍绑在一起, 默认是 1/64 秒,也就是 15.625 毫秒。 你想要 16 毫秒一帧,它可能给你 15.6 或者 31.2——60 帧每秒这个数,靠 WM_TIMER 是做不稳的。

所以正路是第三种:解码与同步放在工作线程里,渲染完再 PostMessage 让主线程贴图。 这也是 ffplay 本来的架构,你只是把它的「SDL 窗口」换成了「MFC 窗口」,线程结构一行没动。

但要注意一个铁律:MFC 的窗口和 GDI 对象只能在创建它的线程里碰。 工作线程里能做的只有「算」,不能「画」,画必须回到主线程。 在这一点上,MFC 和 Qt 的规矩是一模一样的——08.04 讲的「跨线程那一跳」,本质上就是同一件事。

五、画在窗口的哪一块

窗口大小和视频大小几乎不可能刚好相等,所以必须算一个矩形出来。 ffplay 里这个函数叫 calculate_display_rect,逻辑只有五行。

double dar = (double)vw * sar / vh;   /* 显示宽高比 */
w = width;  h = w / dar;
if (h > height) { h = height; w = h * dar; }
rect.x = (width - w) / 2;  rect.y = (height - h) / 2;

五行里有两个细节:一是先按宽度算、超了再按高度算(保证不溢出), 二是最后除二居中(保证黑边均分)。少任何一个,画面就贴边或者溢出。

Demo 拿四组尺寸真算了一遍,其中第二行最能说明问题: 640×480 的视频放进 1000×400 的扁窗口,实际只用到 533×400,左右各留 233 像素黑边。 如果偷懒把窗口尺寸当画面尺寸贴,人脸会被横向压扁。

还有一个「差一个像素」的问题:300×300 的窗口装 320×240,算出来画面是 300×225, 上下留 37 像素——37 是奇数,除二之后两边各 18,还剩 1 像素。 这个 1 像素会落在某一边,看起来就像「画面没居中」。 想两边严格均分,得把宽高先取成偶数。

六、纹理生命周期:别写进 OnPaint

MFC 程序员有个根深蒂固的习惯:缺什么就在 OnPaint 里现建。 画笔、字体、CImage 都这么干,所以顺手也会把纹理这么干。

这在 SDL 里很贵:Demo 量了同一件事的两种做法——复用一张纹理是每帧两百多微秒, 每帧新建再销毁是七百多微秒,慢了近四倍。 因为 SDL_CreateTexture 不只是分配内存,还要跟渲染后端通一次气。

正确的生命周期是一句话:

什么时候 做什么
视频分辨率变了 销毁旧纹理,按新尺寸建
窗口尺寸变了 只改目标矩形,纹理不动
每画一帧 只 UpdateTexture 加 RenderCopy

中间那一行是关键:缩放在 RenderCopy 里做,纹理不需要知道窗口多大。 很多人搞反了,一改窗口就重建纹理,结果拖动窗口边缘时卡成幻灯片。

七、2026 现状:MFC 还要不要学

这一节写的是 MFC,但它真正的用处是「迁移旧项目」。 新项目几乎不会选 MFC,这一点书上没直说,但现在已经是事实。

场景 建议
接手一个已有的 MFC 播放器 照这一节改,别重写
新做 Windows 桌面播放器 Qt 或 WinUI
只要一个能播的窗口 控制台版加 SDL 就够

但 MFC 这一节不能跳, 因为它讲的是「把引擎塞进别人的窗口」这个通用动作——Qt 版要做同一件事,只是句柄从 GetSafeHwnd() 变成 QWindow::fromWinId。 学会了这一节,08.04 只是换个函数名。

八、动手 Demo:把窗口交给别人——句柄这条路,先看它怎么断

移植的第一道坎是句柄,所以先把「句柄不对会怎样」演一遍。 然后测两个跟框架无关、但每个移植版都会撞上的数:黑边怎么算、纹理该不该重建。

# 1) 空句柄:看错误文本长什么样
win = SDL_CreateWindowFrom(None)
print(win, SDL_GetError())

# 2) 反方向:从 SDL 窗口问系统句柄
info = SDL_SysWMinfo(); SDL_VERSION(info.version)
print(SDL_GetWindowWMInfo(win, byref(info)), info.info.win.window)

# 3) 宽高比适配:ffplay 的 calculate_display_rect
dar = vw * sar / vh
w = ww; h = w / dar
if h > wh: h = wh; w = h * dar
print(int(w), int(h), int((ww - w) / 2), int((wh - h) / 2))

实跑输出:

--------------------------------------------------------------------
把窗口交给别人——句柄这条路,先看它怎么断
--------------------------------------------------------------------
**MFC 移植 ffplay 的全部难点,可以压成一句话**:
**SDL 平时自己建窗口,移植时要改成「用别人给的窗口」。**
**对应的 API 只有一个:`SDL_CreateWindowFrom`。**

  `SDL_CreateWindowFrom(NULL)` -> **失败**
  错误文本:**That operation is not supported**

**注意这句错误文本**:
**它说的不是「句柄无效」,而是「这个操作不支持」**——因为 dummy 驱动根本没有真正的系统窗口可以嵌。
**换到 Windows 真机上,传一个无效句柄给的是「Invalid window」**,**传对了才返回非 NULL。**
**这两种错误长得像、含义完全不同**,**「先看错误文本,再怀疑句柄」是排查这类问题的第一道岔路。**

**反过来,从 SDL 窗口往外套一层框架(Qt 的做法),要拿 HWND**:

  `SDL_GetWindowWMInfo` 返回值:**0**(0 表示失败)
  错误文本:**That operation is not supported**

**这里测出来的结果要老实说**:**dummy 驱动下没有真正的系统窗口,所以这一步拿不到真句柄。**
**有屏幕的机器上,同一步会返回一个非零的 HWND,那就是 MFC 的 `GetSafeHwnd()` 能给的东西。**

**比句柄更容易出错的是「画在窗口的哪一块」**。
**ffplay 里这个函数叫 `calculate_display_rect`,逻辑只有五行,但算错就是拉伸变形。**

  视频          窗口         画面尺寸     左右黑边     上下黑边
  -----------------------------------------------------------------
  1920x1080     800x600      800x450      0            75
  640x480       1000x400     533x400      233          0
  1080x1920     800x600      337x600      231          0
  320x240       300x300      300x225      0            37

**第二行最能说明问题**:**640×480 的片子放进 1000×400 的扁窗口,画面只用到 533×400**,
**左右各留 233 像素黑边。**
**如果直接把窗口尺寸当画面尺寸贴上去,人脸就会被压扁。**

**第四行还有个边界**:**300×300 的窗口装 320×240,实际画面是 300×225**,
**下面留 37 像素黑边**——**这个「除不尽就取整」的舍入,是黑边宽度差一个像素的来源。**

**最后一个坑:窗口大小变了,纹理要不要重建**

  复用同一张纹理:**215.9 微秒/帧**
  每帧新建再销毁:**789.3 微秒/帧**
  慢了 **3.7 倍**

**结论很直接**:**纹理只在「画面尺寸变了」的时候重建**,
**窗口尺寸变了不重建**——**画面贴到窗口上是缩放,不是重建。**
**MFC 移植里最典型的性能问题,就是把 `SDL_CreateTexture` 写进了 `OnPaint`。**

第一段的错误文本值得逐字看一眼:它说的是「这个操作不支持」,不是「句柄无效」。 因为 dummy 驱动根本没有系统窗口可以嵌,走不到「检查句柄」那一步。 换到真机上,传空句柄给你的是「Invalid window」。

这两种错误文本长得像、含义天差地别: 「不支持」是这条路本身走不通,「无效」是路能走但你给错了东西。 排查时先分清是哪一种,能省掉一半时间。

第二段在沙箱里拿不到结果,因为虚拟驱动没有真正的窗口。 这里如实写「验不到」:有屏幕的机器上它应该返回一个非零句柄, 那个数就是 QWindow::fromWinId 想要的输入。

第三段的四行数,是本节最有用的东西:它跟框架无关,MFC、Qt、控制台版用的是同一套算法。 而且它算错的表现不是崩溃,是「画面变形」——这种错用户一眼就看得出来,你却很难从日志里发现。

最后那句「慢了将近四倍」的数字会随机器变,但结论不会变: CreateTexture 是「跟渲染后端握手」的操作,不是普通的内存分配,放进每帧的路径里一定亏。

30 秒实验

  1. 把 SDL_CreateWindowFrom 换成 SDL_CreateWindow,看 MFC 里会出现什么现象。
  2. 算一下:1920×1080 的视频放进 1280×720 的窗口,画面多大、黑边多少?
  3. 判断一下:窗口被拖动改变大小时,纹理要不要重建?为什么?

常见误区

  1. 以为 SDL_CreateWindowFrom 连消息一起接管了。 它只接管渲染上下文, 窗口过程和消息泵还是 MFC 的。
  2. 以为窗口交出去就完事。 SDL_QUIT 不会自己来了,必须在 OnClose 里通知解码线程退出。
  3. 用 WM_TIMER 做 60 帧节拍。 分辨率约 15.6 毫秒,想要 16 毫秒可能给你 31.2。
  4. 在 OnPaint 里建纹理。 实测慢近四倍;纹理只在分辨率变化时重建。
自测三问(点开对答案) 1. `SDL_CreateWindowFrom` 之前必须做什么? —— 先 `SDL_Init(SDL_INIT_VIDEO)`。顺序错了它只会返回 NULL,不会告诉你「没初始化」。 2. 为什么工作线程不能直接画到 MFC 窗口上? —— MFC 的窗口和 GDI 对象有线程亲和性,只能在创建它的线程里碰。 工作线程只能算,画必须 PostMessage 回主线程。 3. 窗口尺寸变了,纹理要重建吗? —— 不要。缩放在 RenderCopy 里做,只改目标矩形。只有视频分辨率变了才重建纹理。

MFC 这条路把「窗口归谁」讲透了。Qt 那条路表面上换了个函数名,实际上多了一个新问题——信号槽跨线程会排队,一排队就有延迟。下一节用两个实验把这个延迟量出来,结论跟常识不太一样。

相关笔记