上个月接了个外勤人员的轨迹回放需求,要在 uniapp 项目里做一张人员轨迹绘制图,横跨微信小程序、H5、安卓 App 三端。说白了就是把一群人一天跑过的地方按时间顺序画在地图上,带播放、缩放,还要兼顾老手机的性能。这类需求在物流调度、外勤巡检、孩子电话手表、老人防走失这些场景里非常常见,都属于“人员轨迹绘制图”的范畴。
这篇文章我不是来给你贴一堆官方文档的,而是把我实际落地这套功能的选型过程、坐标数据清洗、polyline 绘制、时间轴播放、常见坑点全部摊开讲清楚。不管你是刚接手 uniapp 项目的新人,还是已经写过不少地图功能但被某个端卡住的开发者,按我这套逻辑去实现,都能少走两到三天的弯路。
先说结论:轨迹绘制图的难点从来不在“画线”这一步,而在多点跨端适配、GPS 坐标漂移、大数据量绘制卡顿这三座大山上。下面我从选型开始,一步步拆。
1. 先想明白:这张轨迹图到底要画给谁看
1.1 需求背后的真实场景
“人员轨迹绘制图”听起来是个很明确的功能,但产品经理嘴里的“轨迹”和开发手里的“坐标点数组”之间,往往隔着不小的距离。我做这个需求时,对方最初只给了一句话:“把这个人今天经过的地方连成一条线,能播放就行。”结果一聊才发现,他们要的远不止一条线:
- 管理后台要在 PC 浏览器上查看当天所有外勤人员的轨迹,要求能按时间段筛选;
- 外勤人员的手机 App 端要能查看自己的历史轨迹,有回放按钮;
- 微信小程序端要给客户展示“配送员当前在哪里、走过了哪些路”;
- 海外的行程数据以后可能也要接入,坐标体系得兼容。
也就是说,同一套轨迹数据要适配 App、H5、小程序三种运行环境。这就是 uniapp 项目的典型处境:一套代码到处跑,但地图能力在三个端上的实现完全不是一个妈生的。你不可能指望<map>组件在微信小程序、App 原生层、H5 浏览器里表现完全一致,不可能。
1.2 三端地图方案的选型对比
我在正式开始写代码前花了大半天时间对比选型。说实话,unaipp 生态里做地图无非三条路:用内置<map>组件、装原生插件、用 web-view 嵌网页地图。我把它们摆一起做了个表:
| 方案 | 微信小程序端 | App 端 | H5 端 | 我的评价 |
|---|---|---|---|---|
| uniapp 内置 map 组件 | 底层是腾讯地图 | 底层是原生地图或高德 | 底层也是地图 SDK 渲染 | 写起来最快,80% 的轨迹需求够用 |
| 高德/百度原生 SDK 插件 | 不支持 | 功能最全、性能最好 | 不支持 | 适合要做复杂的图层、热力图、点聚合的场景 |
| web-view 嵌入天地图/高德 JS | 小程序基本不可用 | 可用但体验较差 | 体验最佳、功能最全 | 适合做成纯 H5 大屏,不适合嵌小程序 |
经过对比,我最后选了“内置 map 组件为主,canvas 预览图为辅”的路线。理由很现实:我给这个项目定的技术底线是“三端都能跑、不追求极致性能”,而带原生插件和 web-view 的复杂度,会直接拖垮交付周期。如果以后真要做热力图或者点在多边形内的计算,再单独升级也不迟。
1.3 一个反直觉的备选方案:不画在地图上
这里插一句,如果你只需要一个概览图,比如“这个人今天大致走了哪些区域、什么时间段在哪片活动”,其实不一定非要上地图,直接用 canvas 把经纬度点映射到平面坐标系,画一条折线加时间刻度就行。我上一个项目甚至用 echarts 的 custom series 做了一张“轨迹热力俯视图”,展示效果比地图还直观。
道理很简单:地图是参考系,不是终点。对很多管理场景而言,看轨迹是看“形状”和“节奏”,不是看“精确在哪条路上”。备选方案在遇到 GPS 采集频率低、定位漂移严重的脏数据时,反而能保住展示效果。我建议你先想清楚需求,再决定动不动地图组件。
2. 坐标数据才是最大的坑:清洗、纠偏、抽稀一个都不能少
2.1 你拿到的坐标可能根本画不在地图上
这是整个项目里我最想吐槽的一点。外勤人员的定位数据不是手机 App 上报的,而是他们戴的定位手环、车辆上的 GPS 盒子通过后端推送过来的。这些设备上报的坐标大多是 WGS84 原始坐标(就是 GPS 设备直接算出来的经纬度),而国内的地图平台——腾讯、高德、天地图——用的都是 GCJ-02 坐标(民间俗称“火星坐标”)。你要是把 WGS84 的点直接丢进小程序 map 组件里画线,轨迹会整体平移到马路对面,严重的时候直接偏出去一个街区,看起来像人瞬移了一样。
所以第一步,写个坐标系转换工具函数,在数据进入页面之前统一转成 GCJ-02。核心思想是引入一个官方公开的偏移算法,把 GPS 原始坐标换算成国内地图坐标。下面是我用的简化版转换逻辑:
// wgs84 转 gcj02,纯前端实现,不需要请求任何接口 function outOfChina(lng, lat) { return (lng < 72.004 || lng > 137.8347) || (lat < 0.8293 || lat > 55.8271) } function transformLat(x, y) { let ret = -100 + 2 * x + 3 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * Math.sqrt(Math.abs(x)) ret += (20 * Math.sin(6 * x * Math.PI) + 20 * Math.sin(2 * x * Math.PI)) * 2 / 3 ret += (20 * Math.sin(y * Math.PI) + 40 * Math.sin(y / 3 * Math.PI)) * 2 / 3 ret += (160 * Math.sin(y / 12 * Math.PI) + 320 * Math.sin(y * Math.PI / 30)) * 2 / 3 return ret } function transformLng(x, y) { let ret = 300 + x + 2 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * Math.sqrt(Math.abs(x)) ret += (20 * Math.sin(6 * x * Math.PI) + 20 * Math.sin(2 * x * Math.PI)) * 2 / 3 ret += (20 * Math.sin(x * Math.PI) + 40 * Math.sin(x / 3 * Math.PI)) * 2 / 3 ret += (150 * Math.sin(x / 12 * Math.PI) + 300 * Math.sin(x / 30 * Math.PI)) * 2 / 3 return ret } export function wgs84ToGcj02(lng, lat) { if (outOfChina(lng, lat)) return { lng, lat } let dLat = transformLat(lng - 105.0, lat - 35.0) let dLng = transformLng(lng - 105.0, lat - 35.0) const radLat = lat / 180.0 * Math.PI let magic = Math.sin(radLat) magic = 1 - 0.006693421622965943 * magic * magic const sqrtMagic = Math.sqrt(magic) dLat = (dLat * 180.0) / ((6378245.0 * (1 - 0.006693421622965943)) / (magic * sqrtMagic) * Math.PI) dLng = (dLng * 180.0) / (6378245.0 / sqrtMagic * Math.cos(radLat) * Math.PI) return { lng: lng + dLng, lat: lat + dLat } }如果你用的是百度地图,还要在 GCJ-02 基础上再转一次 BD-09,这里不展开。总之,数据进页面之前先统一坐标系,后面所有点的经纬度都要基于同一套标准,这样轨迹线才能和路网贴合。
2.2 过滤 GPS 漂移点:别让轨迹穿楼
坐标系概念理清之后,下一个硬骨头是漂移点。做一个星期的测试你就会发现,GPS 模块在楼宇密集区、隧道里、车辆启动瞬间,经常报出跳变几个百米的点。如果不处理,轨迹线会直接穿楼、穿河,用户一看截图就投诉。
我采用的过滤策略很朴素但好用:根据相邻两点之间的时间和距离,反算出移动速度,如果速度超过一个离谱的阈值,就判定为漂移点并丢弃。判定逻辑大概是:
// 计算两个经纬度点之间的距离,单位米(Haversine 公式) function getDistance(lat1, lng1, lat2, lng2) { const radLat1 = lat1 * Math.PI / 180 const radLat2 = lat2 * Math.PI / 180 const a = radLat1 - radLat2 const b = (lng1 - lng2) * Math.PI / 180 const s = 2 * Math.asin(Math.sqrt( Math.pow(Math.sin(a / 2), 2) + Math.cos(radLat1) * Math.cos(radLat2) * Math.pow(Math.sin(b / 2), 2) )) return s * 6378137 } // 过滤漂移:按速度阈值,超过 200km/h 的跳过 function filterDirtyPoints(rawPoints) { const result = [] for (let i = 0; i < rawPoints.length; i++) { const prev = result[result.length - 1] if (!prev) { result.push(rawPoints[i]) continue } const distance = getDistance(prev.lat, prev.lng, rawPoints[i].lat, rawPoints[i].lng) const timeGap = (rawPoints[i].time - prev.time) / 1000 // 秒 if (timeGap <= 0) continue const speed = distance / timeGap * 3.6 // 转成 km/h if (speed >= 200) { // 这个点太离谱,丢了 continue } result.push(rawPoints[i]) } return result }注意,这里time我建议在进页面之前统一转成毫秒时间戳,否则字符串转来转去很容易出负值。还有一个细节:人和车的速度阈值不一样,你做的是“人员轨迹”,默认按 200km/h 是给外勤车辆留的余量;如果是纯步行的轨迹,阈值可以压到 30km/h 以下,过滤效果更好。
2.3 点太多画不动?用抽稀算法降数据量
有段时间我特别头疼一个问题:定位设备每 5 秒上报一次,一个人跑 8 小时就是 5760 个点,三端同时加载,H5 端画 polyline 直接卡成 PPT。后来我换了个思路:轨迹线在地图缩放级别不够高的时候,根本不需要那么多点。这时候用 Douglas-Peucker 抽稀算法,把偏离轨迹主线很小的点去掉,只保留关键转折点,展示效果几乎无损,但点数能砍掉 70%。
// 简化版 Douglas-Peucker 抽稀 function douglasPeucker(points, epsilon = 0.0005) { if (points.length <= 2) return points const first = points[0] const last = points[points.length - 1] let maxDist = 0 let index = -1 for (let i = 1; i < points.length - 1; i++) { const dist = pointToLineDistance(points[i], first, last) if (dist > maxDist) { maxDist = dist index = i } } if (maxDist > epsilon) { const left = points.slice(0, index + 1) const right = points.slice(index) return douglasPeucker(left, epsilon).concat(douglasPeucker(right, epsilon).slice(1)) } return [first, last] }这里的epsilon是经纬度单位下的距离阈值,0.0005 大概是几十米级别,具体值取决于你需要的精细度。我把抽稀放在后端接口返回前做,因为前端在弱网下直接压几千个点,序列化和渲染都是负担。
3. 地图轨迹绘制的核心实现:polyline、marker、播放器联动
3.1 地图组件的基础搭建
选型定了、数据清洗完,接下来就是实打实写代码。我先给你一版可以直接用在页面上的最小结构。这里用的是 uniapp 内置的<map>组件,配合uni.createMapContext控制地图实例。
<template> <view class="track-page"> <map id="trackMap" :latitude="center.lat" :longitude="center.lng" :scale="14" :markers="markers" :polyline="polyline" :include-points="includePoints" show-location class="track-map" /> <view class="play-bar"> <button size="mini" @click="togglePlay">{{ playing ? '暂停' : '播放' }}</button> <slider :value="currentIndex" :min="0" :max="displayPoints.length - 1" @change="onSliderChange" /> <text>{{ currentIndex + 1 }} / {{ displayPoints.length }}</text> </view> </view> </template>data 部分需要一个完整的轨迹状态。我习惯把原始坐标、展示坐标、当前播放位、地图视野范围都拆开存,这样后续处理逻辑不会混在一起:
data() { return { center: { lat: 39.90469, lng: 116.40717 }, markers: [], polyline: [], includePoints: [], displayPoints: [], currentIndex: 0, playing: false, timer: null } }, methods: { async loadTrack(trackId) { // 请求轨迹接口,拿到原始点 const raw = await this.$api.fetchTrack(trackId) // 1. 坐标转 gcj02 const converted = raw.map(p => { const { lng, lat } = wgs84ToGcj02(p.lng, p.lat) return { latitude: lat, longitude: lng, time: p.time } }) // 2. 过滤漂移点 const cleaned = filterDirtyPoints(converted) // 3. 抽稀(如果点特别多) this.displayPoints = cleaned.length > 1000 ? douglasPeucker(cleaned, 0.0002) : cleaned this.currentIndex = 0 this.updateTrack(0) } }为什么要把 displayPoints 和原始点分开?因为播放器后续要按时间轴逐帧显示,抽稀之后的时间戳还在,不影响回放语义。但如果要做“真实距离统计”,就得用原始点,不能用抽稀后的点,这个教训我记了很久。
3.2 polyline 的配置细节:颜色、宽度、箭头
轨迹线最直观的展示就是 polyline。uni-app 的 map 组件里,polyline 是一个数组,每一项包含points(数组)、color、width、dottedLine、arrowLine等属性。我踩过一个坑:有些版本的 App 端不支持虚线,尤其是在高德渲染层里dottedLine会直接失效,而arrowLine的支持质量也是参差不齐。所以对跨端要求高的场景,我建议把轨迹线做成实线加宽,再叠加一个细的浅色底边,视觉上有层次,兼容性最好。
updateTrack(index) { const current = this.displayPoints[index] const shownPoints = this.displayPoints.slice(0, index + 1) this.polyline = [ { points: shownPoints.slice(0, shownPoints.length - 1), color: '#E6F0FF', // 底边颜色 width: 10 }, { points: shownPoints, color: '#2B7EF7', width: 4 } ] // 起点、终点、当前点三个 marker const first = this.displayPoints[0] const isLast = index === this.displayPoints.length - 1 this.markers = [ { id: 1, latitude: first.latitude, longitude: first.longitude, iconPath: '/static/track-start.png', width: 24, height: 24, label: { content: '起点', fontSize: 11 } }, { id: 2, latitude: current.latitude, longitude: current.longitude, iconPath: isLast ? '/static/track-end.png' : '/static/track-current.png', width: 24, height: 24 } ] // 让地图视野始终包住已走过的点 this.includePoints = shownPoints }这里面有几个细节值得说:
第一,include-points是控制视野的关键。它每帧都会随着播放入口点变化,uni-app 底层会自动把视图缩放到能包住这些坐标的范围。播放到远处的点时,地图不会丢当前 marker 的位置,因为视野是动态的。
第二,marker 的label在不同端上渲染效果差别很大。小程序支持得不错,App 端有时会不显示背景色,所以对关键位置我干脆用 icon 图片来表达,不依赖 label 样式。
第三,起点和终点的 iconPath 要用本地静态路径,不能用网络路径,更不能是 base64。网络路径在 App 端经常加载失败,导致 marker 消失,这是一个排查了很久的坑。
3.3 时间轴播放:setInterval 与 translateMarker 的取舍
时间轴回放是最容易翻车的地方。我在实现时先试了微信小程序端的translateMarker,它能平滑地把 marker 从一个点移动到另一个点,动画效果很酷。可问题是,uni-app 里translateMarker的跨端兼容性很迷,部分 Android 高德地图版本压根没有这个方法,直接调用会报not a function。所以我最终的策略是条件编译:小程序端用translateMarker做动画,其他端干脆直接替换 marker 位置。
play() { if (this.playing) return this.playing = true this.timer = setInterval(() => { this.currentIndex++ if (this.currentIndex >= this.displayPoints.length - 1) { this.stop() return } this.updateTrack(this.currentIndex) // #ifdef MP-WEIXIN const mapCtx = uni.createMapContext('trackMap', this) mapCtx.translateMarker({ markerId: 2, destination: { latitude: this.displayPoints[this.currentIndex].latitude, longitude: this.displayPoints[this.currentIndex].longitude }, autoRotate: false, duration: 800, animationEnd() {} }) // #endif }, 1000) }stop() { this.playing = false if (this.timer) { clearInterval(this.timer) this.timer = null } }这里还有一个常见问题:播放到一半用户拖动 slider,如果定时器还在跑,会出现“播放头乱跳”的现象。我的做法是在onSliderChange里先stop(),再updateTrack(newIndex),让用户手动接管播放状态。等 slider 松手后,如果用户想继续播放再点一次播放按钮,逻辑就很干净。
3.4 备选实现:用 canvas 画一张不依赖地图的轨迹预览图
如果你只是在卡片页面上展示“今日轨迹缩略图”,不要求地图上的路网和地名,我用 canvas 画的预览图是性价比最高的方案。核心思路是把经纬度根据最大最小值归一化到画布坐标:
drawPreview(canvasId, points) { const ctx = uni.createCanvasContext(canvasId, this) const width = 300 const height = 200 const lngList = points.map(p => p.longitude) const latList = points.map(p => p.latitude) const minLng = Math.min(...lngList) const maxLng = Math.max(...lngList) const minLat = Math.min(...latList) const maxLat = Math.max(...latList) ctx.setStrokeStyle('#2B7EF7') ctx.setLineWidth(3) ctx.beginPath() points.forEach((p, i) => { const x = (p.longitude - minLng) / (maxLng - minLng) * width const y = (maxLat - p.latitude) / (maxLat - minLat) * height if (i === 0) ctx.moveTo(x, y) else ctx.lineTo(x, y) }) ctx.stroke() ctx.draw() }注意这里有一个细节:canvas 的 y 轴向下,经纬度 y 轴向上,所以画布上的y要用maxLat - p.latitude取反,否则轨迹会上下颠倒。这种预览图好处是不占内存、加载快、不怕地图 key 配置出错,适合作为列表页的封面图。
4. 常见的鬼问题与性能优化实录
4.1 轨迹线死活不显示?先查这三个地方
我做这个项目遇到过不下五次“polyline 配了但地图上没线”的情况。排查思路基本固定在三个地方:
第一,看points数组里的字段名是不是latitude和longitude。uni-app 的 polyline 点必须叫这两个名字,如果你后端返回lat、lng,直接透传进去就是一条看不见的线。第二,看color属性是不是带#的六位色值,某些端不支持缩写#2B7,老老实实写全。第三,检查points数量太少时是否被include-points挤出了视野范围——两个相距不到十米的点同时放进 include-points,地图会被缩放到最小级别,轨迹看起来像消失在像素里。
4.2 H5 端白屏?先把 key 和域名对清楚
H5 端是这个需求里最容易出问题的:地图 key 配错了,组件白屏;请求第三方服务跨域,接口报错;H5 打包后指向两个域名部署,某个域名下地图模块失效。我一并说一下我的处理经验。
manifest.json 里不同端要配不同的地图 key。微信小程序端是在腾讯位置服务后台申请,App 端如果是高德云图,就在高德开放平台申请;而 H5 端更多是直接加载地图脚本或依赖 uni-map 的 key 配置。所有 key 都关联域名白名单,尤其是腾讯位置服务和微信公众平台的“合法域名”校验。
如果你遇到 H5 端地图正常,但定位到具体地址时请求报跨域,这种我建议不要在前端硬拼 CORS,直接把坐标逆编码这个动作挪到后端代理转发,前端只传经纬度,后端返回地址,既干净又能顺带做缓存。网上的“proxy”方案本质就是后端帮你转发一遍,你也别嫌老,生产环境里最稳的还是这个。
4.3 画面卡顿与内存优化
轨迹播放最怕的就是点太多+刷新频率太高。这里我给出三个经过实测的优化手段。
首先是抽稀,这个前面已经写过代码。其次是includePoints不要每帧都塞全量路径点,实际上只要塞“当前播放点”和“几个即将到达的后续点”,视野就能稳定跟随,又不至于让地图底层做大量坐标范围计算。最后,用v-show而不是v-if切换轨迹页面,避免地图组件反复创建销毁。地图组件销毁再重建是非常昂贵的操作,浏览器端甚至会闪一下白屏。
onSliderChange(e) { this.stop() const val = Number(e.detail.value) this.currentIndex = val this.updateTrack(val) // 只把当前点和后面 30 个点纳入可视范围 const tail = this.displayPoints.slice(val, val + 30) this.includePoints = [this.displayPoints[val], ...tail] }这个优化用户体感差异很明显,老手机上尤其明显。
4.4 App 上架后的地图 key 失效问题
如果你后续要打包上架安卓应用市场,千万别忘了 Android 签名文件的 SHA1 变了,地图 key 就全废了。我第一次打包测试时用的是 debug 签名,后来上应用市场换正式签名,地图直接灰屏,查了一下午才想起来 key 和签名绑定。所以在 manifest 配置地图 key 的时候,一定要预留两套:调试签名一套、正式签名一套。发布后如果地图白屏,第一反应就去检查签名的 SHA1 对不对。
4.5 一个小彩蛋:canvas 导出白图的坑
最后分享一个最近遇到的怪问题,在 iOS Safari 上,我用 uniapp canvas 队列连续draw()多张轨迹截图时,导出的图片经常是白图。后来发现这是因为 canvas 的draw回调在 Safari 上偶尔不同步,导出uni.canvasToTempFilePath时画布还没完成渲染。解决办法很简单:连续导出时给每个draw之间加一个setTimeout200ms 的延迟,或者把多张截图压成一张时,用 Promise 串行处理,不要并发调用。
5. 写在最后的个人体会
这套轨迹绘制功能最终按“地图组件显示 + 播放器回放 + 列表页 canvas 预览”三件套交付了,三端都跑得稳。回头看,真正的核心工程其实不在画线,而是选型、坐标清洗、性能优化这三件事,它们决定了一张轨迹图在用户眼里是“酷炫”还是“拉了”。
我个人现在再做类似需求,一定会先把坐标清洗模块单独拆出来,哪怕只是个工具函数也要写单元测试,因为 GPS 数据的脏程度永远会超出你想象。然后是地图选型,不管产品怎么催,先确认三端哪个端是核心,哪个端是“能看就行”,再决定要不要上原生 SDK。路线定错了,后面所有适配都会变成连环坑。
如果你现在正卡在“轨迹线不显示”或“某个端白屏”这种问题,建议你冷静下来,按我上面的排查顺序走一遍——八成是 key 、字段名、坐标系这三件事。等你把第一张干净的轨迹图画出来的时候,那种成就感还是相当上头的。