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 秒实验
- 把
SDL_CreateWindowFrom换成SDL_CreateWindow,看 MFC 里会出现什么现象。 - 算一下:1920×1080 的视频放进 1280×720 的窗口,画面多大、黑边多少?
- 判断一下:窗口被拖动改变大小时,纹理要不要重建?为什么?
常见误区
- 以为
SDL_CreateWindowFrom连消息一起接管了。 它只接管渲染上下文, 窗口过程和消息泵还是 MFC 的。 - 以为窗口交出去就完事。
SDL_QUIT不会自己来了,必须在OnClose里通知解码线程退出。 - 用
WM_TIMER做 60 帧节拍。 分辨率约 15.6 毫秒,想要 16 毫秒可能给你 31.2。 - 在
OnPaint里建纹理。 实测慢近四倍;纹理只在分辨率变化时重建。
自测三问(点开对答案)
1. `SDL_CreateWindowFrom` 之前必须做什么? —— 先 `SDL_Init(SDL_INIT_VIDEO)`。顺序错了它只会返回 NULL,不会告诉你「没初始化」。 2. 为什么工作线程不能直接画到 MFC 窗口上? —— MFC 的窗口和 GDI 对象有线程亲和性,只能在创建它的线程里碰。 工作线程只能算,画必须 PostMessage 回主线程。 3. 窗口尺寸变了,纹理要重建吗? —— 不要。缩放在 RenderCopy 里做,只改目标矩形。只有视频分辨率变了才重建纹理。MFC 这条路把「窗口归谁」讲透了。Qt 那条路表面上换了个函数名,实际上多了一个新问题——信号槽跨线程会排队,一排队就有延迟。下一节用两个实验把这个延迟量出来,结论跟常识不太一样。
- ☐ (本节完,下一节 08.04 Qt移植FFplay播放器及二次开发)