简介:GMAP.net是一套基于C#的开源地图组件,适合需要在WinForms、WPF、ASP.NET或UWP等平台中集成在线/离线地图功能的开发者。它内置对Google Maps、Bing Maps、OpenStreetMap等多地图服务商的支持,同时提供瓦片缓存与离线浏览机制,能够帮助开发者快速搭建跨平台地图应用。压缩包约273.49MB,为官方最新版本源代码,包含GMapControl核心控件、地图提供程序、缓存管理、地理编码与路线计算等模块。通过学习源码,可掌握地图控件的事件处理、属性定制、瓦片数据调度及API交互实现,适合有C#基础并希望深入地图开发或进行二次定制的开发者。目前已有419人学习/下载,是理解C#地图组件设计思路与实践应用的宝贵资料。
1. GMAP.net 到底是什么:一套能直接拿来用的 C# 开源地图组件
前段时间给一个桌面物流调度模拟器补地图功能,需求很简单:窗口里显示一张可缩放的地图,能在上面标几个点,再画一条轨迹。结果翻了一圈在线地图 SDK,要么申请密钥要实名,要么对 WinForms 的支持总是差点意思。后来把 GMAP.net 这套源码拽下来,才发现它把地图渲染、瓦片缓存、图层交互这些都拆开摆在面前,真正做到了既开源又上手快。
GMAP.net 是一套完整的 C# 开源地图组件源代码,不是给你一个调不通的壳,而是把从地图控件到地图源、从瓦片缓存到鼠标事件处理的完整链路都放进了解决方案里。无论是 WinForms 还是 WPF 项目,拿到源码后可以直接引用对应工程,编译出来就是能用的控件。适合那些不想被商业地图厂商的服务条款和密钥流程绑住的桌面端开发者,也适合需要在局域网或离线环境里展示地图的调度、监控、测绘类项目。
2. 架构解析:GMAP.net 的图层模型与瓦片调度链路
整个组件虽然名字叫地图控件,但实际上是一个分层清晰的框架:对外是控件,对内是地图源、缓存、投影、标记、图层五个独立模块。这五个模块各管各的,互不侵入,这也是它二次开发时特别好改的原因。
2.1 核心命名空间:从控件到地图源的职责划分
源码的解决方案里通常包含几个核心工程,先看它们各自的定位:
GMap.NET.Core ├── GMap.NET │ ├── GMapControl.cs # 控件的抽象基类 │ ├── PointLatLng.cs # 经纬度坐标 │ ├── MapProvider.cs # 地图源抽象基类 │ └── Projection.cs # 投影算法 ├── GMap.NET.MapProviders │ ├── GMapProvider.cs │ └── ... ├── GMap.NET.WindowsForms │ ├── GMapControl.cs # WinForms 控件 │ ├── GMapMarker.cs │ └── GMapOverlay.csGMap.NET.Core 不依赖 UI,里面放的是坐标结构、投影、Provider 和缓存逻辑,独立出来就是为了给不同 UI 框架复用。GMap.NET.WindowsForms 和另一个对应 WPF 的工程,才是真正实现控件渲染和鼠标输入的地方。
WinForms 里最关键的是 GMapControl,它继承自 UserControl,所有地图交互都封装在内部。GMapOverlay 是一种图层容器,你可以在一个地图上挂多个 Overlay,每个 Overlay 里再放 Marker 和 Route。同一图层内的对象互不干扰,图层之间也能单独控制可见性,这就让「路网一层、标记一层、轨迹一层」的结构变得很自然。
2.2 瓦片调度链路:地图窗口显示的画面是怎么来的
地图控件并不是加载一张大图片,而是动态加载很多张 256 像素见方的小瓦片,然后拼接成你眼前一屏。GMAP.net 的调度逻辑大致可以概括成三条路:
第一步,根据当前中心坐标、缩放级别和控件尺寸,算出当前可视范围覆盖了哪些行列号的瓦片。这个计算是完全数学化的,不涉及任何网络请求。第二步,拿着行列号先去内存缓存查,再去磁盘缓存查,都没有,才去请求地图源。第三步,下载完成后把瓦片写入磁盘缓存,同时在 UI 线程异步刷新到画布上。
这一整套操作都发生在后台线程池里,所以拖动和缩放时,界面不会一直转圈。磁盘缓存的目录结构一般是按照地图源前缀、缩放级别、行号、列号组织起来的。正是因为有这层缓存,你只要把某个区域完整浏览过一遍,之后断网再打开同一区域也能看到地图,这对工控和车载现场特别有用。
2.3 坐标转换与交互模型:经纬度到屏幕像素的计算
在线地图都是先把地球投影到平面,再按级别缩放。GMAP.net 内部用 Projection 类保存投影算法,不同地图源可以覆盖 X、Y 轴的计算逻辑。比如一个常见的 Web 墨卡托投影,把经纬度换算成像素坐标的核心代码类似:
public Point LatLngToPixel(PointLatLng point, int zoom) { double scale = 256 * Math.Pow(2, zoom); int x = (int)((point.Lng + 180) / 360 * scale); int y = (int)((1 - Math.Log(Math.Tan(point.Lat * Math.PI / 180) + 1 / Math.Cos(point.Lat * Math.PI / 180)) / Math.PI) / 2 * scale); return new Point(x, y); }这个方法里,zoom 是当前缩放级别,point.Lng 和 point.Lat 是传入的经纬度。算出来的 x、y 是像素坐标,再减去控件中心点的像素坐标,就能得到屏幕上应该绘制的位置。GMAP.net 在处理鼠标交互时,本质上就是把滚轮事件改写 Zoom 值,把拖拽位移反向加到中心坐标上,从而形成“地图跟随鼠标移动”的观感。
需要说明的是,不同地图服务用的投影可能不一样,比如有些用的是本地坐标偏移算法,这也是后面会提到的坐标偏移问题根源。好在 GMAP.net 的 Provider 抽象层留了口子,可以在自定义地图源里换自己的投影实现。
3. 编译运行:从源码到第一个地图窗口
下载源码后别急着双击打开,先确认本机环境是否匹配。GMAP.net 这套东西跨了几个 .NET 版本,解决方案里既有 .NET Framework 项目也有 .NET Core 项目,所以第一次编译时很容易翻车。下面是我实际跑通全过程的操作路线。
3.1 环境准备与源码编译
开发环境推荐 Visual Studio 2019 或 2022,安装时勾选“.NET 桌面开发”工作负荷。打开解决方案后,找到 GMap.NET.WindowsForms 这个工程,把它设置为启动项目。如果只是想拿 dll 引用到自己的工程,右键生成即可,不需要跑示例。
第一次生成经常会报一些程序集引用找不到的问题,原因是源码里引用了部分 NuGet 包,但 Visual Studio 还没有还原。右键解决方案,执行“还原 NuGet 包”,然后再重新生成,基本就能过了。这里最容易踩坑的是目标框架不一致,比如示例工程是 .NET Framework 4.8,而你本机只装了 .NET 6,会把类型引用搞得乱七八糟。我一般会直接把工程的目标框架改成本机现有的 .NET 6 或 .NET 4.8,并统一全解决方案的目标版本。
3.2 最小示例:在 WinForms 中显示地图
编译通过后,新建一个 WinForms 工程,添加对刚才生成的 GMap.NET.Core.dll 和 GMap.NET.WindowsForms.dll 的引用,然后写下这段最小代码:
using System; using System.Windows.Forms; using GMap.NET; using GMap.NET.MapProviders; using GMap.NET.WindowsForms; public class MapForm : Form { private GMapControl _map; public MapForm() { _map = new GMapControl(); _map.Dock = DockStyle.Fill; Controls.Add(_map); // 选择内置的免费地图源 _map.MapProvider = GMapProviders.OSM; _map.MinZoom = 3; _map.MaxZoom = 19; _map.Zoom = 10; // 设置初始中心坐标和缓存目录 _map.Position = new PointLatLng(31.2304, 121.4737); _map.CacheLocation = @".\mapcache"; } }再补一个入口类:
static class Program { [STAThread] static void Main() { Application.Run(new MapForm()); } }这段代码里的 MapProvider 属性必须赋值,否则控件不会拉取任何瓦片。Position 是地图初始中心点,Zoom 是缩放级别。CacheLocation 指向一个本地目录,GMAP.net 会把下载过的瓦片全部落在这里,下次启动直接读盘。
3.3 控件关键配置项说明
GMapControl 上有一堆属性,但真正影响日常使用和二次开发的其实就几个。整理成表格会清晰一些:
| 属性 | 作用 | 参考值 |
|---|---|---|
| MapProvider | 地图源实例 | GMapProviders.OSM 或自定义 Provider |
| Position | 地图中心点经纬度 | new PointLatLng(...) |
| Zoom / MinZoom / MaxZoom | 当前缩放级别与边界 | 3 到 19 |
| CacheLocation | 瓦片缓存目录 | ./mapcache |
| DragButton | 拖拽地图时按下的鼠标键 | MouseButtons.Left 或 Middle |
| MarkersEnabled | 是否显示 Marker 图层 | true |
| Overlays | 图层集合 | 通过 Add 挂载 |
有一个容易忽略的点:GMAP.net 的拖动默认绑的是鼠标左键,但如果你后续要在左键单击时添加标记,就必须把 DragButton 改成 Middle,否则会出现“单击没效果、拖拽又莫名触发标记添加”的诡异情况。这个细节在写工具类时会经常遇到。
4. 核心模块拆解:标记、图层和地图源的完整实现
这一章直接从源码层面看 GMAP.net 是怎么把一个点显示到屏幕上的,以及如何扩展出自己的地图源。
4.1 自定义地图源:接入自己的瓦片服务器
GMAP.net 的地图源抽象层做得很干爽。想接入自己的瓦片服务器,其实就是继承 GMapProvider,然后重写一个方法。一个最小实现长这样:
public class MyTileProvider : GMapProvider { public static readonly MyTileProvider Instance = new MyTileProvider(); public override Guid Id => Guid.NewGuid(); public override string Name => "MyTileSource"; public override PureProjection Projection => new MercatorProjection(); public override PureImage GetTileImage(int x, int y, int zoom) { string url = $"http://my-server/tiles/{zoom}/{x}/{y}.png"; return GetTileImageUsingHttpGet(url); } }这个类里,Id 和 Name 主要用于缓存目录命名和日志识别,Projection 决定坐标换算方式,这里偷懒直接用了内置的 MercatorProjection。真正下载瓦片的核心是 GetTileImage 方法,GMAP.NET 的调度引擎会并发调用它,而下载线程数量、超时时间、缓存写入都已经被基类封装好了。
自定义 Provider 里只需要根据 x、y、zoom 拼出瓦片地址,调 GetTileImageUsingHttpGet 拿到图片流即可。如果瓦片服务器要求带 token,也可以通过传递 header 或直接在 URL 上拼接参数实现。把这个 Provider 挂到控件上:
_map.MapProvider = MyTileProvider.Instance;切换地图源后,同一套 Marker 和 Route 代码完全不用改,这就是 Provider 抽象最大的收益。
4.2 添加标记与路径绘制
在 GMAP.net 里,一切需要显示的图形都放在 Overlay 里。下面的代码演示如何添加一个点标记和一条轨迹:
var overlay = new GMapOverlay("markers"); overlay.Markers.Add(new GMapMarker(new PointLatLng(31.2312, 121.4745))); _map.Overlays.Add(overlay); var route = new GMapRoute(listOfPoints, "route"); overlay.Routes.Add(route);GMapOverlay 构造参数是图层名,后面可以用这个名字控制图层显示或隐藏。GMapMarker 默认画一个红色小圆点,你也可以传入自定义图片。GMapRoute 接收一个 List ,内部会自动连线。
线上地图项目里,我经常会把轨迹打点和 Marker 标记放在两个 Overlay 里,这样在实现“隐藏轨迹只看点位”这类需求时,不需要重建整个集合,直接控制 Overlay 的 Visible 属性就行。
4.3 图层事件:悬停与点击
GMAP.net 还提供了一组事件回调,典型的是鼠标进入或离开 Marker 时触发:
_map.OnMarkerEnter += (marker) => marker.ToolTipText = "设备点"; _map.OnMarkerLeave += (marker) => marker.ToolTipText = "";ToolTipText 弹出来的提示窗口由控件内部管理,不需要额外写悬浮球。这个机制在做一些设备巡检、停车定位之类的应用时很省事。需要注意,如果 Marker 数量多,频繁设置 ToolTipText 会造成一定开销,建议只在需要展示信息的一两个 Marker 上使用。
5. 避坑手册:编译和运行中的四个常见踩坑记录
这部分每一行都是我实际调试时摔过的坑,按“现象、原因、解决”的顺序记录,方便遇到问题时快速定位。
5.1 地图黑屏,什么瓦片都不显示
现象:控件是灰色的,窗口能打开,但地图区域一片空白,滚轮缩放也没反应。
原因:最常见的是 MapProvider 没赋值,或者赋值的地图源在当前网络环境下无法访问。还有一种情况是 Zoom 设成了 0,或者超出 MaxZoom,导致控件算不出合法瓦片行列号。
解决:先确认 MapProvider 非空,然后设置一个可用的内置源,再给定 MinZoom 和 MaxZoom,比如 3 到 19,最后把 Zoom 设成 10。如果缓存目录没权限,也会出现空白,检查 CacheLocation 是不是指向了系统保护目录,改成当前程序目录下的子目录即可。
5.2 拖拽地图变成框选
现象:按住鼠标左键拖动时,地图不动,反而在界面上拉出一个虚线框,或者不停触发其他鼠标事件。
原因:GMAP.net 的 DragButton 默认值是左键,但如果你的控件外层或者父窗口拦截了鼠标事件,左键拖拽就不会生效。反过来,如果你已经注册了 OnMouseDown 去添加标记,拖拽的同时也会触发标记添加,导致行为混乱。
解决:把 DragButton 显式设置成 MouseButtons.Left,同时不要在 OnMouseDown 里做添加标记这类重操作,或者把拖拽键改成 Middle,给左键腾出手势。我一般会在创建控件时统一设定 DragButton = MouseButtons.Middle,避免和业务手势冲突。
5.3 标记的文本乱码或中文不显示
现象:GMapMarker 上设置了 ToolTipText,但弹出的窗口里是方块,或者直接空白。
原因:默认的 ToolTip 字体可能不是中文字体,另外 GMapMarker 默认不使用 ToolTipText 或 Font 属性时,控件不会主动去画文字。
解决:给 Marker 显式设置字体,或者自定义一个继承 GMapMarker 的类,在 OnRender 里面用 System.Drawing.Drawing2D 绘制字符串。最简单的方式是在创建 Marker 后设置 Font:
var marker = new GMapMarker(pos); marker.ToolTipText = "设备A"; marker.Font = new Font("微软雅黑", 9);注意字体名称在 Linux 或 Windows 精简系统上可能不存在,最好先做字体存在性判断。
5.4 切换地图源后坐标偏移
现象:在默认免费地图源下标记的位置准确,换成某个自定义源之后,同样的经纬度在整个地图上偏了几十米到几百米。
原因:不同地图服务对同一经纬度的投影并不完全一致,有些源用的是 GCJ-02 这类加偏坐标,有些则是 WGS-84。GMAP.net 的 Provider 抽象层只是把瓦片拼接和渲染做统一,但瓦片本身所属的坐标系如果不一致,叠加出来的结果自然对不上。
解决:需要明确你的业务坐标系。如果标记数据是 WGS-84,而你用的地图源是加偏坐标系,就先把经纬度做一次转换,或者干脆让自定义 Provider 的 Projection 返回一组修正参数。这个没有通用值,只能根据实际地图源的转换算法来。
6. 二次开发实战:从自定义瓦片源到性能调优
最后聊一下拿到这套源码后,怎么把它改造成适合自己业务形态的组件。
6.1 自定义投影和坐标系
默认的 MercatorProjection 可以满足在线地图显示需求,但如果你要把数据叠加在本地测绘图上,坐标系可能不一样。GMAP.net 提供了一个 PureProjection 基类,你可以重写 LatLngToPixel 和 PixelToLatLng 两个方法。我通常会复制一份内置的 MercatorProjection,然后按当地坐标系修正系数。这样做的好处是,不管 GMAP.net 内部怎么调度,坐标转换逻辑始终集中在一个类里,不会散落到业务代码中。
6.2 批量加载标注与性能优化
一次往 Overlay 里塞几千个 Marker,控件重绘时会非常卡。GMAP.net 给 Markers 提供了 BeginUpdate 和 EndUpdate 方法,批量添加期间暂停刷新,全部加完后再一次性重绘:
var overlay = new GMapOverlay("devices"); overlay.Markers.BeginUpdate(); foreach (var p in pointList) { overlay.Markers.Add(new GMapMarker(p)); } overlay.Markers.EndUpdate();如果点位数量再多,建议做视口裁剪,只添加当前视野范围内的 Marker。拖动结束后再重新加载视野内点位,这也是地图性能优化的常见手段。
6.3 改造后的验证习惯
每次改完地图源或者投影,我都会写一个半小时能跑完的验证脚本:在地图中心放一个已知坐标点,然后缓慢拖动地图到边缘,确认点位没有漂移。这个验证方法看起来很笨,但能把坐标修正类问题暴露得很彻底。
用 GMAP.net 做二次开发,最大的体会是:它把地图控件的边界划得很清楚,你可以改得很深,却不需要把整个框架推倒重来。从那以后,我每次新增自定义地图源都会先做一次缓存目录写权限验证,再继续调样式。希望这些踩坑记录能帮你少走几步弯路。
本文还有配套的精品资源,点击获取