简介:这是一份基于MCIWnd控件实现的AVI视频播放器完整工程源码,面向正在学习Windows多媒体编程、MFC框架与VC6.0项目开发的初、中级开发者,可用于理解如何通过菜单打开AVI文件、在客户区左上角创建MCIWnd窗口并显示首帧画面,以及借助控制条完成播放等基本交互。压缩包共39个文件、约2.04MB,包含6个cpp与7个h源文件、6个obj中间文件,以及exe可执行文件、rc资源脚本、ico图标、bmp位图、dsp/dsw工程文件和ReadMe说明等,覆盖从代码、界面资源到编译产物的较完整结构,便于直接运行对照与二次修改。已有134人学习,适合作为MFC多媒体小项目入门参考,也能帮助读者梳理MCIWnd窗口创建、消息响应和工程组织方式。
1. 需求先立住:为什么 AVI 视频播放器要用 MCIWnd 来做
看题目「基于 MCIWnd 的 AVI 视频播放器」,多数人第一反应是:为什么不直接拖一个现成的播放控件?这正是这类课程设计最该讲清楚的地方。MCIWnd 是 VFW 时代留下的一层薄封装,把 MCI 命令、视频输出窗口、设备打开与关闭统统收进一个子窗口里,代码量小、依赖少、链接一个 vfw32.lib 就能跑,适合把「媒体控制接口到底怎么用」这件事讲透。这套方案面向三类人:需要交 Win32 课程设计的学生、要做播放器 Demo 的初学者,以及想用一个能跑起来的小工程理解 MCI 设备模型的人。下面按需求、架构、实现、避坑、验收的顺序,把这套 AVI 视频播放器从零到能演示完整拆一遍。
2. 总体架构设计:三层结构与 MCIWnd 子窗口的职责划分
2.1 三层结构:界面层、控制层与媒体层的边界
MCIWnd 最大的诱惑在于它开箱即用:自带播放条、自带右键菜单、能自动调整窗口大小,挂上文件就能出声出画。很多工程就顺势把所有逻辑压在按钮的点击响应里,几十行代码跑得挺欢,等要加进度条、加循环播放、加全屏,立刻开始互相打架。根因不复杂:MCIWnd 是一个有状态的设备句柄,而按钮响应是一堆无状态的散装代码,中间少了一层统一的状态管理。
我一般切成三层,边界画死,后面加功能就不用返工。
| 层 | 职责 | 主要接口 | 明确不该做的事 |
|---|---|---|---|
| 界面层 | 按钮、进度条、时间标签、全屏绘制 | WM_COMMAND、TBM_* 消息、WM_SIZE | 不直接调 MCI 命令 |
| 控制层 | 播放状态机、定时器、错误提示、配置记忆 | MCIWndPlay / Pause / Seek | 不负责任何绘制 |
| 媒体层 | 打开设备、解码、把画面输出到子窗口 | MCIWndCreate / Open / Close | 不持有业务状态 |
这样分层之后,一个具体好处:进度条拖动时要暂停刷新、松开才 Seek,这条逻辑只存在控制层一处,界面层不需要知道设备当前是什么模式;换皮肤、换布局的时候,媒体层那几十行代码一行都不用动。
控制层的核心是一个四态枚举:空闲、播放中、已暂停、已停止。所有按钮点击最终都翻译成对当前状态的一次判断加一次 MCI 调用,而不是各自直接怼 MCIWndPlay。这个约束看起来多余,等到加循环播放和「播完自动回零」时,就知道它省了多少事。
2.2 工程骨架:MCIWnd 的创建时机与窗口样式
整个工程是一个标准 Win32 窗口程序,不需要 MFC,也不需要托管代码。头文件包含 vfw.h 与 mmsystem.h,链接库里加上 vfw32.lib、winmm.lib,资源文件里放主窗口的按钮、进度条和加速键表。真正关键的只有一处:MCIWnd 子窗口什么时候创建、用什么样式创建。
// 创建 MCIWnd 子窗口:控制权全部收回自己的 UI BOOL CPlayerApp::InitMciWnd(HWND hParent) { // 显式注册一次,重复注册返回 FALSE,忽略即可 MCIWndRegisterClass(); DWORD dwStyle = WS_CHILD | WS_VISIBLE | MCIWNDF_NOPLAYBAR // 隐藏自带播放条,用自己的按钮 | MCIWNDF_NOMENU // 隐藏自带右键菜单 | MCIWNDF_NOAUTOSIZEWINDOW // 禁止它去改父窗口大小 | MCIWNDF_NOTIFYSIZE; // 视频尺寸变化时通知父窗口 // 创建时不带文件,空窗口先占位,等用户选择后再 Open m_hMci = MCIWndCreate(hParent, m_hInst, dwStyle, NULL); if (m_hMci == NULL) { MessageBox(hParent, TEXT("MCIWnd 创建失败,请检查 vfw32.lib 是否已链接"), TEXT("错误"), MB_ICONERROR); return FALSE; } // 统一时间基准为毫秒,后续所有位置换算都按 ms 走 MCIWndSetTimeFormat(m_hMci, TEXT("ms")); return TRUE; }几个参数需要说明清楚。MCIWNDF_NOPLAYBAR和MCIWNDF_NOMENU决定你看到的是一个干净的视频区域而不是一个带控制条的小播放器,这是自绘界面的前提;MCIWNDF_NOAUTOSIZEWINDOW更关键,它的作用是禁止 MCIWnd 反过来去调整父窗口尺寸,否则你拖动主窗口边框时会被它反向拉扯,体验很怪;MCIWNDF_NOTIFYSIZE则在视频分辨率变化时给父窗口发消息,全屏切换时用得上。
创建时机放在主窗口WM_CREATE里,销毁放在WM_DESTROY里,顺序必须是先MCIWndStop、再MCIWndClose、最后MCIWndDestroy。这三步顺序颠倒,退出时偶发崩溃的概率会明显上升。
2.3 消息路由:WM_COMMAND、WM_TIMER 与通知消息怎么分工
主窗口的消息循环是这套架构的中枢,几个消息各管一段,不要混着用:
WM_COMMAND处理按钮与菜单,低字是控件 ID,直接映射到控制层的公开方法,比如OnPlayPause()、OnStop()、OnOpenFile()。按钮只负责触发,不负责判断状态。
WM_TIMER用一个 200 毫秒的定时器统一做两件事:刷新进度条与时间标签,以及轮询设备模式判断播放是否自然结束。为什么不完全依赖MCIWNDM_NOTIFYMODE?这条通知消息在不同版本的 SDK 头文件里打包方式略有差异,wParam 里到底放模式还是放通知码,各家资料说法不完全一致,调试起来相当费神。用定时器轮询MCIWndGetMode反而稳,代价只是 200 毫秒的延迟,人眼根本感知不到。
WM_SIZE负责在窗口尺寸变化时把 MCIWnd 子窗口重新铺满客户区,同时在路上做一次等比计算,避免画面被拉成细长条。WM_HSCROLL只处理进度条的拖动,别在这里做别的事,否则拖动时的消息洪水会把播放卡住。
3. 播放内核实现:从打开文件到解码输出的完整链路
3.1 打开与关闭:路径、返回值与错误文本
打开文件这一步,看起来只有一行MCIWndOpen,实际上最容易埋雷。返回值 0 表示成功,非 0 是 MCI 错误码,光看这个数字没有任何意义,必须配合MCIWndGetError把错误翻译成人话。
// 打开媒体文件,失败时把 MCI 错误码翻译成可读文本 BOOL CPlayerApp::OpenMedia(LPCTSTR szPath) { if (m_hMci == NULL) return FALSE; // 先停再关,避免上一个设备句柄残留 MCIWndStop(m_hMci); MCIWndClose(m_hMci); // 第三个参数为 0 表示普通打开,MCIWNDOPENF_NEW 用于新建文件 if (MCIWndOpen(m_hMci, (LPVOID)szPath, 0) != 0) { TCHAR szErr[256] = {0}; MCIWndGetError(m_hMci, szErr, sizeof(szErr) / sizeof(TCHAR)); ShowStatus(TEXT("打开失败:%s"), szErr); m_eState = ST_IDLE; return FALSE; } // 打开成功后重新确认时间格式,部分设备会把它重置 MCIWndSetTimeFormat(m_hMci, TEXT("ms")); m_nDuration = MCIWndGetLength(m_hMci); // 毫秒 m_eState = ST_STOPPED; SetProgressRange(m_nDuration); // 进度条范围 0 ~ 时长 UpdateTimeLabel(0, m_nDuration); return TRUE; }参数上有两个细节值得注意。第二个参数是LPVOID,工程若开了 Unicode,直接把TCHAR*传进去没问题;但若混用了 ANSI 字符串,路径里的中文就会变成乱码,表现出来就是「文件明明存在却打不开」。第三个参数平时填 0 就行,MCIWNDOPENF_NEW是给录制场景用的,播放器不需要。关闭时先停后关,是因为设备正在播放时直接 Close,某些驱动会返回设备忙。
3.2 播放控制状态机:Play、Pause、Resume、Stop 的迁移条件
按钮直接调 MCI 函数最典型的翻车场景是:播放到一半点暂停,再点播放,视频从头开始了。原因是MCIWndPlay的语义是「从当前位置开始播放」,而暂停时位置已经被清过或者设备被重置,结果就成了重播。正确做法是把暂停和恢复拆成两个命令,并且在控制层维护状态。
// 播放/暂停按钮的统一入口:按当前状态决定调哪个 MCI 命令 BOOL CPlayerApp::OnPlayPause() { if (m_hMci == NULL || m_eState == ST_IDLE) return FALSE; switch (m_eState) { case ST_PLAYING: MCIWndPause(m_hMci); // 暂停,保留当前位置 m_eState = ST_PAUSED; break; case ST_PAUSED: MCIWndResume(m_hMci); // 从暂停点继续,不是从头播 m_eState = ST_PLAYING; break; case ST_STOPPED: // 停止状态下若已在末尾,先回到开头再播 if (MCIWndGetPosition(m_hMci) >= m_nDuration - 50) MCIWndHome(m_hMci); MCIWndPlay(m_hMci); m_eState = ST_PLAYING; break; default: break; } UpdateButtonState(); // 按钮图标与可用性跟着状态走 return TRUE; }停止按钮的语义要单独定义,别和暂停混。我一般定为「停止并回到开头」,对应MCIWndStop加MCIWndHome。如果设备不支持MCIWndResume,退化方案是记录暂停时的位置,恢复时用MCIWndPlayFrom(m_hMci, m_nPausePos)。判断某个命令是否被设备支持,可以直接看 MCI 命令的返回值,非 0 就说明不支持,这时候别硬来。
拖动进度条的跳转用MCIWndSeek(m_hMci, nPosMs),单位必须和SetTimeFormat设的保持一致。播放中跳转建议先暂停再 Seek 再恢复,某些解码器在播放状态下强制跳转会把画面卡住几秒。
3.3 时间基准:为什么先 SetTimeFormat 再读 Length
MCIWndGetLength返回的数值单位取决于设备当前的时间格式,而不同设备打开后的默认格式并不统一:有的给毫秒,有的给帧数,有的给采样数。如果你不先设格式就直接拿 Length 去当进度条最大值,换一个 AVI 文件就可能出现「进度条走一半就满」或者「走满了视频还在播」——这类现象最容易让人怀疑是自己算错了,其实是单位没对齐。
统一的做法是在打开成功后立刻MCIWndSetTimeFormat(m_hMci, TEXT("ms")),紧接着再读 Length 和 Position。这个顺序不能反。时间标签的格式化也就顺理成章:
// 毫秒转 时:分:秒,供时间标签与进度条提示使用 void FormatTimeLabel(long nMs, TCHAR* szBuf, size_t nSize) { if (nMs < 0) nMs = 0; long nTotalSec = nMs / 1000; long nHour = nTotalSec / 3600; long nMin = (nTotalSec % 3600) / 60; long nSec = nTotalSec % 60; _stprintf_s(szBuf, nSize, TEXT("%02ld:%02ld:%02ld"), nHour, nMin, nSec); }nSize传的是字符数而不是字节数,用sizeof(buf)/sizeof(TCHAR)算,这是_stprintf_s最容易写错的地方之一。至于总时长超过一小时要不要显示小时位,我倾向于始终显示三位,宁可界面上多两个零,也别让它播到 60 分钟时突然从59:59跳成01:00:00导致标签宽度抖动。
3.4 播放结束、循环与进度回零
播放自然结束时,设备模式会变成MCI_MODE_STOP,但窗口位置仍停在末尾,进度条也就停在 100%。如果用户此时再点播放,不处理的话什么都不会发生——看起来像按钮失灵。
定时器里轮询模式的这段逻辑同时解决三件事:识别自然结束、执行循环、把进度归零。
// 200ms 定时器:刷新进度 + 检测播放是否自然结束 void CPlayerApp::OnTimerTick() { if (m_hMci == NULL || m_eState == ST_IDLE) return; // 拖动进度条期间不刷新,否则滑块会被定时器拽回去 if (!m_bDragging) { long nPos = MCIWndGetPosition(m_hMci); SetProgressPos(nPos); UpdateTimeLabel(nPos, m_nDuration); } TCHAR szMode[64] = {0}; LONG nMode = MCIWndGetMode(m_hMci, szMode, 64); // 播放中却报告已停止,说明是播完了而不是用户点的停止 if (m_eState == ST_PLAYING && nMode == MCI_MODE_STOP) { if (m_bLoop) { MCIWndHome(m_hMci); MCIWndPlay(m_hMci); // 循环:回到起点继续 } else { m_eState = ST_STOPPED; MCIWndHome(m_hMci); // 非循环:归零,方便再播 SetProgressPos(0); UpdateTimeLabel(0, m_nDuration); UpdateButtonState(); } } }注意判断条件里带了m_eState == ST_PLAYING,这是区分「用户点停止」和「自然播完」的关键。少了这个条件,用户点停止后定时器又会把它当成播完处理,状态来回跳,界面上表现为按钮图标闪烁。循环播放打开时不要额外调MCIWndStop,直接 Home 加 Play 就够了,多一次 Stop 会让画面黑一帧。
4. 界面与交互落地:进度条、音量、全屏与快捷键
4.1 进度条双向绑定:拖动期间不要 Seek
进度条是这套播放器里唯一一个「双向」控件:定时器往里写位置,用户拖动又从里面读位置。两个方向同时发生就会打架,典型症状是手指还按着滑块,它自己弹回去了。
解法是加一个m_bDragging标志,拖动开始置位、结束清位,定时器在置位期间跳过刷新,用户松开时才真正执行 Seek。
// 进度条拖动:过程中只更新时间标签,松开才跳转 case WM_HSCROLL: { if ((HWND)lParam != m_hProgress) break; int nCode = LOWORD(wParam); int nPos = (int)SendMessage(m_hProgress, TBM_GETPOS, 0, 0); switch (nCode) { case TB_THUMBTRACK: // 拖动中 m_bDragging = TRUE; UpdateTimeLabel(nPos, m_nDuration); break; case TB_ENDTRACK: // 松开鼠标 m_bDragging = FALSE; MCIWndSeek(m_hMci, (LONG)nPos); UpdateTimeLabel(nPos, m_nDuration); break; case TB_LINEUP: // 键盘方向键微调 case TB_LINEDOWN: m_bDragging = FALSE; MCIWndSeek(m_hMci, (LONG)nPos); break; } return 0; }nCode的取值要分清楚:TB_THUMBTRACK是拖动过程,TB_ENDTRACK是松手,TB_LINEUP和TB_LINEDOWN是键盘或点击轨道两端的微调。很多人只处理了TB_THUMBTRACK,结果拖动时每一帧都发一次 Seek,播放直接卡成幻灯片。进度条范围用TBM_SETRANGE设成 0 到总时长(毫秒),这样位置换算不需要任何比例运算,减少出错面。
4.2 音量与静音:0 到 1000 的换算和持久化
MCIWndSetVolume和MCIWndGetVolume的取值范围是 0 到 1000,而界面上给用户看的通常是百分比或者 0 到 100 的滑块,中间需要一次换算。这个换算写在一处,别在按钮响应里到处乘 10。
// 界面 0~100 与 MCI 0~1000 之间的唯一换算入口 void CPlayerApp::SetVolumePercent(int nPercent) { if (nPercent < 0) nPercent = 0; if (nPercent > 100) nPercent = 100; MCIWndSetVolume(m_hMci, (UINT)(nPercent * 10)); m_nVolume = nPercent; } int CPlayerApp::GetVolumePercent() { return (int)(MCIWndGetVolume(m_hMci) / 10); }静音用一个单独的标志位,记住静音前的音量,取消静音时恢复。直接设 0 再想恢复原值,就只能靠用户自己拖回去,体验很差。音量值我一般写进注册表或者 ini,下次启动沿用,这是个很小的功能,但答辩时提一句「状态持久化」,属于加分项。
另外要注意,个别 AVI 的音频流由系统混音器统一管理,MCIWndSetVolume对这些文件的音量可能不起作用。遇到这种情况不算 bug,在验收说明里写清楚「音量控制依赖设备支持」即可,别硬拗。
4.3 全屏切换:样式备份、显示器定位与子窗口重排
全屏的实现要点不是「把窗口放大」,而是「换一套窗口样式 + 定位到显示器矩形 + 重排子窗口」,退出时再原样还回去。中间任何一步漏了,要么退出后标题栏没了,要么全屏跑到副屏上去了。
// 全屏切换:备份原样式与位置,退出时原样还原 void CPlayerApp::ToggleFullScreen(HWND hMain) { static WINDOWPLACEMENT s_wp = { sizeof(WINDOWPLACEMENT) }; static DWORD s_dwStyle = 0; if (!m_bFullScreen) { s_dwStyle = GetWindowLong(hMain, GWL_STYLE); GetWindowPlacement(hMain, &s_wp); // 取当前所在显示器,而不是主显示器,多屏环境下才不会跑错屏 MONITORINFO mi = { sizeof(MONITORINFO) }; GetMonitorInfo(MonitorFromWindow(hMain, MONITOR_DEFAULTTONEAREST), &mi); SetWindowLong(hMain, GWL_STYLE, WS_POPUP | WS_VISIBLE); SetWindowPos(hMain, HWND_TOP, mi.rcMonitor.left, mi.rcMonitor.top, mi.rcMonitor.right - mi.rcMonitor.left, mi.rcMonitor.bottom - mi.rcMonitor.top, SWP_FRAMECHANGED | SWP_NOOWNERZORDER); m_bFullScreen = TRUE; } else { SetWindowLong(hMain, GWL_STYLE, s_dwStyle); SetWindowPlacement(hMain, &s_wp); SetWindowPos(hMain, NULL, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED); m_bFullScreen = FALSE; } LayoutMciWnd(hMain); // 立即重排子窗口,别等下一次 WM_SIZE }MonitorFromWindow用MONITOR_DEFAULTTONEAREST而不是写死主屏,这是双显示器环境下最容易漏的一点。SWP_FRAMECHANGED必须带,否则改完样式后客户区尺寸不会重新计算,视频区域会留一条边。LayoutMciWnd要在切换后立刻调用一次,因为某些情况下WM_SIZE的到达有延迟,中间会闪一下原尺寸的画面。
4.4 快捷键表与键盘焦点问题
快捷键这套东西,写了不生效是最常见的反馈。原因基本只有一个:焦点被 MCIWnd 子窗口抢走了,WM_KEYDOWN发给了子窗口,主窗口根本没收到。解决办法是用加速键表配合消息循环里的TranslateAccelerator,它作用于整个线程的消息队列,不受焦点在哪个子窗口影响。
| 按键 | 功能 | 对应命令 |
|---|---|---|
| 空格 | 播放 / 暂停 | ID_PLAY_PAUSE |
| 左方向键 | 后退 5 秒 | ID_SEEK_BACK |
| 右方向键 | 前进 5 秒 | ID_SEEK_FORWARD |
| 上 / 下方向键 | 音量 +5 / -5 | ID_VOL_UP / ID_VOL_DOWN |
| 回车 | 全屏切换 | ID_FULLSCREEN |
| Esc | 退出全屏 | ID_EXIT_FULLSCREEN |
| Ctrl+O | 打开文件 | ID_FILE_OPEN |
// 消息循环里插入加速键翻译,焦点在子窗口也能响应 HACCEL hAccel = LoadAccelerators(m_hInst, MAKEINTRESOURCE(IDR_MAINFRAME)); MSG msg; while (GetMessage(&msg, NULL, 0, 0) > 0) { if (!TranslateAccelerator(m_hWndMain, hAccel, &msg)) { TranslateMessage(&msg); DispatchMessage(&msg); } }TranslateAccelerator的第一个参数必须是拥有加速键表的顶层窗口,传子窗口句柄是无效的。跳转 5 秒的实现要夹紧边界:位置减去 5000 若小于 0 就归零,加上 5000 若超过总时长就停在末尾,否则 Seek 到负数会被设备拒绝,表现为按键无反应。
5. 避坑与排查:MCIWnd 项目里最容易翻车的五个点
5.1 打开失败,界面上却什么提示都没有
现象:双击一个 AVI 文件,窗口一片黑,按钮全灰,既没有报错也没有画面,用户以为程序卡死。
原因:MCIWndOpen返回了非 0 错误码,但代码里判断完就return FALSE,没有把错误码翻译出来。MCI 的错误码是数字,不查表根本看不出含义。
解决:所有打开、关闭、Seek 的返回值都要检查,失败时用MCIWndGetError取文本并显示在状态栏,同时在调试期用OutputDebugString打一份到调试输出窗口。加上这一步之后,「缺解码器」「路径不存在」「文件被占用」这三类问题一眼就能区分开,排查时间从半天缩短到五分钟。
5.2 进度条走到一半自己弹回去
现象:视频播放正常,但进度条走到某个位置后反复回退再前进,像心跳一样来回抖。
原因有两个可能,按概率排序。第一是拖动标志没生效,用户之前拖动过,m_bDragging忘了在TB_ENDTRACK之外的分支里清位,定时器就一直在写、界面一直在被覆盖。第二是时间单位不一致,进度条最大值按毫秒设的,MCIWndGetPosition却返回帧数,位置换算后超出范围又被截断。
解决:先确认拖动标志的置位与清位是否成对出现在所有分支里,包括鼠标在轨道上直接点击产生的TB_LINEUP。再确认SetTimeFormat在每次 Open 之后都重新调用了一次。这两点都排除后,基本不会再有回弹。
5.3 全屏之后视频画面不跟着变大
现象:主窗口铺满全屏了,视频还是原来那么小一块,缩在左上角,周围全是黑边。
原因:全屏只改了主窗口样式,MCIWnd 子窗口的位置和尺寸没有跟着重排。另一种情况是创建时带了MCIWNDF_NOAUTOSIZEWINDOW,然后误以为 MCIWnd 还会自己适应父窗口——这个样式恰恰是禁止它这么做的。
解决:在WM_SIZE里统一做子窗口重排,并且在切换全屏后手动调一次,不要指望消息自动到达。重排时按视频原始宽高比计算目标矩形并居中,避免画面被横向拉伸。计算比例时用MCIWndGetSource拿到源矩形,别写死 4:3。
5.4 关窗时崩溃,或者下次打开设备失败
现象:点右上角关闭,程序偶尔弹一个异常退出;或者同一次运行里连续打开两个文件,第二个没声音。
原因:设备句柄没按顺序释放。有的是直接DestroyWindow而没先MCIWndStop,设备还在播放时窗口没了,回调打到野指针上;有的是重复MCIWndCreate却没MCIWndDestroy上一个,造成设备句柄泄漏。
解决:把关闭流程固定成三步——MCIWndStop、MCIWndClose、MCIWndDestroy,写在WM_DESTROY里,并且在OpenMedia开头也先执行前两步。顺序不能换,Stop 之后 Close 才不会返回设备忙。加上这条之后,连续打开十几个文件都不会再出问题。
5.5 换一台机器就播不动了
现象:在自己电脑上一切正常,拷到实验室另一台机器上,同一个 AVI 打开就报错,或者有画面没声音。
原因:AVI 只是一个容器,里面的视频流和音频流用什么编码压缩,取决于当初生成文件时的设置。系统里没有对应的解码器,MCI 就打不开。这不是代码问题,是运行环境依赖。
解决:答辩演示用的素材,优先选系统自带解码器就能处理的编码,别用冷门编码压缩。同时在程序里把MCIWndGetError的返回值显示出来,让使用者知道是「文件打不开」还是「设备不支持」。文档里写清楚依赖关系,比在答辩现场被问住要好得多。
6. 验收与答辩:一份能自己证明自己的演示脚本
功能做完不等于能验收。我见过不少工程代码写得没问题,一演示就乱:先点了全屏,再想开文件,发现按钮被盖住了,只能强退重来。演示顺序要事先排好,按「一条主线走完所有功能」来设计。
6.1 功能验收清单:按顺序点一遍
| 序号 | 验收项 | 判定标准 | 通过 |
|---|---|---|---|
| 1 | 启动与初始状态 | 窗口正常显示,按钮除「打开」外均置灰 | 是 / 否 |
| 2 | 打开 AVI | 画面出现,总时长显示正确 | 是 / 否 |
| 3 | 播放 / 暂停 / 恢复 | 恢复时从暂停点继续,不从头播 | 是 / 否 |
| 4 | 停止 | 画面回到第一帧,进度条归零 | 是 / 否 |
| 5 | 进度拖动 | 拖动中不卡顿,松手后画面跳到对应位置 | 是 / 否 |
| 6 | 音量与静音 | 拖动生效,静音后能恢复原音量 | 是 / 否 |
| 7 | 全屏切换 | 铺满屏幕,Esc 退出后布局与字体无错位 | 是 / 否 |
| 8 | 快捷键 | 空格、方向键、回车均生效 | 是 / 否 |
| 9 | 播放结束 | 进度条归零,再点播放能重新开始 | 是 / 否 |
| 10 | 重复打开 | 连续打开三个不同文件无异常 | 是 / 否 |
| 11 | 异常路径 | 打开不存在或非视频文件,有明确提示 | 是 / 否 |
| 12 | 退出 | 无报错弹窗,任务管理器无残留进程 | 是 / 否 |
这张表不只是给自己用的,答辩时把它当演示脚本念一遍,评委的注意力会跟着你的顺序走,不会中途插问把节奏打乱。
6.2 答辩讲法:先讲取舍,再讲代码
讲这套东西,顺序比内容重要。开头别急着翻代码,先回答一个问题:为什么用 MCIWnd 而不是现成的播放控件。答案要落在具体收益上——调用层级低、能直接讲清 MCI 设备的打开、播放、暂停、跳转这套模型,代码量控制在可讲解的范围内。
接着讲那张三层结构表,说明为什么要把状态机单独抽出来,用「暂停后恢复不该从头播」这个具体例子来证明分层的必要性,比空谈设计模式有说服力。然后挑一段最有信息量的代码展开,通常是状态机那个 switch,逐行说明每个分支对应什么用户操作。
最后主动交代局限:MCIWnd 依赖系统解码器,现代编码格式支持有限,这是技术选型的历史包袱,不是实现缺陷。主动说出来,比被问出来强。
从那以后我每次带这类课程设计,都会在动手写第一行代码之前,先把验收表写出来贴在显示器边上,做完一项勾一项。工程能跑只是及格线,能把「为什么这么设计」讲明白才算交付。希望帮到你。
本文还有配套的精品资源,点击获取