
简介一套面向C# Winform开发者的离线地图集成例程基于GMap.NET实现帮助桌面应用开发者摆脱在线地图的网络依赖在无网或弱网环境下完成地图展示与交互。资源包共41个文件、38.7MB包含C#源码、GMap.NET核心库DLL、可执行程序、资源文件及项目配置文件源码与依赖已打包可直接加载运行。例程依次演示了在NuGet中安装GMap.NET.Core与GMap.NET.WindowsForms、导入本地瓦片数据、设置GMapControl核心属性、通过LocalTileLoader加载离线瓦片以及添加自定义标记、绘制图层和处理鼠标事件等完整流程并附有坐标转换与性能缓存的实操说明。已有2374人学习下载适合希望快速搭建离线地图原型、熟悉GMap.NET在Winform中工作机制的桌面应用工程师。1. 离线地图为什么是 C# Winform 项目里绕不开的模块在工控上位机、巡检系统或者部门内部工具里离线地图经常是那个“看着简单、一接就翻车”的需求客户现场没有外网却又要求在地图上标注设备位置、查看半径范围。C# Winform 项目里GMap.NET 是大多数人绕不开的选择拖上 GMapControl 就能显示瓦片地图配合缓存机制能做到断网可看、可拖、可打点。这篇按我自己的落地顺序拆开讲先让控件在工程里跑起来再把瓦片下载成离线缓存最后处理地图源、坐标偏移和交付现场的坑。适合正在做 Winform 上位机、还在用 .NET Framework 老工程又需要在现场快速出图的工程师对照着改。2. 让 GMapControl 显示第一张地图控件属性与 SQLite 缓存模式GMap 在 Winform 里的使用方式跟 PictureBox 差不多但它有一组全局静态配置设置不对后面离线模式根本出不来。我习惯先把在线源跑通再切离线这样能够分清“控件没配置好”和“缓存没做好”两类问题。2.1 从 NuGet 引入 GMap.NET.WinForms代码创建控件老规矩先在 NuGet 里搜GMap.NET.WinForms它会顺带把GMap.NET.Core也引用进来。VS2015 / .NET Framework 4.x 的老工程建议装 1.9 系列的包新一点的 .NET 6 工程用 2.x 的包。这里有个容易翻车的点GMapControl 有时不会出现在工具箱里或者拖上去之后设计器生成代码报错所以我一般直接在代码里创建控件可控制性更强。using GMap.NET; using GMap.NET.MapProviders; using GMap.NET.WindowsForms; var gmap new GMapControl { Dock DockStyle.Fill, // 先用在线源验证地图服务和坐标范围跑通之后再切离线 MapProvider GMapProviders.OpenStreetMap, MinZoom 2, MaxZoom 18, Zoom 12, Position new PointLatLng(30.5728, 104.0668), DragButton MouseButtons.Left, ShowCenter true, // 缓存目录放到独立磁盘目录别丢在桌面或者 Debug 输出目录 CacheLocation D:\MapCache\Demo }; this.Controls.Add(gmap);这段代码里MapProvider决定瓦片来源Position是初始化视野中心点DragButton设置用什么鼠标键拖拽地图ShowCenter控制在中心点画不画十字线。CacheLocation是 GMapControl 实例上的缓存路径后面还有一个全局静态配置也要指向同一个目录很多人在这里踩坑后文会再提。第一次跑通后你应该能在窗口里拖动地图瓦片会按需从网上加载此时离“离线”还差一步。2.2 切到离线模式CacheType、PreferCacheOnly 与缓存目录GMap.NET 的缓存机制是全局静态配置需要在创建控件之后、加载地图之前设置。常见的离线配置是这样using GMap.NET; using GMap.NET.CacheProviders; // SQLite 是 GMap.NET 默认的本地缓存格式单文件存瓦片 GMapProvider.CacheType CacheType.SQLite; // true 表示只读本地缓存不尝试请求网络 GMapProvider.PreferCacheOnly true; // 这里的路径必须和 gmap.CacheLocation 保持一致 GMapProvider.CacheLocation D:\MapCache\Demo; GMapProvider.Language LanguageType.ChineseSimplified;PreferCacheOnly是离线模式的总开关。设为 true 之后控件在遇到本地没有的瓦片时不会去联网而是画空白块所以必须在切到 true 之前保证需要的瓦片已经下载好。CacheType有所选SQLite 是单文件存储好拷贝、好备份如果现场有很多台机器也可以用CacheType.Memory但那只适合纯演示场景。这时候可以做个验证先在PreferCacheOnly false的状态下浏览一遍目标区域把瓦片缓存写进去然后改成true并断开外网重新启动程序地图应该还能正常显示。如果第二遍启动出现大片空白先回查两个CacheLocation是不是同一个路径这是离线地图最典型的“配置对了但没生效”问题。3. 预下载瓦片把在线地图搬进本地缓存真正要交付离线地图你不能指望用户打开程序时手动拖动浏览每个角落。正确做法是在开发机上先用代码把目标区域的瓦片全部下载进 SQLite 缓存再把缓存文件随程序一起部署。这个过程叫预下载GMap.NET 提供了现成的 Prefetch 接口不用自己去写瓦片爬虫。3.1 Prefetch 圈范围矩形区域、缩放区间和进度回调预下载的核心是圈一个矩形经纬度范围再指定从哪一级缩放到哪一级。常见写法是这样var center new PointLatLng(30.5728, 104.0668); // FromLTRB左、上、右、下也就是西经度、北纬度、东经度、南纬度 var area RectLatLng.FromLTRB( center.Lng - 0.15, center.Lat 0.10, center.Lng 0.15, center.Lat - 0.10); GMaps.Instance.Prefetch(area, 10, 15, progress { int percent (int)(progress.Current * 100.0 / progress.Total); if (percent % 5 0) { Console.WriteLine(${percent}% {progress.Current}/{progress.Total}); } }, false);这里RectLatLng.FromLTRB四个参数分别是左、上、右、下边界千万别按 X/Y 的思维去填填反了会下载一片错误区域。Prefetch的参数里10 是最小缩放15 是最大缩放最后的false表示“不强制重新下载已经存在缓存里的瓦片”用于断点续传。Prefetch 本身是异步的不会卡死界面但进度回调跑在后台线程里直接操作 UI 控件会报跨线程错误这个坑在第 5 章单独说。我对大区域的建议是先下 10 到 14 层确认范围正确、比例合适再补 15 到 17 层。不要一次把 10 到 18 层全铺开万一范围画错几个 GB 的缓存就白下了。Prefetch 进度异常时程序日志里通常会有瓦片请求失败记录先看日志再判断是网络问题还是范围问题。3.2 先算瓦片量再下zoom 与硬盘占用对照表很多新手不知道离线地图占空间的量级随手一个城市范围选到 18 层下载到一半硬盘满了。瓦片数量有一套粗略估算方法我按纬度 30° 附近、20km×20km 范围做了一张对照表实际会因区域边界取整略有浮动。zoom单张瓦片地面跨度约20km×20km 瓦片数预估磁盘占用1133.9 km约 1 张忽略不计128.5 km约 6 张约 0.5 MB142.1 km约 90 张约 8 MB16530 m约 1400 张约 120 MB18132 m约 23000 张约 2 GB这里按单张瓦片平均 80KB 估算实际矢量瓦片或 JPEG 瓦片会在这个值上下浮动。重点不是记数字而是记住量级放大一级瓦片数量变成四倍磁盘占用也差不多变成四倍。所以离线地图的层级选择要克制工控和巡检场景通常下到 16 层足够看清道路和建筑轮廓只有仓库级精细定位才需要 18 层。下完 16 层先用着不够再补 17、18 层这样最稳。我还会把整个预下载任务拆成几个小地块执行每块下完就校验一次文件大小。这样一块失败可以单独重下不用从头再来。中断恢复时利用前面说的false参数已经存在的瓦片会自动跳过。4. 地图源改造天地图接入与 WGS-84 坐标转换默认的 OpenStreetMap 在国内访问不稳定而且坐标系与国内硬件采集的 GPS 数据对不齐。做国内项目时我一般会把地图源换成天地图或者高德瓦片天地图有明确的申请渠道适合做正经项目交付。这里的关键有两件事一是让 GMap.NET 认识自定义地图源二是把 WGS-84 坐标转成 GCJ-02。4.1 自定义 MapProvider换成天地图矢量瓦片GMap.NET 内置了一批 MapProvider但国内源经常需要自己按 WMTS 拼接瓦片地址。继承GMapProvider并重写GetTileUrl是最常见的做法using GMap.NET; using GMap.NET.MapProviders; public class TiandituVectorProvider : GMapProvider { public static readonly TiandituVectorProvider Instance new TiandituVectorProvider(); private readonly string _tk 你的天地图密钥; TiandituVectorProvider() { RefererUrl https://www.tianditu.gov.cn/; } public override string GetTileUrl(int x, int y, int zoom) { int server (x y) % 4 1; return $https://t{server}.tianditu.gov.cn/vec_w/wmts?SERVICEWMTSREQUESTGetTileVERSION1.0.0LAYERvecSTYLEdefaultTILEMATRIXSETwFORMATtilesTILEMATRIX{zoom}TILEROW{y}TILECOL{x}tk{_tk}; } }使用的时候把 control 的MapProvider指向这个自定义实例即可gmap.MapProvider TiandituVectorProvider.Instance;这里server是天地图瓦片服务的子域编号按(x y) % 4 1取模是为了把请求分散到 t1 到 t4避免单域名请求过密。TILEMATRIX、TILEROW、TILECOL分别对应 GMap.NET 传入的 zoom、y、x顺序别搞错。还有一个容易被忽略的点自定义 Provider 的瓦片同样会写入 SQLite 缓存所以预下载完成之后部署现场完全不需要重新接入外网。如果你用的是高德瓦片地址原理也一致替换 URL 模板就行。但要注意各类地图服务商对客户端访问频率和商用场景都有各自约定批量预下载时建议把 Prefetch 的线程速度压低一点避免触发服务端限流。密钥申请请走服务商官网流程这里不展开。4.2 GPS 采集点转入 GCJ-02避免打点偏移几百米国内地图源用的是 GCJ-02 坐标也叫“火星坐标”而 GPS 模块、北斗模块直接读出来的是 WGS-84。两者在城市区域能差出几百米直接在地图上打点会落在完全不对的位置上。我工程里会放一个静态坐标转换类核心就是用流传已久的公开纠偏公式using GMap.NET; public static class GeoTransform { private const double A 6378245.0; private const double EE 0.00669342162296594323; private const double PI Math.PI; private static bool OutOfChina(double lng, double lat) { return lng 72.004 || lng 137.8347 || lat 0.8293 || lat 55.8271; } private static double TransformLat(double lng, double lat) { double d PI * lat / 180.0; double ret -100.0 2.0 * lng 3.0 * lat 0.2 * lat * lat 0.1 * lng * lat 0.2 * Math.Sqrt(Math.Abs(lng)); ret (20.0 * Math.Sin(6.0 * lng * PI) 20.0 * Math.Sin(2.0 * lng * PI)) * 2.0 / 3.0; ret (20.0 * Math.Sin(lat * PI) 40.0 * Math.Sin(lat / 3.0 * PI)) * 2.0 / 3.0; ret (160.0 * Math.Sin(lat / 12.0 * PI) 320.0 * Math.Sin(lat * PI / 30.0)) * 2.0 / 3.0; return ret; } private static double TransformLng(double lng, double lat) { double d PI * lng / 180.0; double ret 300.0 lng 2.0 * lat 0.1 * lng * lng 0.1 * lng * lat 0.1 * Math.Sqrt(Math.Abs(lng)); ret (20.0 * Math.Sin(6.0 * lng * PI) 20.0 * Math.Sin(2.0 * lng * PI)) * 2.0 / 3.0; ret (20.0 * Math.Sin(lng * PI) 40.0 * Math.Sin(lng / 3.0 * PI)) * 2.0 / 3.0; ret (150.0 * Math.Sin(lng / 12.0 * PI) 300.0 * Math.Sin(lng / 30.0 * PI)) * 2.0 / 3.0; return ret; } public static PointLatLng Wgs84ToGcj02(PointLatLng wgs) { if (OutOfChina(wgs.Lng, wgs.Lat)) return wgs; double dLat TransformLat(wgs.Lng - 105.0, wgs.Lat - 35.0); double dLng TransformLng(wgs.Lng - 105.0, wgs.Lat - 35.0); double radLat wgs.Lat / 180.0 * PI; double magic Math.Sin(radLat); magic 1 - EE * magic * magic; double sqrtMagic Math.Sqrt(magic); dLat (dLat * 180.0) / ((A * (1 - EE)) / (magic * sqrtMagic) * PI); dLng (dLng * 180.0) / (A / sqrtMagic * Math.Cos(radLat) * PI); return new PointLatLng(wgs.Lat dLat, wgs.Lng dLng); } }实际打点时这样用var rawGps new PointLatLng(30.5728, 104.0668); var displayPos GeoTransform.Wgs84ToGcj02(rawGps); marker.Position displayPos;这段公式不是从哪个项目里复制出来的“官方代码”而是从早年流传的公开纠偏算法里整理出来的你可以在自己的工具类里按同样逻辑封装。需要注意三点第一它的输入是 WGS-84 经纬度输出是 GCJ-02反过来用会越纠越偏第二中国境外坐标会被OutOfChina直接挡掉这也是这个算法的设计意图不是 bug第三如果地图源换成天地图影像或者高德同样要先做这个转换。用 GPS 原值打点的现象非常典型路网显示正常但你的点位整体偏向东南或西北几百米这时候先怀疑坐标系而不是怀疑 GMap 控件。5. 离线地图排坑笔记从空白屏到缓存丢失的 5 个实录离线地图的主要问题不在控件本身而在缓存一致性、线程模型和渲染方式。下面这 5 个坑是我在多个项目里反复踩过的按“现象、原因、解决”的顺序给你一条条过。5.1 地图空白、黑块和 CPU 飙升坑 1预下载跑完硬盘上也有缓存文件但断网启动后地图整块空白。原因最常见的是两台机器的CacheLocation不一致。你在开发机上用的是D:\MapCache\Demo程序部署到现场后配置文件把路径改成相对路径Winform 的工作目录一变缓存就找不到了。第二个常见原因是杀毒软件把 SQLite 缓存文件隔离了程序运行时写入被拦截。解决把缓存路径固定成配置文件里的绝对路径并检查程序启动目录下有没有生成.db文件。如果文件存在但依然空白临时把PreferCacheOnly改成 false联网跑一次看是否恢复就能判断是路径问题还是缓存文件损坏。坑 2Prefetch 之后 CPU 占用 100%窗口拖不动任务管理器显示 GMap 线程一直在跑。原因Prefetch虽然是异步的但默认的下载线程数和磁盘 IO 竞争会拖垮 Winform 界面线程。尤其是在机械硬盘上SQLite 单文件写入放大非常明显界面读取同一个缓存库时会被阻塞。解决把缓存目录放到固态硬盘上同时把预下载范围拆小一块一块下。界面卡顿严重时可以在 Prefetch 前把地图控件暂时隐藏或者直接Opacity 0等这一块下完再显示。5.2 线程、DPI 与内存泄漏的场景坑 3Prefetch 进度回调里直接更新进度条程序抛异常“线程间操作无效”。原因进度回调跑在后台线程Winform 控件有线程亲和性不能跨线程直接改属性。这是 C# 线程模型里的老规矩放到 GMap 里照样适用。解决把更新操作转到 UI 线程执行用BeginInvoke包一层。this.BeginInvoke(new Action(() { toolStripProgressBar1.Value percent; toolStripStatusLabel1.Text ${percent}%; }));需要注意的是BeginInvoke是异步的如果进度回调每秒触发几十次界面还是会被大量委托淹没。我一般会在回调里加节流判断比如 percent 每变化 1 才更新一次。坑 4高 DPI 屏幕上地图文字模糊拖动后出现残影。原因GMapControl 是自绘控件Winform 默认的 DPI 缩放方式会让它先拉伸后绘制文字边缘发虚加上拖动时整幅刷新残影就出来了。解决在程序清单里声明 PerMonitorV2 DPI 感知渲染时不要用Refresh()做整幅重绘而是用Invalidate()只让系统重画失效区域。双缓冲也打开很多人觉得界面美化就是换主题换颜色实际上 GMap 这类高频重绘控件先把双缓冲和 DPI 适配做好观感提升比换皮肤明显得多。坑 5窗口关闭后程序进程退不掉内存越涨越高。原因Prefetch 的下载线程还在跑GMapControl 的 Overlays 集合里还有事件引用导致控件没有被释放。解决在 FormClosing 里显式结束下载任务并释放资源。private void MainForm_FormClosing(object sender, FormClosingEventArgs e) { GMaps.Instance.CancelPrefetch(); gmap.Overlays.Clear(); gmap.Dispose(); }CancelPrefetch是这里的关键不调用它下载线程会继续持有后台线程程序关窗口之后看起来像消失了其实还在进程列表里挂着。现场机器配置低这种问题会放大成“软件退出后风扇还在狂转”的诡异现象。6. 交付前的缓存自检让离线地图在客户机上直接可用离线地图项目交付时最怕的不是程序 bug而是开发机上的缓存没完整拷过去。几百 MB 的瓦片文件拷到现场少了一个区域界面就会出一块白斑。我现在的习惯是在部署包里放一个缓存自检入口启动时或交付前跑一遍用 SQLite 连接库直接查瓦片表的记录数。using System.Data.SQLite; private void CheckCacheDb(string dbPath, int expectCount) { using (var conn new SQLiteConnection( $Data Source{dbPath};Version3;Read OnlyTrue;)) { conn.Open(); using (var cmd new SQLiteCommand(SELECT COUNT(*) FROM Tiles;, conn)) { int actual Convert.ToInt32(cmd.ExecuteScalar()); if (actual expectCount * 0.9) { MessageBox.Show($缓存不完整实际 {actual} 张预期 {expectCount} 张请重新预下载。); } } } }这段代码依赖System.Data.SQLiteNuGet 里引用一下即可。GMap.NET 的 SQLite 缓存表在不同版本里可能叫Tiles也有老版本带前缀第一次接的时候用一个 SQLite 浏览器打开.db文件确认表名之后就可以把这个自检函数固定下来。预期瓦片数写入一个region_manifest.json里面记录经纬度范围、缩放区间和瓦片总量。这样交付维护时能按日志定位是一台机器缓存没拷全还是预下载时本来就没下完整。打包安装程序前也注意不要把整包缓存塞进安装工程几百 MB 的瓦片会让安装过程像死机我一般把缓存做成独立压缩包安装时解压到指定目录并提供校验工具。早些年有一次交付我拿着笔记本上下好的缓存直接拷到工控机结果现场有一半区域显示空白后来发现开发机屏幕缩放和现场分辨率不一致导致运行时缓存判定错乱。从那次起我就把自检脚本写进了每一次交付流程。离线地图这种事八成问题出在缓存一致性上不是出在 GMap 控件上。希望帮到你。本文还有配套的精品资源点击获取