简介:面向使用 VC++ 6.0 开发行情界面的开发者,这是一份可直接运行的沪深股市五档行情获取与显示示例工程,定位是帮助入门者理解股票软件的数据链路与界面组织。压缩包共 18 个文件,以 5 个头文件、4 个源文件为核心,另有 .dsw/.dsp 工程文件、.rc/.rc2 资源脚本、.ico 图标和两个 .aps 状态文件,完整还原了一个 MFC 对话框项目的标准目录;整体仅 33KB,代码量精炼,适合在旧版 VC++ 工程中直接打开研读。工程封装了 HttpClient 网络请求类与 HttpDlg 主对话框界面,演示了从 HTTP 接口获取实时买一买二等五档报价、解析响应数据并刷新列表展示的完整流程;同时还给出了多线程刷新、网络异常处理和用户交互等实战处理方法。目前已有 578 人学习浏览,对于想复刻经典炒股软件界面或熟悉 MFC 网络编程的开发者,这是一份低门槛、可复用的参考代码。
1. 为什么要拆这个VC++ 6.0的五档行情工程:老代码里最完整的请求链路
五档行情在股票软件里是最基础的展示模块,但真正要拿到一个老旧的VC++ 6.0工程里从零做一遍,很多人上手才发现坑比想象中多。这个资源是一份可以直接编译的MFC对话框程序,文件里已经把HttpClient封装、HTTP请求、行情文本解析和界面刷新完整串在了一起,打开工程就能看到一条“请求→解析→显示”的闭环链路。对想学网络编程、或者打算自研轻量看盘工具的开发者来说,它的价值在于代码量不大、依赖少,适合读,也适合在自己机器上改着跑,比东拼西凑看教程靠谱得多。下文按我拆项目的习惯,从请求封装讲到界面刷新,最后把最容易翻车的地方一条条列出来。
2. 项目骨架与HttpClient封装:先搞清楚请求从哪里发出去
2.1 解压后哪几个文件是真正要看的
这个工程的文件结构很干净,核心代码集中在两个文件里,其余都是VC++ 6.0工程附带的支撑文件。我一般建议按下表顺序读,先看入口,再追请求实现,最后看界面逻辑。
| 文件 | 作用 | 优先级 |
|---|---|---|
| Stock.dsw / Stock.dsp | VC++ 6.0工作区和工程文件,打开入口 | 高 |
| HttpClient.h / HttpClient.cpp | 基于WinInet的HTTP请求封装 | 高 |
| HttpDlg.h / HttpDlg.cpp | 主对话框逻辑,行情显示与刷新 | 高 |
| Http.rc / resource.h / Http.rc2 | 对话框模板、菜单、图标资源 | 中 |
| StdAfx.h / StdAfx.cpp | MFC预编译头文件 | 低 |
| ReadMe.txt | 工程说明 | 低 |
打开Stock.dsw时,VC++ 6.0会把整个工作区加载进来。如果你遇到“文件打不开”或资源丢失,多半是Http.rc2或者res目录没有和.dsw放在同一层级,把整个目录原样解压、不要单独拖文件出来,能避免掉一大半路径问题。
2.2 HttpClient核心:WinInet四步调用链
HttpClient这个类的作用很纯粹:给定一个URL,返回一段字符串。工程里没有用MFC的CInternetSession那一套封装,而是直接调WinInet API,好处是缓冲区、超时、缓存策略都能自己控制,出了问题也容易定位。
// HttpClient.cpp 核心请求函数 int CHttpClient::GetData(const CString& strUrl, CString& strOut) { // 1. 打开一个Internet会话句柄 HINTERNET hSession = InternetOpen( _T("StockQuoteAgent/1.0"), INTERNET_OPEN_TYPE_PRECONFIG, NULL, NULL, 0); if (hSession == NULL) return -1; // 2. 直接打开发送的URL,加RELOAD标志绕过缓存 HINTERNET hReq = InternetOpenUrl( hSession, strUrl, NULL, 0, INTERNET_FLAG_RELOAD | INTERNET_FLAG_NO_CACHE_WRITE, 0); if (hReq == NULL) { InternetCloseHandle(hSession); return -2; } // 3. 读取返回内容,缓冲区要留出结尾'\0'的位置 char szBuf[2048] = {0}; DWORD dwRead = 0; strOut.Empty(); while (InternetReadFile(hReq, szBuf, 2047, &dwRead) && dwRead > 0) { szBuf[dwRead] = '\0'; strOut += szBuf; } // 4. 关闭句柄,释放会话 InternetCloseHandle(hReq); InternetCloseHandle(hSession); return 0; }调用链是标准的四步:InternetOpen建立会话,InternetOpenUrl发起请求,InternetReadFile循环读取,最后关闭句柄。这里的几个参数值得说明一下。
第一个参数StockQuoteAgent/1.0是User-Agent,有些行情接口会校验这个字段,如果你的请求被服务器拒绝,可以先排查这里。INTERNET_OPEN_TYPE_PRECONFIG表示使用系统代理设置,对于大多数直接上网的环境够用。读取缓冲区设成2047字节,是为了保证每次追加前都留下一个字节放字符串结束符,防止内存越界。返回值里-1代表会话创建失败,-2代表URL请求失败,这两个错误码在排查时能快速缩小范围。
2.3 超时参数与重试逻辑
免费行情接口在开盘时段经常变慢,如果不设超时,InternetReadFile可能被卡住十几秒。网上最常见的做法是用InternetSetOption单独设置连接和接收超时。
// 设置超时,单位是毫秒 DWORD dwConnectTimeout = 5000; // 连接超时 DWORD dwReceiveTimeout = 10000; // 接收超时 InternetSetOption(hSession, INTERNET_OPTION_CONNECT_TIMEOUT, &dwConnectTimeout, sizeof(dwConnectTimeout)); InternetSetOption(hSession, INTERNET_OPTION_RECEIVE_TIMEOUT, &dwReceiveTimeout, sizeof(dwReceiveTimeout));连接超时5秒、接收超时10秒是我试下来比较稳的组合。设太短,稍微波动就失败;设太长,界面会长时间没反应。如果接口走的是代理,还要额外给INTERNET_OPTION_DATA_CONNECT_TIMEOUT设个值,否则代理握手阶段可能不认前面这两个参数。
重试也一样有讲究。我一般不会无脑重试三次以上,而是用“最多2次 + 短等待”的方式:
// 请求重试封装 for (int nRetry = 0; nRetry < 2; nRetry++) { if (hc.GetData(strUrl, strResp) == 0) break; // 成功则跳出 Sleep(300); // 两次请求之间休息300ms }这里Sleep(300)的作用是给服务器和本地网络一个缓冲,避免行情接口把你当恶意请求封掉。300毫秒是经验值,太快没效果,太慢影响刷新体验。
3. 行情解析与界面绑定:五档数据从文本到控件的完整映射
3.1 先弄清楚响应文本里每一段是什么
拿到HTTP响应后,常见行情接口返回的是类似这样的文本:
var hq_str_sh600000="浦发银行,10.320,10.280,10.310,10.350,10.250,...,2024-01-15,15:00:00,00";这段文本以逗号分隔字段,前面几项是股票名称、开盘价、昨收价、最新价,后面跟着一批买卖挂单数据。不同数据源的字段顺序会有差别,所以解析代码最忌讳写死“神秘数字”,最好的办法是把字段索引用常量定义清楚。
下面是一份常见返回格式的索引对照表,五档字段从索引6开始顺序排列:
| 索引 | 含义 | 索引 | 含义 |
|---|---|---|---|
| 0 | 股票名称 | 16 | 卖一价 |
| 1 | 今日开盘价 | 17 | 卖一量 |
| 2 | 昨日收盘价 | 18 | 卖二价 |
| 3 | 最新价 | 19 | 卖二量 |
| 4 | 今日最高价 | 20 | 卖三价 |
| 5 | 今日最低价 | 21 | 卖三量 |
| 6 | 买一价 | 22 | 卖四价 |
| 7 | 买一量 | 23 | 卖四量 |
| 8 | 买二价 | 24 | 卖五价 |
| 9 | 买二量 | 25 | 卖五量 |
买一到买五在索引6到15之间,卖一到卖五紧跟在后面,从16开始。这类格式在沪深行情的免费接口中很常见,拿到数据后先按这个表核对一遍字段数量,再做解析不迟。
3.2 用CString拆字段并安全转浮点
VC++ 6.0里没有现成的Split函数,但CString自带的Tokenize足够完成这个任务。下面这段解析逻辑先把两个双引号之间的正文取出来,再按逗号拆成CStringArray。
// 解析入口:strRaw是HTTP返回的原始文本 BOOL ParseStockQuote(const CString& strRaw, CStringArray& arrBidPrice, CStringArray& arrAskPrice, CString& strName) { // 1. 截取两个双引号之间的正文 int nL = strRaw.Find(_T('"')); int nR = strRaw.ReverseFind(_T('"')); if (nL < 0 || nR <= nL + 1) return FALSE; CString strBody = strRaw.Mid(nL + 1, nR - nL - 1); // 2. 按逗号拆分到CStringArray CStringArray arrFields; int nPos = 0; while (nPos >= 0) { CString strItem = strBody.Tokenize(_T(","), nPos); if (strItem.IsEmpty() && nPos >= strBody.GetLength()) break; arrFields.Add(strItem); if (nPos < 0) break; } // 3. 字段数量不够直接判定为失败 int nCount = arrFields.GetCount(); if (nCount < 26) return FALSE; strName = arrFields[0]; // 4. 五档字段:买一从6开始,卖一从16开始 for (int i = 0; i < 5; i++) { int nBid = 6 + i * 2; int nAsk = 16 + i * 2; arrBidPrice.Add(arrFields[nBid]); arrAskPrice.Add(arrFields[nAsk]); } return TRUE; }Tokenize的第一个参数是分隔符,第二个参数nPos会移动,传入的是分隔符前一字符的位置,循环结束时nPos变为-1,循环自然退出。字段数量这里判断了nCount < 26,因为如果接口返回的是停牌股或者无效代码,字段往往不足,提前返回FALSE比在界面上显示一堆空值好得多。
这一步把买一价到买五价、卖一价到卖五价分别装进了两个数组。后续要转成浮点时,在VC++ 6.0里用atof()要注意编码问题,接口返回的是char类型,直接atof((LPCSTR)strPrice)就可以,但不要用它转整数,量大时精度会有微妙的差异,我习惯在赋值时才转。
3.3 把数据填到控件:SetDlgItemText和CListCtrl
界面层有两种常见做法。如果只显示最新价和几个核心价位,SetDlgItemText最省事:
// 在对话框的刷新逻辑里 CString strPrice; strPrice.Format(_T("%.2f"), dCurPrice); SetDlgItemText(IDC_EDIT_PRICE, strPrice);如果要做成类似股票软件的五档盘口,一般用CListCtrl,分别建“档位”“价格”“数量”三列:
// 初始化列表:设置三列表头 m_listQuote.SetExtendedStyle(LVS_EX_GRIDLINES | LVS_EX_FULLROWSELECT); m_listQuote.InsertColumn(0, _T("档位"), LVCFMT_CENTER, 60); m_listQuote.InsertColumn(1, _T("价格"), LVCFMT_CENTER, 80); m_listQuote.InsertColumn(2, _T("数量"), LVCFMT_CENTER, 100); // 填充买一至买五,卖一至卖五 for (int i = 0; i < 5; i++) { int nRow = m_listQuote.InsertItem(i, _T("买一")); m_listQuote.SetItemText(nRow, 1, arrBidPrice[i]); m_listQuote.SetItemText(nRow, 2, arrBidVol[i]); }注意LVCFMT_CENTER是列宽对齐方式,价格那列别设太窄,至少80像素,否则五位数价格显示不全。填充前先DeleteAllItems()清空旧数据,不然刷新几次后行数会一直往上堆。
4. 刷新机制与刷新频率:定时器、工作线程和UI更新策略
4.1 SetTimer固定间隔刷新
拿到行情数据后最直接的想法是“每隔几秒自动刷新一次”。MFC对话框里做定时刷新,绕不开SetTimer:
// OnInitDialog中启动定时器 SetTimer(1, 3000, NULL); // 3秒触发一次WM_TIMER然后在OnTimer里处理刷新:
void CHttpDlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent == 1) { RequestAndRefresh(); // 发起请求并刷新界面 } CDialog::OnTimer(nIDEvent); }为什么是3秒而不是1秒?免费行情接口的更新频率没那么高,1秒一次除了给自己制造封号风险,没有任何实际意义。3秒间隔既保证盘口变化能及时反映,又不至于让界面闪烁得很厉害。如果你确实需要更高频率,也要设计成“请求完成后再过3秒发起下一次”,而不是固定每3秒无脑发请求,否则慢接口会越积越多。
4.2 把网络请求放到工作线程
这是整个工程里最值得学的设计。HttpClient的读取操作是阻塞的,如果直接放在主线程,一旦服务器响应慢,界面就会卡住。常见做法是用AfxBeginThread开一个工作线程去请求,完成后通过PostMessage通知主线程刷新界面。
// 传给工作线程的参数结构 struct QuoteRequest { CString strCode; // 股票代码,例如sh600000 HWND hWndNotify; // 接收通知的窗口句柄 UINT nMsg; // 自定义消息ID }; // 工作线程函数 UINT ThreadRequestQuote(LPVOID pParam) { QuoteRequest* pReq = (QuoteRequest*)pParam; CHttpClient hc; CString strUrl = BuildUrl(pReq->strCode); CString strResp; int nRet = hc.GetData(strUrl, strResp); // 用PostMessage把结果传回主线程 ::PostMessage(pReq->hWndNotify, pReq->nMsg, nRet, (LPARAM)new CString(strResp)); delete pReq; return 0; }启动线程的地方在按钮事件或定时器里:
QuoteRequest* pReq = new QuoteRequest; pReq->strCode = strCode; pReq->hWndNotify = GetSafeHwnd(); pReq->nMsg = WM_MY_QUOTE_RESULT; AfxBeginThread(ThreadRequestQuote, pReq);这里有几个关键点:PostMessage是非阻塞的,能立刻返回,不会卡住界面;结果用new CString传递,是为了避免栈内存在线程结束后被回收;主线程收到WM_MY_QUOTE_RESULT后,在消息处理函数里解析CString并刷新控件。
把网络请求挪到工作线程之后,界面卡死的问题基本消除。哪怕接口完全没响应,最多是界面上数据不更新,不会出现“鼠标变沙漏”的糟糕体验。
4.3 防止重复请求和显示刷新时间
定时器和手动刷新会叠加触发,比如定时器还没请求完,用户又点了“刷新”按钮,两个线程同时请求同一个接口,返回顺序错乱后显示的数据可能是旧的。我一般用一个BOOL标志位来做互斥:
BOOL m_bRequesting; // 初始为FALSE void CHttpDlg::RequestAndRefresh() { if (m_bRequesting) return; // 已有请求在跑,直接忽略本次触发 m_bRequesting = TRUE; // 启线程…… } // 在WM_MY_QUOTE_RESULT消息处理函数末尾 m_bRequesting = FALSE;再把上一次成功刷新的时间显示在界面上,strTime.Format(_T("%02d:%02d:%02d"), wHour, wMin, wSec),这样用户能判断当前数据到底新不新。如果请求失败,界面保留旧数据同时把时间戳停住,比直接清空显示要专业得多。
5. 避坑手册:VC++ 6.0环境下最常见的五个问题
5.1 编译报错:无法打开包含文件WinInet.h
现象:把工程拷到另一台机器编译,报fatal error C1083: Cannot open include file: 'WinInet.h': No such file or directory。
原因:VC++ 6.0默认的Include路径里找不到WinInet.h,或者整个环境是精简安装,没把网络相关的SDK装全。
解决:在Project Settings的C/C++选项卡里,把C:\Program Files\Microsoft Visual Studio\VC98\INCLUDE加进Additional include directories;同时在Link选项卡的Object/library modules里手动补上wininet.lib。如果你用的是vc++ 6.0 sp6安装包,这些路径一般已配好,但换机器重装后经常漏,遇上了先查这两处。顺手把ws2_32.lib也加上不会错,有些网络初始化逻辑会用到。
5.2 程序运行一段时间后界面冻住
现象:连续刷新几次后,程序标题栏出现“未响应”,鼠标点哪里都没反应,过十几秒自己恢复。
原因:网络请求做在了UI线程里,某次接口响应特别慢,InternetReadFile长时间阻塞,主窗口的消息循环被卡死。
解决:把请求挪到工作线程,用PostMessage回传数据。如果暂时不想改线程模型,至少把2.3节的超时参数加上,连接5秒、接收10秒,这样即使阻塞也能在10秒内解套。我在实际项目里是两种方案一起用,线程保证不卡界面,超时保证线程不堆积。
5.3 界面显示中文乱码
现象:实时行情都出来了,但股票名称和涨跌幅显示成全问号或乱码。
原因:行情接口返回的编码和工程字符集不一致。VC++ 6.0默认工程是MBCS,而不少接口返回的是UTF-8,两者直接对接必然乱码。
解决:先确认接口编码格式,如果是UTF-8,用MultiByteToWideChar先转成宽字符,再通过WideCharToMultiByte转成ANSI,或者直接保持工程为MBCS字符集,在解析阶段做一次Unicode到ANSI的转换。这里没有统一银弹,关键是在HttpClient拿到响应后提前处理,不要等到显示层再转,那时代码会越改越乱。
5.4 每次请求拿到的都是旧数据
现象:界面价格一直不变,重启程序才更新,但用浏览器访问同一地址却能刷新。
原因:WinInet默认启用了HTTP缓存,短时间重复请求相同URL时,直接返回了上次的结果。
解决:在InternetOpenUrl的第5个参数上加上INTERNET_FLAG_RELOAD | INTERNET_FLAG_NO_CACHE_WRITE。这两个标志位一个强制重新下载,一个禁止写缓存,组合使用后每次请求都是真实请求。这个问题在免费接口调试时很容易被忽视,因为浏览器表现正常,但WinInet的行为不一样。
5.5 股票代码带前缀才能查询
现象:输入“600000”返回空数据,输入“sh600000”正常;输入“000001”结果跑到深发展上去了。
原因:行情接口要求明确市场,沪市代码前面加sh,深市加sz。如果用户不区分市场,接口只能用代码前缀猜,常把平安银行和上证指数搞混。
解决:在BuildUrl里做判断,代码以6或9开头的补sh,以0或3开头的补sz,并提供手动覆盖入口。更稳妥的做法是在界面放一个“沪市/深市”下拉框,把市场选择权交还给用户,避免单靠代码猜测出错。
6. 从能跑到跑稳:验证方法、缓存快照和日志埋点
6.1 数据正确性校验不能只看一眼
行情程序最怕的不是没数据,而是数据错了但界面照常显示。我在解析函数返回后一定会过一个校验,逻辑很简单:最新价必须大于0、买一价不能大于卖一价、成交量不为负。另外加一个常识性判断——最新价与昨收价的波动超过10%视为无效,因为正常涨跌幅限制不会超过这个范围。
// 简单但有效的行情校验 BOOL ValidateQuote(const QUOTE_DATA& q) { if (q.dPrice <= 0.0) // 价格为正 return FALSE; if (q.dBidPrice > q.dAskPrice) // 买一价不能高于卖一价 return FALSE; if (q.dVolume < 0) // 成交量非负 return FALSE; if (fabs(q.dPrice - q.dPrevClose) > q.dPrevClose * 0.11) return FALSE; return TRUE; }这四条规则能挡住大部分接口返回的脏数据。超过10%那条之所以用0.11而不是0.10,是给ST股或异常波动留一点容差。
6.2 内存里存一份最近行情快照
网络波动时请求会失败,这时候与其让界面显示空白,不如把内存里最后一次成功的行情快照拿出来继续显示,同时在界面标注数据时间。我的做法是定义一个全局结构体保存快照:
// 全局缓存最近一次成功的行情 struct QUOTE_SNAPSHOT { CString strCode; CString strName; CString strTime; double dPrice; CStringArray arrBidPrice; CStringArray arrAskPrice; }; QUOTE_SNAPSHOT g_LastQuote;请求成功时更新这个结构体;请求失败时直接用里面的数据刷新界面,用户至少还能看到最近的价格和报价时间。这个兜底策略在行情断线时非常有用,用户会觉得这个程序“能扛事”。
6.3 日志记录:用最土的方式给自己留后路
VC++ 6.0的工程一般不建议引入第三方日志库,我习惯直接写一个几十行的日志函数,把每次请求的关键状态追加到log.txt:
void WriteLog(const CString& strMsg) { FILE* fp = fopen("StockQuote.log", "a"); if (fp == NULL) return; CString strTime = COleDateTime::GetCurrentTime().Format(_T("%Y-%m-%d %H:%M:%S")); fprintf(fp, "[%s] %s\n", (LPCSTR)strTime, (LPCSTR)strMsg); fclose(fp); }日志内容至少包括三样:请求返回码、读取到的字节数、解析出的字段个数。遇到“数据明明拿到了但界面上是空的”这种问题,打开日志一看字段数量不对,立刻就能定位是解析逻辑的问题,而不是请求的问题。从那以后我每次写完行情程序,都会强制先补上日志和校验函数,哪怕时间再紧张也要留这两行,这个习惯帮我跳过了不少线上调试的坑。希望帮到你。
本文还有配套的精品资源,点击获取