ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

高德地图轨迹回放升级:车速展示、倍速与进度条拖拽实战

高德地图轨迹回放升级:车速展示、倍速与进度条拖拽实战 做高德地图轨迹回放这个需求前前后后我改了三个版本。第一版只是把历史轨迹画在地图上车辆图标按顺序跑一遍结果领导看完直接打回来了光有个点在地图上动根本看不出车现在开多快客户看回放跟看无声电影一样。于是第二版加了消息框在车辆图标旁边实时展示当前车速并且让这个速度气泡跟着车一起走。用了一段时间又冒出新问题回放一条一个小时的记录没法快进也没法拖进度客户只能干等体验非常差。第三版才把回放速度调节和进度条拖拽补上也就是标题里说的“升级支持调整速度、回放进度”。这篇文章把这三版从需求拆解、核心实现到排查问题的完整过程梳理一遍给接下来要做车辆轨迹回放的同学一个能直接照抄的参考。1. 需求拆解与技术选型先想清楚再动手1.1 轨迹回放的真实场景往往没有标题那么轻松我这次接到的需求来自一个车队管理平台。车上的T-Box终端每3到5秒上报一条位置数据包含经纬度、时间、方向角和终端计算好的瞬时速度。后台存下来之后需要在地图上回放某辆车某段时间的行驶过程。让人物化一点的场景就是经理在电脑前面看一台车今天上午到底有没有绕路或者在地图上复盘一台车在某个时间段内的车速变化。第一眼看上去这个需求就是“画一条线放一个Marker按时间一个一个点走过去”。但真正做起来就会发现消息框里展示速度并且跟随车辆移动这一条已经把“静态轨迹展示”和“动态轨迹回放”拉开了差距。如果只是画线用高德地图自带的Polyline就能完成如果要回放就得处理播放状态、进度、速度计算和UI同步再加一个可调速度、可拖进度本质上就是在做一个“地图播放器”。如果一开始没把需求拆到这个层面后面很容易被各种边界情况打乱。1.2 选高德地图SDK还是JS API取决于车在哪里看回放平台选型这一步不能拍脑袋。如果回放功能是在安卓车机App里用那直接用高德地图Android SDK最合适如果是在管理后台网页里用就选高德地图JavaScript API如果是在微信小程序里用路线又会不一样这个问题我放到后面单独讲。我当时选定的是Android SDK因为调度员手里拿的是安卓平板回放是封闭场景不发到公网。用高德地图还有一个现实原因国内地图都要求使用GCJ-02坐标系高德自己的SDK天然就是这套坐标省掉了WGS84转GCJ-02的成本。如果选海外地图方案坐标转换和路网匹配都会额外增加不少工作量。这里要提醒一句“高德地图API收费”的坑。很多人以为地图SDK是免费的就可以无脑用实际上高德地图的Web服务API比如逆地理编码、路径规划、地理围栏这些都是有独立配额和计费规则的。轨迹回放本身只用到地图展示和坐标计算基本都在免费额度里。但如果你的需求里还带着“把轨迹匹配到道路”“根据路线算剩余里程”这类能力就可能触发服务端API计费。建议先到高德开放平台的控制台看清楚配额再决定功能范围。1.3 接入前的基础事项Key、权限和坐标系高德地图SDK接入不算难但基础配置错了会在后面浪费大量时间。Android端首先需要在高德开放平台申请Key申请时要填应用的SHA1和包名。这个SHA1在Debug和Release模式下不一样如果只填了Debug的SHA1打出来的正式包地图会显示成空白或者一直定位在海南附近的一个海岛。踩过这个坑之后我的做法是把Debug和Release的SHA1都配进去省得后面反复排查。权限方面轨迹回放需要网络权限、定位权限如果要在回放中显示“当前定位”、读写存储权限用于缓存瓦片。我用的是高德SDK 8.1.0版本在AndroidManifest里加上必要权限在代码里动态申请位置权限。还有一个小细节申请定位权限时用户如果拒绝地图显示不受影响但“定位当前位置”这个功能会不可用。所以要让产品提前想清楚回放页面里到底要不要显示本机定位如果不需要就别申请定位权限避免隐私合规上多一个风险点。坐标转换也是容易忽略的点。T-Box上报的坐标如果是WGS84直接扔给高德地图轨迹会整体偏出去几十米。这种情况需要使用高德SDK提供的CoordinateConverter工具类将WGS84坐标转换成GCJ-02坐标系。如果后台存的时候已经做了一次转换App端就不要再次转换否则会双重偏移。数据链路不同转换节点可能不同一定要在需求阶段就确认好坐标源。1.4 顺手准备离线瓦片别让轨迹画在一堆灰块上做轨迹回放时地图瓦片的加载速度直接决定体验。车辆在地图上移动如果瓦片还没加载出来地图就是一片灰绿色块再流畅的车标动画也白搭。尤其车队管理场景很可能在郊区、工业园区网络环境不一定好。我当时的做法是在App启动后检测当前城市然后提示用户下载高德官方离线地图包。高德Android SDK支持直接下载离线地图用户可以在页面上下载需要回放的省市区瓦片。这样即使到了地下车库或者隧道地图底图依然能显示。离线包体积不小一个省会城市大概几十MB到上百MB不等不能强制下载只能做引导。如果产品场景是给固定几个城市的车辆做回放甚至可以提前把离线包内置到安装包里不过这样会增加安装包体积需要和团队商量着来。2. 第一版实现车辆动起来、消息框跟着跑2.1 轨迹数据清洗先处理掉漂移点再做动画第一版我犯了一个错误拿到后台返回的轨迹点就直接往地图上丢。结果回放到一个桥洞下面时车辆图标突然从桥上瞬移到河里速度显示飙到170km/h然后又瞬移回桥上。原因很简单GPS在隧道、高架下丢星或者反射导致定位点漂移生成了一个异常坐标。如果不清理这些异常点会直接影响速度计算还会让折线穿过建筑物视觉上非常不专业。清洗规则我按三条来按时间戳排序过滤掉时间戳异常比如早于上一个点的数据。过滤连续重复点。如果相邻两个点的经纬度完全一样时间却走了好几秒说明终端没抓到新的定位直接保留一个点即可。过滤漂移点。用相邻两点距离和时间差算一次速度如果速度超过200km/h且持续时间只有两个点就判定为瞬时漂移把后一个点剔除。这里的阈值要看具体场景。私家车在城市道路的极限速度一般不会超过120km/h我把阈值设为200km/h是为了避开高速上偶尔的瞬时加速。阈值不能设得太小否则会把从服务区出来那段加速过程误删掉。数据清洗做完把清洗后的轨迹点数量变化打出来看一眼通常能去掉5%到10%的异常点。2.2 速度计算不要迷信终端上报的速度终端上报数据里其实带了一个“速度”字段但我第一版直接用的时候发现很多终端的速度不更新或者长时间停留在某一个数值。后来我就不太依赖这个字段了而是用相邻轨迹点之间的距离除以时间差来计算速度。计算公式很容易写fun calcSpeedKmh(prev: LatLng, cur: LatLng, prevTime: Long, curTime: Long): Double { val distanceMeters AMapUtils.calculateLineDistance(prev, cur) val timeSeconds (curTime - prevTime) / 1000.0 if (timeSeconds 0.0) return 0.0 return (distanceMeters / timeSeconds * 3.6) }AMapUtils.calculateLineDistance是高德SDK自带的距离计算工具内部做了球面距离转换单位是米。时间差单位是毫秒换算成秒后用“米/秒”乘以3.6就能得到“公里/小时”。但这个直接算出来的速度有个问题点与点之间的距离如果因为GPS噪声出现几十米波动算出来的速度会在0和120之间疯狂跳。轨迹回放场景里消息框数字一直跳会让人感觉很廉价。我的处理方式是做一个5个点的滑动窗口平均只有连续5段速度都算出来以后才把平均值展示在消息框里。平滑后的速度虽然略有滞后但稳定性好得多。2.3 让车辆Marker动起来插值动画与朝向计算高德地图SDK没有直接提供“让Marker沿路径移动”的API需要自己控制Marker的position。第一版相对简单我用一个Handler每隔500毫秒取下一个轨迹点然后直接更新Marker坐标。但这样车辆是一格一格跳过去的动画效果很生硬。后来改成在两点之间做线性插值用ValueAnimator控制一段动画的推进。核心逻辑是这样从当前播放队列里取出两个连续轨迹点start和end指定这段动画的时长duration然后每帧计算一个fraction把Marker位置插值到中间某个位置。val animator ValueAnimator.ofFloat(0f, 1f) animator.duration durationMillis animator.addUpdateListener { animation - val fraction animation.animatedValue as Float val lat start.latitude (end.latitude - start.latitude) * fraction val lng start.longitude (end.longitude - start.longitude) * fraction marker.position LatLng(lat, lng) } animator.start()如果是跨海大桥或者超长隧道这种连续几百米的直路用线性插值问题不大。但如果两点之间有明显弯道直接用直线插值会把车拉出道路。后来我在两个点之间根据距离再等分插入若干中间点让行驶轨迹更贴合道路走向。再进一步就是调用高德道路匹配API把轨迹点贴合到实际路网上但这个会触发服务端计费性价比不高普通回放场景线性等分够用。车辆朝向也很重要。如果车辆图标是一张有朝向的图片不旋转的话车辆会一直“横着走”。我在每个动画帧里计算起点到终点的方位角然后通过marker.rotateAngle设置图片旋转角度。方位角可以用Math.atan2计算val bearing Math.toDegrees( Math.atan2( end.longitude - start.longitude, end.latitude - start.latitude ) ).toFloat()需要根据实际情况对角度做标准化处理否则会看到车头偶尔反过来。2.4 消息框怎么“贴”在车上默认InfoWindow为什么靠不住这部分是标题里最核心的细节也是我第二版踩坑最多的地方。需求要求“消息框内展示车辆速度且随车辆移动”很多人第一反应是用高德地图的InfoWindow。默认InfoWindow确实能展示一个自定义View并且绑在某个Marker上。理论上Marker移动InfoWindow也应该跟着移动。但实际上我在用Android SDK的时候发现当不断更新Marker的position时InfoWindow并不总会自动刷新位置特别是你自定义了InfoWindowAdapter之后有些版本只在Marker创建、点击或者调用showInfoWindow时刷新一次。我试过每次更新Marker位置后重新调用marker.showInfoWindow()这样确实能追上但间隔很短的时候屏幕会明显闪烁消息框一抖一抖的观感很差。后来我换了一个思路不用InfoWindow直接用一个普通Marker来当“消息框”。Marker的图标是用代码动态绘制出来的ViewView里有一个TextView显示速度文本然后用BitmapDescriptorFactory.fromView(view)把这个View转成Bitmap。车辆Marker每移到新位置就把这个消息框Marker放到车辆Marker右上方偏移一点的位置。这样从视觉上看起来就是速度气泡一直跟在车旁边而且不会闪烁。绘制消息框View要注意尺寸。如果每次更新速度都重新创建View再转Bitmap频繁操作内存和图片会造成卡顿。我的做法是把View和TextView作为类成员变量缓存起来速度变化时只更新TextView的文本内容再用同一个View重新生成Bitmap。实测下来性能好很多。2.5 第二版上线后的反馈能跑但只能干等第二版做完后产品验收时提了一个很现实的问题回放一条一个小时的记录调度员不可能盯着一小时看完能不能拉进度条能不能倍速回放所以就有了第三版升级。这个阶段我才真正意识到轨迹回放功能的核心不是地图动画而是播放状态管理。播放、暂停、拖动进度、切换速度、消息框实时刷新五个环节环环相扣只要一处不统一UI就会乱。3. 升级改造可调速回放与进度条拖动3.1 回放速度调整的本质时间缩放而不是硬加速一开始我想得简单以为2倍速就是把每个轨迹点的停留时间减少一半。但实际回放时相邻两个轨迹点之间的真实时间间隔并不均匀有的隔2秒有的隔5秒。如果直接粗暴地把每次动画时间乘以一个系数会导致车辆有时候快得离谱有时候又在原地磨蹭很久。正确的思路是做时间缩放。对每一段轨迹动画原生播放时长应该等于两个轨迹点之间的真实时间差倍速播放时这段动画的时长等于真实时间差除以倍速。比如真实时间差是4秒2倍速时动画时长就是2秒0.5倍速时动画时长就是8秒。这样无论多少倍速整条轨迹的总回放时间和真实行驶时间的比例始终一致。具体实现时我给播放器定义了一个倍速属性speedRate默认是1.0f。播放队列里每取出一组相邻轨迹点计算durationMillis realTimeDiff / speedRate然后交给ValueAnimator去跑。切换倍速时不需要从当前位置重新开始只需要把正在跑的动画按倍速比例重新计算剩余时间重建一个ValueAnimator。3.2 用SeekBar拖回放进度关键是把索引映射做对进度条我用的是Android的SeekBar范围是0到1000。进度条的作用不是精确到毫秒而是让用户能大致跳到回放中任意一个位置。所以进度值到轨迹点的映射可以简单处理fun onProgressChanged(progress: Int) { val targetIndex (progress / 1000f * (trackPoints.size - 1)).toInt() seekToIndex(targetIndex) }这里的targetIndex就是轨迹点列表的下标。0对应第一个点1000对应最后一个点。seekToIndex做的事情包括暂停当前动画、把车辆Marker直接移动到目标点、刷新消息框速度、刷新进度条位置。用户松手后如果有自动播放的需求就从targetIndex的下一个点继续播放。这里有一个容易踩的坑如果拖动进度条后你直接把handler.removeCallbacksAndMessages(null)清掉所有回调那么这个点之后的所有动画都被清空再次启动播放时如果没重新设置当前索引车辆会从头开始播放。所以必须把“当前播放索引”抽成播放器的一个全局状态任何跳转操作都更新这个状态而不是依赖Handler队列里的隐式位置。3.3 消息框、Marker、进度条三处状态必须同步正常播放过程中同一时刻有三个东西在变车辆Marker的位置、消息框里的速度文本、进度条的progress。如果这三处各写各的很快就会出现“车已经到第50个点了速度框还显示第30个点的速度”这种错位。我后来把UI刷新统一到一个方法里fun syncUiToIndex(index: Int) { updateMarkerPosition(index) updateSpeedInfo(index) updateProgressBar(index) }无论正常播放走完一个点还是用户拖动进度条跳转都只调用这一个方法。正常播放时每播放完一段轨迹索引加1调用一次拖动进度条时计算出targetIndex后也调用一次。这样无论什么操作三处UI始终基于同一个索引问题就少了一大半。3.4 调速状态下的Handler调度管理我用的是Handler Runnable来做播放调度而不是Timer。Timer在Android里有个问题如果消息循环稍有阻塞TimerTask的执行时间就会漂移不适合做逐帧动画。Handler.postDelayed的调度精度在普通回放场景足够用。调速时最麻烦的是旧任务没清理干净。我第一版调速逻辑里切换倍速时只是把speedRate改了忽略了旧的Runnable可能还在Handeler队列里。结果出现两个Runnable同时往外取轨迹点Marker一会儿往前走一会儿又跳回去。正确做法是每次切换倍速时先handler.removeCallbacksAndMessages(null)把所有跟播放相关的回调清掉再根据当前索引重新post下一个动画任务。如果用户操作很频繁反复切换倍速和拖动进度条Handler里的回调很容易堆积。我在播放器的所有入口都统一走stopPlaybackInternal()和startPlaybackFromIndex(index)两个方法确保一条路径进、一条路径出避免状态混在一起。4. 实测中的问题与排查思路4.1 消息框不跟着车辆走卡在最开始的位置第一版升级后测试同事反馈说“速度气泡有时候在原地不动车走了气泡还在起点”。这个问题的根源就是我前面提到的默认InfoWindow刷新机制。排查方式很简单在更新Marker位置的回调里打日志打印Marker的position确实变了但再看截图的InfoWindow位置没变说明是SDK内部没有刷新信息窗位置。解决方式是改用独立Marker作为消息框载体。改完后车辆Marker每移动一帧消息框Marker也跟随设置新位置。我还为消息框Marker单独设置了一个z轴层级避免它被道路或者其他覆盖物挡住。具体到高德SDK可以通过marker.setToTop()来控制显示层级。4.2 拖动进度条卡顿、闪屏SeekBar拖动时onProgressChanged回调非常密集如果每次都重新创建速度消息框的View卡顿在所难免。我第一次实现时在seekToIndex里直接调用了createSpeedBubbleView()导致拖动过程中频繁inflate布局、加载Bitmap机型稍旧一点就开始掉帧。优化方向有两个。第一消息框的View实例在播放器初始化时创建一次后续只改TextView的文本不重新创建View。第二拖动过程中不需要每1毫秒都刷新一次消息框我增加了一个节流判断只有目标索引变化超过3个点时才刷新消息框文本。车辆Marker的位置必须实时刷但速度文本可以稍微滞后一点人眼很难察觉。还有一个细节拖动进度条时如果轨迹点很多不要每次都用aMap.clear()清掉所有覆盖物再重新画Polyline。Polyline的更新开销很大容易出现黑屏闪动。正确做法是拖动过程中只更新Marker和消息框Polyline保持不动只有当跳转后的索引跨过当前Polyline覆盖范围时才考虑重新绘制折线。4.3 弱网/无网环境下的瓦片和离线包处理回放功能在弱网环境下的表现非常影响用户信心。我在4G信号不稳的仓库园区实测地图经常只渲染出当前视野范围内的瓦片车辆开过去之后瓦片还没加载完地图上出现大片空白。解决手段就是前面提到的离线地图包在高德SDK初始化完成之后检测当前城市是否已有离线包没有就弹窗引导下载。这里要注意离线包更新策略。有的离线包会自动更新当网络恢复时可能偷偷下载更新包如果用户正在回放会占用网络资源。我的做法是让用户在设置页手动更新离线地图回放页面不自动触发。虽然少了自动化提醒但稳定性更重要。在我做的Web端管理和小程序端方案里弱网问题也可以用瓦片缓存来处理。高德JS API支持瓦片缓存加载过的地图块会缓存在浏览器中但小程序内嵌WebView时缓存策略可能不一致回放期间仍可能出现灰块。后来我在小程序端干脆做了一个降级方案回放页默认显示路线示意图用户可以点击“在导航App中打开”跳转到高德App查看避免在小程序内加载大量瓦片导致内存暴涨。4.4 瞬时速度突变180km/h的“幽灵车”这是数据清洗不彻底导致的残留问题。测试环境里有一段轨迹车辆明明在等红绿灯速度却突然跳到180km/h然后又回到0。原因是一个坐标点漂移到了几百米外用这段距离除以几秒的时间差算出来的瞬时速度自然高得离谱。我的处理方案是清洗阶段再做一步“合理性校验”用整条轨迹的平均速度和95分位速度作为参考如果某段算出来的速度超过参考值的3倍就认为该点异常将该点的速度标记为不可信并用前后两个点的速度做线性插值填充。这里的倍数关系要根据场景调跑高速的车队正常速度就高阈值设小了会把真实高速行驶数据误删。另外消息框里显示的速度尽量用“最近3段速度的平均值”而不要用当前一段的瞬时速度。这样即使清洗漏掉一个异常点消息框数字的变化也会平滑很多。等红绿灯这种场景平滑后的速度能很快降到0但不会出现从180突然掉到0的夸张跳变。4.5 小程序接入高德地图的几条参考不少人在小程序里直接搜“高德地图”SDK结果发现官方并没有提供完整的小程序原生SDK。目前主流做法有两种一种是在WebView里嵌入高德地图JS API的H5页面小程序和H5之间通过postMessage通信另一种是直接跳转高德App通过地图URI API唤起高德。后者适合“查路线”“导航”这种强意图场景但轨迹回放要求用户在地图上连续观看跳转App体验很碎。如果选了WebView方案需要注意高德JS API需要对域名做白名单配置而且小程序WebView的域名校验比较严格开发环境经常因为域名没备案导致地图加载不出来。这时候先用H5页面在浏览器里调通再嵌套进小程序会顺很多。另外JS API有并发限制和配额限制多人同时回放时要预留足够配额否则接口频繁报错别等上线了才发现是高德API收费和配额限制在背后卡你。5. 最后说几句实操体会这三版做下来我最深的感受是轨迹回放这类功能真正花时间的地方不在“画线”和“动点”而在播放状态管理。播放、暂停、拖动、调速、速度展示五个状态互相交错任何一个没同步UI就会乱。动手之前最好先拿一张纸把状态机画一遍想清楚每个操作会改变哪些状态再开始写代码后面能少改很多。另外消息框这个细节很容易被当成“小功能”但它恰恰是用户感知最强的部分。车标只是背景速度气泡数字一跳一跳客户才会觉得“这系统真的在实时反映车辆状态”。先把车标和消息框的联动打磨好后面加进度条、加调速都是在同一套骨架上长出来的。最后再分享一个小技巧轨迹回放功能上线后建议保留一份真实脱敏轨迹数据用于回归测试。不要只用接口返回的标准轨迹一定要用真实车跑出来的脏数据。因为脏数据里的漂移点、停顿、绕路才最能检验你的清洗和回放逻辑到底扛不扛得住。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进