尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

MFC定时器与列表框实操:实现Windows桌面应用动态刷新

MFC定时器与列表框实操:实现Windows桌面应用动态刷新 在开发 Windows 桌面应用时经常需要让界面“自己动起来”——比如每隔一秒刷新一次状态、持续采集设备数据并显示在列表里、或者在日志列表里自动追加新记录。很多新手最容易想到的做法是写一个while(1)循环不断更新控件结果窗口直接卡死鼠标转圈。其实在 MFC 里有一个非常轻量的解决思路就是用定时器配合列表框完成间歇性刷新。这篇文章就是围绕“添加定时器更新列表框内容”这个实战场景展开的。我会从概念讲起再逐步带你完成一个可运行的 MFC 示例程序。内容包括定时器如何创建和销毁、WM_TIMER消息怎么处理、CListBox控件如何添加和删除数据、以及实际开发中最容易踩到的坑。适合刚开始接触 MFC 的读者也适合需要快速实现定时刷新功能的开发者。1. 定时器与列表框的基础概念1.1 定时器是什么在 Windows 编程里定时器不是硬件定时器也不是高精度计时器而是一个基于消息机制的软件定时工具。你告诉系统“每 1000 毫秒通知我一次”系统就会在到达时间后把一条WM_TIMER消息投递到你的窗口消息队列里。MFC 把这个机制封装得很简单调用SetTimer创建定时器重写OnTimer接收消息调用KillTimer销毁定时器。整个流程对开发者来说非常直观。需要注意的是WM_TIMER消息的优先级比较低只有在消息队列中没有其他更高优先级消息时才会被处理。所以定时器的实际触发间隔会受到系统繁忙程度的影响不适合用在需要精确计时的场合。1.2 列表框ListBox的作用列表框是一个大家都很熟悉的 Windows 标准控件用于显示一列文本条目。在 MFC 中它对应CListBox类常用方法包括AddString在末尾添加一个字符串。InsertString在指定位置插入字符串。DeleteString删除指定索引的字符串。ResetContent清空所有项。GetCount获取当前条目数量。当我们要动态展示不断产生的数据时列表框是非常直观的容器。1.3 为什么要把两者结合定时器负责“产数据”列表框负责“展示数据”。比如你要做一个环境温度实时监测工具定时器每隔 1 秒读取一次温度值然后把温度字符串添加到列表框中屏幕上就能看到一条条不断滚动的记录。这种组合方式既避免了手动刷新界面的繁琐操作又不会因为高频刷新导致程序卡顿。它会成为你编写上位机、小工具、日志监控程序时的常用套路。2. 环境准备与项目说明2.1 开发环境本文示例以 Microsoft Visual Studio 为开发环境。版本可以是 VS2017、VS2019 或 VS2022操作步骤基本一致。项目类型选择“MFC 应用程序”在向导中选择“基于对话框”。需要提前安装的组件是使用 C 的桌面开发适用于最新 v143 生成工具的 C MFCx86 和 x64如果你在安装 Visual Studio 时没有勾选 MFC 组件可以打开“Visual Studio Installer”选择“修改”然后勾选对应组件即可。2.2 创建 MFC 对话框项目打开 Visual Studio按下面步骤创建项目选择“创建新项目”。搜索“MFC”选择“MFC 应用程序”。项目名称可以命名为TimerListDemo。点击“创建”进入 MFC 应用程序向导。在“应用程序类型”中选择“基于对话框”。其他选项保持默认点击“完成”。此时Visual Studio 会为你生成一个最基本的对话框程序。项目中有两个核心类CTimerListDemoApp应用程序类负责程序启动。CTimerListDemoDlg对话框类负责界面逻辑。我们要写的代码基本上都在CTimerListDemoDlg这个类里。2.3 界面布局设计打开资源视图找到对话框资源IDD_TIMERLISTDEMO_DIALOG。在工具箱中拖入一个按钮和一个列表框。为了演示方便我们设计两个按钮IDC_BTN_START标题为“启动定时器”IDC_BTN_STOP标题为“停止定时器”IDC_LIST_LOG一个 ListBox 控件界面大致如下[ 启动定时器 ] [ 停止定时器 ] [ 列表框显示日志 ]这个布局最简单也最容易理解。你还可以在按钮下面加一个静态文本用来显示当前状态不过那不是本次重点。3. MFC 定时器的工作原理3.1 SetTimer 函数SetTimer用来创建一个定时器它有四种重载形式。我们先看最常用的一种UINT_PTR SetTimer( UINT_PTR nIDEvent, UINT nElapse, void (CALLBACK* lpfnTimer)(HWND, UINT, UINT_PTR, DWORD) nullptr );参数含义如下nIDEvent定时器 ID。我们可以在一个窗口中创建多个定时器通过 ID 区分它们。nElapse触发间隔单位是毫秒。比如 1000 表示每隔 1 秒触发一次。lpfnTimer回调函数指针。通常传nullptr这样定时器消息会发送到窗口的OnTimer消息处理函数中。在 MFC 中我们通常写SetTimer(1, 1000, nullptr);这条语句的意思是创建一个 ID 为 1 的定时器每 1000 毫秒触发一次。如果返回值非零说明创建成功。3.2 KillTimer 函数当不再需要定时器时必须调用KillTimer来销毁它避免资源泄漏。KillTimer(1);参数就是定时器 ID。销毁之后窗口中不再收到该 ID 对应的WM_TIMER消息。3.3 WM_TIMER 消息与 ON_WM_TIMER 宏当定时器的时间到了之后系统会把WM_TIMER消息发送到创建定时器的窗口。在 MFC 中我们需要在消息映射中加入ON_WM_TIMER()宏并在对话框类中声明和实现OnTimer函数。消息映射部分如下BEGIN_MESSAGE_MAP(CTimerListDemoDlg, CDialogEx) ON_WM_TIMER() ON_BN_CLICKED(IDC_BTN_START, CTimerListDemoDlg::OnBnClickedBtnStart) ON_BN_CLICKED(IDC_BTN_STOP, CTimerListDemoDlg::OnBnClickedBtnStop) END_MESSAGE_MAP()OnTimer函数的原型是void OnTimer(UINT_PTR nIDEvent);在这个函数内部我们可以根据nIDEvent判断当前是哪一号定时器触发了。4. 完整实现定时更新列表框下面进入核心部分。我会按照文件位置一步步写清楚保证代码可以直接复制到你的项目中。4.1 添加控件变量在对话框头文件TimerListDemoDlg.h中为列表框添加一个关联变量。按CtrlShiftX打开类向导在“成员变量”选项卡中选择控件 IDIDC_LIST_LOG点击“添加变量”。我们将其命名为m_listLog类别选择Control变量类型选择CListBox。添加完成后头文件中会多出这样一行// 文件路径TimerListDemoDlg.h public: CListBox m_listLog;另外我们还需要一个计数器成员变量用来记录当前是第几次插入数据。手动在头文件类定义中添加public: int m_nCount 0;4.2 添加按钮点击事件在资源视图中双击“启动定时器”按钮VS 会自动生成点击事件处理函数。双击“停止定时器”按钮也会自动生成对应的函数。生成的函数位于TimerListDemoDlg.cpp中void CTimerListDemoDlg::OnBnClickedBtnStart() { // TODO: 在此添加控件通知处理程序代码 } void CTimerListDemoDlg::OnBnClickedBtnStop() { // TODO: 在此添加控件通知处理程序代码 }我们将在下一步分别实现“启动”和“停止”逻辑。4.3 在 OnInitDialog 中初始化OnInitDialog是对话框初始化函数程序启动时会执行到这里。我们可以在这里设置一些初始状态比如让“停止定时器”按钮在开始时不可用。在TimerListDemoDlg.cpp的OnInitDialog中找到return TRUE;之前添加如下代码BOOL CTimerListDemoDlg::OnInitDialog() { CDialogEx::OnInitDialog(); // 其他初始化代码... // 初始化界面 GetDlgItem(IDC_BTN_STOP)-EnableWindow(FALSE); m_nCount 0; return TRUE; // 除非将焦点设置到控件否则返回 TRUE }这里我们用GetDlgItem(IDC_BTN_STOP)-EnableWindow(FALSE);让“停止”按钮初始不可用避免程序一启动就误点击。4.4 启动定时器在“启动定时器”按钮的点击事件中调用SetTimer同时更新按钮状态。void CTimerListDemoDlg::OnBnClickedBtnStart() { // 创建定时器ID 为 1间隔 1000 毫秒 BOOL bRet SetTimer(1, 1000, nullptr); if (bRet) { // 启动成功更新按钮状态 GetDlgItem(IDC_BTN_START)-EnableWindow(FALSE); GetDlgItem(IDC_BTN_STOP)-EnableWindow(TRUE); } }注意这里我把SetTimer的返回值赋给了BOOL类型变量。在 MFC 中SetTimer返回值为定时器 ID非零表示成功。用BOOL接收是可行的但更准确的写法是UINT_PTR nTimerID SetTimer(1, 1000, nullptr); if (nTimerID ! 0) { // 启动成功 }为了保持代码直观下面我们都用布尔判断方式。4.5 编写 OnTimer 处理函数现在是最关键的一步。我们需要在对话框类中添加OnTimer函数。如果项目中还没有ON_WM_TIMER()消息映射你可以在类向导中手动添加。最简单的方式是在类视图中选中CTimerListDemoDlg在属性窗口顶部点击“消息”图标找到WM_TIMER添加处理函数OnTimer。添加后VS 会自动在头文件和源文件里生成函数声明与实现框架。我们直接在里面填写逻辑void CTimerListDemoDlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent 1) { // 每触发一次计数器加 1 m_nCount; // 生成一条包含计数值的字符串 CString strText; strText.Format(_T(记录 %d第 %d 次刷新), m_nCount, m_nCount); // 添加到列表框 m_listLog.AddString(strText); // 如果条目过多自动删除最旧的一条保持列表只显示最近 100 条 if (m_listLog.GetCount() 100) { m_listLog.DeleteString(0); } } CDialogEx::OnTimer(nIDEvent); }这段代码的含义很容易理解每次定时器触发时计数器加 1。用Format生成格式化字符串。通过AddString把它加到列表框末尾。当总数超过 100 条时删除第 0 条最上面的最旧的记录保证界面不无限增长。4.6 停止定时器在“停止定时器”按钮的点击事件中调用KillTimer同时恢复按钮状态。void CTimerListDemoDlg::OnBnClickedBtnStop() { // 销毁定时器 KillTimer(1); // 更新按钮状态 GetDlgItem(IDC_BTN_START)-EnableWindow(TRUE); GetDlgItem(IDC_BTN_STOP)-EnableWindow(FALSE); }这样用户就可以随时启动和停止定时器。4.7 在窗口关闭时销毁定时器我们还需要注意一个问题如果用户没有点击“停止”按钮直接关闭了对话框窗口那么定时器仍然存在吗答案是当窗口销毁时Windows 会默认销毁该窗口创建的定时器所以一般不会造成严重泄漏。但为了代码严谨我们可以在OnDestroy消息处理函数中主动销毁定时器。打开类向导在对话框类中添加WM_DESTROY消息处理函数OnDestroy然后实现void CTimerListDemoDlg::OnDestroy() { // 销毁定时器 KillTimer(1); CDialogEx::OnDestroy(); }这样做的好处是即使按钮状态没有正确保存窗口关闭时也会把定时器资源清理干净。4.8 运行效果编译并运行程序。点击“启动定时器”按钮后你会看到列表框里每秒增加一条记录内容类似记录 1第 1 次刷新 记录 2第 2 次刷新 记录 3第 3 次刷新 ...点击“停止定时器”列表停止增长。再次点击“启动定时器”记录会继续从上次的计数往下增长。这个小程序就完整跑通了“定时器 列表框”的联动逻辑。5. 扩展功能多定时器与数据处理实际项目中一个窗口往往不止有一个定时器。比如一边定时刷新传感器数据一边定时检查网络连接状态。这时就需要通过不同的 ID 来区分。5.1 使用多个定时器你可以像下面这样创建两个定时器SetTimer(1, 1000, nullptr); // 秒级刷新 SetTimer(2, 5000, nullptr); // 5 秒级刷新然后在OnTimer中分别处理void CTimerListDemoDlg::OnTimer(UINT_PTR nIDEvent) { switch (nIDEvent) { case 1: m_listLog.AddString(_T(1 秒定时器触发)); break; case 2: m_listLog.AddString(_T(5 秒定时器触发)); break; default: break; } CDialogEx::OnTimer(nIDEvent); }这样设计条理清晰扩展起来也很方便。5.2 在列表框中显示时间戳如果我们需要在每条记录前加上当前时间可以使用CTime类void CTimerListDemoDlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent 1) { CTime now CTime::GetCurrentTime(); CString strTime now.Format(_T(%H:%M:%S)); m_nCount; CString strText; strText.Format(_T([%s] 第 %d 条刷新数据), strTime, m_nCount); m_listLog.AddString(strText); if (m_listLog.GetCount() 100) { m_listLog.DeleteString(0); } } CDialogEx::OnTimer(nIDEvent); }这样列表里每条记录都带上了时间看起来更像一个完整的日志系统。5.3 限制列表框容量上面的代码已经展示了限制最大行数的方式。这里再强调一下如果功能是持续采集数据不能无限制地向列表框中添加内容。每添加一条记录内存占用就会增加一点。虽然单条字符串很小但长时间运行后数量会非常庞大导致程序内存暴涨、界面变卡。所以最佳做法是每次添加后判断总数超过阈值就删除最旧的一条索引 0。也可以用下面这种写法保证列表始终显示最新的 100 条while (m_listLog.GetCount() 100) { m_listLog.DeleteString(0); } m_listLog.AddString(strText);两种方式都可以第一种更节省性能因为只在超限时删除一次。6. 常见问题与排查思路在实际开发中你可能遇到各种奇怪的现象。下面整理了一些高频问题并给出排查方向。问题现象常见原因解决思路定时器不触发忘记添加ON_WM_TIMER()消息映射检查消息映射中是否有ON_WM_TIMER()并确认OnTimer函数签名正确定时器不触发SetTimer调用失败检查返回值确认窗口句柄有效确认没有重复创建相同 ID 导致冲突界面卡死在OnTimer中执行了耗时操作不要在定时器处理函数中做耗时计算可以放到工作线程中列表框内容无限增长没有限制条目数量添加后判断GetCount()超过阈值删除旧记录关闭窗口后程序仍在运行OnDestroy中没有销毁定时器重写WM_DESTROY消息处理调用KillTimer多个定时器互相干扰没有用nIDEvent区分为每个定时器分配不同的 ID在OnTimer中用switch或if分支处理定时器间隔不准系统繁忙或定时器精度限制使用更低优先级的方式运行其它任务或使用WaitableTimer等高精度定时器程序刚启动时列表为空还没有触发定时器检查初始化时是否调用了SetTimer或者间隔设置是否过大7. 最佳实践与工程建议7.1 定时器处理函数不要做耗时操作OnTimer运行在 UI 线程中。如果你在里面写了大循环、文件读写、网络请求界面就无法及时处理鼠标、键盘和重绘消息表现出来就是窗口卡死。正确的做法是在OnTimer中只做轻量操作比如从共享变量中读取数据、更新控件。真正的耗时任务放到工作线程中完成线程计算完后再通过自定义消息或PostMessage通知 UI 线程更新。7.2 建议使用成员变量保存定时器 ID有些例子直接用数字1来创建和销毁定时器这样写确实简单但工程维护时不太友好。更推荐的做法是定义常量或枚举// 在头文件中定义 #define TIMER_ID_REFRESH 1 #define TIMER_ID_CHECK 2或者使用enumenum { TIMER_ID_REFRESH 1, TIMER_ID_CHECK 2 };这样在代码中看到SetTimer(TIMER_ID_REFRESH, 1000, nullptr)一眼就能明白这个定时器的用途也方便后续添加新定时器。7.3 使用类向导维护消息处理函数虽然可以手动在头文件和源文件中添加OnTimer声明与实现但使用向导更安全。向导会自动生成正确的消息映射和函数签名减少低级错误。尤其是初学者建议在类向导中添加WM_TIMER和WM_DESTROY的处理函数而不是手动复制网上代码。这样可以避免因为头文件格式不一致而导致的编译错误。7.4 定时器间隔不是越小越好有人为了让界面看起来更“实时”把定时器间隔设置为 1 毫秒。这在 Windows 消息定时器上并没有意义因为WM_TIMER消息并不会保证 1 毫秒触发一次而且频繁触发会让 UI 线程忙于处理定时器消息浪费 CPU。对于常规界面刷新100 毫秒到 1000 毫秒是常见的选择。如果需求是精确测量时间或者高频采样建议使用多媒体定时器或高精度事件定时器。7.5 注意定时器 ID 冲突同一个窗口内定时器 ID 必须唯一。如果你在多个地方都使用SetTimer(1, ...)后者会覆盖前者导致前一个定时器失效。所以在多模块协作时尽量使用较大的数字或者枚举值或者由公共模块统一分配。7.6 列表框更新后的滚动定位当列表框条目很多时AddString会在最后添加一行但滚动条不一定自动滚到底部。如果你希望用户永远看到最新一条需要在添加后设置当前选中项并确保它可见int nIndex m_listLog.AddString(strText); m_listLog.SetCurSel(nIndex);如果你使用的是CListBox的默认风格SetCurSel会让选中项高亮同时自动滚动到可见区域。如果你不想显示选中状态可以改用SetTopIndex来调整滚动位置但通常日志场景里允许高亮。8. 最后的经验小结到这里“定时器 列表框”的完整链路已经打通了。这个功能虽然看起来简单但它背后包含了 Windows 消息机制、控件操作、资源管理和 UI 线程安全等一系列基础知识。以后你再遇到“每隔一段时间刷新界面”“动态往列表里加数据”的需求都可以直接套用这套思路SetTimer创建定时器OnTimer处理逻辑AddString更新列表框KillTimer释放资源。把这个套路练熟后面再学习工作线程、自定义消息、数据库轮询等功能时会顺利很多。希望这篇文章能帮你解决实际问题。如果你在运行过程中遇到了其他报错欢迎对照上面的排查表一步步检查。动手练习一遍比你只看不做要有效得多。
返回列表