1. 第三篇教程解决什么问题:把业务数据“画”到地图上
GMap.NET入门这个系列写到现在,前两篇已经把最基础的地图加载、地图源切换、缩放级别和坐标定位讲完了。也就是说,地图能正常显示出来,初始化配置也跑通了。但这时候你会明显感觉到一件事:地图空空的,项目里真正要用的业务数据还没地方摆。这篇教程就是来解决这个问题的——把标记点、线、多边形三类最常用的图形数据叠加到地图上,也就是地图应用里常说的“标绘”。
我不止一次在技术群里看到有人卡在这个阶段:地图能出来,但不知道往上面加点。查文档又觉得GMap.NET的资料少,网上东拼西凑的示例多半是照着一个旧版本抄的,运行起来一堆坑。这篇我尽量把标记点、线、多边形从创建、样式、交互到性能层面的操作都讲清楚,代码基于WinForms环境,版本以GMap.NET 2.x系列为准,不同小版本API略有差异,但核心对象名字基本没变过。
这篇更适合谁看?已经完成地图初始化、想进入实际业务开发阶段的开发者。如果你连地图控件都还没拖到界面上,建议先回看这个系列的前两篇,把地图加载和基础属性配置搞明白再继续。标记点、线、多边形这三类图形,对应到真实场景里非常直观:标记点就是门店、摄像头、车辆、设备这些离散点位;线就是轨迹、路径、巡航路线;多边形就是围栏、园区、行政区划、热区范围。几乎所有GMap.NET项目,到最后都离不开这三样。
我再多说一句我在实际项目里的体会:标绘功能看着简单,但真正决定项目体验的,往往不是“能不能把图形画上去”,而是图层怎么规划、数据一多怎么不卡、交互绘制过程中怎么让用户觉得顺手。所以这篇不是只贴三段能用就行的代码,而是把背后的逻辑也一并讲清楚,后面的踩坑记录也值得你花几分钟扫一眼。
2. 开画前必须搞懂的三件事:图层、图形对象与经纬度
2.1 Overlay是统一管理单元:为什么建议每个业务模块建一个图层
在GMap.NET里,不管是标记点、线还是多边形,都不建议直接裸加到地图控件上,而是先放到一个叫GMapOverlay的对象里,再把Overlay挂到地图控件上。Overlay翻译过来就是图层。你可以把它理解成一张透明的玻璃纸,上面可以画各种图形,然后一层一层叠在地图上。
为什么要有这个机制?因为真实项目里图形数据不是只有一类。我做过一个设备巡检项目,界面上同时要显示设备点位、巡检路线、电子围栏,如果这些全混在一个容器里,后面想控制某一个模块的显隐、想单独清空某个模块的数据,就会非常痛苦。有了Overlay,每个模块一个图层,显示、隐藏、销毁都是隔离的,互不干扰。
代码层面也很简单:
GMapOverlay overlay = new GMapOverlay("overlay_device"); gMapControl1.Overlays.Add(overlay);字符串参数是图层名,建议起名规范一些,比如“设备层”“轨迹层”“围栏层”。后续想读取某个图层,直接按名字取:
GMapOverlay deviceOverlay = gMapControl1.Overlays.FirstOrDefault(o => o.Id == "overlay_device");图层叠加的顺序会影响绘制层级。Overlays集合里索引靠前的图层会先绘制,索引靠后的图层绘制在最上面。所以业务上一般把面状数据放在靠下的图层,点和线放在靠上的图层,避免面把线的关键信息挡住。
2.2 三个核心对象的分工:GMapMarker、GMapRoute、GMapPolygon
标记点、线、多边形在GMap.NET里分别对应GMapMarker、GMapRoute、GMapPolygon三个类。名字容易混淆,尤其是GMapRoute和GMapPolygon,两者都有一组坐标点,初次接触很容易搞混。
我先用一张表把它们的核心区别列出来:
| 对象 | 对应的图形 | 类中关键属性 | 放在哪个集合 |
|---|---|---|---|
| GMapMarker | 点 | Position(点位坐标) | overlay.Markers |
| GMapRoute | 线 | Points(坐标点列表)、Stroke | overlay.Routes |
| GMapPolygon | 面 | Points(坐标点列表)、Fill、Stroke | overlay.Polygons |
GMapRoute本质上是一条折线,用来连接多个经纬度坐标点,不要求首尾闭合,也不会有填充色。GMapPolygon则需要一组坐标点围成一个闭合区域,绘制时会自动把首尾连起来,内部可以用半透明颜色填充。
有一个细节值得注意:这两个类虽然都叫“地图图形”,但都继承自GMapMarker,所以它们本身也具备标记点的某些特性,比如可以被鼠标命中、可以绑定ToolTip。逻辑上不用太纠结继承关系,用的时候记住“线放Routes集合、面放Polygons集合”这个约定就够了。
2.3 经纬度坐标与缩放级别的连带影响
GMap.NET里所有位置都用PointLatLng表示,也就是经纬度。添加点、线、面时,最终都要转化成这个结构。一个常见的误区是,有人直接把控件的像素坐标当成地图坐标来用,画出来的图形位置完全不对。
PointLatLng pos = new PointLatLng(31.2304, 121.4737);这个结构里的第一个参数是纬度,第二个是经度,写反了图形会飞到完全不相干的地方。我见过不止一次,排查半天发现只是参数顺序的问题。
缩放级别也会直接影响标绘的观感。线宽、点的大小在低缩放级别下会挤成一团,在高缩放级别下又会显得太细。GMap.NET默认不会像一些Web地图那样自动适配图形尺寸,所以你要么固定线宽和点尺寸,要么在缩放级别变化时重新调整图形样式。后面标记点和线的小节里,我会给一个缩放的响应示例。
3. 添加标记点:从单个点位到几千个批量的实战代码
3.1 一个标记点的完整生命周期
先写最基础的添加标记点。很多初学者以为标记点对象本身自带样式,其实不是这样,GMapMarker只是一个逻辑载体,真正在界面上显示出来的图形,是它的Shape属性。GMap.NET.WindowsForms.Markers命名空间下提供了几种内置标记样式,比如圆点、红色图钉、绿色图钉,用起来最快。
using GMap.NET; using GMap.NET.WindowsForms; using GMap.NET.WindowsForms.Markers; GMapOverlay overlay = new GMapOverlay("overlay_points"); PointLatLng pos = new PointLatLng(31.2304, 121.4737); GMapMarkerGoogle marker = new GMapMarkerGoogle(pos, GMapMarkerGoogleType.red); overlay.Markers.Add(marker); gMapControl1.Overlays.Add(overlay); gMapControl1.Position = pos; gMapControl1.Zoom = 14;这段代码跑起来后,地图上会看到一个红色图钉标记。GMapMarkerGoogleType枚举里还有green、blue等几种颜色可选。内置样式只适合快速验证,真正做项目时,大家更常用自定义样式的Shape,比如用一个Panel或者自定义绘制的Bitmap来做标记图标。GMapMarker的Shape属性可以接受任意Control对象,这意味着你能把图片、按钮、甚至一个带数据的控件直接当成标记图钉挂到地图上,自由度很高。
我实际项目里更推荐第二种方式:用图片做标记。GMap.NET项目里有一个常用的写法,把一张PNG图片转成Bitmap,再构建标记样式:
GMapMarker marker = new GMapMarker(pos); marker.Shape = new GMapToolTip(marker, "自定义内容"); // 或者用如下方式直接给Shape一个Panel Panel panel = new Panel(); panel.BackgroundImage = Properties.Resources.marker_icon; panel.BackgroundImageLayout = ImageLayout.Zoom; panel.Size = new Size(32, 32); panel.Tag = "我是业务数据ID"; marker.Shape = panel; overlay.Markers.Add(marker);用Panel做标记的好处是,你可以在标记上叠加文本、颜色变化、事件响应,它本质就是一个WinForms控件,任何控件能做的事它都能做。代价是图形数量一多,控件模式的标记会比纯图片模式更耗费资源,这一点在第7节性能部分会展开。
3.2 批量加载上万点位:循环添加和线程安全
实际项目里很少只加一个点,最常见的是从数据库或文件里读出几千个设备坐标,一次性标到地图上。批量添加的套路非常简单:循环创建标记,逐个加到同一个overlay里。
GMapOverlay overlay = new GMapOverlay("overlay_batch"); List<DeviceData> devices = LoadFromDatabase(); // 业务数据 foreach (DeviceData device in devices) { PointLatLng pos = new PointLatLng(device.Lat, device.Lng); GMapMarkerGoogle marker = new GMapMarkerGoogle(pos, GMapMarkerGoogleType.red); marker.ToolTipText = device.Name; overlay.Markers.Add(marker); } gMapControl1.Overlays.Add(overlay); gMapControl1.ZoomAndCenterMarkers("overlay_batch");这里我特意提一下ZoomAndCenterMarkers这个函数,它可以根据当前图层里的所有标记点自动计算缩放级别和中心位置,让全部点位都落在可视范围内。批量加载完数据后用这个函数,体验比手动设置Position和Zoom好得多。
线程安全是一个常见的坑。假如加载数据放在后台线程,千万不要在子线程里直接操作overlay.Markers.Add,因为GMapControl是一个UI控件,所有对图层的修改最终都会触发界面刷新,必须回到UI线程执行。用BeginInvoke是最稳妥的:
this.BeginInvoke((MethodInvoker)delegate { gMapControl1.Overlays.Clear(); GMapOverlay overlay = new GMapOverlay("overlay_batch"); // 循环添加标记 gMapControl1.Overlays.Add(overlay); gMapControl1.ZoomAndCenterMarkers("overlay_batch"); });如果你非要在子线程里直接加,大概率会遇到间歇性崩溃,而且这种崩溃还不是每次必现,非常难排查。我早期做数据采集回放功能时在这个坑里耗过不少时间,后来统一走BeginInvoke之后就再没出过问题。
3.3 标记点的ToolTip和点击响应
标记上给用户展示信息有两种常见方式:悬浮提示和点击弹窗。悬浮提示对应ToolTip,GMapMarker有两个关键属性:
marker.ToolTipMode = MarkerTooltipMode.OnMouseOver; marker.ToolTipText = "设备名称:巡检点A\n状态:正常";MarkerTooltipMode枚举有三个取值:OnMouseOver、Always、Never。需要一直显示就用Always,但注意图上标记多了会很乱,一般只对重点标记设置。
点击响应需要用到GMapControl的鼠标事件。这里有一个GMap.NET特有的写法:控件提供了OnMarkerClick等静态事件,也可以在普通的MouseClick事件里判断鼠标位置是否命中了某个标记。我建议在项目中优先使用OnMarkerClick,它内置了命中判断,不用手动去算坐标范围。
GMapControl.OnMarkerClick += (marker, args) => { if (marker.Shape != null && marker.Shape.Tag != null) { MessageBox.Show(marker.Shape.Tag.ToString()); } };需要留意的是,OnMarkerClick是静态事件,处理完业务后如果不用了,要记得取消挂接,否则反复创建窗体时旧窗体的事件处理依然会响应,长期运行会出现内存泄漏或者事件被触发多次的问题。做桌面程序时间久了你会明白,事件管理不规范,是WinForms项目性能下降的一个隐形因素。
4. 添加线:路线绘制与轨迹回放中容易被忽略的细节
4.1 GMapRoute画线:一条线从创建到显示
线和点的差别在于线要表达“多个坐标点之间的连接关系”。GMapRoute接收一个List 作为坐标序列,加上线宽和颜色,就能绘制出一条路线。
GMapOverlay overlay = new GMapOverlay("overlay_route"); List<PointLatLng> points = new List<PointLatLng>(); points.Add(new PointLatLng(31.2200, 121.4600)); points.Add(new PointLatLng(31.2250, 121.4650)); points.Add(new PointLatLng(31.2300, 121.4700)); points.Add(new PointLatLng(31.2350, 121.4750)); GMapRoute route = new GMapRoute(points, "route_001"); route.Stroke = new Pen(Color.FromArgb(255, 0, 120, 255), 3); overlay.Routes.Add(route); gMapControl1.Overlays.Add(overlay);GMapRoute的构造函数第一个参数是坐标点集合,第二个是名称。这里名称不光是标识,后面需要按名查找或批量删除时用得上。
有一个细节容易被忽略:route.Stroke的Pen宽度在缩放级别变化时不会变。地图放大后线看起来变粗,缩小后线看起来变细,这是正常的,因为GMap.NET绘制线和地图瓦片一样是叠加渲染,不重新计算线宽。
如果你的业务要求线宽在不同缩放级别下保持一致观感,可以在控件的OnMapZoomChanged事件或者刷新逻辑里重新设置Pen的宽度。下面是一个最简单的响应示例:
gMapControl1.OnMapZoomChanged += () => { foreach (GMapRoute route in gMapControl1.Overlays .SelectMany(o => o.Routes)) { route.Stroke = new Pen(Color.Red, gMapControl1.Zoom > 12 ? 4 : 2); route.ForceUpdate(); } };ForceUpdate方法用于告诉控件当前图形需要重新绘制,这个调用比重新设置一遍图层更高效。关于缩放和刷新,在第7节性能部分还会再提到。
4.2 给线动起来:轨迹回放的本质就是“不断更新坐标点”
轨迹回放是车辆监控、外卖配送、运动轨迹类项目的高频需求。它的实现逻辑其实不复杂:按照时间顺序不断更新线对象里的坐标点,同时刷新地图。
我早前做轨迹回放时,踩过一个很大的坑:直线思维想着“每来一个坐标点就重建一条GMapRoute”,结果发现地图闪得厉害,而且频繁增删图层还伴随内存抖动。正确做法是复用一条线,只更新它的Points集合。
// 假设route已经添加到overlay中 List<PointLatLng> history = new List<PointLatLng>(); while (history.Count < totalPoints) { history.Add(nextPoint); // 模拟逐帧追加 route.Points.Clear(); route.Points.AddRange(history); route.ForceUpdate(); gMapControl1.Refresh(); Thread.Sleep(100); // 控制播放速度,实际项目用Timer更合适 }这里有几个关键点。第一,每次只更新Points,不重建route对象,这样性能开销小很多。第二,要主动调用ForceUpdate,让GMapControl知道这条线已经变化、需要重新绘制。第三,如果轨迹很长,不要一次性把所有坐标都放进去,而是根据当前视觉范围只保留附近一定数量的点,否则线特别长时拖动地图也很卡。
速度控制建议用WinForms的Timer,因为Thread.Sleep会阻塞UI线程。我在真实项目里使用System.Windows.Forms.Timer,每100毫秒往前推进几个点,用户界面不会假死,还能顺带做播放暂停进度条。
4.3 线的命中判断和视觉分层
线比点更容易出现“看得见摸不着”的问题。用户想点击路线弹出路线详情,却发现鼠标怎么点都触发不了。原因通常是没设置命中检测属性。GMapRoute继承自GMapMarker,所以它也有IsHitTestVisible属性,把线也当成可点击对象对待。
route.IsHitTestVisible = true;除此之外还要保证Pen的宽度别太细。太细的线鼠标很难精准点中,尤其是屏幕缩放比例比较高的环境。我建议线宽至少设置2像素以上,如果业务上没有特殊要求,推荐用3到4像素,既兼顾美观也兼顾点击。
一个项目里如果同时有轨迹线和区域边界线,视觉上最好做区分。我的习惯是:轨迹线用实线带一定透明度,边界线用虚线或者明显的边框色。这里给一个依赖Pen配置的做法:
// 轨迹线:半透明实线 route.Stroke = new Pen(Color.FromArgb(180, 30, 144, 255), 4); // 边界线:虚线 routeStroke = new Pen(Color.FromArgb(255, 220, 20, 60), 2); routeStroke.DashStyle = DashStyle.Dash;透明度这个参数比较主观,但在地图叠加多层图形时确实能明显改善可读性。后面多边形填充也是同样的思路。
5. 添加多边形:区域圈选、热区展示与顶点操作
5.1 GMapPolygon:首尾自动闭合的面数据
多边形和线最大的不同在于,它会自动把首尾坐标点连起来形成闭合区域,并且可以对内部做填充色设置。添加一个多边形区域的代码结构非常清晰:
GMapOverlay overlay = new GMapOverlay("overlay_polygon"); List<PointLatLng> points = new List<PointLatLng>(); points.Add(new PointLatLng(31.2200, 121.4600)); points.Add(new PointLatLng(31.2250, 121.4680)); points.Add(new PointLatLng(31.2320, 121.4660)); points.Add(new PointLatLng(31.2280, 121.4720)); points.Add(new PointLatLng(31.2200, 121.4600)); GMapPolygon polygon = new GMapPolygon(points, "polygon_area_001"); polygon.Fill = new SolidBrush(Color.FromArgb(50, Color.Green)); polygon.Stroke = new Pen(Color.FromArgb(255, 0, 128, 0), 2); overlay.Polygons.Add(polygon); gMapControl1.Overlays.Add(overlay);坐标点列表的最后一个点你可以写成和第一个点相同,也可以不写,GMapPolygon在绘制时会自动闭合。这个和Web地图里的Polygon行为类似,新手不用为此纠结,推荐写成不重复首尾点,数据层更清爽。
Polygon几乎不用单独设置IsHitTestVisible,但它同样支持ToolTip。在实际项目中,我更常用ToolTip来展示区域名称、面积、围栏内设备数量等业务信息。
5.2 填充透明度的视觉层级
多边形最怕的就是填充色太浓,把底下的地图瓦片和标注全遮住了。经验做法是填充色使用低透明度,边框色使用高透明度或完全不透明。
GMap.NET的Color.FromArgb方法前一个参数是透明度,范围0到255,0代表完全透明,255代表完全不透明。填充色我常用35到80之间的值。如果你填充色用的是50透明度,那么地图上的道路、建筑依然隐约可见,又能够一眼看出这是一个独立区域。
这里有个视觉层级原则可以减少很多调试时间:面数据永远放在线数据下边,线数据永远放在标记点下边。同样是一块围栏区域,围栏边界线如果被填充色盖住,会显得边界模糊。如果边界线所在的图层在填充面所在图层之上,就完全不会有这个问题。所以我在项目里的Overlays布局一般是这样:
// 添加顺序:先添加围栏图层,再添加轨迹图层,最后添加点位图层 gMapControl1.Overlays.Add(fenceOverlay); gMapControl1.Overlays.Add(routeOverlay); gMapControl1.Overlays.Add(markerOverlay);Overlays集合的添加顺序最终决定绘制顺序,先添加的先绘制。大家按照“面在上层就看不清线”的思路反向编排即可。
5.3 顶点编辑和删除注意事项
有些项目需要用户手动调整多边形顶点,比如电子围栏画完之后挪个角、改个形状。GMap.NET没有现成的顶点编辑器,需要自己实现。逻辑上就是三个步骤:点击多边形命中它,找到离鼠标最近的顶点,然后更新这个顶点的坐标。
// 在MapClick中判断命中了哪个polygon,以及哪个顶点 GMapControl.OnMapClick += (point, mouseArgs) => { GMapOverlay overlay = gMapControl1.Overlays["overlay_polygon"]; GMapPolygon polygon = overlay.Polygons.FirstOrDefault(); // 遍历顶点,找到距离点击点最近的顶点下标 int index = -1; double minDistance = double.MaxValue; for (int i = 0; i < polygon.Points.Count; i++) { double distance = Math.Abs(polygon.Points[i].Lat - point.Lat) + Math.Abs(polygon.Points[i].Lng - point.Lng); if (distance < minDistance) { minDistance = distance; index = i; } } if (index >= 0) { polygon.Points[index] = point; polygon.ForceUpdate(); } };删除多边形或者线比删除标记点稍微复杂一点,因为一个图层里可能有多个对象。GMap.NET的图层集合支持Remove操作,删除指定对象没问题:
overlay.Polygons.Remove(polygon);要注意的是,删除后要调用地图控件的Refresh方法,不然残留的图形会一直在界面上显示。如果想一次性清空整个图层里的所有多边形,可以直接执行:
overlay.Polygons.Clear();批量Clear常用于“刷新数据前先清空旧数据”,比逐条Remove更高效,因为它不会触发多次重绘。如果在刷新逻辑里发现界面有残影,多数情况就是忘了在Clear之后执行控件Refresh。
6. 交互式手绘:让用户用鼠标直接在地图上画点线面
6.1 鼠标点击加点和双击结束画线
前面几节讲的都是代码里写死坐标,直接把图形添加到地图。但真实产品往往需要用户自己在地图上点选坐标,比如用鼠标画一条巡检路线、圈一个围栏范围。GMap.NET的鼠标交互实现有两条路径:一是使用控件提供的OnMapClick事件,二是自己挂MouseClick事件。
以画线为例,最直观的交互是:单击地图添加一个路线点,双击结束画线,形成一条完整的GMapRoute。
private List<PointLatLng> tempPoints = new List<PointLatLng>(); private GMapRoute tempRoute; public void StartDrawRoute() { tempPoints.Clear(); tempRoute = new GMapRoute("drawing_route"); GMapControl.OnMapClick += OnDrawRouteClick; } private void OnDrawRouteClick(PointLatLng point, MouseEventArgs e) { if (e.Button == MouseButtons.Left) { tempPoints.Add(point); tempRoute.Points.Clear(); tempRoute.Points.AddRange(tempPoints); tempRoute.Stroke = new Pen(Color.Orange, 3); // 先确保route已在overlay中 gMapControl1.Refresh(); } }双击结束判断在MouseClick里不太直观,建议用MouseDoubleClick事件,或者维护一个点击时间间隔状态机。很多项目直接用“右键结束绘制”,实现起来更顺畅。我实际使用中发现,右键结束比双击更不容易误操作,尤其用户画到一半想平移地图时,如果平移和单击冲突,双击判定很容易乱。
我不建议在GMapControl里直接把默认的鼠标拖拽操作禁用掉,因为用户画线过程中经常要拖动地图调整视野。一种可以接受的方案是:画线状态下,鼠标左键在地图上单击时先判断是否发生了超过几像素的拖动,拖动了就认为是地图平移,否则才算是加点。
6.2 绘制过程中的临时预览
画线也好,画多边形也好,用户画的时候最需要一个实时反馈:当前鼠标悬停的位置可以有个半透明预览点或者预览线尾。GMap.NET里实现这个效果通常用临时叠加一个小的GMapMarker即可。
private GMapMarker tempMarker; private void OnDrawRouteClick(PointLatLng point, MouseEventArgs e) { // ... 上文的加点逻辑 if (tempMarker == null) { tempMarker = new GMapMarkerGoogle(point, GMapMarkerGoogleType.green); overlay.Markers.Add(tempMarker); } else { // 注意Marker的位置不能直接改,需要新构造或者重建 tempMarker.Position = point; } }这里有一个GMapMarker类的限制:Position属性在部分版本里只读或者赋值后不触发重绘。更稳妥的做法是不要尝试复用同一个marker去移动,而是每次更新时重新创建marker或者调用ForceUpdate。如果你的需求是“所有已经点击过的点都显示出来”,那就在单击时添加多个小圆点标记,绘制结束后再统一删除,这也是很常见的实现。
6.3 把绘制结果转换成结构化数据导出
手绘最后一步是把图形落到业务数据里。说白了就是把Route或Polygon的Points集合遍历出来,转成可存储的经纬度序列。最常见的是存成JSON数组,或者写入数据库专门的距离字段。
List<PointLatLng> finalPoints = tempRoute.Points.ToList(); StringBuilder sb = new StringBuilder(); for (int i = 0; i < finalPoints.Count; i++) { sb.Append($"{finalPoints[i].Lat.ToString("F6")},{finalPoints[i].Lng.ToString("F6")}"); if (i < finalPoints.Count - 1) sb.Append(";"); } string result = sb.ToString();经纬度保留6位小数,大约是厘米级精度,对业务已经足够。导出的时候还要注意一个细节:GMapRoute的Points顺序就是用户点击顺序,不要自己额外排序,否则轨迹会乱。如果是多边形,建议导出前补一个“首尾闭合”检查,把最后一个点设置为和第一个点一致,方便后端做面积计算和边界判断。
交互式绘制完成后,记得恢复地图默认事件状态,避免进入下一轮操作时旧事件还在触发。把OnMapClick置空,或者用一个独立的绘制状态字段控制,是比较干净的方案。
7. 性能毛刺与踩坑记录:数据量上来之后的事
7.1 标记数量上去之后,性能瓶颈到底在哪
GMap.NET处理几百个点很轻松,但几千甚至上万个标记同时显示时,帧率会明显下降,拖动地图时尤其明显。原因是每个GMapMarker在绘制时都会走一遍WinForms控件绘制流程,尤其是Shape是Panel或自定义Control的标记,开销更大。纯图片类标记比控件类标记要快很多,这是一个重要的选型原则。
如果业务点位特别多,我建议分三个层次优化。第一层是减少同时显示的标记数量,比如根据当前缩放级别和地图可视范围做视野裁剪,只显示可视范围内的点位。第二层是切换标记样式,把控件型Shape换成纯图片型或者重写自绘,减少WinForms控件的内部开销。第三层是考虑用多个Overlay做分级管理,缩放级别大时显示聚合层级,缩小时显示详细层级。
视野裁剪逻辑不复杂,关键是拿到底图当前的可视经纬度范围:
RectLatLng viewArea = gMapControl1.ViewArea; bool isVisible = viewArea.Contains(marker.Position);RectLatLng带有Contains方法,能直接判断一个经纬度点是否在当前视野内。在循环添加标记时把这个判断加进去,几千个点能过滤掉一半以上,渲染压力就会明显下降。
7.2 一些容易反复踩的坑整理
我把自己在GMap.NET项目里遇到过的、问过别人的、帮别人排查过的问题汇总成了一张表,你大概率也会遇到:
| 现象 | 原因 | 解决办法 |
|---|---|---|
| 图形位置偏差,跑到海上去 | PointLatLng的纬度、经度参数写反 | 牢记构造函数第一个是纬度,第二是经度 |
| 标记太多之后拖动卡顿 | Shape使用控件,或者未做可见性裁剪 | 优先用图片Shape,配合ViewArea过滤 |
| 线更新不生效,界面还是旧图形 | 修改Points后没有调用ForceUpdate | 修改完立即调用ForceUpdate并刷新 |
| 跨线程添加标记偶尔闪退 | 在后台线程直接操作图层集合 | 统一用BeginInvoke切回UI线程 |
| OnMarkerClick事件多次触发 | 静态事件重复挂接未取消 | 页面销毁时或不再使用时主动移除事件 |
| 多边形填充盖住了其他图形 | 填充色透明度设置太高 | 使用Color.FromArgb,透明度控制在50左右 |
| 多边形删除后有残影 | Clear或Remove后未刷新控件 | 删除完成后调用gMapControl1.Refresh |
| ZoomAndCenterMarkers定位不准 | 图层里混入了无效空点 | 添加标记时过滤空坐标和无效坐标 |
这里我想重点展开“ForceUpdate和Refresh到底有什么区别”。ForceUpdate是让图形对象重新计算内部缓存,可以理解为“让对象知道自己变了”。Refresh是让地图控件重绘整个界面。只调用ForceUpdate不够,还需要Refresh才能真正呈现变化。但反过来,如果只是整体重绘而没有ForceUpdate,某些图形可能依然沿用旧的缓存数据。每次修改线或面的坐标后,两个方法一起调,是最省心的组合。
7.3 标记和图形数据的清理规范
长时间运行的程序,标记点可能会被反复加载、清空、再加载。很多人图省事,直接每次加载前执行一次:
gMapControl1.Overlays.Clear();这么做虽然简单,但会把所有图层都清掉,如果界面上还有其他模块的图层,就全没了。更规范的做法是只清理目标图层:
GMapOverlay overlay = gMapControl1.Overlays["overlay_points"]; overlay.Markers.Clear();如果你的图层对象不存在,访问Overlays["xxx"]可能会返回null,建议先判空再操作。养成“清空对象集合而不是清空全部Overlays”的习惯后,多个业务模块共存时就不会互相干扰了。
还有一个容易忽略的资源问题:如果标记Shape是动态创建的Bitmap或者Panel,清理图层时不会自动释放它们占用的非托管资源。图形数据频繁加载时,建议在Clear之前遍历集合手动释放资源,比如调用Bitmap的Dispose。这个在短时间运行的验证程序里感觉不出来,但在长时间不关闭的桌面应用里,内存会缓慢增长,时间一长就非常明显。
7.4 最后的几条实操建议
写到这里,这篇教程的核心内容就差不多了。最后我再按自己的实际使用经验,分享几个本可以不用写、但真的很有用的细节。
第一,代码里给地图控件和Overlay命名时,尽量加上“业务前缀”,比如设备图层就叫device_overlay,不要叫overlay1、overlay2。GMap.NET按名称取图层的操作非常频繁,命名规范化能省去后面联调时的大量猜测时间。
第二,版本升级时多留意API变化。GMap.NET不同的发行版本在构造函数上有些差异,比如GMapRoute有的版本要求先传points再传name,有的版本允许先传name。我在项目升级时就被这个坑过。遇到编译报错不要慌,优先查对应版本的源码或者示例工程。
第三,图形样式调整时,Pen和Brush这类对象建议用完就释放。虽然WinForms有GC兜底,但GDI对象是有限的,长时间高频创建Pen会导致程序抛出“内存不足”的异常。这个问题不常见,但一旦出现,极难定位。
如果你把这篇文章里的示例代码跑通,再把性能建议结合到自己的项目里,GMap.NET的标绘功能应该能覆盖到你实际业务中的大部分场景了。点线面三种对象的操作思路是相通的,理解了Overlay的管理方式,后面就算遇到什么GMapMarker的自定义扩展、路线动画、区域聚合之类的高级需求,也能触类旁通。