
简介针对C# Winform桌面开发的离线地图集成例程面向需要在业务系统中嵌入离线地图的.NET开发者。资源完整演示了基于GMap.NET 1.7.5的无网络地图方案涵盖NuGet依赖安装、本地瓦片导入与目录组织、GMapControl属性配置、离线瓦片加载、Marker标记、多边形与圆绘制、鼠标事件监听及经纬度坐标转换等完整链路功能覆盖相当系统。工程内包含核心窗体与坐标处理的C#源码及解决方案文件关键操作均有对应实现并涉及瓦片缓存、地图类型切换、位置实时显示等实用功能可直接打开编译调试。压缩包共41个文件主要包含7个C#源文件、6个DLL库、2个EXE程序、2个资源文件及PDB、缓存、配置等辅助文件整体大小38.7MB目录结构规整便于按模块查阅。目前已有2374人学习下载适合具备C#基础、希望快速掌握GMap离线地图集成并优化Winform交互体验的开发者参考。1. 离线地图这需求绕不开GMap.NET做C# Winform开发的人迟早会遇到一个需求给桌面程序塞一张地图而且是在没网的内网机器上。最常见的方案就是GMap.NET这个控件库把离线瓦片缓存做到了极致。这篇文章就沿着“C# Winform开发使用离线地图 GMap 例程”这条主线从瓦片准备、控件集成、参数配置讲到踩坑记录把一条完整可复现的落地路径铺平。先泼一盆冷水离线地图的大部分工作量不在C#代码而在瓦片。GMap.NET已经帮你搞定控件、坐标、图层唯一离不开的原料是那些256×256的小图片。适合谁正在做内网GIS、车载终端、安防平台的Winform桌面程序开发者以及被领导一句话“加个离线地图”砸中的倒霉蛋。2. 离线瓦片从哪来预取、裁剪与内网分发很多人拿到GMap.NET第一件事就是写代码结果控件拖上去一片空白然后开始怀疑自己API用错了。其实离线地图的正确打开顺序是反过来的先造瓦片再写代码。GMap.NET本身没有瓦片生成能力它只负责请求、缓存和显示。离线要用的瓦片必须提前在能联网的开发机上准备好再想办法弄到目标机器上。这一章先把瓦片的来龙去脉讲清楚。2.1 瓦片金字塔与数量估算先算账再动手地图的瓦片组织方式是一个金字塔结构。zoom0时全世界拼成1张图zoom1时切成4张每放大一级瓦片数量翻四倍。全局瓦片数大约是4的zoom次方zoom18的全球瓦片数量已经是个天文数字所以离线地图从来不存在“把地图全下下来”这种操作能做的就是圈一个矩形范围、选几个缩放层级把这一小块切出来。以经纬度跨度大约0.2°×0.2°的区域为例横向所需瓦片数可以用一个简单公式估算经度跨度除以360再乘以当前zoom级的全球横向瓦片数也就是(经度跨度 / 360) * 2^zoom纵向同理。下表是按这个公式估算的结果zoom全球横向瓦片数0.2°跨度约需横向张数1010241212409634141638491016655363637横向张数乘以纵向张数再加上多个zoom层级一次预取几千张瓦片是很正常的事。按每秒下载20到50张的速度算一个市区范围预取10到15级耗时在几分钟到半小时之间。我一般会先按这个公式估算一遍再决定预取范围不然很容易出现“跑了一晚上缓存却只有几MB”的尴尬局面。2.2 用GMap自带的PrefetchTiles批量拉瓦片GMap.NET自带瓦片预取方法不需要额外写下载脚本。只要在地图控件初始化完毕、网络通畅的情况下调用PrefetchTiles就行。这个方法接收一个矩形区域和最小、最大缩放层级然后异步往缓存库里写瓦片。代码非常简单// 目标区域以左上角为锚点给出经度跨度与纬度跨度 RectLatLng region new RectLatLng( 40.05, // 顶边纬度 116.20, // 左边经度 0.30, // 经度跨度 0.20 // 纬度跨度 ); // 预取 10~15 级覆盖从城市概览到街道细节 gmap.PrefetchTiles(region, 10, 15);这里有个特别容易踩的坑RectLatLng构造函数是纬度在前、经度在后而且第一个点代表的是矩形左上角。很多人习惯写成经度在前结果整个区域跑到了海上预取出来的全是蓝色空白图。另外minZoom和maxZoom的跨度不要跨太大10到16已经是很大的跨度了。如果业务只需要“看个大概”和“看某几条街”10到14就够了少下两个层级缓存体积能差几十倍。PrefetchTiles是异步的内部有任务队列不会阻塞UI线程。但要注意两点一是预取过程中不要同时拖动地图否则读缓存和写缓存并发容易出问题二是预取任务被打断后可以直接重新调用已缓存过的瓦片会被跳过算是一种天然的断点续传。跑完之后检查一下缓存文件的大小如果只有几MB多半是层级范围写错或者区域坐标填反了。2.3 内网分发SQLite缓存库与文件模式怎么选瓦片预取完之后需要把成果弄到目标机器上。GMap.NET默认的缓存形式是一个SQLite数据库文件所有瓦片都塞进这个库里按地图源、zoom层级和坐标建立索引。分发时直接拷贝这个库文件就行是个单文件很省心对大量小文件零散分布的机器来说性能也还行。另一种常见做法是改用纯文件模式缓存瓦片按地图源/层级/X/Y的目录结构落盘好处是可以用文件同步工具做增量分发坏处是小文件数量动辄几万拷起来反而慢。我的建议是如果目标机器数量少、离线区域固定直接用默认的SQLite文件分发省事如果多台机器共享一份局域网缓存或者经常要增量更新某个区域的瓦片就改成文件模式。这个选择直接决定后续的更新策略建议在项目一开始就定下来中途切换缓存模式等于重新预取一遍。注意GMap.NET默认把瓦片写进SQLite数据库不同版本数据库文件名可能不一样这不要紧。关键是你的CacheLocation要指向存放这个数据库文件的目录而不是某个子目录否则运行时会找不到缓存。3. 在Winform里跑通GMap离线例程最小可运行代码上一章瓦片准备好了这章开始写代码。目标是给出一套能直接跑起来的Winform离线地图例程不依赖设计器拖拽纯代码创建方便你移植到自己的项目里。我习惯用代码创建GMapControl而不是从工具箱拖因为离线模式下初始化顺序很关键纯代码方式最好控制。3.1 新建项目与NuGet引用项目框架这块.NET Framework 4.6.2以上和.NET 6/8都能用我一般看目标机器装了什么。建好Winform项目后打开NuGet包管理器控制台执行Install-Package GMap.NET.WindowsForms如果你用的是dotnet命令也可以这样dotnet add package GMap.NET.WindowsForms装完之后工具箱里不一定能直接看到GMapControl别浪费时间去找直接在代码里new即可。这个包包含两个核心程序集GMap.NET.Core提供缓存、坐标系、地图源等底层能力GMap.NET.WindowsForms提供GMapControl控件和标覆盖层类。3.2 初始化GMapControl并切到纯缓存模式离线模式的核心代码就集中在构造函数里。下面是完整的初始化过程每一行都有注释public partial class MainForm : Form { private GMapControl gmap; public MainForm() { InitializeComponent(); InitOfflineMap(); } private void InitOfflineMap() { // 1. 先把全局模式切成纯缓存避免任何网络请求 GMaps.Instance.Mode AccessMode.CacheOnly; // 2. 代码创建地图控件铺满窗体 gmap new GMapControl { Dock DockStyle.Fill, // 地图源必须与预取瓦片时一致换源等于换缓存 MapProvider GMapProviders.OpenStreetMapProvider, // 缓存根目录开发机和目标机保持一致 CacheLocation D:\MapCache, MinZoom 3, MaxZoom 18, Zoom 12, CanDragMap true }; Controls.Add(gmap); // 3. 定位到预取区域的中心点 gmap.Position new PointLatLng(40.05, 116.20); } }这段代码里GMaps.Instance.Mode AccessMode.CacheOnly是全局总开关它管住整个程序里所有与地图源相关的网络访问。GMapControl自身的MapProvider和CacheLocation则是定位用的Provider决定去缓存库里查哪一套索引CacheLocation决定去哪个目录找库文件。Position必须设置在预取区域内或者区域边缘如果设到一个完全没有瓦片的位置窗体打开后就是一片干净的灰色看不出任何问题。设置Position这一步很多人会漏掉。GMapControl的行为跟普通地图控件不太一样不显式设置Position的话它不会主动加载任何瓦片界面就是一张空白画布。所以写完初始化代码后第一件事是确认Position落在有缓存的区域再谈其他功能。3.3 添加标记点与覆盖层地图能显示了下一步通常是加几个点位。GMap.NET的图层模型叫Overlay标记点、路线、多边形都挂在Overlay上每个Overlay可以单独控制显隐。添加标记点的代码private void AddMarker(PointLatLng pos, string label) { // 每次创建独立的覆盖层便于统一管理 GMapOverlay overlay new GMapOverlay(markers); // 使用预置的红色圆点样式也可以换成自定义图片 GMapMarker marker new GMarkerGoogle(pos, GMarkerGoogleType.red_dot); marker.ToolTipText label; overlay.Markers.Add(marker); gmap.Overlays.Add(overlay); }GMarkerGoogle是GMap.NET内置的一套标记样式red_dot、red_small这些够用。如果要放自定义图标用GMapMarker的自定义模式或者直接在ToolTipText上下文章。ToolTipText在离线场景下也是本地渲染的不依赖任何网络服务这一点在小项目里非常省心。4. 离线模式参数与在线/离线自动切换GMap.NET的离线能力不仅仅是“把网断了用缓存”它提供了一套缓存策略参数用好了可以让程序在“能联网时自动缓存、断网后自动切离线”两种状态间无缝切换。这一章把最关键的参数和切换逻辑讲透。4.1 三个必调参数Provider、CacheLocation、AccessMode开始一个离线地图项目时我只会先调三个参数其他都保持默认。整理成表格参数推荐值具体作用MapProviderOpenStreetMapProvider决定去缓存库哪套索引查瓦片必须与预取时一致CacheLocation开发机与目标机相同的绝对路径指向SQLite缓存库文件所在目录GMaps.Instance.ModeAccessMode.CacheOnly全局开关禁止所有网络瓦片请求这三个参数是互相咬合的。Provider错了缓存库明明有数据也读不到CacheLocation指向不对等于没带地图Mode忘了设成CacheOnly程序在内网环境会反复尝试联网然后每个瓦片都超时地图加载慢得没法用。我一般在项目初期就把这三个值写进一个配置文件方便部署时按机器调整。说一句地图源的选择离线项目里用开放授权的瓦片源最省事不需要申请Key分发时也没有授权风险。商用地图源通常要带Key而且离线分发涉及授权协议问题我基本不碰。4.2 在线/离线切换逻辑与网络探测实际项目里经常有这样的场景设备平时在办公室有网到了现场变成没网。最省事的做法是每次启动时探测一次网络通了就用CacheFirst模式顺便更新缓存不通就切CacheOnly。核心代码如下private void ToggleOfflineMode(bool offline) { if (offline) { // 完全走本地缓存不产生任何网络请求 GMaps.Instance.Mode AccessMode.CacheOnly; } else { // 先查缓存缓存没有再走网络 GMaps.Instance.Mode AccessMode.ServerAndCache; } // 重载地图使模式生效 gmap.ReloadMap(); }ReloadMap这行不能省。切换模式后已经加载到内存的瓦片不会自动清掉不Reload的话界面表现跟切换前一样容易让人误以为切换失败。实际切换后新区域的瓦片加载行为会立即变化CacheOnly模式下缺失的瓦片不会发请求ServerAndCache模式下缺失的瓦片会先走网络并写入缓存。网络探测我一般用一个简单的Ping或者TCP连接超时判断时间控制在2秒以内。注意不要在UI线程里同步Ping用一个Timer异步做private async void NetCheckTimer_Tick(object sender, EventArgs e) { bool online await Task.Run(() { try { using (var ping new System.Net.NetworkInformation.Ping()) { // 改成你环境里真正可达的地址 return ping.Send(192.168.1.1, 2000).Status System.Net.NetworkInformation.IPStatus.Success; } } catch { return false; } }); if (online ! wasOnline) { wasOnline online; ToggleOfflineMode(!online); } }这个方案有个隐藏收益在办公室有网的机器上CacheFirst模式跑几天去过的区域都自动进了缓存到了没网现场直接就有地图。这等于把整个办公区变成了瓦片采集器。4.3 用OnTileCacheMiss统计缺失瓦片离线地图上线半年后最常被问的问题是“某某区域为什么没有地图”。这时候如果纯粹靠肉眼去看效率极低。我习惯在启动时挂上缓存未命中事件把缺失瓦片记录下来int missCounter 0; gmap.OnTileCacheMiss (zoom, pos) { // 统计缺失量并记录坐标便于规划下一次预取 missCounter; };OnTileCacheMiss这个事件在CacheOnly模式下特别有价值。每次地图滚动到未缓存区域都会触发我可以把missCounter周期性地写进日志同时把pos坐标攒起来导出成文本。下次在开发机上做预取时把这些坐标围成一个矩形只补这一块就行。这个机制比“让用户描述哪里没图”靠谱得多我已经靠它在三个项目里避免了返工。5. GMap离线落地避坑空白、卡顿与错位问题排查离线地图项目翻车翻得最多的不是API不会用而是各种看起来像玄学的问题明明缓存文件在那儿地图就是空白第一次启动卡半分钟拖两下地图就花屏。这一章把我自己踩过的和帮别人排查过的坑整理成五条每条都是“现象→原因→解决”的结构可以直接对照排查。5.1 缓存库有数据但运行时地图一片空白现象开发机上预取完成缓存文件有几十MB把整个目录拷到目标机器程序跑起来却完全没有地图。原因九成是Provider不一致。GMap.NET的缓存库内部按地图源建索引你在开发机用OpenStreetMapProvider预取的瓦片目标机器运行时代码里写的是另一个Provider查索引查不到表现就是空白。剩下一成是CacheLocation指到了缓存库的子目录GMap在子目录里找不到库文件。解决把开发机和目标机上的MapProvider、CacheLocation逐字对比。我吃过这个亏之后把这几个配置统一放进了配置文件并在程序启动画面上打印出当前生效的Provider和缓存路径部署时一眼就能看出问题。5.2 设了CacheOnly启动仍然卡顿现象程序在没网的目标机器上启动窗体要等好几秒甚至十几秒才弹出来期间像死了一样。原因CacheOnly只管瓦片请求但它管不住控件初始化过程中的其他网络探测动作。如果代码里用的是设计器拖拽的GMapControl控件实例在窗体构造函数之前就被创建了那时候Mode还没设成CacheOnly控件可能已经尝试做过一次网络请求这请求在内网环境里要一直等到超时。解决改成纯代码创建控件并且把GMaps.Instance.Mode AccessMode.CacheOnly放在构造GMapControl之前。还有一个习惯要养成把地图初始化这块从窗体构造函数里挪出去放在Shown事件里异步执行先让窗口完成显示不要让用户感觉程序卡死。5.3 拖动到未预取区域地图缺一块且补不回来现象用户把地图拖到预取范围的边缘那里没有瓦片显示一片灰。再拖回来刚才有图的地方也变灰了怎么缩放都不恢复。原因CacheOnly模式下边缘区域的瓦片请求全部落空GMap不会自动重试。而用户来回拖动的过程中边缘空瓦片被读进了当时的缓存状态UI上就一直显示空块。解决预取时把矩形范围往外扩一圈比如每边扩0.05度。边缘缺少的瓦片往往只有几张扩一圈的成本极低但能避免绝大多数“拖出界”的显眼灰块。如果业务上不允许用户看到界外空白就在逻辑上限制Position的移动范围拖到边界就停。5.4 地图花屏、道路错位、出现斜向撕裂缝现象地图能显示但某些区域的道路和标注位置偏移缩放到特定层级会出现像撕纸一样的裂缝。原因混用了不同地图源的瓦片。同一个zoom层级不同地图源的瓦片编码规则不一样某几张来自A源、某几张来自B源拼在一起必然错位。另一种可能是手工拷贝缓存文件时把某个层级目录里的文件放错了位置。解决删掉整个缓存库重新预取这不是能靠代码修补的问题。分发时尽量用脚本整目录复制不要手动拖拽更不能“只拷更新的瓦片”然后覆盖很容易把不同源的瓦片混在一起。5.5 预取过程中程序崩溃缓存库损坏现象预取跑了一半用户操作了几下地图程序异常退出。之后再启动地图加载特别慢甚至直接报数据库错误。原因GMap的预取线程和UI线程同时访问SQLite缓存库写写冲突导致数据库锁死严重的会让整个库文件损坏。这个在官方库的默认实现里是真实存在的并发边界。解决预取时不要操作地图最好做一个专门的预取窗口只显示进度条不加载地图控件。或者更极端一点写一个独立的控制台程序负责预取跑完把缓存文件拷贝给主程序用。这个“预取归预取、展示归展示”的隔离策略我后来在几个数据量大的项目里一直沿用。6. 离线地图的进阶玩法多源隔离与轨迹回放地图能显示、能离线项目基本就交付了一大半。但真正用起来之后还会有两个高频需求一套程序里切换多套底图以及把历史轨迹画在地图上。这两个功能都不复杂但都有值得注意的细节。6.1 多地图源缓存隔离有些项目需要同时支持默认街道图和另一套业务底图切换时缓存不能混。在SQLite缓存模式下缓存库内部本身就按源隔离切源后查不到对方的瓦片问题不大。但在文件模式缓存下两个源的图片会落在同一套目录结构里如果不分目录就会互相覆盖、花屏。代码层面要做的是切换源时同步切换缓存目录private void SwitchMapProvider(BaseMapProvider newProvider) { gmap.MapProvider newProvider; // 每个源独立目录互不干扰 gmap.CacheLocation D:\MapCache\ newProvider.DbName; gmap.ReloadMap(); }这里用Provider自带的DbName作为子目录名是文件模式下最常见的隔离方案。切换后原来的缓存还在下次切回来不需要重新预取体验上更接近在线地图的多源切换。6.2 离线轨迹回放与自绘标注轨迹回放在离线场景下完全靠本地坐标数据就能实现。GMap.NET的路线用GMapRoute表示把一串有顺序的点传进去再设置颜色和宽度// 轨迹点列表来自本地数据库或日志文件 ListPointLatLng trackPoints GetTrackPoints(); GMapOverlay trackOverlay new GMapOverlay(track); GMapRoute route new GMapRoute(trackPoints, history); // 粗一点的红色线在地图上更醒目 route.Stroke new Pen(Color.Red, 3); trackOverlay.Routes.Add(route); gmap.Overlays.Add(trackOverlay); // 视野定位到轨迹起点 gmap.Position trackPoints[0];轨迹回放有个细节点数量特别多时GMapRoute内部要逐一计算绘制会卡。我一般先做抽稀比如每5个点保留1个或者按距离阈值过滤把超过几米的点才留下。肉眼完全看不出差别但地图拖动时的响应速度能快很多。我现在接到任何地图需求第一句话一定是问三个问题目标环境能不能联网、要覆盖多大范围、用户会在哪几个缩放层级反复切。这三个问题答案定下来预取策略和缓存目录就该长什么样基本就清楚了。这个习惯来自一次项目现场被打回来的经历——当时预取层级跨度太大边缘瓦片全是灰块演示时在客户面前翻车翻得很彻底。希望你不用重走这一趟希望帮到你。本文还有配套的精品资源点击获取