基于vue+百度地图的多车实时运动及轨迹追踪实现(上帝视角篇)

3,630 阅读11分钟

开局两张图,剩下全靠吹

origin.gif

drawline2.gif

基于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. 之前每个车只要管它自己,与其他车无关,所以可以直接递归调用;但轨迹线在画自己的时候,还要清除别的线,相当于多了一个筛选条件。
  2. 只清除了当前时刻已有的线,但发出去的画线任务已经在调用栈里了,后面还是执行了。

对于1,增加了一个全局变量drawingLine,用来标记当前正在画轨迹线的车,后面只在判断为需要画轨迹线的车还是当前的车,才接着画

对于2,中间想过的方案包括:

  • 加额外的递归终止条件,似乎不太可行,轨迹不正常移动了
  • 自己搞个任务队列,模拟递归,但添加任务的时机不太好把握

这时突然灵光一闪,宏任务本来就是个消息队列啊,我为啥不直接利用它呢?

于是把直接递归调用改成使用requestAnimationFrame来调用,这样可以拿到任务id,便于后面取消。

信心满满地改完后,试了一把,变成了下图的效果。这次的线没有一直跟着原来的车走了,但还有一段小尾巴。

来不及取消的帧_1.png

离胜利又近了一步,加油!

这时我把关注点聚焦到两点之间的动画上,我每次都是等上一个点画完再取下一个点进行绘制的。如果新的轨迹线在这两个点之间的时间点触发绘制,则可能出现上面那种场景,即这两个点之间的轨迹没有被清除。

看来得引入一个机制,让外部可以打断这个绘制过程。想到了可中断的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())
          }
        })
      }
    }

总结

稍微有点烂尾,主要是写不动了。两张图收尾吧,疫情下的上海,加油!

WechatIMG640.jpeg

WechatIMG628.jpeg

参考文献

1. 百度,高德,Google地图定位偏移以及坐标系转换

2. 没事来吐槽一下百度地图的坐标转换

3. web地图坐标转换库