尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

从SetTimer到增量更新:VC6.0行情刷新性能优化全解析

从SetTimer到增量更新:VC6.0行情刷新性能优化全解析 简介Windows桌面应用开发中定时刷新与界面流畅性是工程师常需平衡的难题。SetTimer作为经典定时器机制依赖消息循环在低频刷新场景下实现简单、稳定可靠。结合增量更新策略可避免大量数据重建引发的界面闪烁与卡顿。通过引入工作线程隔离网络接收与数据解析再以消息通知主线程渲染UI能有效提升实时行情列表的响应速度。以VC6.0时代股票行情软件为例解析定时器选型、数据解析、列表更新与性能调优的工程实践为处理周期性刷新类任务提供参考。2. 定时刷新机制的核心实现2.1 三种刷新方案对比与选型VC6.0时代做定时刷新最常用的方案无非三种SetTimer定时器、timeSetEvent多媒体定时器、独立工作线程Sleep。我在这套源代码里看到的是经典的SetTimer 标志位方案这个选择非常务实下面把三种方案的优劣说清楚。SetTimer定时器实现最简单几行代码就能跑起来依赖消息循环WM_TIMER消息优先级低最小精度约55ms对于3秒刷新绰绰有余缺点是当窗口拖动、弹菜单时消息会堆积导致刷新不均匀timeSetEvent多媒体定时器精度可达1ms独立于消息循环适合高频数据采集但来回切换线程上下文开销大对3秒级别的刷新是杀鸡用牛刀而且回调里不能直接操作UI还得PostMessage回主线程徒增复杂度工作线程 Sleep灵活性最高可以在后台做网络请求和解析再投递到UI但涉及线程同步问题临界区、事件、消息投递都得处理好如果数据量大、解析耗时Sleep的误差会被放大结合“3秒刷新一次”这个低频需求SetTimer确实是最顺手的选择。代码中大概是这样的骨架// 在创建窗口后启动定时器 SetTimer(hWnd, TIMER_QUOTE, 3000, NULL); // 消息循环中处理 case WM_TIMER: if (wParam TIMER_QUOTE) { // 设置标志位防止重入 if (!g_bRefreshing) { g_bRefreshing TRUE; RefreshStockList(hWnd); g_bRefreshing FALSE; } } break;这里的关键在于g_bRefreshing标志位。如果一次刷新耗时超过3秒比如网络慢或行情服务器响应延迟下一次WM_TIMER到来时直接跳过避免多个刷新请求堆叠。第二个细节是WM_TIMER的低优先级。当用户拖动窗口或弹出右键菜单时Windows会暂时让消息循环处理其他高优先级消息这会导致刷新间隔拉长到4秒甚至5秒。对行情刷新来说这个误差可以接受但如果做毫秒级K线刷新就必须要换方案了。第三个我实测下来的经验SetTimer回调里不要做太重的解析工作。行情数据到达后先接收和校验再把JSON或二进制数据解析成结构体最后才更新到列表控件。每一步都要拆分清楚哪个环节卡住了马上能定位。2.2 数据接收与增量更新策略股票列表行情刷新最大的坑不在于“能不能刷新”而在于“怎么刷才不卡”。我见过不少新手直接把整个ListView的Item全部删掉再重新插入数据量大时界面明显闪烁而且滚动位置丢失。这套源代码的处理思路是“增量更新”具体流程如下第一次启动时完整拉取所有股票的基础信息和当前价格构建列表之后每次3秒刷新只拉取价格、涨跌幅、成交量、成交额等变动数据根据股票代码在列表中的索引位置逐个更新对应行的文本内容// 增量更新列表项避免整体刷新导致闪烁 LVITEM lvi {0}; char szText[32]; for (int i 0; i g_nStockCount; i) { // 按代码定位列表索引或直接根据排序顺序映射 int nIndex g_StockArray[i].nListIndex; sprintf(szText, %.2f, g_StockArray[i].fPrice); lvi.mask LVIF_TEXT; lvi.iItem nIndex; lvi.iSubItem 2; // 价格列 lvi.pszText szText; ListView_SetItem(hListView, lvi); // 涨跌幅、成交量等列依次更新 }注意ListView_SetItem比SendMessage(ListView_SetItemText...)更高效因为前者一次调用可以设置多个属性。实际测试中4000只股票全部刷新一次用SetItem大概耗时50ms用SetItemText会到100ms以上差距在极端情况下还是很明显的。增量更新有个前提列表的排序和索引映射必须稳定。如果用户切换了排序方式比如从“按代码排序”切成“按涨跌幅排序”那增量更新的索引映射就要重新计算。这块的实现通常在LVN_COLUMNCLICK消息里做排序排序完成后重建nListIndex映射表。2.3 界面卡顿与数据解析分离很多人在做行情刷新时会遇到一个现象刷新瞬间整个窗口鼠标变沙漏拖动不跟手。原因往往是把网络请求和UI更新做成了同步串行。正确的思路是“先后台接收再主线程更新”。代码里我建议这样组织// 工作线程连接行情服务器接收数据包 DWORD WINAPI QuoteWorkerThread(LPVOID lpParam) { while (g_bRunning) { // 非阻塞接收数据 int nBytes recv(sock, g_szBuffer, sizeof(g_szBuffer), 0); if (nBytes 0) { // 解析数据包填充全局行情数组 ParseQuoteData(g_szBuffer, nBytes); // 通知主线程更新列表 PostMessage(g_hMainWnd, WM_UPDATE_LIST, 0, 0); } } return 0; } // 主线程处理UI更新 case WM_UPDATE_LIST: UpdateListView(g_hListView); break;这样做的好处是网络延迟、分包粘包问题全部被隔离在工作线程里主线程只做轻量的UI渲染。即使行情服务器阻塞1秒界面也不会因为网络等待而卡住。工作线程模式要和前面的SetTimer配合。一种常见组合是工作线程负责接收数据和解析SetTimer只负责触发“从本地缓冲区渲染UI”这个动作3秒一次。这样即使网络偶尔延后UI的刷新节奏始终保持稳定视觉体验更平滑。3. 股票列表与行情的实时联动3.1 股票列表数据结构选型“策略为王”这种老牌源码里股票列表的数据结构通常是数组加哈希索引的组合而不是简单的CArray或CList。为什么要这么设计因为行情刷新场景下有两种高频操作按代码查找和按索引遍历。按代码查找用户输入“600519”要能瞬间定位到对应股票按索引遍历刷新时按列表顺序逐个更新显示单纯用数组按代码查找是O(n)的遍历4000只股票每次都全量遍历虽然不至于卡死但效率太低。单纯用哈希表遍历顺序又无法和ListView的行号对应。这套源代码用的方案是一个结构体数组保存所有股票数据再用一个std::map或自实现哈希表把股票代码映射到数组下标。// 股票数据结构 typedef struct _STOCK_INFO { char szCode[8]; // 股票代码 char szName[16]; // 股票名称 double fOpen; // 今开 double fHigh; // 最高 double fLow; // 最低 double fPrice; // 最新价 double fPreClose; // 昨收 long nVolume; // 成交量 double fAmount; // 成交额 int nListIndex; // ListView中的行索引 } STOCK_INFO; // 全局容器 STOCK_INFO g_StockArray[MAX_STOCK_COUNT]; std::mapCString, int g_StockCodeMap; // 代码-数组下标g_StockCodeMap的查找复杂度是O(log n)4000只股票查一次大概几微秒用户名搜索股票时响应速度可以接受。但要注意市场里股票的代码是唯一的用CString做键没问题如果将来扩展到多市场建议把交易所前缀也拼进去比如“SH600519”和“SZ000001”避免跨市场代码冲突。日常维护中新增股票时同步插入数组和Map删除股票时反向操作两者必须保持一致性。一个常见的Bug是只删了数组没删Map导致后续查找时下标越界。我在写这类逻辑时习惯加一个全局版本号每次增删股票后自增调试时能快速判断数据是否过期。3.2 行情数据协议解析不管是模拟行情还是真实接入行情源数据协议的解析都是核心环节。老代码常见的协议是自定义的二进制头加数据体。头结构一般长这样typedef struct _QUOTE_HEADER { unsigned short nVersion; // 协议版本号 unsigned short nCount; // 本次数据包包含的股票数量 unsigned int nTimestamp; // 服务器时间戳 } QUOTE_HEADER;后面紧跟nCount个定长或变长的行情记录。定长记录解析简单但字段扩展不方便变长记录灵活但需要小心指针偏移。我在解析时坚持一个原则所有字段按网络字节序转换成本机字节序后再填充到结构体避免大小端问题在关键时刻咬你一口。void ParseQuoteData(char* pBuffer, int nLen) { QUOTE_HEADER* pHeader (QUOTE_HEADER*)pBuffer; pHeader-nVersion ntohs(pHeader-nVersion); pHeader-nCount ntohs(pHeader-nCount); char* pData pBuffer sizeof(QUOTE_HEADER); for (int i 0; i pHeader-nCount; i) { QUOTE_RECORD* pRec (QUOTE_RECORD*)pData; // 字段逐一转换 pRec-fPrice ntohl(pRec-nPriceBits) / 100.0; // 价格通常放大100倍传输 pRec-nVolume ntohl(pRec-nVolume); // 根据股票代码找到数组下标 int nIndex FindStockIndex(pRec-szCode); if (nIndex 0) { // 更新行情字段 g_StockArray[nIndex].fPrice pRec-fPrice; g_StockArray[nIndex].nVolume pRec-nVolume; // 计算涨跌幅 g_StockArray[nIndex].fChangePercent (pRec-fPrice - g_StockArray[nIndex].fPreClose) / g_StockArray[nIndex].fPreClose * 100.0; } pData sizeof(QUOTE_RECORD); } }特别注意ntohl和ntohs的转换。当年在Windows XP时代x86是小端而网络协议是大端如果漏掉转换数值会错得离谱。我吃过一次亏某次接入行情源把所有价格都解析成巨大的数字查了半天才发现是字节序没转。涨跌幅计算用浮点数做除法在实时行情场景下精度足够。但如果你对精度有洁癖可以把价格全部转成整数“分”单位处理最后再除以100显示。避免浮点误差代码更稳。3.3 分时走势与指标联动“策略为王”这类软件不只是简单显示价格还会根据行情数据实时刷新分时走势图。分时图的实现逻辑当天从开盘到当前时刻每分钟或每3秒记录一个价格点连成折线。在前面的3秒刷新基础上分时走势的更新是同一套数据源。每次行情刷新后把当前时间和价格追加到分时数组中同时重绘走势图。重绘用双缓冲避免闪烁基本步骤// 双缓冲绘图 void DrawTrendLine(HDC hDC, RECT* pRect) { HDC hMemDC CreateCompatibleDC(hDC); HBITMAP hBmp CreateCompatibleBitmap(hDC, pRect-right - pRect-left, pRect-bottom - pRect-top); HBITMAP hOldBmp (HBITMAP)SelectObject(hMemDC, hBmp); // 在hMemDC上绘制背景、网格、折线 DrawBackground(hMemDC, pRect); DrawGrid(hMemDC, pRect); DrawPolyline(hMemDC, pRect); // 一次性拷贝到窗口 BitBlt(hDC, pRect-left, pRect-top, pRect-right - pRect-left, pRect-bottom - pRect-top, hMemDC, 0, 0, SRCCOPY); SelectObject(hMemDC, hOldBmp); DeleteObject(hBmp); DeleteDC(hMemDC); }分时图的坐标轴计算有个细节Y轴的上下限不是固定百分比而是取当日最高价和最低价并留出10%的边距。如果只在3秒刷新时计算一次坐标范围股价剧烈波动时走势线会顶出边界。我建议每次追加数据点后都重新计算Y轴范围虽然多几次浮点运算但绘制效果更专业。4. VC6.0工程配置与源码运行准备4.1 VC6.0编译环境的搭建与兼容拿到一份VC6.0的源代码第一件事不是急着看代码而是先把编译环境搞定。VC6.0虽然是上世纪的产品但在XP虚拟机甚至Windows 10的兼容模式下依然可以稳定运行。我自己的习惯是在Win10上用“Windows XP SP3兼容模式”运行VC6.0编译旧工程基本没毛病。安装时几个关键点选择“Custom”安装确保安装VC MFC库和Platform SDK如果计划编译网络相关的代码需要确认系统SDK里包含Winsock 2的头文件VC6.0默认的malloc行为在后续Windows版本上可能触发堆损坏建议在stdafx.h里定义_CRT_SECURE_NO_WARNINGS并检查编译器警告级别打开源码工程文件.dsp或.dsw时如果VC6.0提示“工程文件版本过新”是读不了VS2002之后的工程格式的。这时只能手动新建工程把所有.cpp和.h文件加入进去。这类“策略为王”源码老工程基本都是VC6格式正常双击即可打开。一个很多人忽略的配置VC6.0的C编译器对标准C支持有限模板、异常处理、STL的使用都要注意。源码里如果用了std::map在VC6.0下问题不大但如果用到std::shared_ptr这类C11特性就必须自己实现或者改用老式指针这也是老源码常见改造点。4.2 关键宏定义与依赖库实时行情软件通常会依赖以下库链接时要注意ws2_32.libWinsock网络编程必选comctl32.lib列表视图控件所需winmm.lib如果用到多媒体定时器在stdafx.h里添加#pragma comment(lib, ws2_32.lib) #pragma comment(lib, comctl32.lib) #pragma comment(lib, winmm.lib)另一个容易踩坑的是Unicode与ANSI字符串混用。VC6.0默认工程是ANSIMBCS如果代码里的char数组和CString混用在某些系统区域设置下中文股票名称会乱码。解决办法有两种要么全部使用CStringA要么统一转成宽字符。考虑到老代码大量使用char我倾向于保持ANSI编码并确保工程文件的代码页和系统区域一致。4.3 模拟行情源的准备拿到源码后如果没有真实行情服务器地址可以先用模拟数据调试界面。做法很简单写一个生产者线程每3秒生成一批随机价格波动填充行情结构体再PostMessage通知界面刷新。这样不依赖网络也能验证列表刷新、排序、分时绘制等逻辑是否正常。void GenerateMockQuote() { for (int i 0; i g_nStockCount; i) { double fDelta (rand() % 200 - 100) / 1000.0; // -10% ~ 10% g_StockArray[i].fPrice * (1.0 fDelta); g_StockArray[i].fChangePercent (g_StockArray[i].fPrice - g_StockArray[i].fPreClose) / g_StockArray[i].fPreClose * 100.0; } }模拟数据的随机范围要控制在合理区间。股票一天的波动很少超过10%模拟数据用±10%的随机扰动视觉效果上比较真实。如果你想要更真实的模拟可以在真实历史数据上叠加小幅噪声更接近生产环境的表现。4.4 VC6.0工程常见编译错误速查编译老工程时最常见的问题基本逃不出下面这几类错误信息原因与对策error C2065: AFX_MANAGE_STATE : undeclared identifierMFC扩展DLL才会用到如果工程不是DLL直接删掉或注释error C2039: SetDllItemText : is not a member of CListCtrlAPI拼写错误正确的MFC封装是CListCtrl::SetItemTextfatal error C1010: unexpected end of file while looking for precompiled header directive所有.cpp文件缺#include stdafx.h或预编译头设置不一致error LNK2001: unresolved external symbol __imp__WSAStartup8忘记链接ws2_32.libwarning C4996: strcpy was declared deprecatedVC6.0没有该警告一般出现在更高版本编译器不用改碰到这些错误别慌大多数都是工程配置问题不是代码逻辑问题。我经常直接全局搜索错误的标识符看是不是少加头文件或宏定义。编译通过的顺序建议先编译一个空工程再逐步加入源码文件每加一批编译一次方便定位出错的代码块。5. 常见问题与性能调优5.1 列表刷新卡顿的排查思路如果3秒刷新导致界面卡顿先别急着改代码按下面几个步骤来排查**第一步确定瓶颈在网络还是在UI。**在模拟行情模式下如果模拟数据刷新也卡那问题出在UI更新逻辑如果模拟数据流畅而真实数据卡那就是网络接收和解析太耗时。这一判断能把问题范围缩小一半。**第二步看刷新是否做了全量重建。**我在前面反复强调增量更新如果你发现代码里是DeleteAllItems再加回所有数据那肯定卡。即便数据量不到4000重建列表的耗时也远大于逐项更新文本。**第三步检查是否频繁创建和销毁GDI对象。**在WM_TIMER处理逻辑里如果每次都CreateFont、CreatePen却忘记删除GDI句柄会持续累积。Windows的GDI句柄上限大约是10000个到上限后整个程序绘制会全部失效表现为“刷新越来越卡最终白屏”。排查GDI泄漏的土办法打开任务管理器勾选“GDI对象”列盯着刷新几轮看数值是否只增不减。如果是仔细审计创建和删除的配对。5.2 实时刷新的内存管理细节老式C风格代码在长时间运行时内存泄漏是重灾区。行情软件通常要连续开几天哪怕每小时泄漏1MB一星期也足够导致系统变慢。整理这份源码时特别注意以下几点接收数据的缓冲区如果是动态分配的务必确认所有分支都会free或delete解析过程中临时创建的CString会自动释放没问题但char*手动的务必配对线程结束前要确保循环退出后不会有残留的PostMessage被处理我习惯在关键的循环逻辑里加一个计数器每次“接收-解析-更新”完成后递增疑似内存泄漏时通过日志观察计数是否和预期一致。VC6.0没有内置的内存诊断工具可以用_CrtSetBreakAlloc配合_CrtDumpMemoryLeaks在程序退出前输出泄漏点。// 在程序入口处启用内存泄漏检测 #ifdef _DEBUG _CrtSetDbgFlag(_CRTDBG_ALLOC_MEM_DF | _CRTDBG_LEAK_CHECK_DF); // 如果已知某个分配号泄漏可以设置断点 // _CrtSetBreakAlloc(12345); #endif5.3 刷新频率与网络连接的自适应策略固定3秒刷新在大多数情况下够用但并不是最优方案。连接状态不佳时例如服务器超时重连3秒请求反而会加重服务器负担。更聪明的做法是自适应刷新正常行情期间保持3秒断线重连后降低到10秒重连成功后恢复3秒。// 网络状态切换 case NET_CONNECTED: SetTimer(hWnd, TIMER_QUOTE, 3000, NULL); break; case NET_RECONNECTING: KillTimer(hWnd, TIMER_QUOTE); SetTimer(hWnd, TIMER_RECONNECT, 10000, NULL); break;还有一个细节如果行情数据本身就是快照模式下发的每次刷新的间隔偏差其实不太影响。但如果将来做逐笔成交或Level2数据3秒固定刷新就守不住实时性了那时候需要改成事件驱动服务器推送数据后立即更新UI而不是等下一个周期。5.4 列表排序与颜色标识的联动股票列表行业的惯例是涨红跌绿A股或涨绿跌红美股代码里通常靠ListView_SetItem的LVIF_STATE或自定义绘制来实现。VC6.0时代的做法是设置LVN_CUSTOMDRAW通知在绘制前根据涨跌设置文本颜色。case LVN_CUSTOMDRAW: NMLVCUSTOMDRAW* pLVCD (NMLVCUSTOMDRAW*)pNMHDR; if (pLVCD-nmcd.dwDrawStage CDDS_PREPAINT) { *pResult CDRF_NOTIFYITEMDRAW; return TRUE; } if (pLVCD-nmcd.dwDrawStage CDDS_ITEMPREPAINT) { int nIndex (int)pLVCD-nmcd.dwItemSpec; double fChange g_StockArray[nIndex].fChangePercent; if (fChange 0.0) { pLVCD-clrText RGB(255, 0, 0); // 红涨 } else if (fChange 0.0) { pLVCD-clrText RGB(0, 255, 0); // 绿跌 } else { pLVCD-clrText RGB(255, 255, 255); // 平盘 } *pResult CDRF_NEWFONT; return TRUE; } break;自定义绘制在数据量大时的性能比逐项SetTextColor更好因为它只对可见行调用。4000只股票如果窗口只显示30行那绘制工作只有30行量级这是长期跑实时行情的优化点。5.5 从3秒刷新到分时、日K扩展当列表刷新稳定后下一步通常是扩展分时图、日K线等功能。这个阶段的数据流和列表刷新共享同一份行情数据但请注意分时数据一旦丢失就无法从头补齐所以每次行情刷新不仅要更新当前价还要把“当前时间价格”增量追加到分时数组里。我在实现分时存储时用了一个环形缓冲区#define MAX_TREND_POINTS (4 * 60 * 5) // 4小时*60分*5个点位 typedef struct _TREND_POINT { unsigned int nTime; // 格式 HHMMSS double fPrice; } TREND_POINT; TREND_POINT g_TrendPoints[MAX_TREND_POINTS]; int g_nTrendCount 0; int g_nTrendPos 0; // 环形写入位置环形缓冲的好处是内存固定、无需频繁扩容缺点是满了以后要小心覆盖逻辑。收盘后如果要保留全天分时用于复盘建议在日切时把环形缓冲区内容转存到文件或另一个普通数组避免第二天开盘数据把前一天的覆盖掉。6. 补充开发环境与工具链细节6.1 使用VC6.0调试实时刷新代码的技巧VC6.0的调试器相比现代IDE确实简陋但对付行情刷新这种场景足够。用得最多的几个功能断点尽管老编译器断点没有“条件断点”那么方便但可以配合变量窗口手动检查数值Watch窗口在刷新循环里添加g_StockArray[0].fPrice、g_nStockCount等表达式观察每次触发后的变化Call Stack当崩溃发生时查看调用栈可以快速定位是哪一层的问题我还会在关键函数入口加OutputDebugString用DebugView工具实时查看日志比弹对话框干扰刷新节奏好得多。尤其是3秒一次的刷新如果弹个MessageBox消息循环被阻塞定时器彻底失灵整个程序表现就和“无响应”一样。void RefreshStockList(HWND hWnd) { OutputDebugString(RefreshStockList enter\n); // ... 具体逻辑 OutputDebugString(RefreshStockList leave\n); }6.2 老代码迁移到高版本Visual Studio时的注意点如果你打算把这份VC6.0源码迁移到VS2010甚至更高版本有几个坑几乎必踩for循环变量作用域VC6.0允许for(int i0;...)之后继续用i高版本编译器报错error C2065需要手动把i声明移到循环外std::fstream的打开模式老代码常用ios::nocreate新标准库不支持需要改写成std::ifstream配合std::ios::inWindows API的字符串类型VC6默认char高版本默认Unicode函数调用要显式用TCHAR或添加#define UNICODE兼容层迁移时最稳妥的做法不是大改而是保持ANSI编译。在VS2010项目属性里把字符集设为“未设置”可以最大限度保留老代码语义。只有确认代码里所有字符串操作都是宽字符安全时才建议切到Unicode。6.3 免安装VC6.0环境的使用经验网上流传的“VC6.0免安装版”对快速解压运行确实方便可以临时用来打开工程看代码结构。但我不建议把它作为主力编译环境原因有三个绿色版缺少部分头文件和库链接时偶尔报奇怪错误环境变量和注册表不完整后续安装插件或扩展SDK可能失败调试器和编译器的版本如果被修改过可能导致编译结果与标准VC6不一致如果你只是临时想翻看那份“策略为王”源代码的阅读体验绿色版没有问题。但要实际编译、运行、调试我建议还是装完整VC6.0或者用虚拟机装一个Windows XP环境省得被环境问题消耗大量时间。7. 最后再分享一点心得做行情刷新这类功能技术难点从来不在“怎么实现3秒刷一次”而在“长时间运行后程序还能不能稳定如初”。用户开着软件盯着列表看一天内存、GDI句柄、时间漂移任何一个出问题都会暴露。所以我个人在写完代码后会做一次24小时连续运行测试挂机跑一晚上第二天看日志和内存占用趋势比写一百个单元测试都有用。另外对老代码里没写注释、风格晦涩的部分我的原则是“先跑通、再优化、后重写”。拿到一份代码先别急着重构原样编译通过、运行起来观察行为是否符合预期然后基于最小改动去修Bug和加功能。等你彻底理解了数据流和消息流之后重构才有底气。这个源码项目的核心价值其实不在代码本身而在里面一整套“如何在老Windows环境下稳定运行”的工程经验这一点换到任何技术栈都通用。本文还有配套的精品资源点击获取
返回列表