MFC下C++手工解析SHP文件:从几何读取到地图显示的完整实践 简介压缩包提供了一整套基于 MFC 的 SHPShapefile矢量地图读取与显示工程示例面向 C 桌面应用开发者、GIS 初学者及需要快速上手 MFC 绘图的读者。工程演示了如何借助 MFC 的 CDC、CBrush、CPen 等类完成地理要素的绘制并实现了地图缩放与平移的消息响应机制是理解 GIS 数据集成与 Windows 图形编程的良好起点。包内共 41 个文件体积约 49.56MB除核心的 h/cpp 源码外还包含 Visual Studio 工程配置sln/vcxproj/filters、调试生成产物obj/pdb/ilk/exe以及资源文件rc/ico等整体结构完整可直接打开工程对照学习。目前已有 853 人浏览学习适合需要参考 MFC 与 Shapefile 结合实践的开发者。借助此示例可快速掌握 SHP 几何数据的解析思路、MFC 设备描述表的使用方法以及实现地图交互操作的基本流程。1. 拿到MFCin_out.rar先别急着解压这套shp读取显示资源到底解决了什么事情往往是这样开始的你手里一个MFC维护工程跑得好好的某天产品递过来一个.shp文件说要显示地块边界、点击还能看属性。网上搜“C MFC shp 读取 显示”翻到的多是第三方库配置教程而实际要的是能直接编进旧工程的源码。MFCin_out.rar就是一套在MFC框架下用C手工解析SHAPE三件套.shp几何、.shx索引、.dbf属性并渲染到视图的完整Demo解决“读取显示”两件事不依赖重型GIS库。适合不想为小工具引入庞大依赖、愿意自己掌控解析细节的开发者也适合拿它当shp格式剖析的起点。2. 选路线再动手现成GIS库还是自己写shp解析器2.1 两种路线的边界很多人拿到shp读取需求第一反应是引一个现成的GIS库。它成熟是成熟但一个MFC对话框程序要带上那一堆dll和依赖部署包凭空大几兆脱离编译环境后版本匹配问题能折腾一天。我自己一般不轻易往老工程里塞这种重型库除非后面要做投影转换、写shp、读栅格。如果只是“读取并显示”这四个字手写解析完全够用shp格式本身不复杂点线面的二进制布局是公开的加上shx做索引一百多行C就能把几何数据倒出来。MFCin_out.rar走的也是这条轻量路线。2.2 用CFile读shp文件头大小端混排是第一个坑shp文件头固定100字节前12字节是大端字节序从偏移12开始往后的版本号、shape type和四角范围又变成小端。初次写解析器的人最容易在这里翻车统一用ntohl翻后面范围读出来的bbox是天文数字。下面是我会在工具类里放的读取函数bool CShpReader::ReadHeader(const CString strPath) { CFile f; if (!f.Open(strPath, CFile::modeRead | CFile::shareDenyWrite)) { return false; } BYTE buf[100] {0}; f.Read(buf, 100); // shp文件头固定100字节 // 前4字节文件码固定99940x0000270A手动拼大端 int nFileCode (buf[0] 24) | (buf[1] 16) | (buf[2] 8) | buf[3]; // 偏移8的文件长度大端单位是16位word乘以2才是字节数 int nFileWords (buf[8] 24) | (buf[9] 16) | (buf[10] 8) | buf[11]; int nFileBytes nFileWords * 2; // 后面字段是小端可以直接按内存拷贝读 int nVersion 0; memcpy(nVersion, buf 12, 4); memcpy(m_nShapeType, buf 16, 4); memcpy(m_dXMin, buf 20, 8); memcpy(m_dYMin, buf 28, 8); memcpy(m_dXMax, buf 36, 8); memcpy(m_dYMax, buf 44, 8); // 顺手做合法性检查避免拿别的二进制文件来解析 if (nFileCode ! 9994 || nVersion ! 1000) { return false; } m_nTotalBytes nFileBytes; return true; }逻辑说明这个函数只负责shp主文件的头部。文件码9994和版本1000是shp文件的两个硬性标识一个文件是不是shp先看这两个数。nFileBytes可以拿来和CFile::GetLength()做比对偏差超过两个记录长度时就该怀疑文件被截断过。文件头里坐标范围是double精度读出来后记得检查XMin小于XMax有些工具写出的文件这两个值颠倒会在缩放计算时出问题。参数说明m_nShapeType保存几何大类后续分支全靠它。m_dXMin这些是文件级范围绘制缩放要用后面每条记录里还有一个范围那条范围的优先级高于文件头。使用这个函数前要确保文件路径是UnicodeCFile不负责给你做代码页转换。2.3 shx索引的作用顺序读取与随机定位shp主文件里的记录是顺序排列的每条记录前有8字节记录头。要显示整个文件顺序读没问题但如果想实现“点哪个图元查哪条”每次都从文件头扫到目标记录文件大的时候肉眼可见的卡。shx文件就是干这个的每条主文件记录在shx里对应一个8字节索引项前4字节是记录相对文件头的偏移按word计后4字节是记录内容长度按word计都是大端。常见做法是启动时把shx整个读进内存建一个offset数组bool CShpReader::LoadIndex(const CString strShxPath) { CFile f; if (!f.Open(strShxPath, CFile::modeRead | CFile::shareDenyWrite)) { return false; } ULONGLONG nLen f.GetLength(); // 每条索引8字节去掉100字节文件头 int nCount (int)((nLen - 100) / 8); m_vOffsets.resize(nCount); m_vRecordLens.resize(nCount); f.Seek(100, CFile::begin); // 跳过shx自己的文件头 for (int i 0; i nCount; i) { BYTE idx[8] {0}; f.Read(idx, 8); int nOff (idx[0] 24) | (idx[1] 16) | (idx[2] 8) | idx[3]; int nLen (idx[4] 24) | (idx[5] 16) | (idx[6] 8) | idx[7]; m_vOffsets[i] nOff * 2; // word转字节 m_vRecordLens[i] nLen * 2; } return true; }逻辑说明shx文件头也是100字节但内容只有文件码和文件长度有意义做索引直接跳过。nCount算出来就是主文件里的记录数和后面第5章要讲的记录数不一致坑直接相关。把offset和len都乘以2是因为规范里长度一律按16位word写这是shp这套格式里最常被人忽略的单位。参数说明idx数组8个字节前4字节拼偏移、后4字节拼长度一律手动大端重组。m_vOffsets[i]指向的是该记录内容起点已包含8字节记录头跳转时用CFile::Seek到m_vOffsets[i]先读8字节记录头再读内容就正好。如果之后要做随机定位这个数组比反复读shx文件高效得多。2.4 记录头的解析8字节里藏着序号和长度记录头在每条几何数据前面8字节大端。前4字节是记录序号从1递增后4字节是内容长度word计。内容数据的前4字节又是小端的shape type然后才是真正的几何数据。记录读取我一般用一个独立函数避免OnDraw里塞IO逻辑bool CShpReader::ReadRecord(int nIndex, std::vectorBYTE vData) { if (nIndex 0 || nIndex (int)m_vOffsets.size()) { return false; } CFile f; if (!f.Open(m_strShpPath, CFile::modeRead | CFile::shareDenyWrite)) { return false; } f.Seek(m_vOffsets[nIndex], CFile::begin); BYTE recHead[8] {0}; f.Read(recHead, 8); int nRecNo (recHead[0] 24) | (recHead[1] 16) | (recHead[2] 8) | recHead[3]; int nContentWords (recHead[4] 24) | (recHead[5] 16) | (recHead[6] 8) | recHead[7]; int nContentLen nContentWords * 2; vData.resize(nContentLen); f.Read(vData.data(), nContentLen); return nRecNo nIndex 1; // 序号从1开始 }逻辑说明这里有个值得养成的习惯——把读出来的记录序号和nIndex1做一致性校验。shp被某个编辑器改坏后经常出现记录号跳变不校验的话后面按索引画图几何和属性错位了都发现不了。校验失败时可以选择返回false也可以在调用侧打印一条警告继续读。参数说明vData是出参存这条记录除记录头外的完整内容大小等于nContentLen。调用方拿到vData后前4字节按小端解释成shape type再根据type解析后面剩余字节。整条记录读完指针不需要回退因为每个文件都是从文件头顺序存放的。到这里读取侧主干就通了文件头给范围shx给索引记录头给长度剩下的就是几何体和属性的解析。第4章再补属性先把显示这条线讲完。3. 从经纬度到像素坐标归一化和OnDraw双缓冲3.1 客户区与数据范围的映射关系shp里的坐标是地理坐标数值范围和窗口像素完全不是一个量级显示核心就是一次线性映射把数据范围m_dXMin..m_dXMax映射到客户区的左边距..右边距Y方向同样处理但要注意屏幕Y轴向下、地理Y轴向上所以Y方向要翻转。不做翻转的后果是图形上下颠倒地块边界看多了容易头晕。我一般会留一个边距免得图形顶到窗口边缘void CShpSView::CalcViewParam(CDC* pDC, CRect rcClient) { const int nMargin 30; // 单位像素四周留白 m_dScaleX (double)(rcClient.Width() - 2 * nMargin) / (m_dXMax - m_dXMin); m_dScaleY (double)(rcClient.Height() - 2 * nMargin) / (m_dYMax - m_dYMin); m_dOffsetX nMargin - m_dXMin * m_dScaleX; m_dOffsetY nMargin - m_dYMin * m_dScaleY; // 后面再处理Y翻转 } CPoint CShpSView::WorldToScreen(double dX, double dY) { int nX (int)(dX * m_dScaleX m_dOffsetX); int nY (int)((m_dYMax - dY) * m_dScaleY m_nMargin); // 翻转 return CPoint(nX, nY); }逻辑说明m_dScaleX和m_dScaleY是缩放因子m_dOffsetX负责平移。WorldToScreen里把dY变成(m_dYMax - dY)地理Y越大、屏幕Y越小图像就不至于上下颠倒。两段代码里的nMargin最好统一成一个成员变量否则改边距要改两处。参数说明dX、dY是shp里的原始坐标返回值已在客户区坐标系里。这个映射没有保持纵横比如果数据范围宽高比和客户区不一致图形会被拉伸。想保持比例的话把m_dScaleX和m_dScaleY取较小值同时赋给两个scale再做居中偏移。3.2 双缓冲绘制不要在OnDraw里直接画点MFC视图的OnDraw在窗口拖动、遮挡恢复时会频繁调用。如果OnDraw里直接读文件、解析几何、再逐点画线窗口会闪烁到不能忍。双缓冲是老办法但依然有效先把所有几何画到内存位图再一次BitBlt上屏。void CShpSView::OnDraw(CDC* pDC) { CRect rcClient; GetClientRect(rcClient); CDC memDC; CBitmap bmp; memDC.CreateCompatibleDC(pDC); bmp.CreateCompatibleBitmap(pDC, rcClient.Width(), rcClient.Height()); CBitmap* pOld memDC.SelectObject(bmp); memDC.FillSolidRect(rcClient, RGB(245, 242, 238)); // 底图颜色 memDC.SetMapMode(MM_TEXT); // 像素单位 if (m_pReader m_pReader-IsOpen()) { CalcViewParam(memDC, rcClient); DrawAllRecords(memDC); // 解析绘制都在内存DC里做 } pDC-BitBlt(0, 0, rcClient.Width(), rcClient.Height(), memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOld); bmp.DeleteObject(); }逻辑说明memDC和bmp是局部对象函数结束自动清理注意SelectObject还原后再DeleteObject否则位图句柄没有正确释放。FillSolidRect先刷一层底色也能提前暴露坐标映射有没有飞出窗口——如果图形范围不对底色会占满窗口而几何不见。SetMapMode(MM_TEXT)保证后面画线用像素单位否则GDI默认的MM_HIMETRIC会让坐标全乱。参数说明RGB(245, 242, 238)是浅色底不是必须的但建议保留。CreateCompatibleBitmap里的pDC是传入的画布DC客户区大小决定位图大小。DrawAllRecords是我自己封的函数内部遍历m_vOffsets逐条解析并绘制见3.3。3.3 先修正Y轴翻转再谈符号化解析出几何后按类型选择绘制方式。点用一个椭圆标出线用Polyline画多段线面用Polygon填色加描边。这里有个实操细节Polyline和Polygon的API都收CPoint数组而CPoint是LONG经纬度直接转CPoint会丢精度。所以WorldToScreen要先把坐标映射到像素再构造CPoint。void CShpSView::DrawPolyline(CDC* pDC, const std::vectorstd::vectorCPoint vRings) { for (size_t i 0; i vRings.size(); i) { const std::vectorCPoint pts vRings[i]; if (pts.size() 2) continue; // 少于两个点无法成线 pDC-Polyline(pts.data(), (int)pts.size()); } }逻辑说明vRings是二维数组每一维对应shp记录里的一个part。线要素每个part是一条独立子线直接Polyline。面要素每个part是一个环用Polygon而不是Polyline——Polygon会自动闭合首尾并且填充规则是奇偶填充环方向不影响填充结果。参数说明pDC已经映射到客户区坐标系pts里存的是屏幕坐标。pts.data()在较新编译环境里可用老工程没有的话改成pts[0]。如果图元数量巨大比如几万个多边形逐条调用Polyline/Polygon会产生大量GDI调用此时可以先判断该记录范围与客户区是否相交不相交直接跳过这个优化在面图层上效果明显。点要素的绘制更简单但要把符号大小和缩放比例挂钩if (nShapeType 1 || nShapeType 8) { int r max(3, (int)(m_dScaleX * 2)); // 至少3像素避免缩没了 for (auto pt : pts) { pDC-Ellipse(pt.x - r, pt.y - r, pt.x r, pt.y r); } }逻辑说明这里把点的半径和m_dScaleX挂钩缩放因子里乘2再和3取最大值保证图形缩得很小时点还是可见的。直接用固定半径3像素也行但大范围缩放到小窗口时点会显得比地块还大反过来也怪。参数说明nShapeType为1是单点、8是多点多点记录的pts里有多个点坐标所以循环画椭圆。Ellipse的最后两个参数是右下角坐标不是宽高写成pt.x r不是pt.x 2*r这个搞错画出来的点会大一倍。4. dbf属性读取把字段描述符变成CString4.1 dbf文件头与字段描述符解析shp文件三件套里dbf存属性。dbf是一种老式表格式文件头32字节是表信息后面跟着若干32字节的字段描述符最后0x0D结束。关键信息在偏移4-7的记录数和偏移8-9的文件头长度字段描述符里有字段名、类型、长度、小数位数。dbf读取和shp的坑不同它没有大小端问题但字段名固定是ASCII字符在中文工程里最好转成宽字符再拼接。bool CDbfReader::ParseHeader(CFile f) { BYTE head[32] {0}; f.Read(head, 32); int nRecCount *(int*)(head 4); // 小端记录数 int nHeaderLen *(short*)(head 8); // 文件头总长度 int nRecLen *(short*)(head 10); // 每条记录长度 f.Seek(32, CFile::begin); std::vectorDBF_FIELD vFields; BYTE fieldDesc[32] {0}; while (f.Read(fieldDesc, 32) 32) { if (fieldDesc[0] 0x0D) break; // 字段描述符结束标志 DBF_FIELD fld {0}; memcpy(fld.szName, fieldDesc, 11); fld.szName[11] 0; fld.chType fieldDesc[11]; fld.nLen fieldDesc[16]; fld.nDec fieldDesc[17]; vFields.push_back(fld); } return true; }逻辑说明nHeaderLen告诉你在文件头之后多远才是第一条记录读取记录数据时直接从文件头这个偏移量开始。字段描述符循环读到0x0D就停这是dbf表的标准结尾如果不做这个判断会把记录数据误当字段描述符读进来。参数说明DBF_FIELD这个结构体需要自己定义szName用11字节因为dbf字段名最长10字符加一个结尾符。chType的取值常见是C字符、N数值、F浮点、L逻辑、D日期。注意nLen是字段总长度一个N型字段可能宽到10个字节读取时要先按长度截子串再转换不能直接拿整个记录去atoi。4.2 按类型还原记录值dbf的记录区是定长表每条记录的字节数等于所有字段长度之和加一个删除标记位。读记录时按字段长度逐个切片再根据type把字节转成合适的类型。CString CDbfReader::GetFieldValue(const BYTE* pRec, const DBF_FIELD fld) { CString strVal; char tmp[256] {0}; memcpy(tmp, pRec fld.nOffset, fld.nLen); tmp[fld.nLen] 0; // 去除首尾空格dbf字段是右对齐的 int nStart 0; while (tmp[nStart] ) nStart; int nEnd (int)strlen(tmp) - 1; while (nEnd nStart tmp[nEnd] ) nEnd--; tmp[nEnd 1] 0; switch (fld.chType) { case N: case F: { int nVal atoi(tmp nStart); strVal.Format(L%d, nVal); break; } case L: { strVal (tmp[nStart] T || tmp[nStart] t) ? L是 : L否; break; } default: strVal tmp; // C型和其他类型统一按字符串显示 } return strVal; }逻辑说明dbf的N型字段不是二进制数值而是ASCII数字字符串且右对齐、左侧补空格所以要先去掉首尾空格再转换。C型字段是纯文本。L型字段只有T/F两个字符存的是逻辑值。日期字段D型格式是YYYYMMDD按字符串读出来再格式化。参数说明fld.nOffset需要在ParseHeader里累加得到第一列偏移是1跳过删除标记位下一列偏移是上一列偏移加nLen。我这里偷懒用CString Format如果字段是浮点而且nDec大于0建议用atof再加CString的Format(L%.2f)否则会丢小数位。4.3 把属性挂在几何对象上结构体设计读取和显示是两条线shp解析出几何dbf解析出属性最后在一个数据结构里汇合。结构体设计得清楚一点后续做点击选中、属性框都省事。struct CShpFeature { int nIndex; // 记录序号 int nShapeType; // 几何类型 std::vectorstd::vectorCPoint vRings; std::vectorCString vFieldValues; CRect rcBounds; // 像素范围用于命中测试 };逻辑说明vFieldValues的顺序和vFields保持一致绘制时不关心属性做点击查询时根据nIndex直接索引属性数组。rcBounds是几何在屏幕坐标下的包围盒命中测试先比较矩形再判断点能减少大量精细判断。参数说明vRings设计成二维数组对点要素退化成一条只有一个点的环对面要素每个part一个环代码里统一处理比分开存点、线、面要少写很多分支。字段类型还有一个容易误用的点dbf里除了C、N、F、L、D还可能有M型备注、I型整型二进制、B型双精度二进制等但这些在GIS导出的shp里比较少见。手写解析时遇到不认识的字段类型先按字符串读别急着报错。画面上显示属性框时数据错位多半是类型分支漏了I型或没跳过删除标记位。5. MFCin_out实战避坑从编译到显示的五个踩坑记录5.1 坑一文件长度是16位word计数忘了乘2现象读一个shp文件nFileCode明明等于9994但后面循环读记录时总是多读或少读甚至文件指针跑到文件末尾之外。原因shp规范里文件长度、记录长度全部按16位word计数也就是实际字节数除以2。第一次写解析器时只记得大端转换忘了乘2导致文件偏移全部错位。解决凡是遇到表示长度的字段先按大端解出数值无条件乘以2再用来做Seek和resize。可以在读取后和CFile::GetLength()比对一次误差在100字节左右文件头就算正常。从那以后我对shp里所有length字段都会先问一句这是word还是byte。5.2 坑二shx记录条数与shp不一致循环越界现象加载索引后程序崩在vector越界上有时不崩但画出来的图元位置错乱。原因某个编辑器把shp主文件改了shx没有同步更新或者反过来shx是旧文件生成的。两个文件的记录数不一致是常态不是异常。解决读shx时先取条文数再打开shp主文件数一遍记录头做校准以实际记录数为准。主文件的真实记录数可以在加载shp时顺带统计从偏移100开始按每条“8字节记录头内容长度”跳读记录数就出来了。我一般在LoadIndex时保留nExpect和nActual两个值不一致时用nActual并输出一条警告。5.3 坑三OnDraw里直接读文件拖一下窗口就白屏现象窗口第一次显示正常拖动标题栏再松开画面要么闪烁要么白屏偶尔还崩。原因OnDraw触发的频率远比你想象的高每次都在里面Open文件、递归解析把磁盘IO放在渲染路径上速度跟不上自然闪烁。解决把文件读取收敛到初始化时完成几何和属性全部放内存OnDraw只做绘制。如果文件实在太大内存吃不消至少也要在OnDraw里加缓存把解析结果按记录号存起来第一次解析后后续直接取缓存。我习惯的做法是启动时先读shx索引和文件头记录数据按需懒加载懒加载也要做LRU缓存别裸着读文件。5.4 坑四面要素填充发黑外环方向判断失败现象多边形区域填充出来的颜色明显比预期深或者填充区域看起来残缺、出现条带。原因shp的面要素part方向是约定外的有的文件顺时针是外环有的逆时针是外环还有带洞的多边形part顺序更乱。用GDI的Polygon画本来不管方向但如果你为了优化自己做了奇偶填充或区域合并方向错了填充结果就翻车。解决不依赖环方向统一用Polygon单环绘制GDI自带奇偶规则处理内外环。如果一定要合并多个环判断面积正负外环面积是正的内环面积是负的把内环从外环里挖掉再用PolyPolygon画。注意shp坐标经过投影后可能是负值面积正负判断要基于叉积和而不是坐标值正负。5.5 坑五路径含中文打不开字符集切换后全乱现象调试机上文件能打开拷贝到另一台电脑就打不开或者用Debug能读Release读出来乱码。原因MFC工程字符集设置不一致一个是Unicode一个是多字节CFile打开路径传参是char还是wchar_t完全两个概念。shp路径里有中文时ANSI下传char*会被系统按本地代码页解释换了系统就全乱。解决统一用Unicode字符集文件路径用CStringW打开文件时走CFile接受LPCTSTR的接口。如果是从外部传入的char*路径先MultiByteToWideChar转一次。这个坑在dbf字段名上同样会出现——字段名读出来是ANSI字节直接赋给CStringUnicode显示会乱要按当前本地代码页转换后再显示。6. 多图层叠加验证半透明渲染检查几何完整性6.1 叠加顺序与AlphaBlend参数框架跑通后怎么确认解析出来的几何没问题我的习惯是多建几个shp测试文件把不同图层叠起来看叠加关系对不对。叠加的常规做法先画底图再画更小的要素图层用GDI的AlphaBlend把上层做成半透明能同时看到上下两层一旦几何位置错位、坐标范围计算错误视觉上立刻暴露。BLENDFUNCTION bf; bf.BlendOp AC_SRC_OVER; bf.BlendFlags 0; bf.SourceConstantAlpha 180; // 0透明255不透明 bf.AlphaFormat 0; // 整个位图用恒定alpha AlphaBlend(hdcMem, 0, 0, rc.Width(), rc.Height(), hdcLayer, 0, 0, rcLayer.Width(), rcLayer.Height(), bf);逻辑说明先把上层图层画到一个独立的hdcLayer内存画布再通过AlphaBlend叠加到主画布hdcMem上。SourceConstantAlpha180时上层约70%不透明底下那层的边界还能透出来方便观察两个图层是否对齐。AlphaFormat设0表示整张位图颜色一致不按像素alpha混合。参数说明hdcLayer宽高与rcLayer对应如果和rc大小不一致AlphaBlend会做拉伸这种拉伸会影响对齐判断所以写代码时保证两个画布尺寸一致。如果图层本身需要透明再把AlphaFormat设为AC_SOURCE_ALPHA源位图要带alpha通道否则会看到异常的黑边。6.2 用顶点数自检数据视觉检查之外我还会在Debug输出里打一个记录摘要每条记录的part数、顶点数、范围。正常shp数据里一个面的顶点数少则几个多则几千如果某条记录顶点数突然是0或者超过文件大小的一半那这条记录十有八九解析错位了。TRACE(Lrec%d type%d parts%d pts%d bbox(%d,%d)-(%d,%d)\n, nIndex, nShapeType, (int)vRings.size(), nTotalPts, rc.left, rc.top, rc.right, rc.bottom);逻辑说明TRACE只跑Debug BuildRelease下自动消失不影响性能。把bbox和顶点数打出来跟GIS桌面软件里看到的属性对照能快速定位是解析问题还是绘制问题。这是我自己一直在用的自检手段比单步断点效率高得多。参数说明rc是这条记录的像素包围盒四条边都是整型在WorldToScreen之后算。如果rc明显不在客户区范围内多半是坐标映射或记录偏移出了问题。做实操Demo时我一般准备两个极端测试用例一个只有几百字节的小shp含几种几何类型另一个是大面积带洞的多边形。小文件用来走通路径大文件用来压性能。两个都过了基本可以放心把这套解析逻辑放进正式工程。从那以后我每接到一个shp相关需求都会先执行一遍“文件头校验shx对账顶点数摘要”这三步再开始动其他代码省掉不少返工。希望帮到你。本文还有配套的精品资源点击获取