简介:在QGIS插件与工具开发中,地图工具是连接用户输入与画布交互的关键环节。这套基于QGIS 3.28与VS2017的二次开发工程,面向需要实现自定义地图工具的C++与Qt开发者,重点演示如何通过继承QgsMapTool基类、重写虚函数和连接信号槽来扩展画布操作能力。资源覆盖平移、点选与要素识别三类典型工具,分别对应画布浏览、坐标拾取与要素查询等常见场景,读者可直观理解不同工具的触发机制与使用差异。资源共45个文件,压缩包约48.84MB,包含核心源程序、界面定义、资源文件、工程配置文件,以及可执行程序、编译中间文件与调试日志,从源码到构建产物一应俱全;工程结构清晰,既方便在VS2017中直接查看整体框架,也能借助调试日志快速定位编译或环境配置问题,便于对照学习与二次改造。已有1566人学习下载。借助这份工程,可快速掌握QgsMapToolPan、QgsMapToolEmitPoint与QgsMapToolIdentifyFeature的用法,理清地图工具与画布之间的协作流程,为后续开发点选、绘制、标注等复杂工具提供可复用的框架与排错思路,对初学者而言是一条高效的上手路径。 QGIS二次开发这件事,很少有一篇真正说清楚"从零搭一个地图工具"的文章。我第一次拿VS2017去对接QGIS的时候,光环境就折腾了两三天,各种缺失DLL、版本不匹配、路径不对,硬是把一个本应半小时的demo拖成了大型排障现场。所以这篇文章我想以QGIS 3.28 LTR + VS2017为例,把创建地图工具的全过程按可复现的方式写下来,包括CMake工程怎么配、QgsApplication怎么初始化、自定义地图工具怎么写,以及最后发布时要注意什么。适合正在接触QGIS C++二次开发,或者想用Qt + QGIS做桌面地图应用的读者。
1. 二次开发选型与整体思路
1.1 这个组合适合谁
先说结论:QGIS 3.28 + VS2017这个组合,最典型的场景是"公司内部已经有比较老的C++项目,不想换工具链,但又需要一个能显示地图、能交互的桌面工具"。
VS2017在2026年看确实不算新,但很多传统行业的工控、GIS、测绘软件还在用。你不可能为了让一个地图模块跑起来,就把整套老代码全部升级到VS2022,那风险太大了。QGIS 3.28是LTR(Long Term Release),代表长期支持版本,修复周期长、API冻结,正好适合这类偏保守的集成场景。
另外,QGIS二次开发常用的是PyQGIS脚本,但脚本只能做数据分析和插件,做独立桌面工具、定制交互逻辑,还是得走C++这条路。Qt的C++框架配合QGIS的QgsMapCanvas、QgsMapTool体系,能实现非常灵活的地图交互,这也是QGIS作为GIS框架最难替代的部分。
1.2 为什么选QGIS 3.28而不是新版
很多刚接触的人会问我:QGIS都出到3.36、3.40了,为什么不直接上最新版?
主要原因是稳定性。3.28 LTR从2022年开始维护,插件生态、第三方库版本、社区文档都非常成熟。对于二次开发来说,API稳定比版本新重要得多。你去搜QgsMapTool、QgsRubberBand的用法,网上90%的代码在3.28下都能直接用,但换到3.38之后可能就踩到接口变化。
再有就是Qt版本。3.28对应Qt 5.15.2,这是Qt 5的最后一个商业支持版本,兼容性非常好。VS2017的C++编译器对Qt 5项目的适配也很成熟,踩坑资料一搜一大把。所以不管是从技术风险还是学习成本考虑,3.28在VS2017环境下都是最省心的选择。
1.3 整体架构:一个最小可运行的地图程序
做一个QGIS地图工具,本质上就是三件事:
- 初始化QGIS运行环境(QgsApplication)
- 在Qt主窗口里塞一个QgsMapCanvas画布
- 给画布挂上各种QgsMapTool工具(平移、缩放、点选、绘图)
我的建议是先搭一个最小骨架,跑通了再加功能。很多人一上来就想要一个功能完整的GIS软件,结果代码写了一堆,编译错误也堆了一堆,最后连第一步都没走出去。先把一个能显示shp文件、能放大缩小、能点选要素的demo跑起来,剩下的都是在这个框架上做加法。
2. 环境搭建:依赖文件与工程配置
2.1 你需要准备的依赖文件
QGIS的C++开发不是装一个软件就完事的,你需要SDK开发包。我推荐的方案是安装OSGeo4W,一个专门用来管理QGIS依赖的软件包管理器。
安装的时候需要注意:在组件选择页面,除了QGIS主程序(qgis,它会自动选好默认依赖)之外,强烈建议额外勾选这些开发组件:
- qgis-dev:C++头文件和CMake模块
- qt5-dev:Qt 5开发头文件与库
- gdal-dev、geos-dev、proj-dev:底层的空间数据读写、几何计算和坐标转换库
- qgis-rel-dev 或者对应3.28版本的devel包
安装完成后,关键目录结构大概是这样的:
C:\OSGeo4W\ ├── apps\ │ ├── qgis\ │ │ ├── include\ QGIS头文件 │ │ ├── lib\ QGIS导入库和DLL │ │ └── lib\cmake\QGIS\ CMake模块 │ └── Qt5\ │ ├── include\ │ └── bin\ └── bin\ GDAL、GEOS等运行库这个路径非常关键,后面CMake、环境变量都要用到。我一般会安装到C:\OSGeo4W这个默认路径,因为很多文档、脚本、示例代码都默认这个路径,能省掉不少麻烦。
2.2 创建CMake工程
QGIS官方推荐用CMake来构建二次开发项目,不建议直接在VS里手动添加头文件目录和库目录。原因很简单:QGIS依赖的库太多了,手动手工配置必然出错,CMake可以通过find_package自动处理。
一个最小可用的CMakeLists.txt长这样:
cmake_minimum_required(VERSION 3.16) project(QgisMapToolDemo) set(CMAKE_CXX_STANDARD 17) set(CMAKE_INCLUDE_CURRENT_DIR ON) set(CMAKE_AUTOMOC ON) set(CMAKE_PREFIX_PATH "C:/OSGeo4W/apps/qgis" "C:/OSGeo4W/apps/Qt5" ) find_package(QGIS REQUIRED COMPONENTS Core Gui) find_package(Qt5 REQUIRED COMPONENTS Widgets) add_executable(MapToolDemo main.cpp MainWindow.cpp MainWindow.h MeasureTool.cpp MeasureTool.h ) target_link_libraries(MapToolDemo PRIVATE Qt5::Widgets QGIS::Core QGIS::Gui )这里有几个细节值得说明:
CMAKE_AUTOMOC ON是必须的。Qt的信号槽机制需要moc预处理器,如果不开这个,运行时会出现"Unknown slot"或者诡异崩溃。QGIS::Core和QGIS::Gui是QGIS提供的CMake导入目标,分别对应矢量、图层、坐标系管理核心库和画布、地图工具等GUI库。CMAKE_PREFIX_PATH要指向两个地方,apps/qgis是QGIS的CMake模块位置,apps/Qt5是Qt的位置。
2.3 VS2017生成与编译细节
在VS2017里,我没用传统的.sln文件,而是直接用CMake的"打开文件夹"方式打开工程目录。VS2017对CMake的原生支持已经很好了,可以直接生成并调试。
如果你更习惯命令行,也可以这样:
cmake .. -G "Visual Studio 15 2017 Win64" -A x64这里有一个非常关键的坑:QGIS 3.28的官方Windows版本是用MSVC 2019编译的,但VS2015/2017/2019的C++ ABI是二进制兼容的,所以VS2017可以正常链接MSVC 2019编译的库。但前提是你必须勾选x64平台,并且使用Release配置。QGIS官方发布的库没有Debug版,用Debug模式去链接Release库,会遇到一连串莫名其妙的链接错误或者运行时崩溃,这是我踩过最深的一个坑。
所以编译的时候,请直接把配置切到Release x64。不用纠结Debug,GIS业务要调试可以靠日志,靠打印,靠逐步定位,没必要为了Debug模式去重新编译整个QGIS。
3. 写一个能跑起来的地图程序
3.1 初始化QGIS运行环境
QGIS二次开发和普通Qt程序最大的区别,就是首先要初始化QgsApplication。它负责设置QGIS的资源路径、插件路径、坐标参考系统数据库,以及各种Provider(OGR、GDAL、PostGIS等)的加载。
主函数长这样:
#include <QApplication> #include "qgsapplication.h" int main(int argc, char *argv[]) { QgsApplication app(argc, argv, true); QString prefix = "C:/OSGeo4W/apps/qgis"; app.setPrefixPath(prefix, true); app.initQgis(); // 在这里创建主窗口并启动事件循环 int ret = app.exec(); app.exitQgis(); return ret; }第三行构造函数里的true表示启用GUI。如果写false,那就进入无界面模式,很多地图渲染和工具类的方法会直接不可用。
setPrefixPath第二个参数传true,表示让QGIS自动去默认位置寻找插件、图标、投影定义等资源。如果这个路径不对,程序启动时通常会弹一个"Unable to load qgis"提示框,或者干脆在initQgis阶段崩溃。
如果你不想硬编码路径,也可以用环境变量:先设置QGIS_PREFIX_PATH,再在代码里用QgsApplication::prefixPath()读取。对于需要分发给别人的工具,我建议还是用环境变量方式,灵活性更好。
3.2 创建主窗口和画布
地图窗口的核心组件是QgsMapCanvas。它相当于一个"窗户",所有地图图层、渲染结果、交互缩放都发生在这个控件里。
一个简单的MainWindow类:
#include <QMainWindow> #include <QgsMapCanvas.h> class MainWindow : public QMainWindow { Q_OBJECT public: MainWindow(QWidget* parent = nullptr); private: QgsMapCanvas* m_canvas = nullptr; };构造函数里的初始化逻辑:
#include "MainWindow.h" #include <QVBoxLayout> #include <QToolBar> #include <QgsMapCanvas.h> #include <qgsmapcanvas.h> #include <QgsMapToolPan.h> #include <QgsMapToolZoom.h> MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) { QWidget* central = new QWidget(this); QVBoxLayout* layout = new QVBoxLayout(central); layout->setContentsMargins(0, 0, 0, 0); m_canvas = new QgsMapCanvas(this); layout->addWidget(m_canvas); setCentralWidget(central); // 设置背景色 m_canvas->setCanvasColor(QColor(245, 245, 245)); // 配置坐标参考系统为 Web Mercator,方便后续叠加在线底图 m_canvas->setDestinationCrs(QgsCoordinateReferenceSystem::fromEpsgId(3857)); // 设置默认工具:平移 QgsMapToolPan* panTool = new QgsMapToolPan(m_canvas); m_canvas->setMapTool(panTool); m_canvas->setFocus(); // 添加工具栏按钮 QToolBar* toolbar = addToolBar(tr("地图工具")); toolbar->addAction(tr("平移"), this, [=]() { m_canvas->setMapTool(panTool); }); }setDestinationCrs设置的是画布显示时的坐标参考系统。你要显示的数据可能是任意坐标系,但画布会通过投影变换实时转成这个坐标系来显示。我这里设为EPSG:3857(Web Mercator),因为后续无论是加载在线瓦片还是和Web端地图对接,都用这个坐标系最方便。
3.3 加载矢量图层
画布有了,下一步往里面塞数据。以加载一个Shapefile为例:
#include <QgsVectorLayer.h> #include <QgsProject.h> #include <QMessageBox> #include <QFileDialog> void MainWindow::openShapefile() { QString filePath = QFileDialog::getOpenFileName( this, tr("选择Shapefile"), "", tr("Shapefile (*.shp)")); if (filePath.isEmpty()) return; QgsVectorLayer* layer = new QgsVectorLayer( filePath, QFileInfo(filePath).baseName(), QStringLiteral("ogr")); if (!layer->isValid()) { QMessageBox::warning(this, tr("错误"), tr("图层加载失败")); return; } QgsProject::instance()->addMapLayer(layer); // 让图层铺满整个画布 m_canvas->setExtent(layer->extent()); m_canvas->setLayers({layer}); m_canvas->refresh(); }这里有几个点要说明:
new QgsVectorLayer的第三个参数是Provider名称,ogr就是针对文件数据的通用Provider。只要GDAL支持的格式,基本都可以用这个加载,比如GeoJSON、gpkg等,不用改代码,改路径就行。
isValid()这个判断非常关键。加载失败时它返回false,导致失败的原因大多是:文件路径包含中文或空格、文件本身损坏、缺少同行文件。你可以用layer->dataProvider()->error()拿到更具体的错误信息。
4. 实现自定义地图工具
地图工具是QGIS交互的核心。上面用的是内置的QgsMapToolPan、QgsMapToolZoom,但实际项目中你往往需要自己的工具,比如画点、画线、测距、属性识别。这一节就说怎么做。
4.1 继承QgsMapTool
QgsMapTool是一个抽象基类,定义了地图交互的接口。你只需要继承它,重写canvasPressEvent、canvasMoveEvent、canvasReleaseEvent等方法,就能自定义鼠标交互逻辑。
一个最基础的点选工具:
#include <QgsMapTool.h> #include <QgsMapMouseEvent.h> class PickPointTool : public QgsMapTool { Q_OBJECT public: PickPointTool(QgsMapCanvas* canvas) : QgsMapTool(canvas) {} void canvasReleaseEvent(QgsMapMouseEvent* e) override; signals: void mouseClicked(const QgsPointXY& point); }; void PickPointTool::canvasReleaseEvent(QgsMapMouseEvent* e) { if (e->button() == Qt::LeftButton) { QgsPointXY mapPoint = e->mapPoint(); emit mouseClicked(mapPoint); } QgsMapTool::canvasReleaseEvent(e); }e->mapPoint()这个方法非常方便,它已经帮你把屏幕坐标反算成地图坐标了。很多刚接触QGIS开发的人会手动去写toMapCoordinates,其实QgsMapMouseEvent内部已经处理好了。
使用这个工具:
PickPointTool* pickTool = new PickPointTool(m_canvas); m_canvas->setMapTool(pickTool);4.2 点选查询:点击要素并输出属性
光拿到一个坐标没意思,我们要的是"点哪查哪个要素"。可以在上面的点选工具基础上,加入缓冲区查询的逻辑:
void PickPointTool::canvasReleaseEvent(QgsMapMouseEvent* e) { if (e->button() != Qt::LeftButton) return; QgsPointXY mapPoint = e->mapPoint(); // 取出当前画布上的所有矢量图层 QList<QgsVectorLayer*> layers; const auto mapLayers = m_canvas->layers(); for (QgsMapLayer* layer : mapLayers) { if (layer->type() == QgsMapLayerType::VectorLayer) { auto* vectorLayer = qobject_cast<QgsVectorLayer*>(layer); layers.append(vectorLayer); } } // 建立一个半径0.001的小方框做空间过滤 double tolerance = 0.001; QgsRectangle rect(mapPoint.x() - tolerance, mapPoint.y() - tolerance, mapPoint.x() + tolerance, mapPoint.y() + tolerance); for (QgsVectorLayer* layer : layers) { QgsFeatureRequest req; req.setFilterRect(rect); QgsFeatureIterator it = layer->getFeatures(req); QgsFeature feature; if (it.nextFeature(feature)) { QgsAttributes attrs = feature.attributes(); qDebug() << "查到要素,字段数:" << attrs.count(); // 这里可以把属性表格显示到界面上 break; } } }tolerance这里先写死了一个经验值。注意单位是地图坐标单位,如果你的数据是WGS84经纬度,0.001大约等于100米左右,点选命中范围偏大。更好的做法是用屏幕像素换算地图距离,比如取屏幕中心一个4x4像素的矩形,再toMapCoordinates转换。这个方法需要查QgsMapCanvas的API,上面这种先撑住场景的做法足以演示。
4.3 测距工具:橡皮筋绘制和长度计算
测距工具是展示QGIS交互能力很好的案例,它用到了QgsRubberBand(橡皮筋图层)。橡皮筋就是你画图时看到的临时红色/绿色半透明线条,它不属于真实图层数据,只是用来做交互预览。
测距工具的完整实现:
#include <QgsMapTool.h> #include <QgsRubberBand.h> #include <QgsDistanceArea.h> #include <QgsPointXY.h> #include <QgsCoordinateReferenceSystem.h> class MeasureTool : public QgsMapTool { Q_OBJECT public: MeasureTool(QgsMapCanvas* canvas) : QgsMapTool(canvas) , m_rubberBand(new QgsRubberBand(canvas, QgsWkbTypes::LineGeometry)) { m_rubberBand->setColor(QColor(255, 0, 0, 120)); m_rubberBand->setWidth(2); } void canvasReleaseEvent(QgsMapMouseEvent* e) override { if (e->button() == Qt::RightButton) { // 右键结束测距 activate(); return; } QgsPointXY point = e->mapPoint(); m_points.append(point); m_rubberBand->addPoint(point); m_rubberBand->update(); if (m_points.size() >= 2) { QgsDistanceArea da; da.setSourceCrs(canvas()->mapSettings().destinationCrs()); da.setEllipsoid(m_canvas->mapSettings().destinationCrs().isValid() ? QgsProject::instance()->ellipsoid() : QStringLiteral("WGS84")); double total = da.measureLine(m_points); emit distanceChanged(total); } } signals: void distanceChanged(double meters); private: QgsRubberBand* m_rubberBand = nullptr; QVector<QgsPointXY> m_points; };这里重点解释QgsDistanceArea的配置:measureLine默认认为你的坐标是平面直角坐标,算出来的是"图上距离"。如果你输入的是经纬度,需要调用setSourceCrs设置坐标系,同时设置椭球体,它内部的测地线算法才会把经纬度换算成真实的地表距离。这个细节很多教程都不会提,导致很多人算出来的距离大得离谱,就是因为缺了这两步配置。
5. 调试、打包与常见问题
5.1 DLL环境与路径
即使程序编译成功了,运行时也有很大概率在加载依赖DLL时崩溃。QGIS的依赖链很长,光底层库就有Qt5、GDAL、GEOS、PROJ,加上QGIS自己的一堆DLL,少一个都不行。
经验做法是在启动程序的入口处设置环境变量。可以在main.cpp开头加这样一段:
#ifdef Q_OS_WIN #include <windows.h> #endif int main(int argc, char *argv[]) { // 设置QGIS运行库路径 qputenv("PATH", "C:/OSGeo4W/bin;C:/OSGeo4W/apps/qgis/bin;C:/OSGeo4W/apps/Qt5/bin;" + qgetenv("PATH")); // ... 后续初始化 }这样做的好处是,即使机器上没有配置系统环境变量,程序也能自己找到依赖。缺点是如果你发布给别人,路径写死就会出问题。更规范的做法是用一个启动器脚本或者在安装包时设置环境变量。
如果程序启动时还是提示缺少DLL,最快的排查工具是 Dependencies ,把生成的exe拖进去,它会列出所有缺失的DLL,照着一个个补就行。
5.2 常见错误速查表
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 编译报错找不到QgsApplication.h | CMake没找到QGIS路径 | 检查CMAKE_PREFIX_PATH是否指向C:/OSGeo4W/apps/qgis;确认安装时勾选了qgis-dev |
| 链接错误:无法解析的外部符号 | 项目是Debug模式,QGIS库是Release | 切到Release x64重新编译;确认运行库是/MT还是/MD统一 |
| 启动时弹Could not load qgis | qgis_core.dll找不到 | 把C:/OSGeo4W/apps/qgis/bin加入PATH;确认QgsApplication的prefix路径正确 |
| 图层加载返回isValid()==false | shp路径包含中文或缺失投影文件 | 临时路径全部用英文;检查同目录有没有shx、dbf文件 |
| 使用的API在当前版本不存在 | QGIS版本不对应 | 检查你的代码和QGIS版本,3.28的API和4.x差异较大 |
| 中文文字显示为方框 | Qt字体加载问题 | 在QgsApplication初始化后用QFont设置中文字体,如Microsoft YaHei |
5.3 发布工具时的几个建议
程序开发完要发给别人用,还有几件事不能忘。
windeployqt是Qt自带的部署工具,它可以把Qt相关的DLL和插件复制到你的exe目录下。但对QGIS来说,还需要手工复制apps/qgis/bin下的DLL和apps/qgis/plugins下的插件目录。我通常把exe所在的文件夹命名为"Program",和"data"目录平级,再把需要的依赖DLL直接丢进去。
一个最省事的方案是:先拷贝整个OSGeo4W目录到客户机器,然后把你的exe放进C:\OSGeo4W\bin目录,这样所有依赖都在,绝对能跑。缺点是体积大,但如果只是内部工具,这个方案稳定性最高,省去很多无谓的排障时间。
客户机器上如果杀毒软件很激进,第一次启动可能很慢,那是因为它在扫描QGIS目录下几千个小文件,正常现象,不用慌。
最后:这个项目还能往哪扩展
做完一个能显示地图、能点选、能测距的小工具之后,你会发现QGIS的二次开发框架其实已经打开了一扇很大的门。后面可以加属性过滤器(QgsAttributeTableDialog一键调出属性表)、专题图渲染(QgsCategorizedSymbolRenderer按某个字段分类着色)、空间查询(点击一个面要素选出所有相交的线要素),每一步都有官方API可以查。
我还想提一个建议:不要一上来就去研究QGIS源码,那不是大多数人需要的。你需要的是掌握QgsMapCanvas、QgsMapTool、QgsVectorLayer、QgsRubberBand、QgsProject这几个核心类的关系,就像掌握了Qt的QWidget和QEvent,就能搞定绝大部分界面一样。把基础骨架跑熟,再遇到新功能需求,你知道"这是不是QGIS应该提供的能力",然后去官方API文档翻一翻,往往很快就能找到对应的类。
最后再说一个我在实际开发里特别受用的习惯:每次编译成功之后,把配置好的工程整个备份一份。因为QGIS依赖的环境变量、CMake缓存、第三方DLL路径,任何一个变了都可能让工程突然罢工。备份好一个"能跑的版本",你的心情会稳定很多。
本文还有配套的精品资源,点击获取