news 2026/10/24 4:20:26

VC读取TXT按行显示源码:MFC编码处理与大文件日志查看

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
VC读取TXT按行显示源码:MFC编码处理与大文件日志查看

简介:这份VC++源码包面向刚接触C++文件操作与MFC工程组织的开发者,围绕“读取TXT文件并按行显示”给出可直接对照学习的完整示例,覆盖文件打开、逐行读取、控制台输出及资源释放等关键环节。压缩包共38个文件,约1.87MB,以6个h头文件和5个cpp实现文件为核心,辅以rc资源、dsp/dsw工程配置、ico/bmp界面素材,以及exe、obj、pdb等编译产物,便于还原ReadTxt项目结构。已有257人学习下载,适合作为文本处理入门与课程示例参考。源码中可见ifstream打开文件、getline循环读取、cout逐行输出和close关闭文件的完整流程,也包含打开失败提示;结合MFC文档/视图框架,可帮助读者理解工程文件分工、编译调试过程和基础排错思路。

1. 拿到一个「vc 读取txt文件 按行显示」的源码,先弄清它替你省了哪几步

前阵子接了个内部小工具,需求只有一句话:把设备导出的 txt 日志按行铺到界面上。听着像三行代码的事,真动手才发现坑全在边角上——ANSI 和 UTF-8 混着来、几万行的文件一读窗口就假死、末尾没换行的那一行总是莫名其妙丢掉。后来翻到一份 vc 读取txt文件 并按行显示的源码,两百来行,把「选文件 → 打开 → 逐行读 → 追加显示 → 收尾」这条链路走得干干净净,直接拿它当骨架,比自己从零试错省了至少两天。

它适合谁?手上已经有 MFC 对话框工程、需要快速给现场做个日志查看小工具的人;以及刚碰 C++ 文件 IO、想看清楚CStdioFile::ReadString的返回值到底怎么判、空行和末行怎么处理的新手。代码不长,但每一段都能单独抠出来复用,这也是我愿意花时间把它拆开的原因。

2. 工程结构与读取链路:从 CFileDialog 到 Edit 控件的五层拆分

小工程最容易犯的错是全塞进按钮响应函数里,两百行挤在一起,改一个编码判断要上下翻三屏。这份源码还算克制,UI 和读取逻辑基本分开了,下面按我实际整理后的样子讲。

2.1 目录与文件清单:一个能编译的最小工程长什么样

解开压缩包,刨掉.ncb、.opt、Debug/、Release/这些 IDE 自己生成的东西,真正要读的没几个文件:

文件类型职责是否需要改
ReadTxtApp.cpp / .h应用类InitInstance里创建主对话框基本不动
ReadTxtDlg.cpp / .h对话框类按钮响应、文件选择、结果展示主要改动区
ReadTxtDlg.rc资源脚本按钮、Edit 控件、进度条的布局与 ID加控件时才动
resource.h资源头控件 ID 宏定义跟着 .rc 走
stdafx.h / .cpp预编译头引入afxwin.h、afxdlgs.h等加库时才动
TextLineReader.cpp / .h读取封装逐行读取 + 编码转换,与 UI 解耦二次开发的重点

前五个是 IDE 向导生成的,你不用逐行看。真正承载业务的是ReadTxtDlg.cpp和那个读取封装类。我在自己的版本里把读取逻辑单独抽成TextLineReader,原因是同一份代码后来被三个工具复用,塞在对话框里根本没法挪。

资源里要提前放好的控件不多:一个「选择文件」按钮(IDC_BTN_OPEN)、一个只读多行 Edit(IDC_EDIT_CONTENT,务必勾上Vertical Scroll、去掉Auto HScroll,否则长行会把控件撑到天边)、一个进度条(IDC_PROGRESS,不放大文件可以先不加)。Edit 控件默认有约 32KB 的文本上限,这是后面第 5 章要单独说的一条坑。

2.2 主链路拆解:一个文件从被选中到显示在界面上的完整路径

整条链路可以切成五层,每层职责单一,出问题时能快速定位是哪一层挂了:

  1. UI 触发层:用户点按钮,弹出CFileDialog,拿到绝对路径;
  2. 打开层:用CStdioFile或CFile以文本模式打开,同时决定共享标志;
  3. 读取层:循环调用ReadString,一次拿一行,直到返回FALSE;
  4. 转换层:把读出来的字节按源编码转成CString(宽字符),处理 BOM;
  5. 显示层:拼接到一个CString缓冲里,最后一次性SetWindowText,或者分段追加。

对应到代码上,入口就是按钮的BN_CLICKED响应:

// ReadTxtDlg.cpp 选择文件并触发读取 void CReadTxtDlg::OnBnClickedBtnOpen() { CFileDialog dlg(TRUE, _T("txt"), NULL, OFN_FILEMUSTEXIST | OFN_HIDEREADONLY, _T("文本文件 (*.txt)|*.txt|日志文件 (*.log)|*.log|所有文件 (*.*)|*.*||"), this); if (dlg.DoModal() != IDOK) return; // 用户取消,直接返回 CString strPath = dlg.GetPathName(); // 拿到完整路径 CString strContent; // 读取失败时给出提示,而不是静默显示空白 if (!TextLineReader::ReadAllLines(strPath, strContent)) { AfxMessageBox(_T("文件打开失败,可能被其他程序占用或没有读取权限")); return; } SetDlgItemText(IDC_EDIT_CONTENT, strContent); // 一次性回填,避免逐行刷新 }

CFileDialog的第一个参数TRUE表示「打开」而不是「保存」;OFN_FILEMUSTEXIST保证用户不能手输一个不存在的路径;过滤器字符串的格式是「显示名|通配符|显示名|通配符||」,末尾的双竖线不能少,少了最后一项会变成乱码。GetPathName()返回的是绝对路径,后面所有打开操作都基于它。

这里有个我踩过的细节:SetDlgItemText一次性回填比分段追加快一个数量级。早期版本我在读取循环里每读一行就SetSel(-1,-1)再ReplaceSel,五千行就要刷新五千次,肉眼可见地卡顿。改成先在内存里拼完再一次性塞进去,同一份文件从三秒多降到几十毫秒。

2.3 三种读法对比:CStdioFile、std::ifstream、Win32 ReadFile 该挑哪个

同一件事在这套技术栈里有三条路,别混着用,选一条走通就行:

方案关键 API优点缺点适用场景
MFC 文本流CStdioFile::ReadString自带按行语义,自动处理 CRLF/LF,代码最短依赖 MFC,内部有缓冲但不暴露中小文本、快速出活
标准库流std::ifstream+std::getline跨平台,可移植性好需手动处理 BOM 和编码,宽窄字符要自己转逻辑层将来要挪到别的平台
Win32 原始读CreateFile+ReadFile+ 手动切行最快,能控制缓冲区大小,支持超大文件和内存映射切行、跨缓冲区半行拼接全要自己写几十 MB 以上的日志

这份源码走的是第一条,原因很实际:在 MFC 对话框工程里,CStdioFile的ReadString已经帮你把\r\n和\n都吃掉了,返回的CString不含换行符,直接就能往界面拼。标准库方案在这点上要自己erase掉末尾的\r,否则在 Windows 上每一行末尾都会多一个看不见的回车,显示到 Edit 里就是每行后面跟个方块。

原始ReadFile那条路不是不能用,而是有前提:你得自己维护一个「上次读剩下的半行」缓冲区,因为一次读 64KB 很可能在一行中间截断。我一般在文件超过 10MB 或者需要边读边统计行数时才上这条,普通日志查看用不上。

顺带说下共享标志。CFile::shareDenyWrite的意思是「我允许别人读,但不许别人写」,这样你在查看日志的同时,采集程序还能继续往里追加,不会互斥。反过来如果写成shareExclusive,现场同事十有八九会反馈「文件被占用打不开」。

3. 核心代码逐段讲:CStdioFile::ReadString 的行循环怎么写才不出乱码

这一章是整份源码最值得抠的部分。看着简单,但四个地方只要错一个,结果就是乱码、丢行或者多出空行。

3.1 打开阶段:文本模式、共享标志与异常捕获

// TextLineReader.cpp 打开文件 BOOL TextLineReader::Open(LPCTSTR lpszPath) { CFileException e; // typeText 让 CStdioFile 用文本模式处理换行转换 // shareDenyWrite 允许其他进程继续读,但不允许写 if (!m_file.Open(lpszPath, CFile::modeRead | CFile::shareDenyWrite | CFile::typeText, &e)) { TCHAR szErr[512] = {0}; e.GetErrorMessage(szErr, _countof(szErr), lpszPath); m_strLastError = szErr; // 把系统错误码翻成人话,方便弹窗 return FALSE; } return TRUE; }

三个标志必须同时给:modeRead只读,避免误改原文件;shareDenyWrite应对「日志正在被写」的场景;typeText是关键,缺了它ReadString遇到\n不会停,整个文件会被当成一行读进来——这个现象很迷惑,界面上只显示一行超长文本,滚动条拉半天。

CFileException那个参数别偷懒传NULL。传了之后GetErrorMessage能把「找不到文件」「拒绝访问」「磁盘已满」翻译成中文描述,用户看到的是人话而不是error 5。

3.2 逐行循环:返回值、空行与末尾没有换行的那一行

// TextLineReader.cpp 逐行读取主体 BOOL TextLineReader::ReadAllLines(LPCTSTR lpszPath, CString& strOut) { if (!Open(lpszPath)) return FALSE; strOut.Empty(); strOut.Preallocate(64 * 1024); // 预留缓冲,减少反复扩容 CString strLine; int nLineNo = 0; // ReadString 返回 FALSE 表示到达文件末尾或读取出错 while (m_file.ReadString(strLine)) { ++nLineNo; // 空行也要保留,否则原始行号会对不上 strOut += strLine; strOut += _T("\r\n"); // Edit 控件认 CRLF,用 \n 会显示成一行 } m_file.Close(); return TRUE; }

ReadString的返回值语义要记牢:读到内容返回TRUE,到达 EOF 或出错返回FALSE。它内部用的是fgets类似的行为,所以最后一行即使没有换行符,也会被正常读出并返回TRUE,下一次调用才返回FALSE。这一点比很多手写的while(!feof())循环靠谱——那种写法最后一行经常被读两遍。

空行返回的是TRUE加一个空CString,别用if (strLine.IsEmpty()) continue;跳过,否则显示出来的行号和原文对不上,排查日志时会怀疑人生。

另外注意换行符用的是\r\n。Edit 控件对单独的\n兼容性不好,某些系统上会整段显示成一行。用\r\n最保险。

3.3 显示阶段:Edit 控件的追加姿势与上限

// ReadTxtDlg.cpp 分段追加,适合边读边显示的场景 void CReadTxtDlg::AppendLine(LPCTSTR lpszLine) { CEdit* pEdit = (CEdit*)GetDlgItem(IDC_EDIT_CONTENT); if (!pEdit) return; int nLen = pEdit->GetWindowTextLength(); pEdit->SetSel(nLen, nLen); // 光标移到末尾 pEdit->ReplaceSel(lpszLine); // 在光标处插入 pEdit->ReplaceSel(_T("\r\n")); pEdit->LineScroll(pEdit->GetLineCount()); // 自动滚到底部 }

SetSel(nLen, nLen)是把选区压成一个点,等价于把插入光标挪到末尾;ReplaceSel在有选区时替换、无选区时插入,所以这里表现为追加。LineScroll那行是体验细节,不加的话新内容加到下面去了,用户还停在第一屏,以为没反应。

追加方式适合边读边看的场景,但每次调用都会触发一次重绘。行数上万就别用了,老老实实拼一个CString再SetWindowText。

3.4 编码处理:BOM 探测与 MultiByteToWideChar

这才是真正的玄学区。同一批 txt,有的存成 ANSI,有的存成 UTF-8 带 BOM,有的甚至是 UTF-16。一律按 ANSI 读,中文全变问号;一律按 UTF-8 读,老文件全变方块。

// TextLineReader.cpp 探测 BOM 并转成宽字符 CString TextLineReader::DecodeBuffer(const BYTE* pData, int nLen, UINT& nCodePage) { nCodePage = CP_ACP; // 默认按本地 ANSI 处理 if (nLen >= 3 && pData[0] == 0xEF && pData[1] == 0xBB && pData[2] == 0xBF) { nCodePage = CP_UTF8; // UTF-8 BOM pData += 3; nLen -= 3; } else if (nLen >= 2 && pData[0] == 0xFF && pData[1] == 0xFE) { nCodePage = 1200; // UTF-16 LE pData += 2; nLen -= 2; } else if (nLen >= 2 && pData[0] == 0xFE && pData[1] == 0xFF) { nCodePage = 1201; // UTF-16 BE pData += 2; nLen -= 2; } if (nLen <= 0) return CString(); // 先问需要多长的宽字符缓冲,再真正转换 int nWide = MultiByteToWideChar(nCodePage, 0, (LPCSTR)pData, nLen, NULL, 0); if (nWide <= 0) return CString(); CString strOut; LPWSTR pBuf = strOut.GetBuffer(nWide + 1); MultiByteToWideChar(nCodePage, 0, (LPCSTR)pData, nLen, pBuf, nWide); pBuf[nWide] = L'\0'; strOut.ReleaseBuffer(nWide); return strOut; }

BOM 是文件开头那几个看不见的字节,UTF-8 是EF BB BF,UTF-16 LE 是FF FE,BE 是FE FF。先判断再决定代码页,比让用户手动选编码靠谱得多。MultiByteToWideChar调两次是标准套路:第一次传NULL拿长度,第二次才真正写缓冲区,避免缓冲区不够导致截断。

没有 BOM 的 UTF-8 是最麻烦的,纯靠字节猜。我的做法是先按CP_UTF8转一次,如果MultiByteToWideChar返回 0(非法序列),回退到CP_ACP。这个启发式不完美,但覆盖了九成现场文件。

4. 大文件与卡 UI:把同步读取换成工作线程加消息投递

代码能跑通只是及格线。真正让工具在现场活下来的,是「点开一个 50MB 日志不假死」这件事。

4.1 为什么几 MB 的 txt 就能把窗口卡住

Win32 是消息驱动的,所有 UI 更新都跑在主线程的消息循环里。你在按钮响应函数里同步读文件,读的这段时间消息泵是停的,窗口不能拖动、不能重绘、任务栏显示「未响应」。文件小的时候几百毫秒就过去了,用户没感觉;超过两三秒,操作系统就会给标题栏加上那层灰白滤镜。

更要命的是,每读一行调一次ReplaceSel会往消息队列里塞重绘消息,读得越快积压越多,形成「越读越卡」的正反馈。所以大文件场景必须同时解决两件事:读的活挪出主线程,显示的活攒批做。

4.2 线程函数、进度回调与安全的消息投递

// ReadTxtDlg.cpp 工作线程读取 + 主线程刷新 #define WM_APP_LINE (WM_APP + 101) // 自定义消息:新增一批行 #define WM_APP_DONE (WM_APP + 102) // 自定义消息:读取结束 struct ReadTask { CString strPath; HWND hWndNotify; // 主窗口句柄,用来 PostMessage }; UINT AFX_CDECL ReadThreadProc(LPVOID pParam) { ReadTask* pTask = (ReadTask*)pParam; CStdioFile file; CFileException e; if (!file.Open(pTask->strPath, CFile::modeRead | CFile::shareDenyWrite | CFile::typeText, &e)) { ::PostMessage(pTask->hWndNotify, WM_APP_DONE, 1, 0); // 1 表示失败 delete pTask; return 0; } CString strLine, strBatch; int nCount = 0; while (file.ReadString(strLine)) { strBatch += strLine; strBatch += _T("\r\n"); ++nCount; // 每 500 行投递一次,避免消息队列被刷爆 if (nCount % 500 == 0) { CString* pPayload = new CString(strBatch); ::PostMessage(pTask->hWndNotify, WM_APP_LINE, 0, (LPARAM)pPayload); strBatch.Empty(); } } file.Close(); if (!strBatch.IsEmpty()) // 最后不足 500 行的尾巴别丢 { CString* pPayload = new CString(strBatch); ::PostMessage(pTask->hWndNotify, WM_APP_LINE, 0, (LPARAM)pPayload); } ::PostMessage(pTask->hWndNotify, WM_APP_DONE, 0, nCount); delete pTask; return 0; }

几个关键点。第一,线程里绝对不能碰任何控件,MFC 的CWnd*不是线程安全的,SetDlgItemText从工作线程调用会偶发崩溃,而且崩溃点飘忽,属于典型的血泪经验。所有 UI 操作必须PostMessage回主线程做。

第二,为什么用PostMessage而不是SendMessage。SendMessage是同步的,会阻塞工作线程直到主线程处理完;如果主线程正在忙,两边互相等,很容易死锁。PostMessage是投递即返回,异步安全。

第三,new CString出来的堆对象要有人释放。主线程的消息处理函数里delete掉:

// ReadTxtDlg.cpp 主线程接收并刷新界面 LRESULT CReadTxtDlg::OnAppendLine(WPARAM, LPARAM lParam) { CString* pText = (CString*)lParam; if (pText) { CEdit* pEdit = (CEdit*)GetDlgItem(IDC_EDIT_CONTENT); int nLen = pEdit->GetWindowTextLength(); pEdit->SetSel(nLen, nLen); pEdit->ReplaceSel(*pText); delete pText; // 谁 new 谁 delete,这里归主线程 } return 0; }

消息映射里用ON_MESSAGE(WM_APP_LINE, &CReadTxtDlg::OnAppendLine)注册。WM_APP起始的自定义消息区间是给应用自己用的,不会和系统消息撞车。

4.3 行数上限与虚列表:什么时候该放弃 Edit 控件

Edit 控件在单行文本超过 32KB 或者总文本量过大的时候会静默截断,你会看到一个「读完了但只显示了一部分」的诡异现象。经验阈值是十万行以内还能忍,再多就该换CListCtrl的虚模式(LVS_OWNERDATA)了。

虚列表的思路是:控件不保存文本,只在需要绘制某一项时回调LVN_GETDISPINFO问你要数据。你手里维护一个std::vector<CString>存所有行,内存占用大概是纯文本的 1.5 倍,但十万行也就几十 MB,比把 AST 全塞进控件里强得多。列可以设成「行号 / 内容 / 时间戳」,后面第 6 章的过滤功能也能直接在 vector 上做。

加个CProgressCtrl会让体验好很多。在工作线程里每读 500 行顺便PostMessage(WM_APP_PROGRESS, nCount, 0),主线程拿文件大小估个百分比设置进度条,用户至少知道程序还活着。

5. 避坑记录:五个把「能跑」变成「能交付」的细节

下面这些不是理论风险,都是实际调试时真实出现过的现象,按「现象 → 原因 → 解决」摆出来。

现象一:界面只显示了一行超长文本,滚动条拉到天荒地老。

原因是CStdioFile::Open的时候漏了CFile::typeText,文件按二进制打开,ReadString找不到行分隔语义,把整个文件当一行返回。解决方法是补上这个标志。另一条路是先CFile二进制打开再用CArchive包一层,但对新手来说不如直接加标志简单。

现象二:中文全变成问号或者方块,英文正常。

原因是没有做编码判断,一律按CP_ACP(本地 ANSI 代码页)解释。UTF-8 文件里的三字节中文被拆成三个单字节,每个都映射不到有效字符。解决办法是先读前三个字节判 BOM,再决定代码页;没有 BOM 的走「先试 UTF-8,失败回退 ACP」的策略。MultiByteToWideChar返回 0 就是明确的失败信号,别忽略这个返回值。

现象三:五千行的文件读完要三四秒,每加一行界面就闪一下。

这是逐行ReplaceSel导致的重绘风暴。解决方法有二:拼一个CString最后一次性SetWindowText;或者按第 4 章那样每 500 行批量投递一次。順便说一句,SetWindowText本身也有开销,二十万行以上就要考虑SetRedraw(FALSE)包一层,全部写完再SetRedraw(TRUE)。

现象四:文件明明存在,一打开就提示「拒绝访问」。

现场日志往往正被采集程序以写方式独占着。CStdioFile::Open的默认共享模式是shareExclusive,跟对方的独占锁冲突。解决办法是显式加shareDenyWrite,表示「允许别人读、不允许别人写」,虽然这要求对方也持兼容的锁,但现场多数采集程序用的是允许共享的写模式,加了这个标志大部分能打开。实在不行就先把文件复制到临时目录再读,牺牲一点实时性换可用性。

现象五:程序运行一阵后崩在ReplaceSel,堆栈飘忽不定。

典型的跨线程操作控件。工作线程直接调用了GetDlgItem(...)->SetWindowText(...),MFC 的窗口对象句柄映射不是线程安全的。解决办法是所有 UI 操作都通过PostMessage回主线程执行。如果一定要在线程里同步等结果,用SendMessage并确认主线程没有在等这个线程,否则就是死锁。

6. 二次开发改造:把它做成一个带过滤和编码自适应的日志查看器

原始源码的定位是「把行读出来」,但工具交给运维之后,反馈最多的永远是「这么多行我怎么找」。所以我一般会在它外面加一层过滤,改动量比想象中小——读取层不动,只在显示前过一道筛子。

// TextLineReader.cpp 带关键字过滤的读取(大小写不敏感) int TextLineReader::ReadFiltered(LPCTSTR lpszPath, LPCTSTR lpszKeyword, std::vector<CString>& vecOut) { CString strLine; CString strKey(lpszKeyword); strKey.MakeLower(); // 统一小写,规避大小写差异 int nHit = 0; while (m_file.ReadString(strLine)) { if (strKey.IsEmpty() || strLine.MakeLower().Find(strKey) >= 0) { vecOut.push_back(strLine); // 命中才收进结果集 ++nHit; } } return nHit; // 返回命中行数,界面可以提示 }

注意strLine.MakeLower()是原地修改,如果后面还要显示原始大小写,得先复制一份再降级。这个坑我踩过,过滤完之后界面上全是小写,用户以为程序把原文改了。

封装成类之后,复用路径就清晰了:读取层只负责「给路径和关键字,吐出vector<CString>」,界面层只负责把vector喂给虚列表。想加正则过滤,换个比较函数就行,不用碰 UI;想支持实时 tail,把Open之后的循环改成定时读新增部分,其余逻辑不变。我的习惯是每加一个功能就先写一组对照文件——纯英文、中文 ANSI、中文 UTF-8 带 BOM、中文 UTF-8 无 BOM、末行无换行的——跑一遍看输出行数是否和wc -l对得上。行数对不上,基本就是空行被吞或者末行被漏。

从那以后我每次接手这类「读文本按行显示」的小工具,都强制先用这五个对照文件跑一遍再谈功能。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/24 4:19:36

XGBoost时序预测实战:从特征工程到滚动回测的完整工程

简介&#xff1a;这份资源面向时间序列预测与机器学习的入门及进阶学习者&#xff0c;用Xgboost算法打通从数据分析、特征工程到模型训练、保存与预测评估的完整链路&#xff0c;可应用于金融、气象、股票等趋势预测场景。压缩包共3个文件&#xff0c;以2个Python脚本和1个CSV数…

作者头像 李华
网站建设 2026/10/24 4:18:32

Tesseract离线中文OCR:安装调参、图像预处理与置信度闸门

简介&#xff1a;面向需要在本地部署文字识别能力的开发者与办公自动化用户&#xff0c;这份资源提供Tesseract-OCR的完整安装包及中文语言包&#xff0c;可用于扫描文档、图片文字提取、批量数据录入等场景&#xff0c;也适合研究OCR训练流程的技术人员。压缩包共724个文件、约…

作者头像 李华
网站建设 2026/10/24 4:17:34

OpenCV图像拼接实战:从特征匹配到多频段融合的Python流水线

简介&#xff1a;全景拼合图片算法资料包面向希望掌握全景拼接与图像融合的开发者、视觉方向学生及图像处理爱好者&#xff0c;围绕OpenCV Stitcher调用、PhotoShop PhotoMerge脚本及其中英文专利文档展开&#xff0c;并补充手动微调代码&#xff0c;兼顾理论文档与工程实践&am…

作者头像 李华
网站建设 2026/10/24 4:17:01

OpenCV+Python单目测距:标定、滤波与误差控制

简介&#xff1a;面向Python与计算机视觉初学者的测距实践项目&#xff0c;围绕OpenCV实现基于三角形相似度的单目相机测距&#xff1a;先利用已知物体的实际宽度及其在设定距离下的像素宽度求取焦距&#xff0c;再在新图像中检测同一标记物&#xff0c;根据其像素尺寸反推相机…

作者头像 李华