股票列表实时行情刷新:VC6.0+MFC定时器与双缓冲实战

发布时间:2026/8/30 2:05:30
股票列表实时行情刷新:VC6.0+MFC定时器与双缓冲实战 简介实时数据刷新是桌面金融终端的基础能力尤其在股票列表场景中高频更新与UI流畅度往往难以兼得。VC6.0与MFC作为经典技术栈通过定时器驱动、数据中心解耦及列表控件局部重绘能够实现稳定且低开销的3秒级行情刷新。从定时器选型、线程模型、双缓冲防闪烁到滚动条位移补偿系统拆解了CListCtrl在实时刷新场景下的工程实践并给出模拟数据源与调试技巧。无论是维护老系统还是构建监控大屏这套设计思路都具备直接参考价值。 策略为王股票软件源代码这个系列我一直在跟进到第123期刚好看到作者放出了股票列表实时行情刷新这个功能3秒刷新一次用VC6.0写的。说句实在话现在还在用VC6.0维护项目的要么是维护老系统的老伙计要么就是像我这样对MFC这套东西有执念的人。看到这个标题我挺有感触的因为列表实时刷新这个需求看着简单真正做起来涉及的东西相当多——定时器怎么选、数据从哪来、UI怎么刷新不闪烁、行情数据拼接怎么不出错层层叠叠全是细节。这个版本的核心亮点就一句话在自研股票软件里成功加入了股票列表的实时行情刷新刷新周期3秒。如果你正在做类似的行情终端、监控大屏、自选股列表或者手里正好有一份VC6.0的老代码要加实时刷新功能这篇就把我当时拆解这套源码、自己动手复现的完整过程写给你。我不光讲它做了什么更会把我复盘过程中想到的为什么这么设计以及换你会踩什么坑一并交代清楚。1. 策略为王项目里3秒刷新这个需求到底卡在哪很多人看到股票列表实时行情刷新第一反应是这不就是个定时器到点拉数据往ListControl里一填就完了真这么简单作者也不会专门出一期来写。我先把这个需求背后真正棘手的地方拆开你才能理解后面那些代码设计是怎么回事。1.1 行情终端的实时和网页的实时完全是两码事网页端做行情刷新前端定时发个Ajax请求后端把JSON返回DOM更新一下这就算完事了。但桌面端股票软件里的实时行情刷新数据量大得多每次刷新可能涉及几百上千只股票每只要更新最新价、涨跌幅、成交量、成交额、换手率、五档盘口等几十个字段。如果直接全部重绘UI线程大概率会卡到用户以为程序死了。策略为王给出的方案是3秒一个刷新周期这个周期选得很有讲究。对股票行情来说1秒刷新对网络和CPU压力都太大尤其是VC6.0这种老框架跑在老机器上5秒又显得不够实时盘口跳动感不足。3秒是个折中值既能感知到价格变化又不会让UI线程持续吃满CPU。从源码的整体结构来看它采用的是典型的MFC文档视图架构主界面左侧是股票列表右侧是分时图或K线图。这个版本的核心任务就是让左侧列表活起来。在MFC里普通CListCtrl默认是不支持高频刷新的直接SetItemText很容易造成界面闪烁这是第一个要解决的问题。1.2 拆解实时行情的核心数据流我从源码里梳理出来的数据流大概是这样一个闭环行情数据源网络接口或本地模拟数据到数据解析模块再到共享数据缓冲区接着是定时器通知UI线程最后是列表控件的局部刷新。这个链路里最容易被忽略的是数据解析和UI刷新之间的解耦。如果直接在定时器回调里做网络请求再解析数据再更新列表遇到网络抖动或数据量大的时候UI线程会被阻塞3秒刷新变成10秒刷新体验就是灾难。策略为王源码里的做法是对的它把数据获取和UI更新分成两个层次定时器只管发消息通知UI去取数据数据本身在进入UI之前就已经被处理好了。这一点我会在后面的线程模型部分详细展开。1.3 这个版本解决了什么没有解决什么从项目演进的角度看第123期这个版本解决的是列表能不能动起来的问题也就是从静态列表跨越到实时行情的核心一步。但如果你期待它已经支持分时图联动、自选股分组、行情预警那可能要往后翻几期才能看到。我研究这个版本的源码时最关注的不是它有多完善而是它的架构是否给后续功能留了接口。事实证明作者在数据封装和刷新机制上的设计确实为后面的扩展留了余地。2. VC6.0MFC的选型逻辑老技术栈做行情软件的合理性你可能想问都什么年代了还用VC6.0这恰恰是这套源码有意思的地方。策略为王这个系列能在老平台上一路做到123期本身就说明了VC6.0MFC在特定场景下依然能打。2.1 为什么MFC的CListCtrl依然是行情列表的好底子提到桌面端列表控件可能有人会想到duilib、Qt的QTableView甚至直接上DirectUI。但策略为王用的是MFC里最经典的CListCtrl报表视图搭配CImageList做图标这在老一代行情软件里几乎是标配。CListCtrl的优势在于系统级控件原生支持虚拟列表LVS_OWNERDATA数据量大时性能依然可接受和MFC的消息机制天然契合定时器消息、自定义消息都能直接驱动刷新轻量不需要引入额外的UI框架编译出来的程序体积小运行内存占用低对一款面向个人研究或小范围使用的股票软件来说这些特性足够了。VC6.0虽然老但它生成的代码在Windows平台上兼容性极好甚至能在XP到Win10的很多精简环境里跑起来这对老用户来说是实打实的优势。2.2 这套源码里的主要模块结构把rAR解压之后你可以看到典型的VC6.0工程结构MainFrm.cpp主框架窗口负责创建菜单、工具栏、状态栏也是定时器的启动场所LeftView.cpp左侧股票列表视图继承自CListView是本次实时刷新的主角RightView.cpp右侧行情图表视图后续版本应该会扩展DataCenter.cpp数据中心模块负责拉取和解析行情数据StockItem.h/cpp股票数据结构的定义比如股票代码、名称、最新价、涨跌幅等字段这个结构清晰之处在于它把UI和数据层做了初步分离。LeftView不直接依赖具体的网络协议而是通过数据中心提供的接口拿数据。这样一来将来无论换数据源还是改协议UI层的代码基本不用动。2.3 虚拟列表模式要不要开启这是个问题我在复现过程中试过两种方案一种是直接用CListCtrl的默认模式往里面InsertItem然后SetItemText另一种是开启LVS_OWNERDATA虚拟列表模式。源码里用的是默认模式加局部更新的方式而不是虚拟列表。原因也很现实虚拟列表虽然适合超大千级别的数据量但对编程模型的要求更高需要自己管理缓存状态而且VC6.0环境下调试虚拟列表的绘制逻辑比较痛苦。股票列表几千行的量级默认模式配合双缓冲和局部更新性能完全够用代码也更直观易维护。3. 3秒刷新闭环定时器、数据中心、列表更新的协同设计这一节是整个复现过程的核心也是你将来自己动手改代码时最值得参照的部分。我会按照源码的执行顺序把3秒刷新一次这个需求从启动到落地的完整链路讲清楚。3.1 定时器的选择SetTimer还是多媒体定时器VC6.0环境下常用的定时方案有三种SetTimer、等待定时器线程、多媒体定时器。三者各有适用场景源码里选的是最稳妥的SetTimer。// 在MainFrm的OnCreate里启动定时器 int nTimerID SetTimer(REFRESH_TIMER_ID, 3000, NULL); if (nTimerID 0) { AfxMessageBox(定时器创建失败请检查系统资源); return -1; }SetTimer的最小精度大约在55毫秒左右对于3秒的刷新周期来说完全够用。有些开发者喜欢用多媒体定时器timeSetEvent因为它精度高1毫秒级但它是回调机制直接在回调里操作UI是绝对不安全的还得自己封装线程切换复杂度直接上了一个台阶。而SetTimer的WM_TIMER消息天然是投递到UI线程消息队列里的在OnTimer里操作UI是安全的这就省去了很多同步问题。这里有一个容易被忽略的细节SetTimer的第三个参数传NULL表示使用默认的定时器回调处理也就是向窗口过程发送WM_TIMER消息。如果你的定时器逻辑需要精确控制每次刷新的时间点可以用回调函数但注意回调是在应用程序消息循环上下文中执行的不是独立线程。我自己测试的时候3秒的周期下WM_TIMER消息的累积误差可以忽略不计除非系统资源极度紧张。不过盘中的时候行情波动大偶尔会出现在某个3秒周期内前一次刷新还没跑完下一次WM_TIMER已经到的情况。源码里对这个情况做了处理用的是刷新标志位下面会详细讲。3.2 数据中心的封装让UI层永远不知道数据从哪来策略为王的数据中心模块设计得相当实用。它对外暴露的接口很简洁class CDataCenter { public: BOOL Login(const CString strUser, const CString strPwd); BOOL FetchStockList(std::vectorCStockItem vecStocks); BOOL FetchRealtimeQuotes(const std::vectorCString vecCodes, std::vectorCStockQuote vecQuotes); void SetCallback(CWnd* pWnd, UINT nMsgID); };左边列表视图不会去关心数据是HTTP接口来的、Socket来的还是本地文件模拟的。它只需要调用FetchRealtimeQuotes传入自选股代码列表然后拿到CStockQuote数组更新UI即可。这是典型的门面模式对后续替换数据源非常友好。我第一次跑通这个模块的时候把数据源从网络接口换成本地模拟数据只改了DataCenter内部实现UI代码一行没动刷新就正常工作。这种设计的价值在项目持续迭代到第100多期的时候体现得尤为明显。3.3 3秒一次的执行流程从OnTimer到列表刷新源码中OnTimer的完整逻辑大概是这样的void CLeftView::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent REFRESH_TIMER_ID) { // 防止上一次刷新还没完成就进入下一次刷新 if (m_bRefreshing) return; m_bRefreshing TRUE; try { // 1. 从数据中心拉取最新行情 std::vectorCStockQuote vecQuotes; if (!m_pDataCenter-FetchRealtimeQuotes(m_vecCodes, vecQuotes)) { // 拉取失败状态栏提示但不中断后续刷新 AfxGetMainWnd()-SetWindowText(策略为王 - 刷新失败稍后重试); return; } // 2. 更新内部缓存用于排序和筛选 m_mapQuoteCache.clear(); for (size_t i 0; i vecQuotes.size(); i) { m_mapQuoteCache[vecQuotes[i].strCode] vecQuotes[i]; } // 3. 刷新列表显示 RefreshListDisplay(); // 4. 状态栏更新 CString strInfo; strInfo.Format(数据更新于 %s, GetCurrentTimeString()); SetStatusBarText(strInfo); } catch (...) { // 捕获所有异常防止UI线程崩溃 OutputDebugString(Refresh exception caught!); } m_bRefreshing FALSE; } CListView::OnTimer(nIDEvent); }这里有三点值得学习第一m_bRefreshing标志位。在网络慢或者数据量大的时候3秒周期内可能完不成一次完整刷新如果不加这个标志下一次WM_TIMER消息到达时会重入刷新逻辑轻则UI卡顿重则内存冲突直接崩溃。这个标志位是处理实时刷新类需求时最容易忽略又最关键的一笔。第二异常捕获。VC6.0时代异常处理机制不如现代C完善但源码里依然加了try-catch防止数据解析时遇到脏数据导致整个程序退出。做行情软件网络数据不可控什么奇葩数据都可能遇到这层保护非常必要。第三刷新状态在状态栏的反馈。用户能直观看到数据是什么时候更新的这不仅是体验问题还能在数据源异常时帮助定位问题。3.4 刷新完成后的列表局部更新机制RefreshListDisplay的实现是本次功能的核心。直接在OnTimer里遍历列表所有行逐一调用SetItemText虽然代码最简单但每一行都触发一次控件重绘几百行下来界面会明显闪烁。源码里采用的方案是先锁定绘制批量更新最后强制重绘可见区域。void CLeftView::RefreshListDisplay() { CListCtrl refCtrl GetListCtrl(); // 锁定绘制避免每一行都触发重绘 refCtrl.SetRedraw(FALSE); // 获取当前可见范围 int nTopIndex refCtrl.GetTopIndex(); int nVisibleCount refCtrl.GetCountPerPage(); for (int i 0; i refCtrl.GetItemCount(); i) { CString strCode refCtrl.GetItemText(i, 0); std::mapCString, CStockQuote::iterator it m_mapQuoteCache.find(strCode); if (it m_mapQuoteCache.end()) continue; const CStockQuote quote it-second; // 价格变化时用不同颜色标识 CString strPrice; strPrice.Format(%.2f, quote.fPrice); int nItemState refCtrl.GetItemData(i); if (nItemState -1) { // 第一次填充不需要颜色闪烁 refCtrl.SetItemText(i, 1, strPrice); refCtrl.SetItemData(i, quote.fPrice 0 ? 0 : -1); } else { // 价格涨跌时更新颜色 refCtrl.SetItemText(i, 1, strPrice); if (quote.fPrice nItemState) refCtrl.SetItemText(i, 1, ▲ strPrice); else if (quote.fPrice nItemState) refCtrl.SetItemText(i, 1, ▼ strPrice); refCtrl.SetItemData(i, (DWORD)quote.fPrice); } // 更新其他列 CString strPercent; strPercent.Format(%.2f%%, quote.fPercent); refCtrl.SetItemText(i, 2, strPercent); refCtrl.SetItemText(i, 3, FormatVolume(quote.nVolume)); refCtrl.SetItemText(i, 4, FormatAmount(quote.nAmount)); } // 重新允许绘制并只刷新可见区域 refCtrl.SetRedraw(TRUE); refCtrl.RedrawItems(nTopIndex, nTopIndex nVisibleCount); refCtrl.UpdateWindow(); }SetRedraw(FALSE)把整个列表的重绘暂停所有SetItemText操作只修改控件内部状态不触发逐行重绘最后SetRedraw(TRUE)后再用RedrawItems强制重绘可见区域。这一步操作能显著减少闪烁。RedrawItems只重绘从列表顶部到底部可见范围的行滚动条以外的行不需要刷新因为用户看不到这进一步减少了无谓的绘制开销。我实测了一下在3000行数据、每次更新约500行可见行的情况下这个方案的刷新耗时从全量更新的120毫秒左右降到了30毫秒以内肉眼几乎感觉不到闪烁。如果你的数据量更大可以进一步配合CListCtrl的LockWindowUpdate这个API记得在刷新完成后调用UnlockWindowUpdate但要注意LockWindowUpdate在锁定期间不能进行滚动操作否则可能出现绘制残影。4. 刷新过程中的线程模型UI线程不能被行情数据卡住在3秒刷新的实现里最容易改动出错的就是线程模型。很多新手会直接在OnTimer里发起网络请求然后解析数据再填充列表。这在数据量小的时候看不出问题但一旦网络延迟或数据源响应缓慢整个UI就会卡死用户拖动窗口、点击按钮都没有响应。我在排查过类似问题后总结出一个核心原则凡是要等待外部数据源的操作都要放到底层线程去做UI线程只负责接收结果通知然后更新界面。4.1 为什么要单独开一个行情工作线程策略为王的源码中数据中心内部持有一个专门的行情线程负责循环拉取数据并解析。UI定时器到点后直接从这个线程维护的缓存里取已经解析好的数据而不是去网络实时请求。这里的关键逻辑是网络请求和解析过程是慢操作而UI刷新是快操作。把一个3秒周期的UI刷新和一个可能要花1秒甚至更久的网络请求耦合在一起刷新周期的确定性就被破坏了。而用工作线程维护数据缓存UI刷新时只做内存数据的读取和显示那么3秒周期几乎不会受网络波动影响。4.2 工作线程怎么和UI线程同步工作线程和UI线程之间的数据共享需要一个同步机制。源码里用的是临界区CCriticalSection虽然这是最简单的同步原语但在这个场景下完全够用。多线程的同步方案很多有事件、信号量、互斥量、临界区为什么选临界区因为行情数据的读写特点是写多读少单写单读数据量不大临界区是最轻量的线程同步手段。它不像互斥量那样需要在用户态和内核态之间切换性能更高。对3秒一次的更新频率来说临界区带来的开销可以忽略不计。看一下数据缓存的实现void CDataCenter::UpdateQuoteCache(const std::vectorCStockQuote vecQuotes) { CSingleLock lock(m_csQuoteCache, TRUE); for (size_t i 0; i vecQuotes.size(); i) { m_mapQuoteCache[vecQuotes[i].strCode] vecQuotes[i]; } } void CDataCenter::GetQuoteCache(std::vectorCStockQuote vecOut) { CSingleLock lock(m_csQuoteCache, TRUE); vecOut.clear(); for (std::mapCString, CStockQuote::iterator it m_mapQuoteCache.begin(); it ! m_mapQuoteCache.end(); it) { vecOut.push_back(it-second); } }所有对缓存数据的读写都通过CSingleLock保护确保同一时刻只有一个线程访问临界资源。UI线程在取数据时加锁拷贝数据然后立即释放锁保证锁的持有时间足够短避免阻塞行情线程更新数据。4.3 自定义消息工作线程如何通知UI线程工作线程什么时候可以更新UI答案是永远不要在非UI线程中直接操作窗口控件。源码的做法是工作线程处理完数据后通过PostMessage向主窗口发送自定义消息主窗口收到消息后在UI线程中执行实际的刷新操作。#define WM_UPDATE_QUOTE (WM_USER 1001) // 工作线程中发送消息 ::PostMessage(AfxGetMainWnd()-GetSafeHwnd(), WM_UPDATE_QUOTE, 0, 0); // 主窗口消息映射 BEGIN_MESSAGE_MAP(CMainFrame, CFrameWnd) ON_MESSAGE(WM_UPDATE_QUOTE, CMainFrame::OnUpdateQuote) END_MESSAGE_MAP() LRESULT CMainFrame::OnUpdateQuote(WPARAM wParam, LPARAM lParam) { // 通知左侧视图刷新列表 m_wndLeftView.PostMessage(WM_UPDATE_QUOTE, 0, 0); return 0; }PostMessage是异步的不等待消息处理完成就立即返回。这意味着即使UI线程还在处理上一次刷新的消息工作线程也可以继续拉取新数据。这和3秒定时器放在一起时形成了一种生产者-消费者模式工作线程是生产者不断产出最新行情快照定时器驱动的UI刷新是消费者每3秒消费一次快照把数据显示到列表上。这里有个细节很微妙既然工作线程已经在持续产出了UI线程每3秒去消费一次那UI刷新周期和行情数据更新周期就完全解耦了。将来如果想把刷新周期从3秒调整到2秒或5秒只需要改SetTimer的第二个参数数据按什么频率拉取由工作线程决定两边互不干扰。4.4 盘点一下我在线程模型上踩过的坑我复现这套源码时第一版图省事把网络请求直接写在了OnTimer里。刚开始数据量小没发现问题后来把自选股数量增加到500只问题立刻暴露程序在刷新时窗口标题栏显示未响应拖动窗口跟拖着果冻一样操作卡顿到几乎不可用。原因就是UI线程在等待Socket超时操作系统在等待UI线程响应所有窗口消息都排在队列里。解决办法就是照源码的思路把网络请求移到工作线程。从那以后我深刻体会到UI线程只用做收到通知更新界面绝不做等待数据分析数据这种事。还有一个坑是线程退出时的资源竞争。程序关闭时如果工作线程还在运行主窗口已经销毁PostMessage会失败。源码里在OnClose和OnDestroy中做了线程停止和等待如下所示void CMainFrame::OnDestroy() { // 通知工作线程退出 m_bThreadRunning FALSE; SetEvent(m_hExitEvent); // 等待线程结束防止在窗口销毁后仍有消息发过来 if (m_pDataThread ! NULL) { WaitForSingleObject(m_pDataThread-m_hThread, 3000); } CFrameWnd::OnDestroy(); }这个处理值得借鉴通知线程退出后等待最多3秒确保线程里所有资源被释放再继续窗口销毁流程。如果不等待线程可能在窗口销毁后访问已经被释放的控件句柄轻则内存访问异常重则程序崩溃。5. 列表刷新的三大硬伤闪烁、抖动和数据错位实时刷新类功能做完之后测试阶段通常会出现几个经典问题。我按照自己复现和排查的顺序把最容易翻车的三个问题单独拿出来说每一个都是实际跑起来才会发现的那种。5.1 闪烁问题双缓冲刷新的完整实现股票列表刷新时的闪烁是高频更新最容易暴露的问题。根源在于列表控件默认的擦除背景操作发送WM_ERASEBKGND和绘制操作发送WM_PAINT之间有一个时间差在这个间隙里窗口背景被擦掉了但新内容还没画上去肉眼就会看到闪动。常规的解法是双缓冲先在内存中创建一张与窗口DC兼容的位图把所有内容绘制到位图上然后一次性BitBlt到窗口DC上。这样用户看到的是完整一帧图像中间不会被擦除。在VC6.0下给CListCtrl做双缓冲的标准做法是重写OnEraseBkgnd和OnPaint或者简单一点用LockWindowUpdate搭配SetRedraw。源码里采用的是SetRedraw加RedrawItems的方案这个方案实现成本低效果也肉眼可见地好但对极高频的刷新比如1秒内刷新多次可能仍会有一点点闪烁感。如果追求更极致的流畅度可以继承CListCtrl写一个CListView派生类在OnPaint里做双缓冲。我提供一个参考实现虽然源码没这么写但这是我在其他项目里验证过的成熟方案void CStockListView::OnPaint() { CPaintDC dc(this); CRect rcClient; GetClientRect(rcClient); CMemDC memDC(dc, rcClient); // 在memDC上绘制所有内容包括背景和子项 // ... // 最后自动从memDC BitBlt到dc }这个实现需要注意的是重写OnPaint后列表控件的手动绘制逻辑比如自定义颜色、图标都需要自己处理维护成本会上升。所以我的建议是先用简单的SetRedraw方案确认有闪烁问题再考虑双缓冲别一上来就追求复杂方案。5.2 列表行高度抖动和“跳动感”3秒刷新一次如果价格和涨跌幅一直在变列表内容高度也在变可能导致列表行的位置偶尔发生微小的跳动尤其是当一行的文本长度发生变化时列宽如果没锁定整列宽度会跟着变用户视角就会觉得界面在“抖”。解决办法是刷新前确定所有列的宽度为固定值并且禁止用户拖动列宽或者把列宽调整交给用户、每次刷新时保持用户设置。源码里在初始化列表时用的是SetColumnWidth固定列宽并且在刷新时没有动态改列宽的逻辑这正好规避了这个问题。另外如果行高的变化来自字体大小不同需要确保所有单元格使用同一字号。VC6.0的CListCtrl默认会用ListView控件的字体如果某列设置了不同的字体行高会被撑大这也算一个隐蔽的抖动来源。5.3 数据错位刷新期间用户拖动滚动条怎么办这是实时刷新列表最隐蔽的一个bug用户正在滚动列表比如往下翻了几百行刚好这时候刷新逻辑执行了SetRedraw(FALSE)加全部SetItemText刷新完毕后再调用RedrawItems只刷新可见区域。如果刷新过程中用户滚动条位置发生了变化RedrawItems的参数还是基于旧位置计算出来的刷新出来的内容可能和滚动条位置对不上表现为列表内容错位、某些行显示的是别的股票。源码里的处理方式比较保守它只重绘可见区域但不处理滚动条位置的变化。如果用户操作很频繁还是可能出现短暂的错位。我的建议是再加一道保险在刷新前记录当前滚动位置刷新完成后恢复它。int nScrollPos refCtrl.GetScrollPos(SB_VERT); // ... 刷新逻辑 ... refCtrl.SetScrollPos(SB_VERT, nScrollPos);这样能保证无论用户怎么滚动刷新后列表的视口位置保持稳定内容不错位。当然如果你希望刷新时自动滚动到价格变动最剧烈的股票上那肯定不能用这个方法但在通用的行情列表场景下保持用户位置是最符合直觉的做法。6. 数据源不通怎么办本地模拟数据的完整思路很多人拿到源码后第一个问题就是我没有行情接口代码跑不起来怎么办这套源码的设计其实已经考虑了这一点。数据中心模块完全可以替换成本地模拟数据用于学习和调试UI刷新逻辑效果不打折。6.1 自己封装一个模拟行情源我调试UI时最常用的一套做法是写一个CSimulatedDataProvider类在内部用随机数生成器模拟股价波动BOOL CSimulatedDataProvider::FetchRealtimeQuotes(const std::vectorCString vecCodes, std::vectorCStockQuote vecQuotes) { ::srand(::GetTickCount()); for (size_t i 0; i vecCodes.size(); i) { CStockQuote quote; quote.strCode vecCodes[i]; // 以代码末尾两个字符作为初始价格基准保证每次启动价格一致 int nSeed _tstoi(vecCodes[i].Right(2)); double fBasePrice 10.0 (nSeed % 50) / 2.0; // 模拟价格浮动上下2%范围内随机波动 double fDeltaPercent (rand() % 401 - 200) / 100.0; // -2.00% ~ 2.00% quote.fPrice fBasePrice * (1.0 fDeltaPercent / 100.0); quote.fPercent fDeltaPercent; quote.nVolume rand() % 1000000; quote.nAmount (int)(quote.fPrice * quote.nVolume); quote.strName.Format(模拟股票%s, quote.strCode); vecQuotes.push_back(quote); } // 模拟网络延迟 Sleep(200); return TRUE; }你可以看到模拟数据不需要真的对接任何行情服务只需要保证每次调用产生不同的价格波动就能让列表动起来。我在调试列表刷新逻辑时用这个模拟源跑了几个小时刷新的稳定性、颜色变化逻辑、滚动条状态全都能验证到。6.2 接入真实行情源时要注意的字段映射如果你要接真实行情源比如某些免费行情API、证券公司的Level-1行情接口需要注意字段的映射关系。不同的数据源对字段的命名、单位、精度定义会有差异比如有的接口成交量单位是手有的接口是股有的接口价格是整数除以1000有的接口是浮点数。建议在数据中心模块里统一做一次单位转换和数据清洗不要让原始的接口字段直接渗透到UI层。UI层只认CStockQuote这个内部结构体这样数据源切换的代价就控制在了数据中心这一个模块里。6.3 行情数据缺失时列表如何显示真实行情中经常出现数据缺失的情况比如停牌股、新股上市首日、临停等。如果直接把缺失数据显示成0.00或者null用户看到会困惑。源码对这块的处理是刷新时先检查quote对象的bValid标志无效数据不更新对应列而是显示--。CString strPrice; if (quote.bValid) strPrice.Format(%.2f, quote.fPrice); else strPrice --; refCtrl.SetItemText(i, 1, strPrice);这个处理虽然简单但对用户体验的提升很明显。至少你在看盘的时候不会因为一只停牌股刷出一堆0而怀疑软件出bug了。7. 从这套源码里还能学到什么内存、配置与后续扩展方向7.1 自选股列表的持久化与启动加载只看标题你可能以为这一期只做了刷新但源码里其实还包括了自选股列表的保存和加载功能用的非常简单就是写INI文件或者注册表。VC6.0时代没有Json这种时髦的配置格式INI文件简单可靠非常适合保存自选股列表这类轻量配置。void CMainFrame::SaveStockList() { CString strPath GetModulePath() \\stocklist.ini; for (int i 0; i m_vecCodes.GetSize(); i) { CString strSection; strSection.Format(Stock_%d, i); ::WritePrivateProfileString(strSection, code, m_vecCodes[i], strPath); } } void CMainFrame::LoadStockList() { CString strPath GetModulePath() \\stocklist.ini; // 遍历所有Section加载股票代码 // ... }需要注意的是如果自选股数量很少频繁启动退出写盘频率不高没问题但如果自选股数量较大每次启动都全量写INI可能有点浪费。更好的做法是只在退出时保存一次启动时加载一次避免每次增删自选股都写盘。7.2 内存使用上的两个小问题实时刷新涉及大量字符串格式化操作CString在VC6.0里频繁创建和销毁长时间运行后可能导致堆碎片。这个问题在XP时代很常见虽然现在内存充足但如果你打算长时间挂机运行还是建议尽量减少临时CString的创建比如在循环外复用字符串变量。另一个隐藏问题是m_mapQuoteCache如果在每次刷新时直接clear再插入会造成频繁的内存分配和释放。更好的做法是保留已有条目只更新变化的值这样能够减少内存分配次数对长时间运行的行情软件有帮助。7.3 基于3秒刷新还能扩展出什么功能拿到这套源码如果你打算继续往下做有以下几个值得扩展的方向价格预警在刷新逻辑里加上对最新价和预设预警价的比较满足条件时弹窗或声音提醒。这是很自然的增强功能。排序功能目前的列表刷新没有涉及点击表头排序。行情软件里用户非常依赖按涨跌幅排序可以在此基础上增加表头点击排序但注意排序后数据的行映射关系会变刷新时要小心错位。分时图联动右侧视图实现分时图点击列表某一行时图表视图显示该股票的分时走势。这需要右侧视图也接入数据中心并响应左侧视图的选择变化。多周期刷新策略不同股票采用不同的刷新周期比如自选股3秒全部A股10秒。这个改动只需要给数据中心增加一个刷新优先级字段难度不高但收益明显。我个人最推荐先做价格预警因为它最能体现实时刷新的价值而且实现成本低只需要在刷新数据之后加一个遍历比对逻辑对当前列表的改动也最小。8. 最后一个实操建议在VC6.0里编译运行这套代码时的注意事项如果你准备实际编译运行有几个环境上的点我必须提醒这些坑我当年都踩过。8.1 VC6.0在Win7以上系统的兼容性设置VC6.0生成的代码在Win7、Win10上运行一般没问题但VC6.0这个IDE本身在Win7以上系统里有时会遇到调试器不稳定的问题。我在Win10上使用VC6.0时的经验是用Visual Studio 2010及以上版本的IDE打开工程来编译MFC代码也是可行的因为VC6.0工程文件.dsp、.dsw可以被VS低版本系列转换。如果你坚持用VC6.0建议在兼容性设置里选择以Windows XP SP2兼容模式运行并且用管理员身份启动IDE。如果遇到调试器附加不了进程可以直接用CtrlF5运行而不调试大部分功能不影响。8.2 别忘了链接WinSock库程序里如果用了Socket或网络相关的API在VC6.0的Project Settings里需要把ws2_32.lib或wsock32.lib加到Link页面的Object/library modules里否则链接会报一堆unresolved external symbol。这是VC6.0新手最常见的问题之一。另外如果用了InternetOpenUrl这些WinINet接口需要加wininet.lib。要注意VC6.0默认的ANSI编码对中文股票名称的支持没有问题但如果数据源返回的是UTF-8编码的字符串需要在数据中心里转成GB2312再显示不然列表里会出现乱码。8.3 调试实时刷新功能时的小技巧我调试这套刷新逻辑时有一个很有效的技巧把定时器周期临时改成100毫秒。这样做不是因为最终产品需要100毫秒的刷新而是因为高频刷新能更快暴露数据错位、线程竞争、闪烁这类问题。把周期调短后跑几分钟问题基本都会现形修好后再把周期改回3000毫秒。这样调试效率非常高。还有一个技巧在OnTimer和RefreshListDisplay入口处用OutputDebugString输出标记再用DebugView工具观察日志确认刷新是否存在卡顿或重入问题。VC6.0的OutputDebugString在DebugView里能看到输出这个组合是我个人最顺手的调试方式。最后总结一下我的总体感受这套代码里3秒刷新实现算不上惊艳但它的架构思路非常稳妥定时器、数据中心、线程模型、局部刷新这几个环节的配合浑然一体恰恰是很多实时刷新类项目里欠缺的。如果你手里的项目也要做类似的定时刷新功能不管是行情、监控还是工控数据看板这套代码的思路都值得直接借鉴。跟着源码走一遍再把这篇里提到的坑都过一遍你基本就能把列表实时刷新这个功能做到稳定可用了。本文还有配套的精品资源点击获取

相关新闻