简介本资源面向具备一定C#与WinForms基础的开发者聚焦GMap.NET地图应用中的轨迹回放功能实现同时覆盖地图文件解析、地图源切换、自定义图标与线路样式、自适应屏幕等常见需求。包内共377个文件以88个dll依赖库、25个png图标素材、14个xml配置、11个cs源码及多个sample示例为主另含exe可执行程序与gmdb地图缓存等压缩包约68.99MB目录结构便于按模块查阅。资源围绕MapSimulator项目展开涉及TXT坐标文件读取与GPoint、GMarker数据结构转换GMapProvider切换Google、OpenStreetMap等地图服务GMapRoute配合PolylineOptions调整线路颜色与宽度以及通过定时器与插值算法实现轨迹平滑播放。已有1607人学习下载适合需要快速搭建轨迹回放原型、参考地图交互细节或排查地图渲染问题的开发者借鉴。1. 轨迹回放这件事GMap 比你想的更能扛做过车载监控或者物流调度的人都有一个共识轨迹回放是地图开发里最容易被低估的功能。表面上看就是把一串经纬度按时间顺序连成线再让一个小图标沿着线跑起来但真到项目里你会发现要处理的问题远不止画线——时间轴拖动、倍速播放、暂停续播、轨迹点聚合、跨天数据分段、地图缩放跟随每一项都能让一个看似简单的模块膨胀成两三千行的代码。这个项目就是冲着这些实际问题去的。它基于 C# 的 GMap.NET 控件在 WinForms 环境下实现了一套完整的轨迹回放方案同时把 DevExpress 的界面组件拉进来做时间轴和播放控制还顺带解决了自定义属性地图和文件管理的问题。如果你手头正好有一个需要在地图上还原车辆或人员历史路线的桌面端项目这套代码可以直接拿来当骨架用。它不依赖任何在线地图密钥离线瓦片也能跑对内外网环境都比较友好。2. 项目结构拆解从地图控件到回放引擎的分层设计2.1 整体目录结构与模块职责拿到源码后我习惯先看目录结构因为目录往往比文档更能说明作者的意图。这个项目的组织方式比较清晰没有把所有逻辑塞进一个 Form 里而是按职责拆成了几个独立的文件夹。GMapTrackPlayback/ ├── Controls/ # 自定义控件层 │ ├── MapControl.cs # 继承 GMapControl封装地图初始化与图层管理 │ ├── TrackSlider.cs # 基于 DevExpress 的时间轴滑块控件 │ └── PlaybackPanel.cs # 播放控制面板播放/暂停/倍速/进度 ├── Core/ # 核心业务逻辑层 │ ├── TrackPlayer.cs # 轨迹回放引擎负责定时推进与状态管理 │ ├── TrackPoint.cs # 轨迹点数据模型 │ └── TrackSegment.cs # 轨迹分段模型处理跨天或中断 ├── Data/ # 数据访问层 │ ├── TrackRepository.cs # 轨迹数据读取接口 │ └── FileTrackRepository.cs # 从本地文件加载轨迹 ├── Models/ # 地图与属性模型 │ ├── CustomMarker.cs # 自定义属性标记 │ └── MapLayerConfig.cs # 图层配置 ├── Forms/ │ ├── MainForm.cs # 主窗口 │ └── MainForm.Designer.cs └── Utils/ ├── GeoUtils.cs # 经纬度计算、距离、插值 └── TimeUtils.cs # 时间格式化与解析这个结构里最值得关注的是Core/TrackPlayer.cs和Controls/TrackSlider.cs的配合。TrackPlayer 不直接操作地图控件而是通过事件把当前应该显示的位置推给 MapControl这样回放逻辑和渲染逻辑就解耦了。你后面如果要换成别的地图引擎只需要改 MapControl 这一层TrackPlayer 几乎不用动。2.2 地图初始化与自定义属性标记GMap.NET 的初始化本身不复杂但有几个坑点缓存路径、访问模式、坐标系。这个项目在 MapControl 的构造函数里做了一层封装把常用的配置都预设好了。public class MapControl : GMapControl { public MapControl() { // 设置地图缓存目录避免默认路径在系统盘导致权限问题 string cacheDir Path.Combine(AppDomain.CurrentDomain.BaseDirectory, MapCache); if (!Directory.Exists(cacheDir)) Directory.CreateDirectory(cacheDir); this.CacheLocation cacheDir; // 使用离线模式适合内网环境如果外网可用可改为 ServerAndCache this.Manager.Mode AccessMode.ServerAndCache; // 设置默认缩放级别和中心点 this.MinZoom 3; this.MaxZoom 18; this.Zoom 12; this.Position new PointLatLng(39.9042, 116.4074); // 默认中心 // 开启鼠标滚轮缩放和拖动 this.MouseWheelZoomType MouseWheelZoomType.MousePositionAndCenter; this.CanDragMap true; this.DragButton MouseButtons.Left; } // 添加自定义属性标记 public void AddCustomMarker(CustomMarker marker) { GMarkerGoogle gMarker new GMarkerGoogle( new PointLatLng(marker.Latitude, marker.Longitude), marker.Icon ?? GMarkerGoogleType.blue_dot ); gMarker.ToolTipText marker.ToolTip; gMarker.ToolTipMode MarkerTooltipMode.OnMouseOver; // 把自定义属性挂到 Tag 上方便点击时读取 gMarker.Tag marker; this.Overlays.Add(new GMapOverlay(marker.LayerName)); this.Overlays[this.Overlays.Count - 1].Markers.Add(gMarker); } }这里有几个参数值得说明。AccessMode.ServerAndCache表示优先从服务器拉取瓦片失败时用本地缓存适合大多数有外网但网络不稳定的场景。如果你的项目完全离线改成AccessMode.CacheOnly就行但前提是缓存目录里已经有瓦片数据。MouseWheelZoomType.MousePositionAndCenter让缩放以鼠标位置为中心体验比默认的以地图中心缩放好很多。自定义属性标记这块作者用了一个取巧但实用的办法把CustomMarker对象直接赋给GMarkerGoogle.Tag。这样在标记的点击事件里你可以直接拿到业务数据不用再维护一个额外的字典去映射。2.3 轨迹回放引擎的核心实现回放引擎是整个项目的心脏。它的职责是给定一组带时间戳的轨迹点按照时间顺序和播放倍速定时计算出当前应该显示的位置并通知地图更新。public class TrackPlayer { private ListTrackPoint _points; private System.Timers.Timer _timer; private int _currentIndex; private DateTime _playStartTime; private double _speed 1.0; public event ActionTrackPoint PositionChanged; public event Action PlaybackFinished; public TrackPlayer(ListTrackPoint points) { _points points.OrderBy(p p.Timestamp).ToList(); _timer new System.Timers.Timer(100); // 100ms 刷新一次 _timer.Elapsed OnTimerElapsed; } public void Play() { if (_points null || _points.Count 2) return; _playStartTime DateTime.Now; _timer.Start(); } public void Pause() { _timer.Stop(); } public void SetSpeed(double speed) { _speed speed; } private void OnTimerElapsed(object sender, ElapsedEventArgs e) { // 根据已播放的真实时间计算当前应该到达的轨迹时间点 double elapsedMs (DateTime.Now - _playStartTime).TotalMilliseconds * _speed; DateTime targetTime _points[0].Timestamp.AddMilliseconds(elapsedMs); // 找到目标时间所在的轨迹段 while (_currentIndex _points.Count - 1 _points[_currentIndex 1].Timestamp targetTime) { _currentIndex; } if (_currentIndex _points.Count - 1) { _timer.Stop(); PlaybackFinished?.Invoke(); return; } // 在当前段内做线性插值让移动更平滑 TrackPoint p1 _points[_currentIndex]; TrackPoint p2 _points[_currentIndex 1]; double segmentMs (p2.Timestamp - p1.Timestamp).TotalMilliseconds; double ratio segmentMs 0 ? (targetTime - p1.Timestamp).TotalMilliseconds / segmentMs : 0; TrackPoint interpolated new TrackPoint { Latitude p1.Latitude (p2.Latitude - p1.Latitude) * ratio, Longitude p1.Longitude (p2.Longitude - p1.Longitude) * ratio, Timestamp targetTime }; PositionChanged?.Invoke(interpolated); } }这段代码的关键在于插值。如果只是简单地在轨迹点之间跳转当两个点间隔较远时图标会一顿一顿的。作者用线性插值在两点之间算出中间位置配合 100ms 的定时器视觉上就流畅多了。_speed参数控制倍速实现方式是放大已流逝的时间。比如 2 倍速时实际过了 1 秒但计算目标时间时按 2 秒算轨迹推进就快一倍。这个设计比去调整定时器间隔要稳定因为定时器间隔改了容易受系统调度影响。PositionChanged事件把插值后的点推给地图层地图层只需要更新标记位置和已走过的轨迹线即可。这种事件驱动的写法后面要加“跟随视角”或者“轨迹颜色渐变”都很容易扩展。2.4 时间轴与播放控制的联动DevExpress 的 TrackBarControl 在这里被用来做时间轴。它的优点是样式统一、支持刻度自定义而且和 WinForms 的集成很顺。private void InitTrackSlider() { trackSlider.Properties.Minimum 0; trackSlider.Properties.Maximum _trackPoints.Count - 1; trackSlider.Properties.TickFrequency Math.Max(1, _trackPoints.Count / 10); trackSlider.Properties.ShowValueToolTip true; // 拖动时暂停播放松手后从新位置继续 trackSlider.EditValueChanged (s, e) { if (_isDragging) return; int idx (int)trackSlider.EditValue; _player.SeekTo(idx); }; trackSlider.MouseDown (s, e) { _isDragging true; _player.Pause(); }; trackSlider.MouseUp (s, e) { _isDragging false; _player.SeekTo((int)trackSlider.EditValue); _player.Play(); }; }这里有个细节拖动过程中要暂停播放否则定时器还在推进会和拖动操作打架。松手后再从新位置继续播放体验上比较自然。SeekTo方法在 TrackPlayer 里需要额外实现核心就是根据索引反推_playStartTime让后续的时间计算从新位置开始。3. 跑起来环境配置与数据准备3.1 依赖项与项目配置这个项目依赖两个主要的库GMap.NET 和 DevExpress。GMap.NET 可以通过 NuGet 安装DevExpress 则需要本地有授权或者使用试用版。# 在 Visual Studio 的 Package Manager Console 中执行 Install-Package GMap.NET.WindowsForms Install-Package GMap.NET.WindowsPresentationDevExpress 的引用稍微麻烦一点因为它是商业组件。如果你没有授权可以先把跟 DevExpress 相关的代码注释掉用原生的 TrackBar 替代功能上不受影响只是样式差一些。项目里跟 DevExpress 耦合最深的是 TrackSlider 和 PlaybackPanel替换成本不高。提示GMap.NET 的 NuGet 包有好几个版本建议选 1.7 以上的稳定版。低版本在 .NET Framework 4.5 以下会有坐标系偏移的问题。3.2 轨迹数据格式与加载项目默认从本地文件加载轨迹数据格式是 CSV每行包含时间戳、纬度、经度三个字段。timestamp,latitude,longitude 2024-01-15 08:00:00,39.9042,116.4074 2024-01-15 08:00:05,39.9050,116.4080 2024-01-15 08:00:12,39.9062,116.4095加载逻辑在FileTrackRepository.cs里核心就是读文件、解析、排序。public ListTrackPoint LoadFromCsv(string filePath) { var points new ListTrackPoint(); var lines File.ReadAllLines(filePath); // 跳过表头 for (int i 1; i lines.Length; i) { var parts lines[i].Split(,); if (parts.Length 3) continue; if (DateTime.TryParse(parts[0], out DateTime ts) double.TryParse(parts[1], out double lat) double.TryParse(parts[2], out double lon)) { points.Add(new TrackPoint { Timestamp ts, Latitude lat, Longitude lon }); } } return points.OrderBy(p p.Timestamp).ToList(); }如果你的数据源是数据库只需要实现一个ITrackRepository接口把查询结果映射成TrackPoint列表就行。项目里已经把接口抽出来了替换起来不费劲。3.3 首次运行与地图瓦片配置第一次运行时地图区域可能是空白的因为 GMap.NET 需要从服务器下载瓦片。如果你在内网环境需要提前把瓦片缓存准备好。// 在 MainForm 加载时设置瓦片来源 private void SetupMapProvider() { // 使用 OpenStreetMap 作为默认瓦片源 GMapProvider provider OpenStreetMapProvider.Instance; // 如果需要用自定义瓦片服务器可以这样设置 // GMapProvider provider new GMapProvider(); // provider.UrlFormat http://your-tile-server/{z}/{x}/{y}.png; mapControl.MapProvider provider; mapControl.Manager.Mode AccessMode.ServerAndCache; }瓦片缓存目录默认在MapCache文件夹下按缩放级别和坐标分文件夹存储。如果你要打包离线地图把这个文件夹一起拷过去就行。注意GMap.NET 默认的瓦片服务器在国外首次加载可能比较慢。如果项目对加载速度有要求建议自建瓦片服务或者预置缓存。4. 避坑与常见问题排查4.1 轨迹线不显示或显示不全现象地图上只显示了一部分轨迹或者完全没有线。原因GMapRoute 的 Points 列表为空或者经纬度顺序搞反了把纬度当经度传了。解决先检查数据源的经纬度顺序。GMap.NET 的PointLatLng构造函数是(lat, lng)不是(lng, lat)。另外确认GMapOverlay已经添加到mapControl.Overlays里很多人忘了这一步标记和路线都加到了 overlay但 overlay 本身没挂到地图上。4.2 回放时图标跳动不流畅现象播放时图标一格一格地跳没有平滑移动的感觉。原因定时器间隔太大或者没有做插值计算。解决把定时器间隔调到 50 到 100 毫秒之间同时确保 TrackPlayer 里做了线性插值。如果轨迹点本身很稀疏比如每 30 秒一个点插值的必要性就更大了。另外检查_speed是否被设成了很大的值倍速太高时插值也救不了。4.3 时间轴拖动后播放位置错乱现象拖动时间轴到中间某个位置松手后播放从错误的地方开始。原因SeekTo方法没有正确重置_playStartTime和_currentIndex。解决在SeekTo里根据目标索引找到对应的轨迹点时间然后反推_playStartTime DateTime.Now - (targetTime - _points[0].Timestamp) / _speed。同时把_currentIndex设为目标索引。这两个值必须同步更新只改一个就会错乱。4.4 地图缩放时轨迹线变粗或变细现象放大缩小时轨迹线的宽度跟着变看起来不协调。原因GMapRoute 的Stroke.Width是固定像素值但地图缩放时渲染比例变了。解决在OnMapZoomChanged事件里动态调整Stroke.Width或者干脆用固定宽度接受它在不同缩放级别下视觉粗细的差异。如果一定要动态调整建议按Math.Log(zoom)来算不要线性缩放。4.5 内存占用越来越高现象长时间运行后内存持续增长最终卡顿甚至崩溃。原因每次刷新都新建了 GMapOverlay 或 GMapRoute旧的没有释放。解决复用 Overlay 和 Route 对象只更新它们的 Points 集合不要反复 new。如果确实需要重建记得先mapControl.Overlays.Remove(oldOverlay)再把引用置空。GMap.NET 的瓦片缓存也会占内存可以在配置里限制缓存大小。5. 二次开发从能跑到好用的改造思路这套代码跑通之后你大概率会想按自己的业务需求改一改。我梳理几个最常见的改造方向都是实际项目里踩过坑的。第一个方向是轨迹数据源替换。项目默认从 CSV 读但实际项目里数据多半在数据库或者通过接口下发。你只需要实现ITrackRepository接口把LoadFromCsv换成LoadFromDatabase或者LoadFromApi。注意两点一是数据量大的时候要分页或者按时间范围查询不要一次性拉几十万条到内存二是时间字段的时区要统一我见过因为时区没对齐导致轨迹回放快了 8 小时的案例。第二个方向是回放视觉增强。默认的轨迹线是单一颜色你可以改成渐变线已经走过的部分用亮色未走的用暗色。实现方式是在PositionChanged事件里更新 Route 的 Points 集合把已走过的点单独画一条 Route。另外可以加一个方向箭头用GMarkerGoogle的旋转功能根据前后两个点的方位角算出角度。第三个方向是性能优化。当轨迹点超过一万个时GMap.NET 的渲染会明显变慢。常见的做法是抽稀在缩放级别低的时候只显示关键点放大后再显示全部。抽稀算法可以用 Douglas-Peucker网上有现成的 C# 实现。另一个优化点是瓦片缓存把Manager.Mode设成CacheOnly并预置瓦片能省掉大量网络请求。第四个方向是导出与分享。很多项目需要把回放结果导出成视频或者 GIF。这个用 C# 做稍微麻烦一点但思路是定时截取地图控件的截图用 FFmpeg 合成视频。截图频率建议 10 到 15 帧每秒太高了文件太大太低了不流畅。// 导出轨迹为 KML方便在 Google Earth 里查看 public void ExportToKml(ListTrackPoint points, string filePath) { var sb new StringBuilder(); sb.AppendLine(?xml version\1.0\ encoding\UTF-8\?); sb.AppendLine(kml xmlns\http://www.opengis.net/kml/2.2\); sb.AppendLine(DocumentPlacemarkLineStringcoordinates); foreach (var p in points) { // KML 的坐标顺序是 经度,纬度,高度 sb.AppendLine(${p.Longitude},{p.Latitude},0); } sb.AppendLine(/coordinates/LineString/Placemark/Document/kml); File.WriteAllText(filePath, sb.ToString()); }这个导出功能我几乎每个地图项目都会加因为客户经常需要把轨迹拿到别的平台去用。KML 的兼容性最好Google Earth、奥维互动地图都能直接打开。最后一个建议是关于状态管理的。回放引擎的状态播放中、暂停、拖动中、已结束最好用一个枚举统一管理不要用多个 bool 变量拼凑。我早期写过一个版本用了_isPlaying、_isPaused、_isDragging三个布尔值结果出现了“既在播放又在拖动”的玄学状态排查了半天。后来改成状态机所有状态转换都走一个SetState方法问题就没了。从那以后我每次做回放功能都强制先把状态机画出来再写代码这个习惯帮我省了很多后悔药。希望这套 GMap 轨迹回放的思路和代码能帮到你手头正在折腾的那个项目。本文还有配套的精品资源点击获取