从“策略为王”源码看MFC股票行情3秒刷新机制
简介:在桌面客户端开发中,实时数据展示与界面流畅度始终是核心挑战。MFC作为经典Windows框架,其消息循环与控件机制为理解这类问题提供了直观视角。基于VC6.0的行情软件,通过定时器驱动、工作线程拉取数据,并结合CListCtrl虚拟列表与自绘机制,实现了股票列表3秒刷新的流畅体验。其核心技术在于分离数据更新与UI渲染,利用LVN_GETDISPINFO按需取数,借助NM_CUSTOMDRAW实现涨跌变色,从而避免闪烁和崩溃。这类经验不仅适用于维护股票行情等金融软件,也对任何高频刷新列表场景具有借鉴意义。本文即围绕“策略为王股票软件源代码”剖析其实时刷新机制与工程实践。 从“策略为王股票软件源代码”这个标题说起。这是一套基于VC6.0的MFC股票行情客户端源码,核心功能点是“股票列表实时行情刷新,3秒一次”。说实话,现在做行情软件的人多数已经转向Qt、C#或者Electron,但如果你需要维护老系统、研究行情客户端的底层数据流,或者想看看在Windows XP年代那种资源受限的环境下,怎么用纯C++把行情列表做到流畅刷新,这套代码的参考价值依然很直接。
这篇文章我会从架构设计、3秒刷新机制的实现方式、列表控件数据更新策略、自绘优化,到实际运行中常见的崩溃和卡顿问题,一层层拆开讲。所有分析都基于我对这类行情客户端的实际开发经验,结合这套源码所体现的技术路线来还原。不保证每一行代码和原工程完全一致,但核心思路是这类软件通用的,拿去对照你自己的项目也完全适用。
1. 先认识这套代码:它到底解决了一个什么问题
先说一个可能被低估的点:一套MFC写的行情软件,能做到股票列表每3秒刷新一次,同时界面不卡、数据不乱、长时间运行不崩溃,这在VC6.0时代是很有门槛的事情。那个年代的机器普遍是单核CPU、内存256MB级别,网络环境还是窄带或早期ADSL,行情数据走的是TCP短连接或UDP组播,不像现在一条WebSocket就解决了。
1.1 这个场景里的核心难点
股票列表实时刷新,看起来就是“定时请求数据 + 更新表格”,但真正做起来有三个坎:
第一,数据到达的节奏是不可控的。网络延迟、服务器处理速度、数据包大小,都会导致请求返回时间不稳定。如果同步请求+刷新UI,一次卡顿就会连带后面所有刷新任务排队,3秒一刷渐渐变成10秒一刷。
第二,列表控件是全套工程里最容易崩的地方。MFC的CListCtrl在数据量几百行、每3秒全量刷新一次的情况下,会频繁触发插入删除、滚动条重算、文本重绘。如果刷新过程中用户正好在拖动滚动条或者点击列头排序,一个野指针就是当场崩溃。
第三,数据一致性和界面流畅度是矛盾的。高频更新时,如果数据在写了一半的时候被界面线程读取,显示出来就是“花屏”的行情——价格一会涨一会跌,成交量对不上。这个视频里展示的“成功加入股票列表实时行情刷新”,其实就同时踩了这三个点。
1.2 为什么选择VC6.0和MFC
很多人不理解,2025年了还有人看VC6.0代码?真实原因是存量系统太庞大了。证券行业的行情分析软件,很多核心模块就是VC6时代写的,后续维护者换了一茬又一茬,文档丢得差不多,只剩下源码能看。而VC6.0对应的MFC版本虽然古老,但它对象化封装了Win32编程模型,窗口、消息、定时器、控件这些概念非常直白,一个懂Windows编程基础的人,看这套代码比看现代化框架更容易理解底层逻辑。
另外一点,VC6.0生成的代码里充斥着大量的Win32 API直接调用、SendMessage、PostMessage、SetTimer、InvalidateRect这些底层操作,这反而是好事——它把界面刷新机制、消息循环、线程交互这些核心知识暴露得很清楚,适合学习。
2. 整体架构拆解:一次行情刷新在系统里走完的路
这套软件的模块划分,是典型的“数据层—逻辑层—展示层”三层结构,只是因为MFC的项目组织方式,三层混在Doc/View和对话框类里,不是那么显眼。
2.1 三个核心模块和它们的分工
网络通信模块:负责连接行情服务器,发送股票代码列表请求,接收行情数据流。在这个工程里,它通常是一个独立的CWinThread派生类,内部维护一个socket连接,有独立的收发缓冲区。行情数据通过自定义协议(一般是压缩后的二进制格式)传入。
数据管理模块:负责维护所有股票的最新行情快照。工程里一般会有一个全局的股票数据池,以股票代码为主键,存放最新价、涨跌幅、成交量、成交额、买卖五档等字段。这个模块要保证读写安全,因为网络线程在写、UI线程在读。
界面展示模块:主要是自选股列表界面,用一个CListCtrl派生类来展示数据。每次刷新时,从数据池中取出需要显示的股票数据,更新到列表对应行。这部分的实现质量直接决定用户看到的软件是“流畅”还是“卡成PPT”。
2.2 3秒刷新机制的两个层次
这个工程里,“3秒刷新一次”不是简单的一条SetTimer就能解释的。它实际上分两层:
第一层是网络层的请求频率控制。网络线程不是每3秒才发一次请求,而是可能维护一个更短的轮询周期,或者保持一个长连接,服务器主动推送。只是为了减轻服务器压力和数据流量,客户端在UI层每3秒提取一次最新快照数据。
第二层是界面层的刷新周期。UI线程每3秒触发一次OnTimer,从数据池同步快照,然后通知列表控件重绘。说句实话,很多老代码里这两个层是混着的,网络线程直接SendMessage驱动界面更新,这个工程如果在“成功加入股票列表实时行情刷新”这个功能上做得顺手,大概率是做了分层处理的。
2.3 为什么是3秒而不是1秒或者5秒
我个人的经验判断,3秒这个阈值是当时的经验值。
行情数据更新太快没有意义——在没有高频交易的时代,分钟级别的K线由3秒级快照合成已经足够;而如果低于3秒,以当时的网络和服务器能力,多客户端并发轮询会给服务器带来不小的压力。5秒又会让用户觉得行情“迟钝”,尤其是在看盘中,3秒是一个体验和成本的平衡点。
这提醒我们一个通用原则:轮询类功能的刷新频率,不能只看“我技术上能做到多快”,而要看“业务上需要多快”和“服务器能否承受”。把刷新周期做成可配置项,是这套代码里值得保留的扩展点。
3. 核心机制实现:3秒刷新的定时器与线程模型
既然标题重点强调“3秒刷新一次”,那我就把这一节做重点,直接拆到代码实现的层次。
3.1 定时器选型:WM_TIMER还是工作线程
在MFC程序里实现定时触发,最直接的方式是SetTimer:
// 在初始化函数中启动定时器 SetTimer(REFRESH_TIMER_ID, 3000, NULL); // 消息映射 ON_WM_TIMER() // 响应函数 void CStockListView::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent == REFRESH_TIMER_ID) { RefreshStockData(); } CListView::OnTimer(nIDEvent); }这种方式胜在简单,UI线程里直接用,但有个隐患:如果RefreshStockData里做了耗时操作(比如等待网络返回),UI会卡住。所以这个工程里更合理的做法是,把SetTimer只当作一个“闹钟”,OnTimer里仅仅发消息或者置标志位,真正的数据拉取在独立线程里完成。
void CStockListView::RefreshStockData() { // 通知网络线程发起一轮行情请求 ::PostMessage(GetSafeHwnd(), WM_USER_REFRESH_DATA, 0, 0); }这里用PostMessage而不是SendMessage,关键区别在于:PostMessage把消息丢到消息队列立即返回,SendMessage要等接收方处理完才返回。在UI线程里用SendMessage等于自己等自己,容易死锁。新手开发最容易在这里踩坑。
3.2 行情请求的拼接与状态管理
每3秒刷新,网络线程要做的第一件事是拼请求包。请求包里包含股票代码列表、请求字段(最新价、涨跌幅、成交量、买卖五档等)、时间戳。关键点是“增量请求”而不是“全量请求”。
全量请求是把用户自选股的所有信息每次都完整拉一遍,代码写起来简单,但流量大、服务器压力大、响应也慢。增量请求则是只请求变化的数据字段,服务器只返回有变动的部分。这套源码里如果做了“成功加入股票列表”的功能,那这里一定包含一个“加入后只增量拉取新股”的逻辑。
伪代码大概是这样的:
void CQuoteClient::RequestQuotes(const std::vector<CString>& codes) { // 构建请求头 QuoteRequest req; req.count = codes.size(); for (int i = 0; i < codes.size(); i++) { req.codes[i] = codes[i]; req.fields[i] = FIELD_PRICE | FIELD_VOLUME | FIELD_AMOUNT; } // 序列化后放入发送队列 SendBuffer((char*)&req, req.GetSize()); }当用户“成功加入”一只新股票时,系统不是全量重新请求,而是在下一次请求周期中把新代码追加进去,同时从本地缓存中读取该股票上次的行情数据先显示出来,等新数据来了再覆盖。这保证了用户加入股票后,能立刻看到上一轮的缓存行情,而不是干等3秒白屏。
3.3 刷新状态的界面反馈
还有一个细节:界面右上角通常会有一个状态栏显示“最近刷新时间”或者闪烁的小红点,用来标识“数据是活的”。实现上是UI线程根据刷新完成标志位,调用InvalidateRect触发状态栏区域的重绘。这块代码虽然不是很起眼,但它承担了“让用户感知到系统在运行”的重要任务——特别是在数据不跳动的盘后时段,这个反馈尤其关键。
4. 股票列表的数据结构与更新策略
行情数据的核心结构,在这套源码里一般是一个全局数据池。我见过很多种写法,最基本的如下:
struct StockQuote { TCHAR code[8]; // 股票代码,如 "600000" TCHAR name[32]; // 股票名称 double price; // 最新价 double lastClose; // 昨收 double open; // 今开 double high; // 最高 double low; // 最低 double volume; // 成交量(手) double amount; // 成交额(元) DWORD updateTime; // 更新时间戳 };在VC6时代,double在内存里占8字节,如果每只股票存几十个字段,一万只股票的内存占用很可观,所以很多工程会用float来存价格和涨跌幅,牺牲一点精度换速度和内存。这个源码里如果追求性能,大概率也是这么做的。
4.1 索引结构与查找效率
股票列表的更新场景是:通过网络线程拿到一条行情记录,然后要快速定位到数据池中对应的字段。如果每次都用strcmp遍历所有股票,最坏情况是O(n),一万只股票每次刷新就要比较一万次,再叠加高频更新,性能灾难。
解决思路是哈希映射,或者更简单的“代码转索引”法。股票代码基本是数字,可以换算成整数后直接做数组下标。例如“600000”可以转换成整型600000,然后用一个大数组,下标就是代码本身。但这样会浪费内存,所以工程里通常用的还是哈希表:
CMap<CString, LPCSTR, int, int> m_codeIndexMap;或者自己写一个简单的取模哈希,用链表解决冲突。这套源码里具体是哪种不重要,关键是要理解:实时数据系统的第一性能瓶颈,往往不是网络,而是数据查找和更新时的内存操作效率。
4.2 增量更新还是全量重建
回到“3秒刷新”这个场景。每次刷新时,UI层拿到的是数据池里的全部快照,还是只拿变化了的记录?
如果只拿变化记录,列表控件需要只更新变化行,效率很高。但在MFC的CListCtrl里,判断“哪些行变了”本身需要遍历,而且数据一旦有排序、有筛选,行号和股票代码的对应关系是动态的,定位成本不低。
如果全量更新,实现简单,但每3秒重建一次列表内容,如果列表有几百只股票,会明显看到闪烁或滚动条跳动。
在这套源码里,一个比较务实的做法是“列表行只更新文本,不重建行”:每次刷新时,遍历所有可见行和在视口附近的可见范围,逐行更新对应文本;不在可视范围内的行,数据只更新到数据池,不触发UI更新。这样既保证了流畅度,又避免了大量无效重绘。
void CStockListView::UpdateVisibleRows() { int nTopIndex = GetListCtrl().GetTopIndex(); int nVisibleCount = GetListCtrl().GetCountPerPage(); for (int i = nTopIndex; i < nTopIndex + nVisibleCount && i < GetListCtrl().GetItemCount(); i++) { CString code = GetListCtrl().GetItemText(i, 0); StockQuote* quote = GetQuoteByCode(code); if (quote) { CString strPrice; strPrice.Format(_T("%.2f"), quote->price); GetListCtrl().SetItemText(i, 1, strPrice); // 同样更新其他列 } } }用GetTopIndex和GetCountPerPage计算可视范围,是MFC列表控件性能优化里很实用的一招。
4.3 排序与筛选状态下的更新一致性
这里有一个容易出现暗坑的地方:如果列表支持点击列头排序,更新数据时一定是先改数据池,再基于数据池重新排序,最后更新界面。如果你先改了界面上的某一行文本,再触发排序,行号就错位了。
正确流程应该是:
- 网络线程把数据写入共享数据池。
- UI线程加锁,从数据池拷贝一个待显示的数据快照。
- 根据当前排序字段和方向,对快照做排序。
- 全量或者可视范围更新到控件。
这里面第2步的“加锁拷贝”是重点,也是最容易被忽视的。不加锁直接读,数据写到一半读出来就是脏数据;每次加锁时间太长,又把UI拖卡了。一个折中是给共享数据池加读写锁,读多写少的场景下,CRITICAL_SECTION或者SRWLock都比互斥锁更合适。
5. 列表控件的自绘与闪烁优化
5.1 为什么CListCtrl三秒闪一次
默认的CListCtrl在每次SetItemText的时候,会触发局部重绘,如果一次更新几十行,每一行都重绘一次,界面就像霓虹灯一样闪。更麻烦的是,如果刷新过程中用户用鼠标滚轮滚动,控件还会同时处理滚动消息,重绘和滚动互相打架,闪烁更严重。
解决闪烁,通用思路是双缓冲:先在内存DC上画好整个列表,再一次BitBlt到屏幕。VC6时代的MFC里,这个做法要自己写,不像后来的控件库直接封装好了。
5.2 双缓冲自绘虚拟列表
一个更现代也更省事的做法是使用虚拟列表(LVS_OWNERDATA)。虚拟列表的特点是:控件自己不存数据,只在需要显示某一行时才通过LVN_GETDISPINFO通知你提供该行的显示文本。这样数据的存储完全由你自己管理,控件只负责按需取数据,刷新时只要发一个Invalidate通知重绘,数据量再大也能保持流畅。
// 设置虚拟列表风格 GetListCtrl().ModifyStyle(0, LVS_OWNERDATA); // 设置行数 GetListCtrl().SetItemCount((int)m_quoteList.size()); // 处理 LVN_GETDISPINFO 消息 void CStockListView::OnGetDispInfo(NMHDR* pNMHDR, LRESULT* pResult) { LV_DISPINFO* pDispInfo = (LV_DISPINFO*)pNMHDR; LV_ITEM* pItem = &(pDispInfo)->item; int nIndex = pItem->iItem; if (pItem->mask & LVIF_TEXT) { if (pItem->iSubItem == 0) { _tcscpy(pItem->pszText, m_quoteList[nIndex].code); } else if (pItem->iSubItem == 1) { CString strPrice; strPrice.Format(_T("%.2f"), m_quoteList[nIndex].price); _tcscpy(pItem->pszText, strPrice); } // 其他列类似 } *pResult = 0; }同时,为了进一步自定义涨跌颜色(红涨绿跌),需要处理NM_CUSTOMDRAW消息:
void CStockListView::OnCustomDraw(NMHDR* pNMHDR, LRESULT* pResult) { NMLVCUSTOMDRAW* pLVCD = (NMLVCUSTOMDRAW*)pNMHDR; *pResult = CDRF_DODEFAULT; if (CDDS_PREPAINT == pLVCD->nmcd.dwDrawStage) { *pResult = CDRF_NOTIFYITEMDRAW; } else if (CDDS_ITEMPREPAINT == pLVCD->nmcd.dwDrawStage) { *pResult = CDRF_NOTIFYSUBITEMDRAW; } else if ((CDDS_ITEMPREPAINT | CDDS_SUBITEM) == pLVCD->nmcd.dwDrawStage) { int nItem = (int)pLVCD->nmcd.dwItemSpec; double price = m_quoteList[nItem].price; double close = m_quoteList[nItem].lastClose; if (price > close) { pLVCD->clrText = RGB(255, 0, 0); // 涨,红色 } else if (price < close) { pLVCD->clrText = RGB(0, 255, 0); // 跌,绿色 } else { pLVCD->clrText = RGB(255, 255, 0); // 平,黄色 } *pResult = CDRF_NEWFONT; } }NM_CUSTOMDRAW配合LVN_GETDISPINFO,是整个列表流畅度和视觉效果的灵魂,这也是这套源码里最值得“抄走”的部分。
5.3 闪烁问题排查的实战心得
如果你接手一套老代码,发现列表闪烁但不确定原因,我建议按这个顺序排查:
- 先看刷新时是否调用了
DeleteAllItems或者InsertItem。如果有,改成SetItemCount或SetItemText,这是最大的闪烁源头。 - 再确认控件是否设置了
LVS_EX_DOUBLEBUFFER扩展样式。在XP之后的系统上,这个样式能自动缓解不少闪烁问题,VC6工程也可以手动加。 - 如果还在闪,检查控件有没有开启
WS_CLIPCHILDREN和WS_CLIPSIBLINGS,这两个样式能让子控件重绘时不互相干扰。 - 最后才考虑手动双缓冲自绘。
这一步一步排查下来,大多数闪烁问题都能找到根因。
6. 实战中遇到的坑与排查实录
这类行情软件,在实际运行中问题最多的其实不是功能缺失,而是稳定性和数据准确性。我梳理几个高频坑,完全是经验之谈。
6.1 崩溃问题:索引失效与野指针
虚拟列表模式下,UI在显示第N行时,会通过LVN_GETDISPINFO去m_quoteList取数据。如果此时网络线程恰好往m_quoteList里插入或删除了一只股票,导致vector重新分配内存,那么UI线程手里的旧指针就悬空了,点击任意一行都可能崩溃。
解决这个问题的常见思路有三个:
- 用
std::deque或者链表结构,插入删除时不使已有迭代器失效; - 更新数据时不在UI读取的间隙里修改容器结构,而是先改共享数据池,UI取数前再从数据池同步一份快照;
- 对容器访问加锁,虽然不是最高效,但安全第一。
我遇到过一个实际情况:软件在某些机器上运行十几个小时后才崩溃,查了一天最后定位到是vector在扩容时,老数据被挪动,而一个保存着旧地址的缓存指针还在被UI周期性地使用。如果早注意到“容器结构变化导致迭代器失效”这个点,能省下至少半天排查时间。
6.2 数据错乱:网络粘包与半包
行情数据走TCP时,应用层收到的是一个连续的字节流,一次recv可能收到半个包,也可能收到两个包连在一起。如果协议没有处理粘包拆包,数据就会错乱,显示出来的价格忽高忽低,偶尔还出现负数。
处理粘包的标准做法是维护一个接收缓冲区,按“包头长度 + 包体长度”来切包。每次收到数据,先判断缓冲区里是否够一个完整包头,够的话解析出包体长度,再判断包体是否已经收全,收全了才交给业务层处理。
std::vector<char> recvBuffer; void OnReceive(const char* data, int len) { recvBuffer.insert(recvBuffer.end(), data, data + len); while (recvBuffer.size() >= sizeof(PacketHeader)) { PacketHeader* header = (PacketHeader*)recvBuffer.data(); int packetLen = header->bodyLen + sizeof(PacketHeader); if (recvBuffer.size() < packetLen) break; // 还没收完整个包,等下一次 ProcessPacket(recvBuffer.data(), packetLen); recvBuffer.erase(recvBuffer.begin(), recvBuffer.begin() + packetLen); } }如果你在看代码时发现这个工程里有类似的while循环处理缓冲区,那说明原作者是踩过这个坑的。
6.3 内存增长:字符串格式化引发的碎片
MFC里CString用起来很方便,但它每次动态分配都会产生堆碎片。高频刷新下,如果每次SetItemText都新建一个CString,运行几小时后进程的内存占用会持续增长,最终可能触发内存不足。
经验做法是,把格式化函数抽出来,统一使用栈上的缓冲数组:
TCHAR szBuffer[64]; _stprintf(szBuffer, _T("%.2f"), quote->price);避免频繁的堆分配。在VC6时代,_stprintf配合定长数组,是性能敏感路径上的标配。
6.4 定时器漂移:3秒刷新越来越慢
WM_TIMER不是精确时钟,它基于系统消息队列,优先级低于鼠标键盘消息。如果系统忙,或者UI线程在处理其他消息,OnTimer的触发间隔会拉长,3秒慢慢变成4秒、5秒。
解决定时器漂移的一个常见方案是用timeGetTime或者GetTickCount记录上次刷新的时间,在OnTimer里判断是否真的过去了3秒,如果没到就立刻返回,下个时钟周期继续检查。这样能让刷新节奏尽量稳定。
另一个方案是干脆把刷新逻辑放到独立线程里,不依赖UI消息循环,用WaitForSingleObject的timeout来实现定时。不过这样一来,跨线程更新UI就需要注意线程安全了,复杂度会高一些。工程上,除非对时间精度要求很高,否则“定时器时间检查”法已经够用。
7. 源码学习与二次开发建议
7.1 如何快速看懂这套代码
拿到源码,不要从头到尾一行行读。我建议按照“入口—数据流—关键输出”的路径来。
第一步,先找到InitInstance或者OnCreate附近,看有哪些初始化工作,特别是网络连接建立在哪、数据线程在哪里启动、定时器在哪里注册。
第二步,跟踪一条行情数据从“收到网络包”到“显示在列表”的完整路径。先找到socket的接收处理函数,看它包解析之后调用了哪个函数存数据;再找到UI的刷新入口,看它从哪里取数据。
第三步,把列表控件的自绘和消息处理单独拎出来,对比NM_CUSTOMDRAW和LVN_GETDISPINFO这两个核心回调。
也就是说,重点是“一个数据包的生命周期”,而不是类之间的调用关系。
7.2 从VC6移植到现代编译器的几个注意点
如果你打算把这套代码拿到VS2019或者VS2022上编译,有几个地方几乎肯定会出错:
for循环里面定义变量,比如for (int i = 0; i < n; i++),VC6默认允许i在外部还要用,但现代编译器在/Zc:forScope下会报错。搜索所有循环变量的作用域问题。std::vector<T>::iterator相关代码,如果用到&vec[0]的方式取数组首地址,注意空数组时行为未定义。- 字符串函数
strcpy、strcat、sprintf等在VC6里能用,但现代编译器开启安全警告后直接报C4996,要么换成_TRUNCATE系列,要么在预处理器里定义_CRT_SECURE_NO_WARNINGS。 - MFC版本差异,VS2019下CListCtrl的部分默认行为变了,尤其是DPI缩放支持,需要手动启用
SetProcessDPIAware。
这些坑都不难处理,但如果没有心理准备,容易在编译阶段就放弃。
7.3 后续可以怎么扩展
如果你有兴趣在这个工程基础上做二次开发,我建议优先考虑下面几个方向:
- 把行情源从TCP轮询改成WebSocket推送,实时性可以从3秒提升到秒级甚至毫秒级,对网络资源消耗更小,代码结构也更清晰。
- 把UI层从MFC迁移到Qt,跨平台能力更强,自绘列表也更方便,还能用QAbstractTableModel天然支持虚拟数据模型。
- 增加历史数据落盘,用SQLite存储分钟线和日线数据,这是一套行情软件从“看盘工具”升级为“分析工具”的必经之路。
- 增加策略回测模块,既然项目叫“策略为王”,可以在此基础上叠加移动平均线策略等简单回测功能,看看行情数据驱动策略到底效果如何。
我个人的体会是,理解这类老代码的价值不在于它能直接跑起来,而在于它把“数据如何从网络到界面”这整条链路的每一个环节都摆在眼前,几乎是裸奔的。你把这条链路走通一遍,再去看现代框架或者成熟项目,很多封装背后的设计逻辑就自然对上了。最后再分享一个小技巧:遇到类似源码,先用一个小工具统计一下文件里出现的Win32 API和MFC宏,哪些用得最多,哪些就是工程的核心热点,调这些地方往往能解决80%的性能问题。
本文还有配套的精品资源,点击获取
