开局两张图,剩下全靠吹
基于vue+百度地图的多车实时运动及轨迹追踪实现,共分为上下两篇,分别为上 篇“心路历程篇”和下篇“上帝视角篇”,上篇是背景介绍和我实现过程中走的弯路,下篇是最终版的实现方案。其实并不存在上帝视角,只希望有一天,我们可以通过不断复盘,少走点弯路。 本篇是下篇,上帝视角篇。相关背景可查看上篇
不同于上篇内心戏满满,本篇内容过干,请自带水杯
剥离响应式数据和非响应式数据
除了页面上需要通过数据变化实时改变状态的数据,其他数据全部改为非响应式,直接在created生命周期中赋值即可。
created钩子函数是在vue完成数据拦截后调用的,所以这里定义的变量是不会自动被vue监听的。
例如websocket连接,缓存的车辆信息,定时器等变量,是不需要监听的,尤其是车辆信息,后端消息发送过来很快,如果每条消息过来引起车辆信息变化都触发vue的递归更新,是很慢的。这些信息均定义到created中。
created () {
this.socket = null // 实时socket连接
this.cachedCarArr = [] // 车辆信息
this.removeDelayed = 3000 // 预设3秒后车辆消失
this.carTimer = {} // 车辆消失前的计数器
this.drawingLine = '' // 正在画实时轨迹的车
this.carLineAnimation = [] // 记录车的requestAnimationFrame id,便于后续取消
this.loopLineTimer = [] // 记录车的setTimeout id,便于后续取消
this.carLineAniInstances = {} // 正在画实时轨迹的车的动画实例
this.trackCar = '' // 用于debug某个车
},
创建version变量来实现车辆列表的响应式
上面提到车辆信息存为了非响应式变量,那如何动态显示右侧的列表信息呢?这里有个变通的方法,在data()中定义一个top10List和一个version变量,当有新车过来的时候,version++,当一辆车消失的时候,version--。
同时监听version的变化,当version变化时,实时计算数组中前10个车,赋值给top10List。列表始终显示这10个车即可。
watch: {
version: {
handler () {
this.top10List = this.cachedCarArr
.filter((car) =>
car.trace.find((t) => t.status === 'drawing' || t.status === 'drawed')
)
.slice(0, 10)
}
}
}
设计存储车辆信息的数据结构
把车辆的运动看成一个状态机,每个点的状态都从'undraw'->'drawing'->'drawed'单向改变。接收信息时,车辆的初始状态为'undraw'
// 对于新车
const newCar = {
...car,
trace: [
{
lng: car.Longitude,
lat: car.Latitude,
rotation: car.PtcHeading,
speed: car.speed,
status: 'undraw',
timestamp: car.Timestamp
}
]
}
this.cachedCarArr.push(newCar)
this.drawCar(item)
this.version++
// 对于已有的车
this.cachedCarArr = this.cachedCarArr.map((item) => {
if (item.id === key) {
item.trace.push({
lng: car.Longitude,
lat: car.Latitude,
rotation: car.PtcHeading,
speed: car.speed,
status: 'undraw',
timestamp: car.Timestamp
})
}
return item
})
多车实时运动
1. 画车
const pointT = transform({
lng: car.trace[0].lng,
lat: car.trace[0].lat
})
const point = new BMapGL.Point(pointT.lng, pointT.lat)
const marker = new BMapGL.Marker(point, { icon: myIcon })
marker.setRotation(car.trace[0].rotation - 90)
marker.id = id
const label = genLabel(pointT)
label.id = id
label.myType = 'label'
this.bMap.addOverlay(marker)
this.bMap.addOverlay(label) // 将标注添加到地图中
car.marker = marker // 后续移动车的时候会用到
car.label = label
car.trace[0].status = 'drawed' // 这里直接将车设置为'drawed'状态,与下面的改进点2有关
// 画完之后去移动
this.moveCar(car)
2. 移动车
每次从队列中取出第一个未画的点作为终点,上一个已画的点作为起点,通过补点函数进行移动。
_getAllPoints (start, end, num) {
var result = []
if (num === 0) {
return result
}
for (var i = 0; i <= num; i++) {
var point = {
speed: start.speed,
lng: ((end.lng - start.lng) / num) * i + start.lng,
lat: ((end.lat - start.lat) / num) * i + start.lat
}
result.push(point)
}
return result
}
这里由于受限于百度地图api,还是先用上篇拍脑袋决定的10个点作为补点的数量。
另外,由于需要上一次动画完成后再取下一个点进行绘制,因此将两点的动画函数封装成一个promise。
this._allPoints = transform(this._getAllPoints(this._start, this._end, this._num))
async startAni () {
const _this = this
const { id, carType } = this._car
return new Promise((resolve) => {
function step (timestamp) {
if (_this._allPoints.length > 0) {
const first = _this._allPoints.shift()
const currentPoint = new BMapGL.Point(first.lng, first.lat)
_this._marks.map((m) => {
m.setPosition(currentPoint)
// 用于改变显示的车速
if (m.myType === 'label') {
m.setContent(_this.genLable(id, first.speed, carType))
}
})
_this._timer = requestAnimationFrame(step)
} else {
resolve()
}
}
requestAnimationFrame(step)
})
}
移动前,将点的状态设为'drawing',移动到下一个点后,将该点的状态设为'drawed'。
递归调用,跳出递归的条件是,当这辆车已经被移除了。如果当前存在未绘制的点,则进行绘制。否则过200ms(此处可根据后端的数据帧进行调整,后端现在同一辆车是1秒给5个点的数据)再看看。
// 移动车,每次取后面一个点进行绘制
async moveCar (car) {
// 如果这辆车已经被移除了,则直接返回
if (!this.cachedCarArr.find((item) => item.id === car.id)) return
// 找到第一个未画的点
const firstUndrawedTraceIndex = car.trace.findIndex(
(item) => item.status === 'undraw'
)
// 如果存在未画的点
if (firstUndrawedTraceIndex !== -1) {
// 存在数据,重新计时
if (this.carTimer[car.id]) {
clearTimeout(this.carTimer[car.id])
}
// 若removeDelayed秒后没有数据,则移除这辆车
this.carTimer[car.id] = setTimeout(() => {
this.removeCar(car)
}, this.removeDelayed)
const firstUndrawedTrace = car.trace[firstUndrawedTraceIndex]
const prevTrace = car.trace[firstUndrawedTraceIndex - 1]
firstUndrawedTrace.status = 'drawing'
prevTrace.status = 'drawing'
// 找到第一个未画的点,取它和前一个点进行轨迹绘制
const ani = new MarkAnimation(
this.bMap,
{ marks: [car.marker, car.label], car },
prevTrace,
firstUndrawedTrace,
{ duration: firstUndrawedTrace.timestamp - prevTrace.timestamp }
)
await ani.startAni()
firstUndrawedTrace.status = 'drawed'
prevTrace.status = 'drawed'
// 一个接着一个画
this.moveCar(car)
} else {
// 过一段时间进来看
this.loopCarTimer = setTimeout(() => {
this.moveCar(car)
}, 200)
}
}
3. 移除车
跟线相关的逻辑可以先忽略,只是因为移除车的时候要同时移除该车可能存在的轨迹追踪线,所以这里也写了相关清除函数。
removeCar (car) {
this.version--
this.cachedCarArr = this.cachedCarArr.filter(
(item) => item.id !== car.id
)
// 清空车的标记
this.removeMarker(car.id, 0)
// 清空线的标记
this.removeMarker(car.id, 3)
// 清空线的任务计时器
this.clearLineTimer(car, 'curr')
const timer = this.carTimer[car.id]
this.carTimer[car.id] = null
clearTimeout(timer)
},
需优化的点
1. 车辆一开始还是有卡顿
这是因为我在车刚到的时候就去画车了,画完车就去移动了,而这时如果网速比较快,移动车的时候第二个点来没有到来,会在200ms后才重新判断,所以每个车刚来的时候感觉会卡一下。
这里我改为当一辆车至少有2个点后才开始绘制:
if (item.trace.length === 2) {
this.drawCar(item)
}
同时把取前10辆车的逻辑进行了相应的修改:
watch: {
version: {
handler () {
this.top10List = this.cachedCarArr
.filter((car) =>
car.trace.find((t) => t.status === 'drawing' || t.status === 'drawed')
)
.filter((car) => {
return car.trace.length > 1
})
.slice(0, 10)
}
}
}
但这时会出现地图上的车与列表中的车不一致的情况。
分析了下,这是因为我判断车一来就给version加1了,但画车的时候,却是至少要有2个点才开始画,导致两边的逻辑不一致。所以我把version++挪到了drawCar语句前面,即
if (item.trace.length === 2) {
version++
this.drawCar(item)
}
想到了高中时候一个作文题:竹子明明是绿色的,为什么我们画成黑色,反而觉得它真实?请讨论艺术的真实性与现实的真实性
咳咳,说好的没有内心戏呢,快回来!
2. 车辆有时候会瞬间移动
因为我写死了把两个点拆分成10个点,所以不管实际用了多长时间,都是在10帧内走完,导致距离相差较远的两个点,看起来就像学会了凌波微步,迅速从起始点移到了终止点。
这个数字10就像卡在心口的刺,每呼吸一下就会疼痛不已。之前因为百度官方用大红颜色标明注意:请勿使用其他非官方转换方法,并且试过网上一个转换函数偏移太大,就跟后端协商,能不能把转换好的坐标传给我。后端毫不留情地say no,我重新开始寻找坐标转换函数。
了解了一下常用坐标系,最后使用了参考3中的库,完美实现了离线坐标转换。哈哈哈,坐标转换再也不是动画路上的拦路虎了!
把所有用到坐标转换的地方改成了离线转换,并把这个固定的数字10,改成了动态值:
this._num = Math.floor(this._opts.duration / 16.6) // 根据两点的时间间隔计算点数
至此,多车实时运动的效果能入得了眼了。
轨迹追踪
有了前面车辆移动的经验,我驾轻就熟地把轨迹追踪分成三个步骤,已有轨迹画线->新增轨迹动态追踪->清除轨迹,我以为2是难点,万万没想到3困扰了我好久。以下是最终方案:
0. 番外
后端:我发现个小bug,你改一下。
我:现在没空看,我还在研究轨迹追踪的功能。
他:我不是给你接口了吗,你愁啥?
我:我没想过用你的接口,话说你接口返回啥数据?
他:给你接口干嘛不用?我返回当前点开始的实时轨迹信息呀!
我:产品要求画这辆车从头到尾的轨迹,你的接口没用。(内心:我根本不知道当时画到了哪个点,我怎么用你的数据)
1. 已有轨迹画线
这个功能比较简单,直接取出对应的点,画就完了。
drawLine (car) {
// 如果还是同一条线,则直接返回
if (car.id === this.drawingLine) {
return
}
const points = []
car.trace
.filter((item) => item.status === 'drawed' || item.status === 'drawing')
.forEach((item) => {
const point = offlineTransform({ lng: item.lng, lat: item.lat })
points.push(new BMapGL.Point(point.lng, point.lat))
})
const polyline = new BMapGL.Polyline(points, {
strokeColor: 'blue',
strokeWeight: 2,
strokeStyle: 'solid'
})
polyline.line = true
polyline.id = car.id
this.removePrevLine(car)
this.bMap.addOverlay(polyline)
this.drawingLine = car.id
this.moveLine(car, points.length)
}
2. 新增轨迹动态追踪
整体思路还是沿用移动车的套路,每次检测到有未画的点,就画线,否则过200ms进来看看。
2和3紧密相关,且重点在于如何清除,所以这里一起讲。
一开始,我是像moveCar一样,如果有未画的点,直接递归调用,但我发现虽然在画新的轨迹时先调用了移除原来轨迹的方法,但过一会儿,原来的轨迹又自己画了起来。 这里有两个原因:
- 之前每个车只要管它自己,与其他车无关,所以可以直接递归调用;但轨迹线在画自己的时候,还要清除别的线,相当于多了一个筛选条件。
- 只清除了当前时刻已有的线,但发出去的画线任务已经在调用栈里了,后面还是执行了。
对于1,增加了一个全局变量drawingLine,用来标记当前正在画轨迹线的车,后面只在判断为需要画轨迹线的车还是当前的车,才接着画
对于2,中间想过的方案包括:
- 加额外的递归终止条件,似乎不太可行,轨迹不正常移动了
- 自己搞个任务队列,模拟递归,但添加任务的时机不太好把握
这时突然灵光一闪,宏任务本来就是个消息队列啊,我为啥不直接利用它呢?
于是把直接递归调用改成使用requestAnimationFrame来调用,这样可以拿到任务id,便于后面取消。
信心满满地改完后,试了一把,变成了下图的效果。这次的线没有一直跟着原来的车走了,但还有一段小尾巴。
离胜利又近了一步,加油!
这时我把关注点聚焦到两点之间的动画上,我每次都是等上一个点画完再取下一个点进行绘制的。如果新的轨迹线在这两个点之间的时间点触发绘制,则可能出现上面那种场景,即这两个点之间的轨迹没有被清除。
看来得引入一个机制,让外部可以打断这个绘制过程。想到了可中断的promise,以下是利用Promise.race实现的简易方案。
const PromiseWithAbort = (p) => {
const obj = {}
// 内部定一个新的promise,用来终止执行
const p1 = new Promise(function (resolve, reject) {
obj.abort = reject
})
obj.promise = Promise.race([p, p1])
return obj
}
改造原来的画线动画函数(类似移动车的动画函数),在外面包一层PromiseWithAbort,并增加取消动画的方法。
async startAni () {
const _this = this
const transT = offlineTransform(this._expandPath).map(item => {
return new BMapGL.Point(item.lng, item.lat)
})
const p = new Promise((resolve) => {
function step (timestamp) {
if (transT.length > 1) {
const point1 = transT[0]
const point2 = transT[1]
const polyline = new BMapGL.Polyline([point1, point2], {
strokeColor: 'blue',
strokeWeight: 2,
strokeStyle: 'solid'
})
polyline.line = true
polyline.id = _this._car.id
_this._map.addOverlay(polyline)
transT.shift()
_this._timer = requestAnimationFrame(step)
} else {
resolve()
}
}
requestAnimationFrame(step)
})
this.promiseObj = PromiseWithAbort(p)
return this.promiseObj
}
stopAni () {
// 取消这个点后面的帧
this.promiseObj && this.promiseObj.abort()
this._clearRAF()
// 擦除这个点已有的帧
removeMarker(this._car.id, 2, this._map)
}
_clearRAF () {
if (this._timer) {
cancelAnimationFrame(this._timer)
}
}
在调用moveLine函数时,把当前点要进行动画绘制的动画实例通过this.carLineAniInstances保存下来,便于后面取消。并在清除上一辆车的轨迹时,调用stopAni()函数。
async moveLine (car, drawedCnt) {
// 如果已经没有这辆车了,则移除这条线,并直接返回
if (!this.cachedCarArr.find((item) => item.id === car.id)) {
this.removeMarker(car.id, 3)
return
}
// 如果有新的未画的点,则取出下一个点,把上一个点作为起始点,下一个点作为终点开启动画
if (car.trace.length > drawedCnt) {
const startTrace = car.trace[drawedCnt - 1]
const endTrace = car.trace[drawedCnt]
const aniIns = new LineAnimation(this.bMap, car, startTrace, endTrace, {
duration: endTrace.timestamp - startTrace.timestamp
})
this.carLineAniInstances[car.id] = aniIns
await aniIns.startAni().promise
drawedCnt++
const findOne = this.carLineAnimation.find(
(item) => item.car === car.id
)
// 如果还是当前的车,接着画
if (car.id === this.drawingLine) {
const nextAniId = requestAnimationFrame(() =>
this.moveLine(car, drawedCnt)
)
if (findOne) {
findOne.timer = [nextAniId]
} else {
this.carLineAnimation.push({
timer: [nextAniId],
car: car.id
})
}
}
} else {
// 如果还是当前要画的车,过一段时间进来看
if (car.id === this.drawingLine) {
const nextTId = setTimeout(() => {
this.moveLine(car, drawedCnt)
}, 200)
const findOne = this.loopLineTimer.find(
(item) => item.car === car.id
)
if (findOne) {
findOne.timer = [nextTId]
} else {
this.loopLineTimer.push({
timer: [nextTId],
car: car.id
})
}
}
}
}
3. 画新轨迹时清除老的轨迹
removePrevLine (car) {
// 清除上一辆车的轨迹
this.clearLineTimer(car, 'prev')
// 清除其他车的动画
const remains = {}
Object.entries(this.carLineAniInstances).map(([key, inst]) => {
if (key !== car.id) {
inst.stopAni()
} else {
remains[key] = inst
}
})
this.carLineAniInstances = remains
this.removeMarker(car.id, 2)
}
// type:prev表示清除上一辆车,curr表示当前车
clearLineTimer (car, type = 'prev') {
if (type === 'prev') {
const removedAnimation = []
const removedTimer = []
this.carLineAnimation.forEach((item) => {
while (item.timer.length > 0) {
const removed = item.timer.shift()
removedAnimation.push(removed)
cancelAnimationFrame(removed)
}
})
this.loopLineTimer.forEach((item) => {
while (item.timer.length > 0) {
const removed = item.timer.shift()
removedTimer.push(removed)
clearTimeout(removed)
}
})
} else if (type === 'curr') {
const carLineTimerRemain = []
const carLineTimerDeletion = []
for (const item of this.carLineAnimation) {
if (item.car !== car.id) {
carLineTimerRemain.push(item)
} else {
carLineTimerDeletion.push(item)
}
}
this.carLineAnimation = carLineTimerRemain
const loopLineTimerRemain = []
const loopLineTimerDeletion = []
for (const item of this.loopLineTimer) {
if (item.car !== car.id) {
loopLineTimerRemain.push(item)
} else {
loopLineTimerDeletion.push(item)
}
}
this.loopLineTimer = loopLineTimerRemain
carLineTimerDeletion.forEach((item) => {
while (item.timer.length > 0) {
cancelAnimationFrame(item.timer.shift())
}
})
loopLineTimerDeletion.forEach((item) => {
while (item.timer.length > 0) {
clearTimeout(item.timer.shift())
}
})
}
}
总结
稍微有点烂尾,主要是写不动了。两张图收尾吧,疫情下的上海,加油!