从接手这个项目到现在差不多两周时间,基本功能已经稳定跑起来了,这里把整个开发过程、踩过的坑和一些关键思路完整记录下来。项目本身不算复杂,核心就一件事:在uniapp地图上把一个人的移动轨迹实时画出来,同时支持轨迹回放。
先说一下背景。这个需求来自一个园区内部人员定位系统,管理人员需要在后台和手机端同时看到巡逻人员在园区内的移动路线,方便调度和追溯。要求适配微信小程序、Android App和H5三个端,其中小程序和App是主要的落地场景。技术栈指定是uniapp,地图服务当时没有硬性指定,给我们留了选型空间。
先说结论,我最终选择了高德地图作为基础地图服务,配合uniapp内置的map组件完成轨迹绘制。为什么这么选、中间踩了哪些坑、具体怎么实现,下面逐一展开。
1. 需求拆解与技术选型
1.1 核心需求其实只有三件事
人员轨迹绘制拆开来看,本质上就三个核心链路:拿定位点、存点、画线。
拿定位点是指实时获取人员当前位置的经纬度,这个需要用到定位服务;存点是指把连续采集到的坐标点按时间顺序串联起来,形成一条完整的数据链;画线则是在地图上用折线把这一串点渲染出来,形成可视化的移动路线。
但实际落地时,这三个环节每一个都有不少细节要处理。定位点怎么拿才稳定?采集频率多高才不会漏点也不会过度消耗电量?画线时点太多会不会卡?轨迹回放怎么做得流畅?这些都是隐藏在工作量背后的难题。
除了基础功能,还有两个非功能需求也必须重视:一是跨端兼容,因为要同时跑在小程序和App上,两端的定位API、地图组件、权限机制差异非常大;二是准确度,轨迹不能是跳来跳去的,需要有一定的平滑处理。
1.2 地图服务选型:高德、百度还是腾讯
在uniapp里做地图,可选的主要有几条路线,我整理了一下它们的差别。
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 高德地图 | uni-app官方插件生态好,文档全,覆盖小程序和App | 部分功能在H5端表现一般 | 需要跨小程序和App的正式项目 |
| 百度地图 | 地图数据在某些区域更丰富,API稳定 | uni-app集成相对麻烦,插件更新慢 | 已有百度系基础服务的团队 |
| 腾讯地图 | 微信生态结合好,小程序上原生支持 | 独立App端集成成熟度不如高德 | 以微信小程序为主的轻量项目 |
| uni内置map组件 | 一套代码多个端都能跑,无需单独申请Key | 功能受限于组件本身,定制化难 | 需求简单、无复杂交互的项目 |
我最终选择高德,原因很实际:uniapp官方有完善的高德地图插件支持,微信小程序端可以直接通过map组件绑定高德地图,App端也能通过原生SDK拿到高性能的定位和地图渲染能力。最关键的是,高德的定位SDK在室外精度表现比较稳定,这一点对轨迹绘制是致命的——如果定位点乱跳,画出来的轨迹就是一团乱麻。
1.3 整体技术方案怎么定的
技术方案我分了三层来设计。
表现层使用uniapp的map组件,配合markers(人员位置标记)和polyline(轨迹线)来渲染。地图组件是uniapp内置的,各端都有原生兼容,不需要自己造轮子。
数据层维护一个坐标点数组,按时间顺序存入本地storage,同时接口上报到服务器。这里有个设计细节:为了支持轨迹回放和轨迹重绘,我不仅存坐标点,还每一条轨迹记录一个时间戳数组,这样回放时可以按时间轴走。
逻辑层负责定位管理、轨迹抽稀、坐标系转换、轨迹完整性校验等。这一层是核心,也是后面要重点展开的部分。
注意:地图组件在不同端的底层实现并不一致,微信小程序用的是腾讯地图的渲染,App端用的是高德原生SDK,H5端则取决于浏览器环境。这会导致同一套坐标在不同端有微小差异,后面我会讲怎么处理。
2. 核心原理与数据链路详解
2.1 坐标系:你拿到的坐标可能不是同一个坐标系
做轨迹系统,第一个必须搞懂的概念是坐标系。国内地图服务使用的坐标系不是全球通用的WGS-84,而是经过加密偏移的GCJ-02,俗称"火星坐标系"。部分地图服务使用的BD-09又是基于GCJ-02再做二次偏移。
具体来说,手机GPS芯片直接返回的坐标是WGS-84标准,而高德地图、腾讯地图内部使用的都是GCJ-02。如果你直接把GPS的原始坐标扔给高德地图去标点,点位会偏移几百米甚至更远——在户外空旷地带这个误差可能不明显,但在楼宇密集的园区里误差会直接导致轨迹穿墙,看起来很离谱。
| 坐标系 | 全称 | 特点 |
|---|---|---|
| WGS-84 | 全球定位系统标准坐标系 | GPS原始输出,国际通用 |
| GCJ-02 | 火星坐标系 | 国内地图通用,经过加密偏移 |
| BD-09 | 百度坐标系 | 在GCJ-02基础上再次偏移 |
处理方式很简单:uni.getLocation在微信小程序端返回的默认就是GCJ-02坐标,但在App端可以通过type参数指定坐标系类型。为了保证各端统一,我在App端定位时显式指定返回GCJ-02坐标,小程序端保持默认,这样落到地图上就不会出现偏移。
如果是后端下发坐标,那就必须在接口层约定好坐标系,必要的时候写一个WGS-84转GCJ-02的转换函数。这个转换算法是公开的,高德开放平台文档里有提供,uni端可以直接用。
2.2 定位采集机制:不是拿到坐标就完事
定位采集是整个轨迹系统最源头的一环,也是最容易出问题的地方。uniapp里拿定位有几种方式,我在这个项目里都试过,说下区别。
uni.getLocation是单次定位,调用一次返回一个坐标点,适合"临时定位"的场景,比如用户点击"我在哪"。但如果要实现持续轨迹记录,就得在一个定时器里反复调用,或者使用持续定位接口。频繁调用getLocation有两个问题:一是定位间隔不好控制,二是部分端会频繁拉起定位导致耗电严重。
uni.startLocation是持续定位接口,可以设置每次定位的时间间隔,定位结果会通过回调持续上报。这个接口更适合轨迹采集场景。Apple和Android都支持后台持续定位,但权限要求各不相同,这一点在App端要特别注意权限配置。
我自己实测下来的建议是:Android端定位间隔设置到2000-5000毫秒比较合理,既能保证轨迹连续,又不会对电量造成太大影响。如果是步行巡逻场景,2000毫秒采集到的点已经足够密了,后面再配合抽稀算法减点即可。
还有一个小细节,定位结果里有个accuracy字段,代表着这次的定位精度。实测中发现,刚进入室内或者信号不佳的区域时,精度值会突然变大,有时候从10米直接飙到100米。这时候如果还继续把坐标点写入轨迹,就会出现"漂移点"。我的做法是:丢弃精度大于50米的点位,同时保留最近一次有效点,等精度恢复后再继续连线。
2.3 轨迹抽稀:点太多不是好事
轨迹采集久了之后,坐标点会积累得非常多。假设2秒采集一个点,一个人巡逻1个小时,就是1800个点。如果直接用这1800个点在地图上渲染折线,性能压力会成倍增加,尤其在低端Android机上会出现明显的卡顿和掉帧。
这里就需要抽稀算法。最常用的是道格拉斯-普克算法(Douglas-Peucker),原理是用一条直线连接轨迹的首尾点,计算中间每个点到这条直线的垂直距离,如果最大距离小于阈值,就认为该点可以忽略;反之则保留那个点,并递归处理。抽稀阈值一般设置在5到10米,既能保持轨迹形状,又能大幅减少点的数量。
我在项目里用的就是这么干的。抽稀算法放在每次轨迹停止后执行,实时记录时还是全量保存原始点,停止的时候统一做一次清洗,把清洗后的精简轨迹上报后端和用于回放。
提示:实时画线的时候不要每次都从头到尾渲染整条折线,性能会很差。我采用增量式更新:地图只画最近一段新轨迹,整体轨迹在轨迹停止或回放时才完整渲染。
3. 实操落地:从零实现轨迹绘制
3.1 工程初始化和地图SDK接入
先讲一下工程怎么搭。我用的HBuilderX创建了uniapp项目,Vue3版本。因为要跨微信小程序、Android和H5三个端,我直接选用了vue3模板,没有用Vite版本,主要是为了HBuilderX的云打包链路更顺,避免一些工程配置上的坑。
地图接入这里要分两步走:微信小程序端需要在小程序公众平台后台配置高德地图的域名白名单,同时在高德开放平台申请Web服务API Key;App端需要在高德开放平台申请Android平台的Key,并且把包名、签名等信息配对。这里有一个非常容易踩的坑:高德的Android Key校验是绑死包名+签名的,如果你云打包和正式打包用不同的签名,就必须分别申请对应的Key,否则地图就是白屏,连报错都没有。
H5端相对简单,直接在代码里配置Key就行。但H5端如果部署的域名不止一个,需要在高德后台把多个域名都加入白名单,否则线上环境请求会被拦截。
3.2 实时定位与轨迹点采集的核心代码
定位采集的核心逻辑我封装成了一个组合式函数,这里把关键代码贴出来。
// useTrackLocation.js import { ref } from 'vue' export function useTrackLocation() { const tracking = ref(false) const currentPoint = ref(null) const trackPoints = ref([]) function startTrack() { // #ifdef MP-WEIXIN uni.startLocation({ type: 'gcj02', interval: 2000, success: () => { tracking.value = true } }) // #endif // #ifdef APP-PLUS uni.startLocation({ coordsType: 'gcj02', type: 'gcj02', interval: 2000, accuracy: 'high', success: () => { tracking.value = true } }) // #endif // #ifdef H5 const watchId = navigator.geolocation.watchPosition( (pos) => { const { latitude, longitude, accuracy } = pos.coords handleNewPoint({ latitude, longitude, accuracy }) }, (err) => console.error('定位失败', err), { enableHighAccuracy: true, maximumAge: 1000, timeout: 5000 } ) // #endif uni.onLocationChange((res) => { const point = { latitude: res.latitude, longitude: res.longitude, accuracy: res.accuracy || 0, timestamp: Date.now() } handleNewPoint(point) }) } function handleNewPoint(point) { if (point.accuracy && point.accuracy > 60) { return } currentPoint.value = point trackPoints.value.push(point) } function stopTrack() { uni.stopLocation() tracking.value = false } return { tracking, currentPoint, trackPoints, startTrack, stopTrack } }这段代码里有几个值得注意的地方。
// #ifdef MP-WEIXIN、// #ifdef APP-PLUS、// #ifdef H5是uniapp的条件编译语法,每个端编译时只保留对应的代码,这是跨端开发的核心手段。小程序和App的定位API虽然都叫startLocation,但参数不完全统一,所以必须分开写。
onLocationChange这个监听器在不同端的生命周期表现不太一样。在App端,只要startLocation成功,这个监听就会持续回调;在微信小程序端,还需要配合后台配置的定位权限说明才能在退到后台时继续定位。
还有一点,我丢弃了精度大于60米的点。这个阈值是我在实际测试中调出来的,园区场景下室内外混合,60米以下的口径能过滤掉大部分漂移点,又不会太激进地砍掉有效点。
3.3 折线绘制与动态更新实现
轨迹线用map组件的polyline属性渲染。uniapp的polyline接收一个坐标点数组,底层会帮我们画一条连续的折线。
<template> <map id="trackMap" :latitude="mapCenter.latitude" :longitude="mapCenter.longitude" :markers="markers" :polyline="polyline" :scale="16" show-location @regionchange="onRegionChange" ></map> </template> <script setup> import { computed, ref } from 'vue' const trackPoints = ref([]) const currentPostion = ref(null) // 增量折线:只展示最近50个点,避免重复渲染 const displayPolyline = computed(() => { if (trackPoints.value.length === 0) return [] const tailPoints = trackPoints.value.slice(-50) return [{ points: tailPoints, color: '#FF6600', width: 4, arrowLine: true, borderColor: '#FFFFFF', borderWidth: 1 }] }) const markers = computed(() => { if (!currentPostion.value) return [] return [{ id: 1, latitude: currentPostion.value.latitude, longitude: currentPostion.value.longitude, iconPath: '/static/location.png', width: 32, height: 32, callout: { content: '当前位置', display: 'BYCLICK', borderRadius: 6, padding: 6, color: '#333333' } }] }) </script>这里有个设计重点:displayPolyline用了computed且只保留最近50个点。原因是map组件的polyline属性每次变化都会触发整条线重新渲染,如果数据量很大,地图会频繁刷新,操作时能明显感觉到卡顿。只保留尾巴上的50个点,画面看起来还是连续的一条线,但性能完全不一样。
轨迹的完整历史线则保存在trackPoints里,不直接渲染到地图上。这样设计的好处是:实时模式下地图始终保持流畅,需要查看完整轨迹时再一次性渲染全量精简点。
还有个细节是箭头的配置:arrowLine: true用来显示轨迹方向。这对人员轨迹回溯特别重要,光一条线看不出移动方向,加了箭头后整个移动方向一目了然。实测下来,4号线的宽度加箭头画出来比较清晰,太细了会看不清箭头,太粗了又遮挡底图。
3.4 轨迹回放功能怎么实现
轨迹回放是我觉得整个项目里最有意思的部分。需求是:选一个人某段时间的轨迹,地图上动态重演他的移动过程。
实现思路其实不复杂,本质是一个定时器驱动的坐标数组遍历。每间隔一段时间,就往后推几个点,同时更新地图上的"当前轨迹"折线和"当前位置"标记。
function startPlayback(points, speed = 1) { let index = 0 const playbackTimer = setInterval(() => { if (index >= points.length) { clearInterval(playbackTimer) return } const p = points[index] // 更新当前播放位置 currentPostion.value = { latitude: p.latitude, longitude: p.longitude } // 更新已播放轨迹 playbackPoints.value.push(p) // 让地图中心跟随 mapCenter.value = { latitude: p.latitude, longitude: p.longitude } index += speed }, 200) }速度控制上我做了倍速选择:1倍、2倍、4倍。这里的speed指的是每次掠过几个点,不是时间倍率。如果点的采集间隔是2秒一个,那么1倍速就是每200毫秒往前推进一个点,播放速度比真实时间快10倍。如果希望接近真实速度,就应该把定时器间隔调到2000毫秒,但这样等待时间太长,实际使用中没人会等。
所以我把播放速度和点密度解耦了,默认用200毫秒一个点的节奏播放,提供倍速选项。这样不管原始点有多密,回放节奏都是可控的。
注意:回放完记得清除定时器,否则组件销毁后定时器还在跑,页面却已经没有了,轻则白屏闪一下,重则直接报内存泄漏。可以在onUnload里统一清理。
4. 跨端适配与打包经验
4.1 微信小程序的差异化处理
微信小程序端是这套系统最常见的落地载体,但小程序平台有一些特殊的限制,我踩过几个实实在在的坑。
第一是后台定位权限。小程序在用户退到后台之后,默认会停止定位,如果轨迹系统需要用户切到微信后台时继续记录位置,必须在app.json里声明requiredBackgroundModes: ['location'],同时在小程序后台的"设置-接口权限"里申请"后台定位"能力。这个能力不是所有小程序类目都能申请的,个人主体小程序基本无缘,企业主体也需要提交审核材料。
第二是从后台回到前台的数据恢复。即使申请了后台定位,小程序被系统回收后代码会重新执行,之前内存里的轨迹数组会丢失。我在本地Storage里同步保存了轨迹点,每次新进入页面时会先检查Storage里有没有未上报的轨迹数据,有就自动补回。
第三是小程序端的map组件有层级限制,markers和polyline都在地图组件内部渲染,无法被普通view盖住。所以所有控件都要放在map组件外部,或者用cover-view来实现。这个限制也挺容易踩的,在小程序里放一个遮罩引导层,用普通view会被地图盖住,必须用cover-view。
4.2 App端的权限与打包配置
App端绕不开权限申请和打包配置。uniapp打包成APK有两种方式:云打包和本地打包。云打包方便,只要在HBuilderX里填好包名、证书等参数,云端自动出包。本地打包需要下载Android Studio和对应的SDK,适合需要深度定制的场景。我这边的项目因为不需要原生插件,直接用了云打包,效率最高。
App端定位权限要注意Android 13+的颗粒化定位权限。以前只分"粗略定位"和"精确位置"两档,现在权限模型中还引入了"仅本次运行允许"选项。如果用户选择了仅本次运行允许,App退到后台后定位就会被系统切断,需要主动引导用户改成"始终允许"才能持续记录轨迹。
在manifest.json里,需要明确的权限声明包括:
| 权限名 | 用途 | 备注 |
|---|---|---|
| ACCESS_FINE_LOCATION | 精确定位 | 必须声明 |
| ACCESS_COARSE_LOCATION | 粗略定位 | 建议同时声明,兼容部分机型 |
| ACCESS_BACKGROUND_LOCATION | 后台定位 | Android 10+需要单独声明 |
| ACCESS_NETWORK_STATE | 网络状态检查 | 定位服务往往依赖网络定位 |
iOS端的权限是另一个套路。iOS定位权限弹窗文案必须写清楚为什么需要定位,如果用户拒绝权限,后续无法通过代码再弹一次,只能引导用户去系统设置里手动开启。我的做法是:第一次拒绝后,在页面上展示一个引导卡片,提示去设置里打开权限,实测下来比直接再次弹窗体验好很多。
4.3 H5端与多域名部署问题
H5端是这个系统里最省事但也最容易被忽略的端。省事在于不用处理原生权限,直接调用浏览器的navigator.geolocation接口;容易被忽略在于跨域和域名白名单。
如果H5页面部署的域名不止一个,比如测试环境一个域名、正式环境一个域名,高德地图的Key就必须同时在后台把两个域名都添加上。这里有个细节:高德后台的域名白名单是按HTTP头里的Referrer判断的,如果你用的是IP访问,或者通过本地代理转发,Referrer可能为空或者携带的是代理服务器域名,这时候请求会被拦截,地图加载不出来但不报错,排查起来非常费劲。
H5端的轨迹记录还有一个限制:浏览器在页面切到后台后,对定时器的执行会节流甚至暂停。也就是说,用户把浏览器标签页切到后台,轨迹记录就基本停了。这个问题在移动端浏览器上尤其明显。如果H5端也有持续记录轨迹的需求,最好加入Web Worker或者用socket连接配合后端去做位置上报,而不是纯依赖前端定时器。
4.4 打包上架的一些建议
关于上架Android应用市场,有几点经验值得分享。
首先是隐私政策必须写到位。现在的应用市场对隐私合规审查非常严格,定位权限属于敏感权限,隐私政策里必须明确说明搜集定位信息的目的、使用范围、保存期限,否则审核直接驳回。我遇见过一个应用因为隐私政策里没有单独说定位权限用途,被打回三次才通过。
其次是小程序端的审核。涉及地图展示的小程序,页面里需要有适当的标注或者示意,不能只是一个光秃秃的地图。部分类目的小程序接入地图能力时还需要提供相关材料,提前准备好可以省不少时间。
5. 常见问题与踩坑实录
5.1 问题速查表
把我在这个项目里实际遇到的典型问题整理成了一张表,如果不是很确定问题出在哪里,可以直接对照排查。
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 地图白屏,什么都没有 | 高德Key未配置或包名签名不匹配 | 检查manifest.json中的应用Key配置,核对高德后台的包名和签名 |
| 定位点长时间不动 | 定位权限未授予 | 检查manifest.json权限声明,引导用户开启定位权限 |
| 定位点大范围跳动 | 室内定位信号差,精度值大 | 在定位回调里判断accuracy,过滤低精度数据 |
| 轨迹线穿墙或明显偏移 | 坐标系不统一 | 统一到GCJ-02坐标系,检查拿到的坐标是WGS-84还是GCJ-02 |
| polyline数据量大时卡顿 | 参与了全量点的渲染 | 改用增量渲染,只渲染最近一段轨迹 |
| 回放开始后页面卡死 | 定时器未清理,反复触发 | 在onUnload中清除定时器,增加防重入判断 |
| H5端地图加载失败 | 域名未加入白名单 | 在高德后台将所有部署域名加入白名单 |
| App后台一段时间后轨迹断了 | 系统限制了后台定位 | 引导用户设置"始终允许",申请后台定位权限 |
| 微信小程序退后台轨迹丢失 | 内存被回收或定位被暂停 | 本地Storage同步保存,重进页面时恢复数据 |
| 地图显示时总是以不恰当的中心点 | 未处理地图中心跟随 | 每次更新点时同步更新mapCenter,或者使用include-points自动调整视野 |
5.2 最容易被忽视的三个细节
第一个是onLocationChange在App端的生命周期问题。onLocationChange要在startLocation成功之后才能触发,如果startLocation失败,比如权限没有授权,这个监听永远不会回调。很多人在权限被拒绝后反复确认代码逻辑,其实问题出在启动定位的时机上。我建议在onReady后再启动定位,不要在onLoad里就调用,因为onLoad时页面还没完全渲染,部分端的权限弹窗和回调可能没有就绪。
第二个是地图组件的scale值和轨迹显示的矛盾。地图缩放级别太大,轨迹只显示一小段,看起来不完整;缩放级别太小,轨迹缩成一团看不清细节。我做了个处理:实时跟踪时固定scale为16,轨迹回放开始时用include-points让地图自动适配全部轨迹点,播放过程中再逐步缩小到合适的级别。这样既能看到全貌,又不影响局部细节。
第三个是轨迹点的时序性。同一个人的轨迹点虽然存在数组里,理论上是有序的,但如果定位回调出现乱序,画出来的轨迹就会来回乱转。我在写入轨迹数组时加了时间戳判断:如果新点的时间戳小于最后一个点的时间戳,就跳过这个点。这样可以有效避免部分端在信号恢复时把缓存的老点位插到新点位后面。
5.3 一些建议和经验
代码层面建议把轨迹采集和轨迹渲染完全解耦。采集只管往数组里塞点,渲染层通过computed或者watch来响应变化。这样即使采集频率很高,渲染层也有机会做节流和合并,不会因为采集频率导致渲染卡顿。
性能调优上,除了抽稀和增量渲染,还有一个技巧:在地图视野变化时暂停折线更新。用户看地图时正在拖动地图,系统每200毫秒画一条新线,画线本身没问题,但触发的地图重绘会跟用户手势打架,导致地图卡顿感明显。我是在regionchange事件里判断,如果地图处于拖拽状态就暂停更新,等拖动结束再补上新点。
数据上报方面,建议轨迹数据不要一条条上报,而是攒够一定量或者每次停顿后作为一个批次上报。一个是减少网络请求,另一个是轨迹本身是有完整性的,单独一个点说明不了问题,只有一条完整的轨迹才有分析价值。
最后再说一点体会
做这个项目最大的感受是:看似简单的功能,把细节抠深了才发现水很深。坐标系、定位精度、跨端差异、性能优化,随便拉出来一个都能埋不少坑。我自己在这一周里来回调试定位参数就花了大半天,最终把阈值焊死在60米时,才真正看到了干净稳定的轨迹线。
如果你现在也在做类似的人员轨迹绘制,我建议你先把坐标系和定位精度这两个地基打牢,再开始写画线代码。否则后面每加一个功能,前面的坑都会重新翻出来找你。上面这些代码和方案,基本可以直接拿去用,只要注意把Key和包名换成你自己的就行。祝顺利。