ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

uniapp跨端人员轨迹绘制实战:从坐标清洗到地图渲染全流程

uniapp跨端人员轨迹绘制实战:从坐标清洗到地图渲染全流程 上个月接了个外勤人员的轨迹回放需求要在 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 classtrack-page map idtrackMap :latitudecenter.lat :longitudecenter.lng :scale14 :markersmarkers :polylinepolyline :include-pointsincludePoints show-location classtrack-map / view classplay-bar button sizemini clicktogglePlay{{ playing ? 暂停 : 播放 }}/button slider :valuecurrentIndex :min0 :maxdisplayPoints.length - 1 changeonSliderChange / text{{ currentIndex 1 }} / {{ displayPoints.length }}/text /view /view /templatedata 部分需要一个完整的轨迹状态。我习惯把原始坐标、展示坐标、当前播放位、地图视野范围都拆开存这样后续处理逻辑不会混在一起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 、字段名、坐标系这三件事。等你把第一张干净的轨迹图画出来的时候那种成就感还是相当上头的。
返回列表