☰
VS+QGIS+Qt开发指南:从鼠标画点到图层存储
2026/10/8 19:43:03 网站建设 项目流程

简介:面向C++开发者的QGIS与Qt地图开发示例,基于Visual Studio在Windows 10环境下实现地图上绘制标注点。资源围绕GIS应用中的核心流程展开,涵盖网络地图加载、新建图层、经纬度坐标到墨卡托投影的转换,以及利用Qt图形组件完成点位绘制,适合正在学习地理信息系统编程或希望快速搭建地图交互功能的开发者参考。压缩包共3个文件,包含2个C++源文件与1个头文件,源码体量仅3KB,结构精简,便于直接阅读核心逻辑。通过分析源码可掌握QGIS API调用方式、Qt界面集成思路及坐标转换的具体实现,对理解地图应用开发中的数据流与图层管理机制有实际帮助。该资源已有3620人学习下载,代码虽短但完整覆盖地图标注的关键环节,可作为入门QGIS+Qt开发的实用参考。

1. 在 VS 里把 QGIS 和 Qt 拼起来画点:这条链路比你想的顺

做 GIS 桌面工具的人应该都有过这种经历:地图组件选型选到头秃,开源的要自己编译,商业的又要授权费。而 VS + QGIS + Qt 这条组合链,是我这几年用得最顺手的一套——VS 负责编译调试,Qt 管界面和事件循环,QGIS 的 C++ API 提供地图渲染、坐标转换、图层管理这些 GIS 内核能力。画点这个看似基础的操作,其实是把鼠标屏幕坐标变成地理坐标再写进图层的完整闭环,正是理解这套架构最好的切入点。这篇文章面向的是手里已经有一个 VS 工程、想接 QGIS 库做地图交互的 C++ 开发者,照着做,你就能在自己的程序里用鼠标往地图上点出带经纬度的点,并把它们稳定存进图层。

2. 环境搭建:版本匹配是第一个坑,配置步骤一个都不能省

2.1 先定版本:Qt、QGIS SDK、VS 的兼容关系

QGIS 的 C++ API 依赖 Qt 的版本非常死。官方编译好的 QGIS Release 包,通常是基于特定版本的 Qt 和 MSVC 工具集构建的。我这边验证过的组合是:Visual Studio 2019(v142 工具集)+ Qt 5.15.2(msvc2019_64)+ QGIS 3.28(OGC 认证版本对应构建)。如果你手上是 VS2022,那就找 Qt 5.15.2 的 msvc2019_64 或者用 Qt 6 系列配合 QGIS 3.34 以上的版本,原则就是:QGIS 库用什么工具集编译的,你的工程就必须用同一套工具集。混用 v141 和 v142 编译出来的库,链接时大概率给你抛一堆 LNK2038 或者 LNK2005,问就是运行时库不匹配,这个后面避坑章节还会提。

安装顺序也有讲究:先装 VS,再装 Qt,最后装 QGIS。QGIS 安装时记得选上 “Desktop” 组件,里面带着qgis_core.dll、qgis_gui.dll和对应的头文件。要确认 SDK 是否完整,去 QGIS 安装目录下apps\qgis\include看一眼,里面有qgsmapcanvas.h这些头文件,apps\qgis\lib下有qgis_core.lib和qgis_gui.lib,齐了就能往下走。

2.2 VS 工程里接 QGIS 头文件和库的完整配置

建一个空的 C++ 控制台工程或者 Qt Widgets 工程都行。如果是前者,先在 VS 的“扩展”里装好 Qt VS Tools 插件,给工程设置 Qt 版本。之后要做的就是把 QGIS 的包含目录、库目录、附加依赖项全部配好。打开工程属性,在C/C++ → 常规 → 附加包含目录里填入:

D:\QGIS\apps\qgis\include D:\QGIS\apps\qgis\include\qgis

在链接器 → 常规 → 附加库目录里填:

D:\QGIS\apps\qgis\lib

在链接器 → 输入 → 附加依赖项里加:

qgis_core.lib qgis_gui.lib

这还没完。VS 编译时不报错,运行时不代表不报错——QGIS 的 dll 依赖 Qt 的 dll,而 Qt 的 dll 又不在系统 PATH 里。每次运行程序前,把下面这些路径追加到PATH环境变量,或者用脚本设置,否则进去就是qt.qpa.plugin: Could not find the Qt platform plugin "windows",黑匣子一样,光看报错你根本不知道是缺了qwindows.dll。

D:\Qt\5.15.2\msvc2019_64\bin D:\QGIS\apps\qgis\bin D:\QGIS\apps\Qt5\bin D:\QGIS\bin

提示:以上路径按你自己的安装目录替换。建议写一个setenv.bat,每次从命令行启动程序前先跑一遍,比反复改系统环境变量干净。设置完记得重启 VS 再加载工程,不然新增的环境变量不一定生效。

2.3 用 CMake 替代手动配置的另一种玩法

如果你的团队是 CMake 管理工程,那就不用去属性表里一条条填了。用find_package找到 Qt 的组件,再用 QGIS 的头文件目录和库文件手动拼接目标:

set(QGIS_INCLUDE_DIR "D:/QGIS/apps/qgis/include") set(QGIS_LIB_DIR "D:/QGIS/apps/qgis/lib") add_executable(MapMarker main.cpp) target_include_directories(MapMarker PRIVATE ${QGIS_INCLUDE_DIR}) target_link_libraries(MapMarker PRIVATE ${QGIS_LIB_DIR}/qgis_core.lib ${QGIS_LIB_DIR}/qgis_gui.lib Qt5::Widgets Qt5::Gui )

这段 CMake 的逻辑就是说:把头文件目录和两个 QGIS 库目录交给链接器,同时把 Qt 的 Widgets 和 Gui 模块也拉进来。QGIS 的 gui 模块依赖 Qt 的 Widgets,少了它,QgsMapCanvas 那类界面控件类编译不过。参数上唯一要注意的是Qt5::Widgets这种写法要求你先find_package(Qt5 COMPONENTS Widgets Gui REQUIRED),没声明的话构建系统会直接报 target not found。

3. 核心交互:从鼠标坐标到地理坐标的完整链路

3.1 选对基类:QgsMapTool 是你的画点入口

在 QGIS 的 C++ 架构里,所有跟地图画布交互的工具都继承自QgsMapTool。它抽象了鼠标按下、移动、释放、右键取消这些动作,并帮你把画布上的像素坐标转成地图坐标。我们的画点工具要做的就是继承它,重写canvasPressEvent和canvasMoveEvent,前者决定“按下鼠标时干点什么”,后者可以在移动时画个预览线或高亮。有一个很关键的点:QgsMapTool构造时需要传入一个QgsMapCanvas*,这个画布指针在后续坐标转换中要反复用到,必须存成成员变量。

先声明自定义工具类:

class PointMapTool : public QgsMapTool { Q_OBJECT public: explicit PointMapTool(QgsMapCanvas* canvas); void canvasPressEvent(QgsMapMouseEvent* e) override; void canvasMoveEvent(QgsMapMouseEvent* e) override; signals: void pointCaptured(double lon, double lat); private: QgsRubberBand* m_rubberBand; };

逻辑说明:canvasPressEvent是左键按下时触发,在里面取坐标转经纬度然后发信号;canvasMoveEvent用于更新橡皮筋(rubber band)的终点,让用户看到一条从上一个点连到当前鼠标位置的线。这里把QgsMapMouseEvent*作参数,它比普通 QMouseEvent 多给了你地图坐标相关的信息,不需要手算偏移量。

3.2 屏幕坐标 -> 地理坐标:toMapCoordinates 是核心转换

QgsMapMouseEvent里直接拿到的pos()是控件上的像素坐标,单位是“多少像素偏移”。要变成 GIS 世界里的经纬度或投影坐标,得靠QgsMapCanvas::toMapCoordinates转换。

void PointMapTool::canvasPressEvent(QgsMapMouseEvent* e) { if (e->button() == Qt::LeftButton) { // 将画布像素坐标转为地图坐标(和当前图层的 CRS 一致) QgsPointXY mapPoint = m_canvas->toMapCoordinates(e->pos()); // 如果画布 CRS 是 WGS84,直接发经纬度;否则需要反算投影 QgsCoordinateReferenceSystem canvasCrs = m_canvas->mapSettings().destinationCrs(); QgsCoordinateReferenceSystem wgs84Crs = QgsCoordinateReferenceSystem::fromEpsgId(4326); QgsCoordinateTransform transform(canvasCrs, wgs84Crs, QgsProject::instance()); QgsPointXY wgsPoint = transform.transform(mapPoint); emit pointCaptured(wgsPoint.x(), wgsPoint.y()); } else if (e->button() == Qt::RightButton) { // 右键取消当前画的橡皮筋 m_rubberBand->reset( QgsWkbTypes::LineGeometry ); } }

这里每一步都值得说清楚。toMapCoordinates返回的QgsPointXY,坐标系是当前画布的destinationCrs,不是你想当然的 WGS84。如果你直接在槽函数里把 x 当经度、y 当纬度,一旦画布设成了 WebMercator 或者其他投影坐标系,画出来的点能偏到海里去。转换用QgsCoordinateTransform,它需要两个 CRS 和一个QgsProject实例,前两个参数是“从哪里来到哪里去”,第三个是为了读取工程级配置,比如如果工程里自定义了 CRS 转换管道,它就会用上。上面的代码里fromEpsgId(4326)是 WGS84 的标准 EPSG 代码——经度在前,纬度在后,别写成 4269(那是 NAD83)。

3.3 橡皮筋预览:让用户知道现在点到哪了

橡皮筋QgsRubberBand是 QGIS 给地图临时图形用的草稿层,不落库,只做视觉反馈。初始化时传入画布指针,addPoint把地图坐标转成屏幕坐标画上去。但这里有个坑:QgsRubberBand在被清空或reset之前会一直存在,所以每次按下左键时,如果上一次残留了旧点,得先reset掉。

// 在工具的构造函数里初始化橡皮筋 PointMapTool::PointMapTool(QgsMapCanvas* canvas) : QgsMapTool(canvas) { m_rubberBand = new QgsRubberBand(canvas, QgsWkbTypes::LineGeometry); m_rubberBand->setColor(Qt::red); m_rubberBand->setWidth(2); } void PointMapTool::canvasMoveEvent(QgsMapMouseEvent* e) { QgsPointXY mapPoint = m_canvas->toMapCoordinates(e->pos()); // 更新橡皮筋终点,实现“一条线跟着鼠标走”的效果 m_rubberBand->addPoint(mapPoint); }

参数说明里最需要注意的是QgsWkbTypes::LineGeometry——这是告诉橡皮筋“我画的是线”,不是点也不是面。如果你想在画点前让用户看到一个半透明的点标记跟随鼠标移动,那就改成QgsWkbTypes::PointGeometry,并且用setIconType指定QgsRrubberBand::ICON_CIRCLE。还有addPoint是追加模式,移动事件里每次都加一个点会让这条线变成折线,所以合理做法是在移动事件里先m_rubberBand->removeLastPoint(0)再addPoint,或者干脆在按下左键前重置,看你的交互设计。

4. 把点写进图层:从内存要素到持久化存储

4.1 创建或拿到一个矢量图层:内存图层与 Shapefile 的取舍

画出来的点不能只活在信号里,得有地方存。两种路子:一是建一个内存图层(QgsVectorLayer,geometry 类型字段指定为 Point),跑完不保存就丢;二是连一个 Shapefile,保存到磁盘。我一般先建内存图层,调试时方便,逻辑没问题再换成文件。

QgsVectorLayer* layer = nullptr; QgsVectorLayer::LayerOptions options; options.loadDefaultStyle = false; // 创建内存点图层,CRS 设为 WGS84 layer = new QgsVectorLayer( QStringLiteral("Point?crs=EPSG:4326&field=name:string(50)&field=time:date", QStringLiteral("marker_layer"), QStringLiteral("memory"), options );

这段代码里最关键的是第一个参数——一个“URI 风格”的字符串:Point?crs=EPSG:4326声明几何类型是点、坐标系是 WGS84;&field=name:string(50)是自定义属性字段,字段名叫 name、字符型、长度 50;&field=time:date是日期类型的字段。QgsVectorLayer的构造第二参是显示名称,第三参是 provider key,memory表示内存数据源。如果layer->isValid()返回 false,八成是 URI 里字段类型写错了,常见的就是把string写成String,或者漏了crs=前缀。

用 Shapefile 的话,把memory换成ogr,URI 换成文件路径,比如D:/data/mymap.shp。但 ogr 驱动创建新文件有个前置条件:文件路径不能已经存在,否则isValid()为 true 但写入时不断报错,这是个玄学点,后面避坑再说。

4.2 QgsFeature + QgsGeometry + QgsPointXY:要素是怎么拼出来的

拿到图层之后,用QgsFeature承载一条点记录,它的几何体由QgsGeometry::fromPointXY生成,属性通过setAttribute赋值。这个环节最容易翻车的是几何体和图层的 CRS 不一致——你在图层创建时声明了 EPSG:4326,那塞进去的QgsPointXY必须是经纬度,用户鼠标点是 3857 你就得先转成 4326 再入层,不然以后叠加其他图层,点虽然在你屏幕上,叠到影像上就漂了。

void addPointToLayer(QgsVectorLayer* layer, double lon, double lat) { // 构造点几何,注意坐标顺序是 (经度, 纬度) QgsGeometry geom = QgsGeometry::fromPointXY(QgsPointXY(lon, lat)); QgsFeature feat(layer->fields()); feat.setGeometry(geom); feat.setAttribute(QStringLiteral("name"), QStringLiteral("marker_%1").arg(QDateTime::currentMSecsSinceEpoch())); feat.setAttribute(QStringLiteral("time"), QDateTime::currentDateTime().date()); // 进入编辑态并添加要素 layer->startEditing(); layer->addFeature(feat); layer->commitChanges(); }

逐行说明:QgsFeature(layer->fields())这句很重要,它让 feature 的属性结构跟上图层现在的字段定义。如果你直接默认构造一个QgsFeature,它的fields()是空的,后面setAttribute("name", ...)会按索引找字段,索引越界直接抛异常。addFeature只是把要素加到编辑缓存里,真正入库是commitChanges的事——这一步会触发数据提供者的addFeatures操作,内存图层瞬间生效,Shapefile 则这一刻才真正写盘。QDateTime::currentMSecsSinceEpoch()在这里用作时间戳生成唯一名字,保证不重名,实际项目里你可以换成 UUID。

4.3 数据提供者视角:直接调用 OGR 写入与事务的边界

如果不想走startEditing/commitChanges这套编辑会话,也可以直接用layer->dataProvider()->addFeatures()。这种做法跳过了 QGIS 之上的编辑痕迹管理(撤销栈、未提交状态),适合批量导入点数据,几千几万个点快速落盘。但要注意:数据提供者直写不做事务回滚,中途挂了图层就是半成品。QGIS 在底层用的 OGR 驱动,Shapefile 的 dbf 文件写入头信息和字段数量是在文件创建时定的,你后来给 layer 加了新字段,老文件不会自动扩展字段列,硬写会报article count mismatch。

void batchAddPoints(QgsVectorLayer* layer, const QVector<QgsPointXY>& points) { QgsFeatureList features; features.reserve(points.size()); for (const QgsPointXY& p : points) { QgsFeature feat(layer->fields()); feat.setGeometry(QgsGeometry::fromPointXY(p)); features.append(feat); } // 直写数据提供者,不走编辑栈 if (!layer->dataProvider()->addFeatures(features)) { qWarning() << "batch add failed"; } }

QgsFeatureList是一个QVector<QgsFeature>的别名,一次性把要素列表传给addFeatures,比循环调addFeature快一个数量级,因为减少了 QGIS 内部信号发射和缓存同步次数。返回的 bool 只是“提供者接受”的信号,不保证实际文件写成功,所以在后面我一般还要调layer->updateExtents()刷新图层范围并检查要素数layer->featureCount()。批量写入完记得layer->triggerRepaint(),不然画布上看起来什么都没变,给用户的反馈就是按钮没反应。

5. 避坑排查:画点接入 VS + QGIS 最常见的五个翻车点

5.1 启动即崩:qt.qpa.plugin找不到 Windows 平台插件

现象:程序编译链接全部通过,双击运行却弹窗报qt.qpa.plugin: Could not find the Qt platform plugin "windows" in "",然后进程直接结束。

原因:Qt 在运行时需要加载qwindows.dll平台插件,这个 dll 在 Qt 安装目录的plugins\platforms下。QGIS 的 bin 目录带了它,但你的程序启动时搜索路径里没有,Qt 找不到就罢工。

解决:在main函数开头显式指定插件目录:

QApplication app(argc, argv); QCoreApplication::addLibraryPath(QStringLiteral("D:/Qt/5.15.2/msvc2019_64/plugins")); QCoreApplication::addLibraryPath(QStringLiteral("D:/QGIS/apps/qgis/plugins"));

addLibraryPath是往 Qt 的库搜索列表里追加路径,第一行指向 Qt 自己的插件目录,第二行指向 QGIS 的插件目录(里面带平台插件和部分 provider 插件)。注意必须在QApplication构造之后、任何窗口构造之前调用,否则路径追加晚于插件加载,还是白搭。另一个更稳的做法是把D:/Qt/.../plugins手动加进 PATH,但这容易污染别的工程,我一般只用addLibraryPath。

5.2 链接通过但运行到new QgsMapCanvas就崩

现象:代码编译链接都正常,一执行到QgsMapCanvas canvas;或者new QgsMapCanvas(...)就异常中断,断点都打不上。

原因:QGIS 的qgis_gui.dll依赖的 Qt 版本跟你工程链接的 Qt 版本不一致。最常见的是你的 VS 工程用的是 Qt 5.15.2,而 QGIS 是拿 Qt 5.12 编的,或者反过来。两个 Qt 版本混跑,Qt 元对象系统的内存布局对不上,构造时崩得毫无预兆。

解决:用 Dependencies(旧版叫 Dependency Walker)打开qgis_gui.dll,看打印出来的 Qt5Core.dll 路径指向哪。如果指向的是 QGIS 自带apps\Qt5\bin下的 5.12,而你的工程链接的 Qt 5.15 在另一个目录,程序运行时到底加载哪个 Qt 取决于 PATH 排序和程序所在目录。最稳妥的方案:让 QGIS 的 Qt bin 目录靠后,工程自己的 Qt bin 靠前。具体做法是把工程目录下拷贝一份Qt5Core.dll、Qt5Gui.dll、Qt5Widgets.dll覆盖你所依赖的版本,让程序优先加载本地 dll。

5.3 坐标转换丢精度:点在 3857 画布上保存后偏了几百米

现象:在 WebMercator 投影的画布上,用toMapCoordinates取点后直接写入 WGS84 的图层,地图上查看时点偏移数百米到数公里。

原因:toMapCoordinates(e->pos())返回的坐标是destinationCrs下的,你的画布如果设置成 EPSG:3857,那返回的是米制坐标,直接当作经纬度写进 4326 图层,等于把 3857 的数字硬塞进 4326 的解释框架里,偏得离谱。

解决:所有落库坐标统一用QgsCoordinateTransform转成图层 CRS。我之前 3.2 节代码里展示的从画布 CRS 转 4326 就是标准操作。还有另一种做法:把整个画布的destinationCrs直接设为 4326,这样toMapCoordinates返回的天然就是经纬度,后台底图投影交给 QGIS 处理。但大范围高纬度地区性能会差一些,因为 WebMercator 切片在 4326 下要动态重投影,我一般不这么干,宁可每次画点时转换一次。

5.4 点了点画布没反应,图层要素倒是涨了

现象:addFeature和commitChanges都执行了,featureCount()也增加了,但画布上就是看不到刚画的点。

原因:QgsMapCanvas 有自己的缓存,当图层发生数据变化时它不会自动感知。commitChanges之后需要通知画布刷新,否则画的点在渲染线程的缓存里不存在。

解决:在提交完数据后调用canvas->refresh()或layer->triggerRepaint()。updateExtents也得一并在图层范围内刷新,否则某些自动缩放逻辑下新建的点不被纳入视野。代码顺序是dataProvider()->addFeatures或者编辑栈提交之后,先layer->updateExtents()再canvas->refresh()。这两行不加,你的画点工具会给用户“画了个寂寞”的体验。

5.5 Release 能跑 Debug 崩,或者反过来

现象:Debug 配置下程序跑得好好的,切 Release 后画点或加载地图就崩;或者 Release 正常 Debug 一跑就中断。

原因:QGIS 官方预编译包只提供 Release 版本的 dll,它的导入库.lib也对应 Release 运行时(/MD)。你的 Debug 工程默认链接的是 /MDd 的调试运行时,两个运行时不兼容。Debug 下工作往往是因为 VS 自动把它链接到一堆 debug 版本的 VC 运行库,而 QGIS 的 dll 内部用的是 release 的堆管理,两边混用,跨 dll 边界传递对象时堆损坏。

解决:这是一个无解的结构性冲突,除非你有 QGIS 的 Debug 版 SDK,否则画点工具必须配置成 Release 构建。Qt 也要选 release 库,即msvc2019_64而不是msvc2019_64_debug。VS 属性表里“运行库”选多线程 DLL (/MD),然后整个工程只用 Release 输出。Debug 你只在编写逻辑时用断点看,真正跑应用请切 Release。这个坑我踩了一下午才搞明白,后来养成习惯:新建工程第一件事就切 Release + x64。

6. 工程化画点:把工具类封装进 QAction 与坐标回显技巧

画点工具跑通了,下一步就是让它像一个正经功能。我的做法是给工具栏加一个 QAction,勾选状态激活工具,退出时自动释放鼠标捕获。封装层面把PointMapTool的pointCaptured信号接到状态栏的 QLabel 上,实时显示经纬度,这样对使用者来说才是闭环的交互。

工具激活与信号连接:

QAction* actAddPoint = new QAction(QIcon(":/icons/point.png"), tr("画点"), this); actAddPoint->setCheckable(true); mCanvas->setMapTool(mPointTool); connect(mPointTool, &PointMapTool::pointCaptured, this, [this](double lon, double lat) { mStatusLabel->setText(QString::fromUtf8("经纬度: %1, %2") .arg(lon, 0, 'f', 6) .arg(lat, 0, 'f', 6)); addPointToLayer(mLayer, lon, lat); });

注意QAction的checkable属性,这在 GIS 工具条上几乎是标配,其他工具激活时要把这个 action 的 checked 状态置 false,避免两个工具同时监听鼠标。lambda 捕获的mLayer如果是裸指针,需要确认它不会在工具生命周期内被删除,否则悬垂。项目大一点我会把mLayer换成QPointer<QgsVectorLayer>,配合if (mLayer.isNull()) return;防止崩溃。状态栏显示f参数是QString::arg的格式控制,6表示保留 6 位小数,对应经纬度大约 0.1 米的精度,够用了。

验证点是否真正落库,我常写一段小工具:遍历图层所有要素,打印几何坐标和属性。这一步要在commitChanges之后做,别在编辑态里查,编辑态下数据还在缓存里,featureCount会混入未提交的部分。

void dumpLayerPoints(QgsVectorLayer* layer) { QgsFeatureIterator it = layer->getFeatures(); QgsFeature f; while (it.nextFeature(f)) { QgsGeometry geom = f.geometry(); if (geom.isNull() || geom.type() != QgsWkbTypes::PointGeometry) continue; QgsPointXY pt = geom.asPoint(); qInfo() << "feat id:" << f.id() << "lon:" << pt.x() << "lat:" << pt.y(); options << f.attributes().at(0).toString(); } }

getFeatures返回的是只读迭代器,不会触发编辑栈,适合做数据核查。geom.asPoint()只对单点几何有效,多点几何要asMultiPoint()再遍历。打印经纬度我习惯用qInfo()而不是qDebug(),因为 Release 构建下qDebug默认不输出,qInfo还在。从那以后,我每次做地图相关的画点功能,都会强制走一遍“坐标源头确认这一点是画布 CRS 还是 WGS84、落库前有没有转、画布刷新触发没触发”这老三样,没有一次因为坐标偏了或画布不刷新返工。这条流程,也希望对你有用。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询