简介:面向C# Winform开发者的离线地图集成例程,基于GMap.NET开源库实现无网络环境下的地图展示与交互,典型适用于网络不稳定、内网部署或需本地化的业务场景。资源包含完整可运行的Winform项目,覆盖从NuGet安装GMap.NET.Core与WindowsForms开始,到离线瓦片数据导入、GMapControl地图类型与中心点设置、Marker与Polygon等图层对象绘制、鼠标点击与拖拽事件响应、经纬度坐标转换、缓存性能优化等完整开发链条,并附有地图服务商选择、离线瓦片目录结构及命名规则说明。同时支持Google Maps、OpenStreetMap等常见地图服务商,离线时自动切换至本地瓦片加载,提升程序响应速度。压缩包共41个文件,以cs源码、dll依赖库为主,另有resources资源、resx设计器文件、配置文件及可执行程序等,整体约38.7MB,项目结构清晰,便于直接对照学习或二次开发。目前已有2363人学习下载,适合希望提升Winform应用地图功能、需要离线地图方案的中高级C#开发者使用。 做C#上位机或者设备管理系统的朋友,应该都遇到过这种场景:现场机器部署在客户内网,压根不连外网,可老板又要求在界面上放一张地图,把设备位置、巡检轨迹标出来。我以前也头大,尝试过WebBrowser套Leaflet,结果内网加载瓦片卡成PPT;试过百度地图API,又要求必须联网还要申请密钥。后来换了GMap.NET,不到一个下午就把离线地图跑通了,配合SQLite缓存,点、线、面都能画,平时完全不用联网。
这篇文章把整个离线地图例程从头到尾拆开讲:怎么引入GMap.NET、缓存机制是怎么回事、离线瓦片数据怎么准备、在线预缓存和离线切换怎么实现,以及标记点、轨迹怎么画。最后会把我踩过的坑和性能优化经验一并放出来。如果你正在做机房设备可视化、巡检系统、特种车辆监控这类C# Winform项目,这篇值得收藏。
1. 一篇能用的离线地图例程到底包含什么
很多人以为"离线地图例程"就是把一张城市地图截图嵌到Winform里,其实完全不是这么回事。一个能真正跑在客户内网里的离线地图,至少得具备四块能力:
- 地图控件本身能拖动、缩放,并且操作要跟在线地图一样顺滑。
- 离线数据有稳定的来源,不能只靠一张静态图撑场面。
- 业务数据能叠加在地图上,比如设备点标记、巡检路线、围栏区域。
- 在线和离线两种状态要能来回切换,开发时联网灌缓存,交付后断网运行。
这四个能力,GMap.NET基本都覆盖了,而且是开源免费的。它做Winform集成非常自然,工具箱拖一下就出来一个GMapControl控件,不需要套浏览器内核,发布包体积也小得多。
这里我给打算选型的朋友一个对比表,都是我实际对比过的方案:
| 方案 | 离线能力 | Winform集成 | 学习成本 | 主要代价 |
|---|---|---|---|---|
| GMap.NET | 强,SQLite瓦片缓存 | 原生控件,拖拽即用 | 低 | 瓦片数据要自己准备 |
| CefSharp + Leaflet | 中,需自己管理瓦片文件 | 中等,有进程开销 | 高 | CEF运行时几十MB起步 |
| 高德/百度离线SDK | 有商用离线方案 | 中,通常要密钥和授权 | 中 | 授权流程、license限制 |
如果项目只是内部工具、不涉及商用地图数据授权,GMap.NET几乎是最省事的选择。它的底层逻辑并不复杂:地图源在联网时把瓦片下载到本地SQLite数据库,断网后改成CacheOnly模式直接读库。吃透这一条,整个离线方案就通了一半。
2. 动手之前:GMap.NET的缓存机制必须搞懂
我见过不少人,控件拖上去之后直接设置Manager.Mode = AccessMode.CacheOnly,然后发现地图一片白,马上怀疑控件坏了。其实不是控件的问题,是没搞懂缓存机制。
先说瓦片。地图不是一整张大图,而是按缩放级别切割成很多256x256的小图片,这些图片叫"瓦片"。级别越低瓦片越少,级别越高瓦片越多。GMap.NET加载地图时,就是根据当前视口范围计算出需要哪些瓦片,然后一个个加载拼起来。
再来说缓存。GMap.NET在第一次联网加载某块区域时,会把下载过的瓦片写进本地SQLite数据库。默认位置是:
C:\Users\你的用户名\AppData\Local\GMap.NET\TileDBv5\TileDBv5.sqlite这个数据库里的Tiles表,大概存着Type、Zoom、X、Y、CacheTime、Data这些字段,Data字段就是瓦片图片的二进制内容。每个瓦片由"地图源标识 + 缩放级别 + X + Y"唯一确定,任何一个参数不对,缓存都命中不了。
GMap.NET的访问模式就两个:
AccessMode.ServerAndCache:联网下载瓦片,同时写缓存。AccessMode.CacheOnly:只从缓存读,不联网,缓存里没有就是空白。
这里有个非常重要的点:缓存Key跟地图源Provider是绑死的。GMap.NET每个Provider都有一个Id,同样是上海外滩的坐标,OpenStreetMap生成的瓦片Key和高德地图生成的Key完全不同。程序里用GMapProviders.ArcGISMap,但缓存数据库里存的是OpenStreetMap的瓦片,那CacheOnly模式下无论如何都是空白。
所以做离线方案之前,先决定用哪个地图源,然后所有环节都跟着这个地图源走。我自己的项目统一用OpenStreetMap,原因很简单:WGS84坐标系,GPS设备拿到的经纬度画上去就能对齐,不涉及复杂的坐标纠偏。
如果要换个说法理解这套机制,可以想象成浏览器缓存:你在联网时打开过某些网页,断网后还能看到这些页面;没打开过的页面,断网后只能报错。GMap.NET就是地图版本的浏览器缓存,只是它把"网页"换成了"瓦片",并且用SQLite存起来方便按坐标检索。
3. 把地图控件请进Winform界面
缓存机制搞明白后,剩下的就是工程落地。
3.1 创建项目并安装GMap.NET
先新建一个Winform项目。注意目标框架,如果是老项目一般是.NET Framework 4.x,新项目可能用.Net 6/8。在NuGet里搜索GMap.NET,找到与你框架匹配的包安装。老一些的资料会让你安装GMap.NET.WindowsForms,新版本则可能是GMap.NET.WinForms,安装时它一般会把GMap.NET.Core一起带过来,不用额外操心。
装完之后,方案里会出现GMap.NET.Core和GMap.NET.WindowsForms两个引用,命名空间主要是:
using GMap.NET; using GMap.NET.MapProviders; using GMap.NET.WindowsForms; using GMap.NET.WindowsForms.Markers;3.2 添加GMapControl控件
安装完程序集后,工具箱里不一定立刻出现控件,可以通过"选择工具箱项"把GMap.NET.WindowsForms.dll加进去,然后把GMapControl拖到窗体上。如果你懒得折腾工具箱,直接在窗体构造函数里new GMapControl()再加到Controls里也行,效果一样。
3.3 核心初始化代码
在窗体的Load事件里做初始化,这是我用的基础配置:
private void MainForm_Load(object sender, EventArgs e) { // 地图源,确定后不要随便换 gMap1.MapProvider = GMapProviders.OpenStreetMap; // 开发期先联网灌缓存,交付时改成CacheOnly gMap1.Manager.Mode = AccessMode.ServerAndCache; gMap1.MinZoom = 2; gMap1.MaxZoom = 18; gMap1.Zoom = 13; gMap1.ShowCenter = false; gMap1.DragButton = MouseButtons.Left; // 把缓存放到程序目录,方便打包迁移 gMap1.CacheLocation = Application.StartupPath + @"\MapCache"; // 业务图层 GMapOverlay mainOverlay = new GMapOverlay("main"); gMap1.Overlays.Add(mainOverlay); // 初始中心点,比如上海 gMap1.Position = new PointLatLng(31.2304, 121.4737); }几个属性展开解释一下:
MapProvider:地图源,必须和缓存数据的Provider一致。Manager.Mode:这一步特别容易漏。默认为ServerAndCache,如果直接改成CacheOnly,还没缓存过的项目区域会白屏。MinZoom / MaxZoom:缩放级别范围。建议把MaxZoom设置成实际准备缓存的最大级别,不要无脑调到20。级别越高瓦片数量指数增长,缓存体积会失控。CacheLocation:默认缓存路径在系统用户目录下,但那种路径在部署时很难带过去。我习惯设到Application.StartupPath + @"\MapCache",整目录拷贝就能发布。Overlays:所有业务图元(Marker、Route、Polygon)都挂在这个容器里,后面加点和画轨迹都用它。
4. 离线瓦片数据准备:两种常用路径
离线地图的真正工作量其实不在代码,而在瓦片数据准备。数据没准备好,代码写得再漂亮,交付现场也是白屏。我自己用过两条路,一条适合开发环境自己搞定,一条适合批量制作发布数据。
4.1 路径一:在线模式地毯式预缓存
这个方案适合小范围、低缩放级别的需求,比如一个园区、一个厂区。操作非常简单:保持ServerAndCache模式,程序里把地图中心移动到目标区域,再用代码或人工拖动,把需要的缩放级别浏览一遍。浏览过的区域会自动进入SQLite缓存。
人工拖动的好处是简单直观,缺点是容易漏。代码遍历更可靠,思路是按当前视口的经纬度范围,计算出每个zoom级别下覆盖的瓦片坐标,然后依次调用瓦片下载逻辑,让GMap.NET自动写入缓存。伪代码如下:
for (int zoom = minZoom; zoom <= maxZoom; zoom++) { foreach (GPoint tile in GetTileRange(area, zoom)) { using (var image = GMapProviders.OpenStreetMap.GetTileImage(tile, zoom)) { // 这里交给GMap.NET的缓存机制处理 } } }GetTileRange其实就是根据经纬度范围反算瓦片坐标范围,公式网上很多。想要省事的话,不少第三方地图下载器直接支持选定区域、选定zoom批量下载,输出成GMap.NET格式的SQLite缓存库,这条路径对应下一节。
4.2 路径二:用地图下载器生成缓存库再导入
我实际项目里用的是这个方案:用第三方瓦片下载器,选好区域和层级,输出SQLite数据库,然后把这个数据库放到程序的MapCache目录下,改名为GMap.NET预期的文件名即可。如果下载器输出的是z/x/y.png这种文件夹结构,那就得自己把图片导入SQLite缓存库。
导入思路也不复杂,打开GMap.NET生成的缓存数据库,每张瓦片对应一条记录,字段包含Type、Zoom、X、Y、CacheTime、Data。插入完成后用GMap.NET的CacheOnly模式去读,能加载出来就说明成功。要注意的是Type字段必须与程序里的MapProvider一致,OpenStreetMap对应OpenStreetMap,其他地图源同理。很多人在这一步栽跟头——下载器明明下的是OpenStreetMap瓦片,程序里却默认用的别的Provider,结果白屏。
4.3 数据准备阶段的三个经验
第一,缓存量级不要贪大。曾经有个同事把整个城市的第10到18级全部下载,生成了十几个G的缓存文件,程序启动都变慢。实际业务区域就那么几条路,按需裁剪才是正解。
第二,缓存数据库定期VACUUM压缩。SQLite在频繁写入大量瓦片后会产生碎片,文件虚胖,压缩一次体积能小很多。
第三,如果交付环境完全内网,安装包或者部署脚本里最好带一个"缓存数据库是否存在"的检查,不存在时给出明确提示,而不是让客户面对一片空白地图发呆。
5. 例程核心代码:离线切换、标记和轨迹
数据准备好之后,真正写业务功能就轻松了。我在项目里常用的几个功能点,直接给可运行的代码。
5.1 离线/在线切换
切换很简单,改一个枚举值:
public void SetOffline() { gMap1.Manager.Mode = AccessMode.CacheOnly; } public void SetOnline() { gMap1.Manager.Mode = AccessMode.ServerAndCache; }切换是即时生效的,不用重启程序。关键是把切换时机放对:交付给客户前,先确认业务区域的瓦片已经全部缓存,再切到CacheOnly模式打包。
5.2 添加设备标记点
设备点、巡检点用Marker实现:
public void AddMarker(double lat, double lng, string text) { var marker = new GMarkerGoogle( new PointLatLng(lat, lng), GMarkerGoogleType.red_dot); marker.ToolTipMode = MarkerTooltipMode.Always; marker.ToolTipText = text; GMapOverlay overlay = gMap1.Overlays["main"]; overlay.Markers.Add(marker); gMap1.Invalidate(); }MarkerTooltipMode.Always会一直显示文字,如果点位多建议改成OnMouseOver,鼠标移上去再显示,否则满屏文字很挡视线。
5.3 绘制行驶轨迹
轨迹用GMapRoute:
public void DrawTrack(List<PointLatLng> points) { var route = new GMapRoute(points, "track"); route.Stroke = new Pen(Color.Red, 3); GMapOverlay overlay = gMap1.Overlays["main"]; overlay.Routes.Add(route); gMap1.ZoomAndCenterMarkers("main"); gMap1.Invalidate(); }如果点位很多,建议画之前做抽稀处理,比如每隔几十个点取一个,线条效果几乎看不出差别,但绘制和拖动流畅度会好很多。
5.4 完整例程的交互逻辑
把这些组装进窗体后,交互流程一般是:程序启动时按配置读取设备列表,用AddMarker把设备标到地图上;用户选择某台设备时,读取历史轨迹点,用DrawTrack画出路径;部署到客户内网时,把Manager.Mode设为CacheOnly,同时收到TileFailed事件时提示"该区域未缓存"。这样一套下来,一个基础的设备可视化离线地图就成型了。
6. 上线前踩过的坑与性能优化
这部分是我最想写的。很多坑不是看文档能发现的,非得自己跑一遍才明白。
6.1 CacheOnly模式白屏,先别怪控件
白屏排查顺序很重要:
- 确认
CacheLocation指向的路径下存在TileDBv5.sqlite文件。 - 确认当前地图缩放级别是否在已缓存范围内。比如你只缓存到14级,MaxZoom却设成18,用户放大到15级,空白是正常的。
- 确认当前中心点是否在已缓存区域。把Position设置到从未浏览过的地方,当然空白。
- 确认
MapProvider和缓存数据的Provider一致。不一致时,连判断都不用判断,数据库里根本没有对应Key。
我之前在项目里吃过一次亏,就是开发机上缓存的是OpenStreetMap,后来同事为了截图效果好,把Provider换成了Google China,结果整个团队盯着白屏排查了半天。
6.2 大量Markers卡顿的处理
点位超过几百个时,GMapControl会明显掉帧。我的优化顺序是:
- 把
MarkerTooltipMode从Always改成OnMouseOver。 - 批量添加点位时先设置
gMap1.HoldInvalidation = true,加完后再Invalidate()一次性重绘。 - 如果点位上千,建议用自定义GMapMarker画小圆点,而不是每次
GMarkerGoogle加载一张图标图,图片绘制成本比绘制图形高很多。
6.3 跨线程更新地图,必须Invoke
很多上位机项目会在后台线程收到设备坐标,然后尝试直接操作GMapControl,结果发现控件不刷新、时好时坏。标准做法是:
this.Invoke(new Action(() => { GMapOverlay overlay = gMap1.Overlays["main"]; overlay.Markers.Add(marker); gMap1.Invalidate(); }));不Invoke会引发跨线程访问问题,即使不报异常,界面刷新也是迷之行为。
6.4 坐标系的坑:GPS直接画偏几百米
如果你用的是OpenStreetMap底图,GPS设备输出的WGS84坐标可以直接用。但如果你选了高德、Google中国版等国内地图源,这些源用的是GCJ-02坐标系,直接画GPS坐标会偏移几百米,点不在路上。解决方式要么换成WGS84底图,要么引入坐标纠偏算法把WGS84转成GCJ-02。
我以前有个外场项目,设备上报的坐标在Google China底图上整体偏移了四五百米,排查了很久才发现是坐标系不统一。从那以后,团队内部约定:小范围离线项目,统一用OpenStreetMap加WGS84,省去所有转换的麻烦。如果业务必须用国内地图源,那就单独封装一个坐标转换工具类,所有进入地图的坐标都过一遍转换函数,不要东改一处处改一处。
6.5 发布部署时别忘了缓存目录
最后提醒一个特别容易在交付阶段翻车的细节。开发机上CacheLocation如果默认在用户目录,辛苦辛苦灌好的缓存根本不会跟着安装包走。把CacheLocation设置到程序目录后,发布时整体带上MapCache文件夹,或者做一个安装完成后自动解压缓存数据库的步骤,这样客户拿到程序打开就能看到地图,体验完全不一样。
我在实际项目里还喜欢在窗体状态栏放一个当前坐标和缩放级别显示,鼠标移动时实时刷新,这对调试瓦片是否命中缓存非常有帮助。离线地图看起来是个小众需求,但只要吃透GMap.NET的缓存机制、瓦片数据准备和业务图层这套链路,无论换什么地图源、加什么业务功能,思路都是通的。希望这篇例程笔记,能帮你少走几次白屏弯路。
本文还有配套的精品资源,点击获取