资讯详情

Visual C++ GIS系统源码解析:从MFC地图渲染到空间索引改造

📅 2026/10/13 10:29:22 | 华诺云谱 👁 阅读
Visual C++ GIS系统源码解析:从MFC地图渲染到空间索引改造
简介陈建春《用Visual C开发GIS系统》配套代码是一份适合初学者的VC图形编程与GIS入门资料可对照书籍学习MFC界面开发、GDI绘图、空间数据组织与基础地图渲染。压缩包共90个文件约144KB以cpp/h源码为主配合bmp位图资源、rtf帮助文档、mdb数据库、dsp工程文件等目录中包含FilterDlg、SortDlg、DatabaseView、TextWriteDlg等示例模块覆盖对话框交互、数据库连接、文本标注与简单图形编辑等常见功能方便按章节边看边练适合课程设计与毕业设计参考。已有303人浏览学习。借助配套代码能理解点线面空间要素的屏幕映射、文档视图架构及简单空间查询的实现思路就资源定位而言偏基础不涉及大数据量管理、空间索引与投影转换适合作为入门练手的起点书中再版后改名为《矢量图形系统开发与编程》内容可作进阶对照。1. 这套用 Visual C 开发 GIS 系统的配套代码为什么现在还有人翻一套二十多年前用 Visual C 写的 GIS 配套源码按理说早该被 ArcGIS、SuperMap 这些成熟平台淘汰了。但真做过底层 GIS 开发的人回头看会发现它刚好把“图层管理、点线面几何、视图渲染、空间查询”这些 GIS 核心骨架用 MFC 串了一遍不依赖任何商业组件也没有黑盒。你打开工程就能看到文档类里怎么存图层视图类里怎么把经纬度换算成屏幕像素甚至连最简单的点选、框选都写成了消息处理函数。对刚入行、想把 GIS 原理弄明白的开发者来说这套配套代码的价值不在可运行而在可读、可断点、可改。我身边不少做三维引擎和桌面 GIS 的人早期都是从这一类老教材配套代码起步的。它适合两类人一类是想自己动手实现一个迷你 GIS 系统、又不想被商业 SDK 牵着走的在校学生另一类是工作中要维护老系统、需要快速理解 MFC 文档视图架构的工程师。这套代码的毛病也很明显编译环境老、字符集混乱、没有空间索引运行起来还动不动闪屏。不过这些恰恰是练手的好素材接下去我把跑通它、读懂它、再把它改现代的经验一条条拆开讲。2. 跑通配套代码版本选择、工程转换和绕不开的属性设置2.1 先看项目后缀.dsw / .dsp 决定了你该用哪个 IDE拿到的老工程文件通常是 .dsw 和 .dsp这是 Visual C 6.0 时代的工作区和工程文件后缀。看到这两个后缀第一反应最好是“别直接双击 .dsw 期待它自己打开”而是先确认你机器上的 Visual Studio 版本。VC 6.0 在 Windows 10、Windows 11 上装起来很折腾兼容性问题多我一般不建议为了这套代码单独装老环境。更可靠的做法是让新版 Visual Studio 做一次工程升级。新版 VS 打开 .dsw 时会自动提示转换到 .sln .vcxproj 结构。转换完成后源码文件不会被动过但工程配置会被重写比如字符集、运行库、平台工具集。转换过程也可能把一些 MFC 静态库或 DLL 的引用写错后面要逐个排查。我的经验是用 Visual Studio 2019 或 2022 打开低版本工程是可行的前提是你安装了“使用 C 的桌面开发”工作负载并且勾选了“适用于最新 v143 构建工具的 C MFC”组件。这一步不勾后面编译会卡在找不到 afxwin.h。# 常见做法先用命令行看一下老工程里有哪些关键文件 ls -la # 期望输出里能看到类似这样的结构 # GISSystem.dsw # GISSystem.dsp # Src/ # Data/这里 .dsw 描述整个工作区.dsp 描述单个项目。如果 .dsw 里引用了多个 .dsp说明这套代码被拆成了若干个子工程比如一个核心库工程、一个界面工程、一个数据测试工程。转换前先弄清依赖顺序特别是哪个工程是启动项目。老教材配套代码一般结构简单一个工程里既有地图渲染也有属性编辑这种单工程的反而好处理。2.2 把老工程转成新项目两步操作和三处属性我习惯直接双击 .dsw让 Visual Studio 弹出转换向导。出现“Security Warning”提示时选 OK然后等待 IDE 生成 .sln 和 .vcxproj。命令行方式也可以在“开发者 PowerShell”里执行升级操作结果一样但更利于重复执行。# 使用 Visual Studio 开发者命令行工具升级老工程 devenv GISSystem.dsw /upgrade # 升级成功后目录下会出现新工程文件 ls *.sln *.vcxproj参数说明/upgrade 会让 devenv 不打开图形界面直接执行就地升级。如果你机器上同时装了多个 VS 版本建议从“开始菜单 - Visual Studio 文件夹 - Developer Command Prompt for VS”进入这样环境变量指向的 devenv 才是当前版本。转换后打开 .sln如果弹出“requires … toolset”之类的错误原因多半是工程里还写着 v60 或 v70 工具集手动改成当前工具集即可。接着做三处属性调整。第一处项目属性 - 常规 - 平台工具集选当前 VS 自带的那一个比如 v143。第二处项目属性 - 常规 - 字符集老代码大多基于 MBCS 多字节字符集新版 VS 默认 Unicode 会导致很多字符串函数编译报错如果你不想改代码就把字符集设为“使用多字节字符集”。第三处项目属性 - 链接器 - 系统 - 子系统确认是 Windows不是 Console。// 老代码常见的字符串处理方式用的是 TCHAR 宏而不是 wchar_t TCHAR szPath[MAX_PATH]; wsprintf(szPath, _T(%s\\Data\\map.shp), szAppDir);逻辑说明MBCS 下 TCHAR 映射为 charUnicode 下 TCHAR 映射为 wchar_t。老代码用 wsprintf 这类函数时没有加 _tcs 前缀的情况很常见如果不想升级代码直接把字符集固定为 MBCS 最省事。配套代码里的数据文件和界面字符串如果是中文 GBK 编码也需要 MBCS 才能正常显示。2.3 让地图窗口第一次弹出来依赖库和运行时编译通过不代表能运行。老教材配套代码通常用 MFC 动态链接库方式运行机器上要有对应版本的 MFC DLL。新版 VS 编译后默认链接到 mfc140u.dll 这类库。如果只把 exe 复制到别的机器会提示缺少 DLL。我一般会在项目属性里把“MFC 的使用”改成“在静态库中使用 MFC”这样发布时不用带一堆运行时。静态链接后 exe 体积会明显变大但省心。第一次运行如果地图窗口空白先不要怀疑代码检查 Data 目录是否拷贝到了工作目录。老工程一般把数据文件放在工程目录的 Data 文件夹里调试运行时的工作目录默认是 .vcxproj 所在目录如果相对路径找错了图层加载会静默失败窗口自然什么都不画。// 视图初始化流程里会读取图层文件代码通常是这个模式 BOOL CMapView::OnInitialUpdate() { CView::OnInitialUpdate(); // 拼接数据文件路径常见错误是写死相对路径 CString strFile _T(Data\\city.shp); if (m_pLayerManager-LoadLayer(strFile, m_nLayerID)) { // 这里会计算整个图层的空间范围 m_pLayerManager-GetExtent(m_dMapMinX, m_dMapMinY, m_dMapMaxX, m_dMapMaxY); // 范围算出来后OnDraw 才用得到比例尺 } return TRUE; }逻辑说明这段代码演示了最典型的加载流程。LoadLayer 返回 true 才继续取包围盒取到的四至值保存在视图成员变量里成为后续坐标变换的基准。如果 Data 目录不在当前工作目录下LoadLayer 会失败但代码可能没有弹错误框而是让 m_bLayerLoaded 保持 false导致 OnDraw 中直接跳过绘制。排错时先在这里下一个断点确认 m_pLayerManager 是否为空以及 LoadLayer 返回真假能筛掉八成问题。3. 配套代码的地图渲染主线从数据模型到屏幕坐标3.1 拆类几何对象、图层、文档与视图这类配套代码里一定跑不掉四个核心类几何类、图层类、文档类、视图类。几何类负责点、线、面的坐标存储图层类维护一组几何对象和显示属性文档类持有图层列表视图类负责把文档内容画到窗口上。它们之间的关系可以用一张表说清也是后面改造的立足点。类常见命名职责与 GIS 概念的对应CGeoPoint / CGeoLine / CGeoPolygon存坐标集、边界计算矢量要素CLayer持有要素数组、可见性、颜色线型图层CMapDoc继承 CDocument持有图层数组、负责读写数据数据源CMapView继承 CViewOnDraw 渲染、鼠标消息处理地图视图理解这套东西的关键是图层不直接画自己而是由视图遍历图层再把每个图层里的几何对象按坐标变换画到 DC 上。MFC 的 Document/View 架构在这里正好等于 GIS 里的数据与显示分离。老代码的几何类通常只支持简单的矩形选择和索引不支持拓扑空间查询也是全量遍历。想知道一套老代码值不值得读先看它的几何类里有没有 GetExtent 和 Contains 这类的函数有说明设计者认真想过 GIS 建模。// 几何对象基类的最小定义老代码里比较常见的做法 class CGeoObject { public: virtual ~CGeoObject() {} // 每种几何都要能算出自身范围供图层求取包围盒 virtual void GetExtent(double dMinX, double dMinY, double dMaxX, double dMaxY) 0; // 判断一个点是否落在这个几何内部鼠标点选时用 virtual BOOL HitTest(double dX, double dY) 0; int m_nID; // 要素编号 CRect m_rcDisplay; // 上次绘制的屏幕范围做粗过滤用 };逻辑说明GetExtent 是虚函数说明子类必须实现边界计算。边界计算一旦实现视图就能在 OnDraw 开头算出整体比例尺不用每画一个要素都临时算。HitTest 用于鼠标点选伴随它的通常还有一个“容差”参数因为屏幕上的点击精度远低于地图数据的坐标精度。m_rcDisplay 是老代码中一种低效但有效的加速设计只重绘在视口范围内的几何。3.2 世界坐标映射到窗口坐标别忘了翻转 Y 轴地图用的坐标系通常是 X 向东、Y 向北而 Windows 窗口坐标的 Y 轴向下。如果直接拿世界坐标去画图形会上下颠倒这是新手最容易翻车的地方。老配套代码的视图类里一定会有一段映射代码把世界范围换算到客户区像素范围。// 把世界坐标转换为设备坐标 void CMapView::WorldToDevice(double dWX, double dWY, CPoint ptDev) { CRect rcClient; GetClientRect(rcClient); // 比例尺计算客户区宽度/地图宽度 double dScaleX rcClient.Width() / (m_dMapMaxX - m_dMapMinX); // 客户区高度/地图高度 double dScaleY rcClient.Height() / (m_dMapMaxY - m_dMapMinY); // X 方向世界坐标减去左边界再乘像素比例 ptDev.x (long)((dWX - m_dMapMinX) * dScaleX); // Y 方向用地图最大 Y 减去当前 Y翻转之后才得到屏幕 Y ptDev.y (long)((m_dMapMaxY - dWY) * dScaleY); }参数说明m_dMapMinX、m_dMapMinY、m_dMapMaxX、m_dMapMaxY 是整幅图的四至范围也叫包围盒。dScaleY 计算里同样存在一个细节如果窗口客户区是 800 像素高地图范围是 200 个单位高那么比例尺是 4 像素每单位。Y 翻转的核心在于用最大值减当前值这样在窗口顶部y0位置对应地图的北部。这套代码单独看很直白但放到整套代码里经常被写成 inline或者拆成两个函数调试时容易忽略。3.3 图层叠加、可见性与重绘调用链图层叠加顺序决定了哪个图层画在最上面。老代码通常用数组保存图层数组下标小的先画下标大的后画后画的覆盖先画的。配套代码中常见的错误是把比例尺用错导致线和面重叠关系不对其实是数组顺序没排好。我一般把底图层比如区划面放在数组最前面道路线放中间标注点放最后。// OnDraw 的简化实现展示图层遍历和双缓冲雏形 void CMapView::OnDraw(CDC* pDC) { CMapDoc* pDoc GetDocument(); if (pDoc NULL) return; CRect rcClient; GetClientRect(rcClient); CDC memDC; CBitmap bmp; memDC.CreateCompatibleDC(pDC); bmp.CreateCompatibleBitmap(pDC, rcClient.Width(), rcClient.Height()); CBitmap* pOldBmp memDC.SelectObject(bmp); // 先清空背景防止残影 memDC.FillSolidRect(rcClient, RGB(255, 255, 255)); int nCount pDoc-GetLayerCount(); for (int i 0; i nCount; i) { CLayer* pLayer pDoc-GetLayerAt(i); if (pLayer-IsVisible()) { // DrawLayer 内部遍历几何对象并调用 WorldToDevice DrawLayer(memDC, pLayer); } } // 一次 BitBlt 上屏替代逐要素绘制减少闪烁 pDC-BitBlt(0, 0, rcClient.Width(), rcClient.Height(), memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBmp); }逻辑说明先用内存 DC 创建兼容位图把所有图层画到内存里最后一次贴到屏幕。这是很经典的双缓冲写法配合 OnEraseBkgnd 返回 TRUE 可以显著消除闪烁。代码里的 GetLayerCount 和 GetLayerAt 是遍历入口实际老代码里可能写的是 GetDocument()-m_layers.GetSize() 这种直接访问式改造时最好统一封装成文档类的接口方法方便以后做图层排序和分组。图层重绘的调用链还有一个隐蔽点MFC 的 InvalidateRect 只是标记区域无效真正绘制发生在下次 WM_PAINT 消息时。如果代码里在鼠标移动时频繁调用 InvalidateRect而没有限制刷新区域性能会非常差。配套代码里常见做法是只刷新鼠标经过的矩形区域或者用一个 m_bNeedsRedraw 标志延迟绘制。读到这里如果发现代码没有做区域判断建议自己加上。4. 一行行跑这套源码时的避坑与排查记录4.1 现象程序一启动就报缺 mfc42u.dll窗口直接退出原因这套老代码最初按 VC 6.0 编译动态链接到老版本 MFC 运行库。你的机器上如果只有新版本 VC 运行库系统找不到 mfc42u.dll进程就会启动失败。解决思路有两个方向一个是把工程改成静态链接 MFC这样目标机器不依赖老 DLL另一个是安装对应版本的运行库。前者对维护旧系统更推荐因为运行库渠道不一定随手可得。# 检查 exe 依赖的 DLL常见做法是直接用 dumpbin dumpbin /dependents CMapView.exe看到输出里有 mfc42u.dll 或 msvcp60.dll就基本确认是动态链接到老库。处理方式在项目属性里把“MFC 的使用”改为“在静态库中使用 MFC”重新编译后依赖列表里不再出现 mfc42u.dll。注意改完静态链接后 exe 体积会变大但发布到新机器上省心得多。4.2 现象编译阶段报 afxwin.h 不存在或者报 SDKDDKVer.h 版本不匹配原因这两类报错都是因为工程没有加载到 MFC 相关组件。新版 Visual Studio 默认不安装 MFC 库你需要在安装器里勾选“适用于最新 v143 构建工具的 C MFC”。另外老工程的行为 Windows SDK 版本可能设置成 8.1 或 10.0.17763.0而你机器上装的 SDK 版本不同。解决项目属性 - 常规 - Windows SDK 版本改成“10.0最新安装的版本”。遇到 afxwin.h 找不到时先不要怀疑 VS 坏了。检查“视图 - 其他窗口 - 属性管理器”里有没有 Microsoft.Cpp.MFC 的属性表如果有说明 MFC 组件已经装上但指向了错误工具集。把平台工具集切换成与 MFC 组件对应的那一个再重新生成解决方案。这个问题我遇到过至少三次每次都花十分钟在搜索框里找补丁其实只是组件没勾。4.3 现象地图中文地名和属性表里的中文全部变成乱码原因老源码文件保存成 GBK 编码老工程编译时使用 MBCS 字符集数据文件里的中文也是 GBK。如果你用新版 VS 打开源文件IDE 会按 UTF-8 解释中文引号、字符串字面量全变乱码如果再把工程字符集设成 Unicode问题会更严重因为运行时把 GBK 字节流当 UTF-16 解析。解决把工程字符集固定为“使用多字节字符集”同时确认源文件编码不是 UTF-8 with BOM。如果源文件已被 IDE 转存成 UTF-8手动改回 GBK 保存别让 IDE 自动转。// 老代码里常见的中文字符串字面量 CString strName 北京市; // 如果工程是 Unicode这里会编译通过但运行时内容乱码 // 最稳妥的做法是统一改成 _T(北京市)注意_T 宏只在 TCHAR 模式下生效如果字符集设为 Unicode_T 会变成 L北京市 这样的宽字符常量。数据文件里的属性表如果用 GBK 编码读取也要改读写方式。常见的配套代码里有个 CShapeFileReader 之类的类里面用 fopen 和 fread 读 dBase 属性如果读取后没有做编码转换中文属性在界面上就会显示成问号或方块。处理这种历史包袱最省事的方式是保留 MBCS而不是大面积改代码。4.4 现象图层加载失败但程序不报错地图窗口一片白原因数据文件采用相对路径加载而运行时的当前工作目录不是你想象的 exe 所在目录。从 Visual Studio 里按 F5 调试时工作目录默认是工程文件所在目录也就是 .vcxproj 所在目录。如果你把数据放在 Src 或 Data 子目录里而代码写的是 _T(Data\city.shp)那路径拼接出来就是 工程目录\Data\city.shp这个路径存在才会加载成功。如果不存在LoadLayer 多半返回 false代码又没弹错误框于是窗口空白。解决在 OnInitialUpdate 里对 LoadLayer 的返回值断点或者干脆把路径写成从模块所在目录定位这样最稳。// 用模块路径定位数据目录避免工作目录变化导致加载失败 TCHAR szAppDir[MAX_PATH] { 0 }; GetModuleFileName(NULL, szAppDir, MAX_PATH); // 去掉 exe 文件名只留下程序所在目录 (_tcsrchr(szAppDir, _T(\\)))[1] 0; CString strDataPath CString(szAppDir) _T(Data\\city.shp); if (m_pLayerManager-LoadLayer(strDataPath, m_nLayerID)) { // 成功后再取范围 }逻辑说明GetModuleFileName 拿到的是从 C 盘开始的完整路径取代了相对路径依赖。这里我习惯把数据目录固定放在 exe 同级目录的 Data 文件夹里。之后调试和打包按同一套目录结构走就不会出现“我本地能跑别人机器上白屏”的尴尬。配套代码里如果有多个数据文件建议在文档类里维护一个成员变量保存基础路径所有图层加载都走这个拼接函数。4.5 现象地图上下颠倒或者要素偏移到窗口外原因世界坐标到设备坐标转换里少了 Y 轴翻转或者四至范围用错。出现上下颠倒通常是直接用 dWY 乘以比例尺没做 m_dMapMaxY - dWY 这一步出现偏移到窗口外通常是计算包围盒时用了某个图层自己的范围而不是所有图层叠加后的总范围。解决在 WorldToDevice 里加断点检查 dWX、dWY、m_dMapMinX、m_dMapMaxY 这几个值是否与数据范围一致。如果 m_dMapMinY 大于 0而窗口客户区高度是按 0 到 某个正值来算整体 Y 方向就会出现偏移。// 一个简单验证打印四至范围 TRACE(_T(Map Extent: %.6f, %.6f, %.6f, %.6f\n), m_dMapMinX, m_dMapMinY, m_dMapMaxX, m_dMapMaxY);输出结果对应到数据文件里的坐标范围如果数据范围是几千万的量级比如投影坐标系而窗口客户区只有几百像素比例尺会是一个很大的数这时浮点精度要小心。老代码常用 double 存坐标但在计算比例尺时如果先做除法再乘可能因为精度不够出现固定偏移。我一般会把比例尺计算里的除法放在最外层也就是先算 (dWX - m_dMapMinX) 再除以范围而不是先除后乘能减少一部分浮点误差。5. 把这套配线代码改造成现代 GIS 底座的三个做法5.1 用双缓冲全量重写地图绘制解决闪烁和刷新撕裂老代码最影响体验的问题就是闪烁。就算原版已经有内存 DC 的思路也可能只对当前图层做双缓冲而不是整帧处理。标准做法是维护一个“地图位图”当地图范围、图层可见性或数据变化时才重绘位图鼠标移动时只把位图贴到窗口上不再重算所有要素。void CMapView::RenderMapToBitmap() { // 只在图层变化时调用鼠标平移滚动时只贴这张位图 CDC memDC; memDC.CreateCompatibleDC(NULL); m_mapBitmap.CreateCompatibleBitmap(memDC, m_rcClient.Width(), m_rcClient.Height()); // 在这张位图上绘制所有图层 DrawAllLayers(memDC); }逻辑说明把渲染结果缓存到成员变量 m_mapBitmap 里OnDraw 只需要 BitBlt。这样做的收益是鼠标拖动时根本不进入要素遍历性能提升明显。针对大数据量还可以把 DrawAllLayers 改成只绘制视口范围内的要素但这取决于你是否先做了空间索引。5.2 给图层做网格索引避免每一帧都全量遍历配套代码里没有空间索引画一万个点也遍历一万次。加一层最简单的网格索引就能让缩放平移时的绘制性能提升一个量级。做法是把图层包围盒划分成 N x N 个网格每个网格记录落入其中的要素 ID 列表。绘制时先算当前视口覆盖了哪几个网格再只取这些网格集合里的要素去画。class CGridIndex { public: void Build(CLayer* pLayer, int nGridCount) { // 根据图层范围计算网格大小再把每个要素分配到网格 // m_Grid[i][j] 保存 std::vectorint存放要素ID } std::vectorint Query(double dViewMinX, double dViewMinY, double dViewMaxX, double dViewMaxY) { // 先算出视口范围覆盖的网格行列再合并候选要素ID } };参数说明nGridCount 通常取 8 到 32 之间网格太少过滤效果弱网格太多则构建和查询开销反而增大。对于一万个要素的图层16 x 16 网格的效果最好。Query 返回的候选集合再去精确判断边界关系点选时拿 HitTest 做二次过滤这样鼠标点选也能复用同一套索引。5.3 把内存数据导出为 Shapefile验证这套数据结构如果你把点、线、面数据结构改得差不多了建议写一个最小的 Shapefile 导出函数用 100 字节文件头加上逐记录写入的方式验证自己的几何类是否真正保真。Shapefile 的 .shp 文件结构很稳定点要素的记录体只包含整数类型和双精度坐标不需要拼接复杂的压缩协议非常适合当自测工具。// 导出点要素 Shapefile 的最小骨架 void ExportPointsToShp(const CString strShpPath, const CGeoPointArray arrPoints) { FILE* fp fopen(strShpPath, wb); // 写 100 字节文件头 int nFileCode 9994; int nFileLen 0; // 先占位 int nVersion 1000; int nShapeType 1; // 点 double dXMin 0, dYMin 0, dXMax 0, dYMax 0; fwrite(nFileCode, sizeof(int), 1, fp); fwrite(nFileLen, sizeof(int), 1, fp); // 中间跳过 5 个 int 的空闲区 fwrite(nVersion, sizeof(int), 1, fp); fwrite(nShapeType, sizeof(int), 1, fp); // 再写 X/Y 的范围到文件头后半段 // 每个点记录 8 字节记录头 4 字节类型 16 字节坐标 for (int i 0; i arrPoints.GetSize(); i) { long nRecordID i 1; int nContentLen 20; // (2 2) * 4点类型 两 double fwrite(nRecordID, sizeof(long), 1, fp); fwrite(nContentLen, sizeof(int), 1, fp); int nRecType 1; fwrite(nRecType, sizeof(int), 1, fp); fwrite((arrPoints[i].m_dX), sizeof(double), 1, fp); fwrite((arrPoints[i].m_dY), sizeof(double), 1, fp); } fclose(fp); }逻辑说明这个导出函数绕过所有库直接按文件格式写字节用来检验配套代码里的几何坐标和字段数据是否完整。写完后再用 QGIS 或其它 GIS 工具打开导出的文件如果位置正确且字段完整说明你对这套数据结构的理解是对的。对于线和面逻辑相同只是记录体里要加 Ring 和 Part 信息建议先从点开始验证。说到底这套老配套代码能不能带来收益取决于你愿不愿意动手改。我最早跑这类代码时被 mfc42u.dll、乱码、坐标翻转连续折腾了三天但正因为每一步都靠自己查和试反而把 MFC 的消息映射和地图坐标变换练成了肌肉记忆。后来再碰商业 GIS SDK心里清楚每一层背后在干什么不会被黑匣子吓住。如果你正卡在某个报错上先按上面五个方向排查如果已经跑通了就去加网格索引把 Data 下的文件导出来看看。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑