在出行类应用开发中,轨迹回放功能是核心需求之一。无论是代驾、配送还是共享单车场景,都需要清晰展示骑手或车辆从起点到终点的移动轨迹。这个功能看似简单,但实际开发中会遇到跨端兼容、性能优化、交互体验等多方面的挑战。
最近我在一个代驾项目中实现了这个功能,支持匀速播放、暂停、倍速和进度拖拽等操作。下面我将分享具体的实现方案和踩坑经验,希望能帮助到有类似需求的开发者。
一个完整的轨迹回放功能需要满足以下核心需求:
整个功能可以分为三个层次:
由于uni-app支持多端发布,我们需要针对不同平台使用不同的地图组件:
后端返回的原始数据格式如下:
javascript复制[
{ lng: 116.40, lat: 39.90, time: 1710000000 },
{ lng: 116.41, lat: 39.91, time: 1710000010 },
// 更多轨迹点...
]
我们需要对这些数据进行以下预处理:
javascript复制function preprocessTrackData(points) {
// 计算总时长
const totalDuration = points[points.length - 1].time - points[0].time;
// 计算相邻点信息
const processedPoints = points.map((point, index) => {
if (index === 0) return { ...point, durationToNext: 0, distanceToNext: 0 };
const prevPoint = points[index - 1];
const duration = point.time - prevPoint.time;
const distance = calculateDistance(
prevPoint.lng, prevPoint.lat,
point.lng, point.lat
);
return { ...point, durationToNext: duration, distanceToNext: distance };
});
return {
points: processedPoints,
totalDuration,
startTime: points[0].time,
endTime: points[points.length - 1].time
};
}
我们创建一个通用的Map组件,根据平台使用不同的实现:
vue复制<template>
<!-- 小程序端 -->
<map
v-if="isMP"
:polyline="polyline"
:markers="markers"
@regionchange="onMapMove"
>
<cover-view class="controls">
<!-- 控制按钮 -->
</cover-view>
</map>
<!-- H5端 -->
<div v-else-if="isH5" id="map-container"></div>
<!-- APP端 -->
<view v-else id="map-container"></view>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { isH5, isMP } from '@/utils/env';
const props = defineProps({
trackData: Array,
currentPosition: Object
});
// 初始化地图
onMounted(() => {
if (isH5.value) initH5Map();
else if (!isMP.value) initAppMap();
});
</script>
不同平台的polyline绘制方式:
javascript复制const polyline = ref([{
points: trackPoints.value.map(p => ({ longitude: p.lng, latitude: p.lat })),
color: '#0088ff',
width: 4
}]);
javascript复制function initH5Map() {
const map = new AMap.Map('map-container');
const path = trackPoints.value.map(p => [p.lng, p.lat]);
new AMap.Polyline({
map,
path,
strokeColor: '#0088ff',
strokeWeight: 4
});
}
javascript复制const playbackState = ref({
isPlaying: false,
currentIndex: 0,
speed: 1, // 播放倍速
timer: null
});
function play() {
if (playbackState.value.isPlaying) return;
playbackState.value.isPlaying = true;
const { points } = trackData.value;
playbackState.value.timer = setInterval(() => {
if (playbackState.value.currentIndex >= points.length - 1) {
pause();
return;
}
playbackState.value.currentIndex++;
updateCurrentPosition();
// 地图跟随
if (shouldFollow.value) {
moveMapToCurrentPosition();
}
}, calculateInterval());
}
function pause() {
clearInterval(playbackState.value.timer);
playbackState.value.isPlaying = false;
}
function setSpeed(speed) {
playbackState.value.speed = speed;
if (playbackState.value.isPlaying) {
pause();
play();
}
}
javascript复制function seekToTime(targetTime) {
const { points } = trackData.value;
let index = 0;
// 找到目标时间点对应的轨迹点索引
for (let i = 0; i < points.length; i++) {
if (points[i].time >= targetTime) {
index = i;
break;
}
}
playbackState.value.currentIndex = index;
updateCurrentPosition();
if (playbackState.value.isPlaying) {
pause();
play();
}
}
当轨迹点过多时(如超过1000个点),需要进行抽稀处理:
javascript复制function simplifyTrack(points, tolerance = 0.0001) {
if (points.length <= 2) return points;
let result = [points[0]];
let lastKeptIndex = 0;
for (let i = 1; i < points.length - 1; i++) {
const distance = perpendicularDistance(
points[i],
points[lastKeptIndex],
points[points.length - 1]
);
if (distance > tolerance) {
result.push(points[i]);
lastKeptIndex = i;
}
}
result.push(points[points.length - 1]);
return result;
}
小程序端map组件是原生组件,层级最高。解决方案:
小程序端对setInterval的频率有限制。解决方案:
需要动态加载地图JS SDK:
javascript复制function loadAMapScript() {
return new Promise((resolve) => {
if (window.AMap) return resolve();
const script = document.createElement('script');
script.src = `https://webapi.amap.com/maps?v=2.0&key=您的key`;
script.onload = resolve;
document.head.appendChild(script);
});
}
需要处理地图手势与页面滚动的冲突:
css复制#map-container {
touch-action: none;
}
vue复制<template>
<view class="track-playback-container">
<!-- 地图容器 -->
<MapView
ref="mapView"
:track-points="processedPoints"
:current-position="currentPosition"
@ready="onMapReady"
/>
<!-- 控制面板 -->
<view class="control-panel">
<button @click="togglePlay">{{ isPlaying ? '暂停' : '播放' }}</button>
<slider
:value="progress"
@change="onSeek"
min="0"
:max="totalDuration"
/>
<view class="speed-control">
<text
v-for="speed in [1, 1.5, 2]"
:class="{ active: currentSpeed === speed }"
@click="setSpeed(speed)"
>
{{ speed }}x
</text>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed, watch } from 'vue';
import MapView from './MapView.vue';
const props = defineProps({
trackData: Array
});
// 播放状态
const isPlaying = ref(false);
const currentIndex = ref(0);
const currentSpeed = ref(1);
const timer = ref(null);
// 预处理后的轨迹数据
const processedData = preprocessTrackData(props.trackData);
// 当前进度
const progress = computed(() => {
return processedData.points[currentIndex.value].time - processedData.startTime;
});
// 播放控制
function togglePlay() {
if (isPlaying.value) {
pause();
} else {
play();
}
}
function play() {
isPlaying.value = true;
timer.value = setInterval(advancePlayback, calculateInterval());
}
function pause() {
clearInterval(timer.value);
isPlaying.value = false;
}
function advancePlayback() {
if (currentIndex.value >= processedData.points.length - 1) {
pause();
return;
}
currentIndex.value++;
updateMapPosition();
}
function setSpeed(speed) {
currentSpeed.value = speed;
if (isPlaying.value) {
pause();
play();
}
}
function onSeek(e) {
const targetTime = processedData.startTime + e.detail.value;
seekToTime(targetTime);
}
</script>
javascript复制// 计算两点间距离(Haversine公式)
export function calculateDistance(lng1, lat1, lng2, lat2) {
const R = 6371e3; // 地球半径(米)
const φ1 = lat1 * Math.PI / 180;
const φ2 = lat2 * Math.PI / 180;
const Δφ = (lat2 - lat1) * Math.PI / 180;
const Δλ = (lng2 - lng1) * Math.PI / 180;
const a = Math.sin(Δφ / 2) * Math.sin(Δφ / 2) +
Math.cos(φ1) * Math.cos(φ2) *
Math.sin(Δλ / 2) * Math.sin(Δλ / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
// 计算垂直距离(用于轨迹抽稀)
function perpendicularDistance(point, lineStart, lineEnd) {
const { lng: x, lat: y } = point;
const { lng: x1, lat: y1 } = lineStart;
const { lng: x2, lat: y2 } = lineEnd;
const A = y - y1;
const B = x1 - x;
const C = x2 - x1;
const D = y2 - y1;
const dot = A * C + B * D;
const lenSq = C * C + D * D;
let param = -1;
if (lenSq !== 0) param = dot / lenSq;
let xx, yy;
if (param < 0) {
xx = x1;
yy = y1;
} else if (param > 1) {
xx = x2;
yy = y2;
} else {
xx = x1 + param * C;
yy = y1 + param * D;
}
const dx = x - xx;
const dy = y - yy;
return Math.sqrt(dx * dx + dy * dy);
}
问题现象:轨迹回放时卡顿,特别是轨迹点较多时
解决方案:
javascript复制function smoothPlay() {
if (!isPlaying.value || currentIndex.value >= processedData.points.length - 1) {
return;
}
updateFrame();
animationFrameId = requestAnimationFrame(smoothPlay);
}
function updateFrame() {
const now = performance.now();
const elapsed = now - lastFrameTime;
const timeToAdvance = elapsed * currentSpeed.value;
// 计算应该前进的轨迹点数
// ...
lastFrameTime = now;
}
问题现象:播放时地图不自动跟随当前位置移动
解决方案:
javascript复制function moveMapToCurrentPosition() {
if (isMP.value) {
// 小程序端
mapContext.value.moveToLocation({
longitude: currentPosition.value.lng,
latitude: currentPosition.value.lat
});
} else if (isH5.value) {
// H5端
map.value.setCenter([currentPosition.value.lng, currentPosition.value.lat]);
} else {
// APP端
// 调用原生SDK方法
}
}
问题现象:长时间使用后页面变卡
解决方案:
javascript复制onUnmounted(() => {
clearInterval(timer.value);
cancelAnimationFrame(animationFrameId);
if (map.value) {
map.value.destroy();
map.value = null;
}
});
对于超长轨迹(如数小时的驾驶记录),我们需要特殊处理:
javascript复制// 在Web Worker中处理轨迹数据
const worker = new Worker('track-processor.js');
worker.postMessage({ points: rawPoints, tolerance: 0.0001 });
worker.onmessage = (e) => {
processedPoints.value = e.data;
};
javascript复制// 使用离屏Canvas预渲染轨迹
const offscreenCanvas = document.createElement('canvas');
const ctx = offscreenCanvas.getContext('2d');
// 绘制轨迹线...
// 然后将Canvas作为贴图应用到地图上
javascript复制// 使用Float32Array存储轨迹点坐标
const positions = new Float32Array(points.length * 2);
points.forEach((p, i) => {
positions[i * 2] = p.lng;
positions[i * 2 + 1] = p.lat;
});
除了基本回放,还可以添加:
javascript复制function analyzeSpeed() {
const speedSegments = [];
for (let i = 1; i < processedData.points.length; i++) {
const p1 = processedData.points[i - 1];
const p2 = processedData.points[i];
const distance = p2.distanceToNext;
const duration = p2.durationToNext;
const speed = duration > 0 ? distance / duration : 0;
speedSegments.push({
startIndex: i - 1,
endIndex: i,
speed: speed
});
}
return speedSegments;
}
对于支持3D的地图(如高德3D地图),可以提升视觉效果:
javascript复制function init3DTrack() {
// 创建3D轨迹线
const line = new AMap.Object3D.Line({
path: trackPoints.value.map(p => [p.lng, p.lat]),
height: 0,
color: '#0088ff'
});
// 添加高度变化
trackPoints.value.forEach((p, i) => {
line.setPointHeight(i, p.speed * 2); // 根据速度设置高度
});
scene.add(line);
}
支持同时显示多条轨迹进行比较:
javascript复制function renderMultipleTracks(tracks) {
tracks.forEach((track, index) => {
const color = getColorByIndex(index);
if (isMP.value) {
// 小程序端
polylines.value.push({
points: track.points,
color,
width: 3
});
} else {
// H5端
new AMap.Polyline({
path: track.points,
strokeColor: color,
strokeWeight: 3
});
}
});
}
开发时可以使用模拟数据:
javascript复制function generateMockTrack(startPoint, pointCount = 100) {
const points = [];
let currentLng = startPoint.lng;
let currentLat = startPoint.lat;
let currentTime = Date.now() / 1000;
for (let i = 0; i < pointCount; i++) {
// 随机生成下一个点
currentLng += (Math.random() - 0.5) * 0.01;
currentLat += (Math.random() - 0.5) * 0.01;
currentTime += Math.random() * 10;
points.push({
lng: currentLng,
lat: currentLat,
time: currentTime
});
}
return points;
}
javascript复制function updateCurrentPosition() {
try {
const point = processedData.points[currentIndex.value];
currentPosition.value = { lng: point.lng, lat: point.lat };
if (isH5.value && map.value) {
map.value.setCenter([point.lng, point.lat]);
}
} catch (error) {
console.error('更新位置失败:', error);
pause();
}
}
在实际项目中实现轨迹回放功能时,有几个关键点需要特别注意:首先是数据预处理的质量直接影响回放效果,特别是时间戳的准确性;其次是跨端兼容性处理,不同平台的地图API有细微差别;最后是性能优化,特别是处理大量轨迹点时。经过多次迭代优化,我们最终实现的方案在各端都能提供流畅的轨迹回放体验。