最近在做一个外勤场景的 uni-app 项目业务方提了个需求把当天某个配送员的行驶轨迹回放出来要能暂停、能变速、能拖进度条。我当时以为这事很简单——把一串经纬度画到地图上不就行了真动手才发现轨迹回放在 uni-app 里从数据采集、坐标处理到动画调度每一步都有隐藏的坑点会漂、坐标会偏、动画会跳不同端上的地图组件行为还不一样。这篇就围绕“场景 方案”梳理一下 uni-app 做轨迹回放的完整思路和落地做法适合正在做地图/定位类应用、想直接参考一套方案的开发者。轨迹回放不是一个“把点连成线”的机械工作它首先取决于你要回放什么样的业务轨迹。同样是轨迹配送、跑步、巡检三者对采集频率、绘制精度、动画体验的要求完全不同。先把场景拆清楚再去选方案才不会做过头或者不够用。1.1 配送/跑腿点稀疏、时间敏感回放要“看懂路线”配送类业务是轨迹回放最常见的需求来源。骑手端通常每 5~30 秒上报一个定位点点与点之间可能有几百米甚至一两公里的直线跨越。回放的观看者往往是运营或客服他们关心的是骑手有没有绕路、在某地停留了多久、是否按规划路线走。这类回放不要求每一帧都平滑到极致但要求时间轴准确停留段要能看出来里程要尽量接近真实。技术重点应该放在坐标插值、里程计算和停留识别上而不是花哨的动画。1.2 跑步/骑行点密集、需要轨迹平滑回放要“复现过程”运动类 App 的轨迹又是另一个世界。手机在运动过程中每秒甚至每几百毫秒就会记录一个点轨迹线非常密但定位抖动也很多经常出现轨迹在路边来回“毛刺”。用户回看时想看的是平滑的路线和速度变化比如某一段是不是匀速、最后冲刺有没有提速。这类回放的核心是抽稀和平滑把密集点处理得干净再用速度/配色增强表现力。如果直接拿原始点去画轨迹会像一团乱麻。1.3 巡检/外勤打卡点少、要准确回放要“证明到达”巡检、外勤打卡这类业务轨迹点很少常常是到了某个位置才上报一次甚至完全由手动打卡生成。回放的意义更多是审计这个人是否按时到了指定地点、停留了多长时间、移动路径是否合规。技术重点不是平滑而是时间轴上的事件标记比如“08:30 到达 A 点停留 15 分钟”这样的信息要能和轨迹线、时间轴对应上。动画效果在这里反而没那么重要。1.4 一张表看清不同场景的方案优先级业务模型采集频率典型点数回放核心诉求优先投入的技术配送/跑腿5~30 秒/点几十到几百路线合理性、时长、停留插值、里程计算、停留分析运动健身1~5 秒/点成百上千轨迹平滑、过程还原抽稀、平滑、速度展示巡检/外勤事件触发个位数到几十按时到点、停留时长事件标记、时间轴校准长途/车队30 秒~分钟级几十到几百整体路线、距离加速回放、多轨迹对比我之前踩过一个教训需求方说“做个轨迹回放”我没有先问清楚是谁要看、要看什么直接按运动类的高频平滑方案去做结果采集点太少回放动画反而像抽搐。所以第一件事永远是确认业务模型再决定点怎么采、线怎么画、动画怎么播。数据是回放的源头。原始定位点如果质量差后面渲染层做得再漂亮回放出来也是一条“鬼画符”。这一部分我分三个环节讲采集方式、抽稀纠偏、数据存储。2.1 uni-app 定位能力与选型在 uni-app 里取定位最常用的是uni.getLocation。它把小程序、App、H5 的定位差异做了一层封装返回经纬度、速度、精度等字段。实际使用中要注意type参数建议统一用gcj02因为国内主流地图组件默认底图都基于 GCJ-02 坐标系后面会专门讲为什么。采集方式上最笨也最常用的办法是setInterval定时调uni.getLocation。适合配送、外勤这类点频不高的场景// 采集定位点定时器 精度过滤防止重复调用 function startCollect(interval 5000) { if (this.collectTimer) return const collect () { uni.getLocation({ type: gcj02, success: (res) { if (res.accuracy res.accuracy 80) return // 精度太差直接丢弃 this.trace.push({ lat: res.latitude, lng: res.longitude, t: res.time || Date.now(), speed: res.speed || 0, accuracy: res.accuracy }) } }) } collect() this.collectTimer setInterval(collect, interval) }这里有个没写在文档里的经验定位接口不是免费的频繁调用会带来明显耗电在高德/腾讯这类 SDK 层面也会有频率限制。如果你的业务是运动类高频采集不要纯靠setInterval硬扛应该在拿到一个新点时判断一下移动距离超过 10 米才记录。又或者直接用平台提供的位置监听 API但跨端兼容性不如定时器稳定需要做一层适配。权限这块我记得最开始就掉过坑。uni.getLocation在 App 端需要在manifest.json里声明定位模块和权限描述在小程序端需要用户授权拒绝后不会自动弹窗。正确做法是先用uni.getSetting检查授权状态如果之前拒绝过要引导用户去设置页手动打开。2.2 抽稀与纠偏别让几千个点拖垮回放轨迹抽稀这个词听着高级实际意思就是“删掉不重要的点”。定位点密集时相邻几米甚至一两米就会产生一个点但画在地图上根本看不出区别。不抽稀的问题不只是存储变大地图 polyline 渲染几千个点也会明显卡顿。最常用的抽稀算法是 Douglas-Peucker适合静态处理一批已经采集完的坐标。如果你在采集端实时处理更推荐“距离阈值 角度阈值”的简化策略当前点与上一个有效点距离小于 5 米并且方向变化不大时直接丢弃。function filterPoints(points, minDist 5) { const result [] let prev points[0] result.push(prev) for (let i 1; i points.length; i) { const p points[i] if (getDistance(prev, p) minDist) continue result.push(p) prev p } return result }这段代码只筛距离足够应付大多数场景。如果你做运动轨迹还可以再叠加角度判断计算当前点与前后两点的方向夹角夹角变化小于 15 度且移动距离较小说明基本在走直线可以丢掉中间点。纠偏要更谨慎。实际定位经常会出现“漂移点”明明没有移动定位却突然跳到几十米外再跳回来。这类点画到轨迹上会形成尖锐的“三角形”很影响观感。我的处理方法是先算当前点与上一个有效点的直线距离和用时如果折算出的速度超过 120km/h并且距离超过 200 米大概率是漂移直接丢弃。宁可损失一点轨迹细节也不能让一条路线穿楼而过。2.3 数据存储设计字段越精简越好轨迹数据最终要落库和上传不建议直接存一个巨大的 JSON 嵌套对象。每个点保留最核心的字段就够了[ { lat: 39.90872, lng: 116.39749, t: 1730000000000, speed: 0 }, { lat: 39.90611, lng: 116.39902, t: 1730000005000, speed: 3.2 } ]几个关键约定t统一用毫秒时间戳不要用本地时间字符串避免时区转换和比较大小出问题。speed如果拿不到就传 0但字段要保留回放时可以用来计算每段的速度。如果需要展示停留点可以额外加event字段比如event: collect表示该点是打卡点。如果你对实时性要求不高甚至可以只在回放前才取完整轨迹平时只保留最近几个点用于实时显示。这样后端接口压力和前端渲染压力都小很多。地图渲染是回放最直观的部分也是跨端问题最多的地方。我试过三种方案map组件 polyline、canvas 自绘、地图叠加 canvas 混合方案分别说一下适用边界。3.1 map polyline marker默认首选uni-app 的map组件底层是各端原生地图提供了polyline覆盖物能力把一串经纬度点传进去就能画出轨迹线。再配合markers里的一个动态 marker 作为“当前位置”回放时不断更新 marker 坐标视觉上就是一个小点沿着轨迹移动。template map idtraceMap :latitudecenterLat :longitudecenterLng :polylinepolyline :markersmarkers :include-pointsincludePoints / /templateexport default { data() { return { centerLat: 39.90872, centerLng: 116.39749, polyline: [], markers: [], includePoints: [] } }, methods: { updateTrace(points) { // 已播放部分画成实线 this.polyline [{ points: points.map(p ({ latitude: p.lat, longitude: p.lng })), color: #2E7CF6, width: 4 }] // 当前点 marker const last points[points.length - 1] this.markers [{ id: 1, latitude: last.lat, longitude: last.lng, width: 24, height: 24, iconPath: /static/location.png }] } } }这套方案的好处是开发成本低轨迹和地图底图天然对齐。缺点是跨端有细节差异小程序的include-points有时候不能一次容纳太多点App 端 marker 的iconPath对本地图片路径更敏感。但这些都在可接受范围内。3.2 canvas 自绘只推荐做缩略图或特殊效果有些人觉得 polyline 不够灵活想用 canvas 自己画轨迹线这样确实能画出速度渐变、渐变宽度、箭头动画等效果。但问题是canvas 是普通组件无法直接覆盖在原生地图上面层级很难控制。在小程序里可能要借助cover-view或同层渲染在 App 端又牵扯原生组件层级成本一下就上来了。所以我的建议是canvas 自绘只用来做轨迹缩略图。比如回放列表页需要显示一条路线的小预览图完全脱离地图背景用 canvas 把投影后的坐标画出来简单又不会跟地图组件打架。3.3 地图 canvas 混合有特殊需求再用如果你一定要在地图上做自定义动画比如沿线流动的光点、双轨迹对比可以在map上覆盖 canvas但要注意设置正确的坐标偏移并且处理好不同端的像素比。这个方案维护成本高容易被原生地图的更新搞坏。我们实际项目里没有走到这一步普通回放用 map polyline 已经完全够用。方案跨端一致性动画效果开发成本适用场景map polyline中需要适配够用低大多数回放canvas 自绘低高高缩略图、脱离地图的轨迹展示地图 canvas 混合低中高需要地图背景的自定义动画回放动画的核心不是“每隔多少秒把 marker 挪到下一点”而是一个基于时间轴的播放器。这一步做不好回放就会要么一顿一顿、要么无法准确定位到指定时刻。4.1 不要用固定帧移动要用时间轴推进轨迹的原始数据本质上是一串(lat, lng, t)三元组。回放时播放器先把首尾点的时间差算成总时长再根据当前播放进度找到“应该处于哪两个点之间”。这样无论你加快倍速、慢放、拖进度条位置计算逻辑都是一套。class TracePlayer { constructor(points) { this.points points this.duration points[points.length - 1].t - points[0].t this.currentTime 0 this.speed 1 this.state paused // playing / paused / ended } // 根据当前时间戳用二分查找定位所在轨迹区间 getPosition(ms) { const target this.points[0].t ms let lo 0 let hi this.points.length - 1 while (lo hi - 1) { const mid (lo hi) 1 if (this.points[mid].t target) lo mid else hi mid } const p this.points[lo] const q this.points[hi] const span q.t - p.t const ratio Math.max(0, Math.min(1, (target - p.t) / span)) return { lat: p.lat (q.lat - p.lat) * ratio, lng: p.lng (q.lng - p.lng) * ratio, index: lo, ratio } } }这里二分查找很关键。如果轨迹有几千个点每次都用for循环从头到尾找当前区间性能会很差尤其在小程序里更容易卡。4.2 让 marker 平滑移动线性插值有了getPosition剩下的就是驱动动画。很多人的第一反应是用setInterval固定每隔 1 秒更新一次 marker但这样只能移动到原始点两个点之间如果隔了 20 秒回放会出现明显“跳变”。正确做法是让动画帧更密集比如每 16 毫秒取一次当前时间对应的插值位置。play() { if (this.state playing) return this.state playing this.lastTick Date.now() this.tick() } tick() { if (this.state ! playing) return const now Date.now() const delta (now - this.lastTick) / 1000 this.lastTick now this.currentTime delta * 1000 * this.speed if (this.currentTime this.duration) { this.currentTime this.duration this.emit(update, this.getPosition(this.currentTime)) this.emit(end) return } const pos this.getPosition(this.currentTime) this.emit(update, pos) setTimeout(() this.tick(), 16) }注意我在tick里用的是setTimeout而不是setInterval。原因很简单setInterval容易叠加如果上一帧逻辑执行太久下一帧会排队挤在一起用setTimeout在每次执行完再安排下一次能保证一帧没走完不会开下一帧。4.3 暂停、变速、拖进度条都是同一套状态机播放器状态只有三个playing、paused、ended。暂停就是停止tick并记录当前currentTime恢复就是重新拿当前时间继续累加变速只需要修改speed不需要重置任何东西拖进度条则直接设置currentTime然后重新计算位置并更新已绘制轨迹。进度条的进度值就是currentTime / duration。拖完进度条后除了更新 marker 位置还要把轨迹线“已播放部分”和“未播放部分”分开已播放部分用实线未播放部分用半透明线或干脆不画。这样观众能一眼看出已经走到哪里。真实项目里轨迹数据不可能像 Demo 那么干净。以下四个边界情况我在开发中基本都遇到过了每次都值得单独处理。5.1 点漂移导致“路线穿楼”最常见的轨迹失真是定位点突然跳进河里、跳到楼里。如果直接把前后点连线整条路线就像“穿模”非常影响判断。我的处理策略是分两层采集端过滤相邻两点距离超过 200 米且折算速度异常大丢弃这个点。绘制端断开如果过滤后仍存在长距离跳跃不把它作为连续轨迹的一部分而是断开画线并在回放时保留这个“瞬移”事件让观看者知道这里发生了异常。const lastPoint validTrace[validTrace.length - 1] const dist getDistance(lastPoint, currentPoint) const timeSpan currentPoint.t - lastPoint.t if (dist 200 timeSpan 20 * 1000) { // 疑似漂移跳过当前点 return }这只是一个阈值方案不完美但足够拦截大部分异常点。如果你的业务对轨迹准确性要求很高可以接入路网匹配服务做纠偏但那就不是前端单独能搞定的了。5.2 坐标系基准差异一个点在不同地图上差几百米很多同行在联调时遇到过这个问题后端返回的坐标在某地图上是准的换到另一家地图 SDK 上却偏了几百米。原因是定位系统返回的原始坐标系和不同地图厂商底图使用的坐标系可能存在偏差。我现在的约定很简单所有入库的定位点统一用gcj02坐标回放展示时也用gcj02不做二次转换。不同地图厂商的地图底图如果都有 GCJ-02 支持就基本不会出现明显偏移。如果后端从设备上报里拿到的是原始世界坐标前端展示前要先做转换一定要在统一入口处理不要在渲染组件里散落各自转换。5.3 App 切后台 / 页面隐藏时的计时器回放进行中用户切到后台或者锁屏setTimeout会被暂停一旦回来计时器会像“时间穿越”一样补偿执行动画直接跳到最后。解决方法是监听页面生命周期在onHide时暂停在onShow时恢复并且恢复时不要按原速度继续而是从当前currentTime重新开始。采集端也一样如果页面不可见应该停掉定位定时器。否则用户切到后台程序还在频繁调定位接口耗电很快。有些 App 要做后台持续轨迹上报那需要用到原生 SDK 的后台定位能力跟普通页面生命周期完全是另一套逻辑。5.4 点非常多的时候性能一定会卡之前有端上测试轨迹 3000 个点我用setData全量更新 polyline直接卡成 PPT。原因是每帧都在重新渲染整条线路数据量太大。优化思路是拆开已经走过的部分每秒钟更新一次 polyline当前 marker 的位置才用高频更新。也就是说动画帧只改 marker 的经纬度polyline 不要跟着每帧变化。另外回放前先对点集做一次抽稀3000 个点抽到 800 个视觉差别很小流畅度提升非常明显。做项目不能只写一版能跑的代码还要考虑怎么给别人复用。最后我分享一个可复用组件的设计思路以及我实际开发中总结的经验。6.1 把播放器数据和地图渲染解耦我最终把轨迹回放拆成两层一层是纯逻辑的TracePlayer只负责时间轴计算和事件抛出不关心地图另一层是地图组件接收播放器吐出的当前位置只做渲染。组件的对外接口很简单propspoints轨迹点数组、autoplay是否自动播放、speed默认倍速methodsplay()、pause()、seekTo(ms)eventsprogress进度变化、playState播放状态、end播放结束这样业务方只需要传入一个轨迹点数组剩下的地图初始化、插值计算、进度同步都由组件内部完成。后续如果要把播放器换成 web-worker 独立计算或者替换地图渲染方案都不影响上层业务代码。6.2 可以继续扩展的方向轨迹回放做到底还能往更多方向延伸路网匹配把 GPS 点吸附到实际道路轨迹线不再穿楼配送场景观感提升明显。里程统计用矢量距离公式累加每段轨迹长度回放时显示实时里程。多轨迹对比同一条路线跑三次把三条轨迹叠加对比非常适合配送路径优化。轨迹分享用 canvas 把轨迹画成图片分享到社交场景轻量且不依赖实时地图。6.3 我的实际体会做过这个功能之后我最大的感受是用户对轨迹回放的诉求多数时候是“能看懂过程”而不是“科学精确到厘米”。与其一开始就追求复杂的路网匹配、三维动画不如先把“采集—存储—抽稀—回放”这条主链路跑通让用户看到一条连续、可拖动的路线。数据质量永远比渲染技术重要原始点全是垃圾动画再流畅也是白搭。另一个小经验开发时尽量用 mock 数据先跑播放器不要边开发边等真机定位。我是在组件里准备了一条固定的模拟轨迹先调通播放、暂停、变速再接入真实数据排错效率高很多。轨迹回放本身不难真正花时间的往往都是这些不起眼的边界情况。