uniapp 封装地图运动轨迹

748 阅读8分钟

注意:封装的地图运动轨迹只测试了微信小程序,其他平台未测试

不过实现功能的核心API就是这几个:uniapp.dcloud.net.cn/api/locatio…

展示的核心组件是:uniapp.dcloud.net.cn/component/m…

不同平台根据文档中的差异说明修改代码即可

实现的功能:

  • 获取当前位置信息并展示
  • 在前/后台实时获取运动轨迹并渲染
  • 支持暂停/继续记录轨迹

功能效果:

PixPin_2025-08-01_14-24-43.gif

轨迹效果如下图(由于开发的时候不方便肉身移动,所以就贴一张静态的轨迹图吧)

a2c1eeb2-2040-41c4-be0c-871d341747d8.png

开始前先在配置文件manifest.json中找到 mp-weixin 的配置项,然后加上下面的配置

"permission": {
			"scope.userLocation": {
				"desc": "你的位置信息将用于小程序位置接口的效果展示"
			}
		},
		"requiredPrivateInfos": [
			"getLocation",
			"startLocationUpdate",
			"startLocationUpdateBackground",
			"onLocationChange"
		],
		"requiredBackgroundModes": ["location"],

代码

<template>
	<view class="container">
		<!-- 地图容器 -->
		<map
			id="map"
			class="map"
			:latitude="mapCenter.latitude"
			:longitude="mapCenter.longitude"
			:scale="16"
			:markers="markers"
			:polyline="polyline"
			:enable-3D="true"
			:show-compass="false"
			:enable-zoom="true"
			:enable-scroll="true"
			:enable-rotate="true"
			:enable-overlooking="true"
			:enable-satellite="false"
			:enable-traffic="false"
			:show-location="true"
		></map>

		<div class="operate-box" v-if="!isViewMode">
			<!-- 定位模式切换 -->
			<view class="mode-panel" v-if="!isTracking">
				<view class="mode-item">
					<text class="mode-label">定位模式 :</text>
					<view class="mode-switch">
						<text class="mode-text" :class="{ active: !isBackgroundMode }">前台定位</text>
						<switch
							:checked="isBackgroundMode"
							:disabled="!isLocationBackgroundAuth"
							@change="toggleBackgroundMode"
							color="#007AFF"
						/>
						<text class="mode-text" :class="{ active: isBackgroundMode }">后台定位</text>
					</view>
				</view>
			</view>
			<!-- 控制按钮 -->
			<view class="control-panel">
				<view class="control-btn" :class="{ active: isTracking }" @click="toggleTracking">
					{{ isTracking ? '结束记录' : '开始记录' }}
				</view>
				<view
					class="control-btn pause-btn"
					:class="{ 'pause-active': isPause }"
					v-if="isTracking"
					@click="togglePause"
				>
					{{ isPause ? '继续记录' : '暂停记录' }}
				</view>
				<view class="control-btn" v-if="!isTracking && trackPoints.length > 0" @click="saveTrack">
					保存轨迹记录
				</view>
			</view>
		</div>

		<!-- 运动信息面板 -->
		<view class="info-panel" v-if="trackPoints.length > 0 || isViewMode">
			<!-- 暂停状态提示 -->
			<view class="pause-status" v-if="isPause">
				<text class="pause-text">⏸️ 记录已暂停</text>
			</view>
			<view class="info-item">
				<text class="info-label">总距离:</text>
				<text class="info-value">{{ !isViewMode ? totalDistance : viewModeTotalDistance }}km</text>
			</view>
			<view class="info-item">
				<text class="info-label">运动时长:</text>
				<text class="info-value">{{ !isViewMode ? formatDuration : viewModeFormatDuration }}</text>
			</view>
			<view class="info-item">
				<text class="info-label">平均速度:</text>
				<text class="info-value">{{ !isViewMode ? averageSpeed : viewModeAverageSpeed }}km/h</text>
			</view>
		</view>
	</view>
</template>

<script setup>
	import {
		ref,
		onMounted,
		computed,
		watchEffect,
		getCurrentInstance,
		onBeforeUnmount,
		nextTick,
	} from 'vue';
	import { onLoad } from '@dcloudio/uni-app';

	const { proxy } = getCurrentInstance();
	const eventChannel = proxy.getOpenerEventChannel();

	import { getSetting } from '@/utils/index';

	onLoad((res) => {
		if (res.viewId) {
			viewId = res.viewId;
			isViewMode.value = true;
			let trackData = uni.getStorageSync('trackData') || [];
			trackData = trackData.find((item) => item.id == viewId);
			if (trackData) {
				mapCenter.value = trackData.mapCenter;
				markers.value = trackData.markers;
				polyline.value = trackData.polyline;
				viewModeTotalDistance.value = trackData.viewModeTotalDistance;
				viewModeFormatDuration.value = trackData.viewModeFormatDuration;
				viewModeAverageSpeed.value = trackData.viewModeAverageSpeed;
			} else {
				uni.showToast({
					title: '轨迹记录不存在',
					icon: 'none',
				});
			}
			return;
		}

		// 检查后台定位权限
		getSetting('scope.userLocationBackground', '请在设置中开启后台定位权限', () => {
			isLocationBackgroundAuth.value = true;
			isBackgroundMode.value = true;
		});
		// 检查前台定位权限
		getSetting('scope.userLocation', '请在设置中开启地理位置权限', () => {
			isLocationAuth.value = true;
			getCurrentLocation();
		});
	});

	onBeforeUnmount(() => {
		// 停止位置监听
		stopLocationTracking();
	});

	const isViewMode = ref(false); // 是否是查看模式
	let viewId = ''; // 查看模式下的id
	const viewModeTotalDistance = ref(0); // 查看模式下的总距离
	const viewModeFormatDuration = ref('00:00:00'); // 查看模式下的运动时长
	const viewModeAverageSpeed = ref('0.00'); // 查看模式下的平均速度

	const isLocationAuth = ref(false); // 是否授权地理位置权限
	const isLocationBackgroundAuth = ref(false); // 是否授权后台定位权限

	const isTracking = ref(false); // 是否正在记录
	const isBackgroundMode = ref(false); // 是否使用后台定位模式

	const isPause = ref(false); // 是否暂停记录
	const isContinueAfterPause = ref(false); // 是否暂停后继续记录
	const pauseNum = ref(0); // 暂停次数
	const pauseDistance = ref(0); // 暂停距离
	const pauseMarker = ref({}); // 暂停点标记

	const trackPoints = ref([]); // 轨迹点
	const markers = ref([]); // 地图标记点
	const polyline = ref([]); // 轨迹线

	// 地图中心点
	const mapCenter = ref({
		latitude: 39.909,
		longitude: 116.397,
	});

	let timer = null; // 定时器
	const startTime = ref(null); // 开始时间
	const currentTime = ref(Date.now()); // 当前时间,用于实时更新
	const pauseStartTime = ref(null); // 暂停开始时间
	const totalPauseTime = ref(0); // 总暂停时间(毫秒)

	// 计算两点间距离(米)
	function calculateDistance(lat1, lng1, lat2, lng2) {
		const R = 6371000; // 地球半径(米)
		const dLat = ((lat2 - lat1) * Math.PI) / 180;
		const dLng = ((lng2 - lng1) * Math.PI) / 180;
		const a =
			Math.sin(dLat / 2) * Math.sin(dLat / 2) +
			Math.cos((lat1 * Math.PI) / 180) *
				Math.cos((lat2 * Math.PI) / 180) *
				Math.sin(dLng / 2) *
				Math.sin(dLng / 2);
		const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
		return R * c;
	}

	// 总距离
	const totalDistance = computed(() => {
		if (trackPoints.value.length < 2) return 0;

		let distance = 0;
		for (let i = 1; i < trackPoints.value.length; i++) {
			distance += calculateDistance(
				trackPoints.value[i - 1].latitude,
				trackPoints.value[i - 1].longitude,
				trackPoints.value[i].latitude,
				trackPoints.value[i].longitude
			);
		}
		distance -= pauseDistance.value;
		return (distance / 1000).toFixed(2);
	});

	// 运动时长
	const formatDuration = computed(() => {
		// 确保所有必要的值都存在且有效
		if (!startTime.value || !currentTime.value) return '00:00:00';

		// 计算实际运动时长(排除暂停时间)
		const actualDuration = currentTime.value - startTime.value - totalPauseTime.value;

		// 防止出现负数或异常值
		if (actualDuration < 0) {
			console.log('运动时长计算异常:', {
				currentTime: currentTime.value,
				startTime: startTime.value,
				totalPauseTime: totalPauseTime.value,
				actualDuration: actualDuration,
			});
			return '00:00:00';
		}

		const hours = Math.floor(actualDuration / 3600000);
		const minutes = Math.floor((actualDuration % 3600000) / 60000);
		const seconds = Math.floor((actualDuration % 60000) / 1000);

		return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds
			.toString()
			.padStart(2, '0')}`;
	});

	// 平均速度
	const averageSpeed = computed(() => {
		if (totalDistance.value <= 0 || !startTime.value) return 0;

		// 使用实际运动时长计算平均速度(排除暂停时间)
		const actualDuration = (currentTime.value - startTime.value - totalPauseTime.value) / 3600000; // 小时

		// 防止出现负数或异常值
		if (actualDuration <= 0) return 0;

		return (totalDistance.value / actualDuration).toFixed(2);
	});

	// 获取当前位置
	function getCurrentLocation() {
		return new Promise((resolve, reject) => {
			uni.getLocation({
				type: 'gcj02',
				success: (res) => {
					mapCenter.value = {
						latitude: res.latitude,
						longitude: res.longitude,
					};
					resolve();
				},
				fail: (err) => {
					console.error('获取位置失败:', err);
					reject(err);
				},
			});
		});
	}

	// 添加地图标记点
	function addMarker(data) {
		markers.value.push({
			id: data.id,
			latitude: data.latitude,
			longitude: data.longitude,
			width: 20,
			height: 20,
			callout: {
				content: data.content,
				color: '#ffffff',
				fontSize: 12,
				borderRadius: 4,
				bgColor: '#007AFF',
				padding: 4,
				display: 'ALWAYS',
			},
		});
		console.log('添加标记点:', data.content, '当前标记点总数:', markers.value.length);
	}

	// 启动定时器
	function startTimer() {
		if (timer) return;
		timer = setInterval(() => {
			currentTime.value = Date.now();
		}, 1000); // 每秒更新一次
	}
	// 停止定时器
	function stopTimer() {
		if (timer) {
			clearInterval(timer);
			timer = null;
		}
	}

	// 暂停/继续记录
	function togglePause() {
		if (isPause.value) {
			// 继续记录
			resumeTracking();
		} else {
			// 暂停记录
			pauseTracking();
		}
	}

	// 暂停记录
	async function pauseTracking() {
		console.log('调用暂停记录函数,当前状态:', {
			isTracking: isTracking.value,
			isPause: isPause.value,
		});

		isPause.value = true;
		pauseStartTime.value = Date.now();

		// 停止位置监听(但不添加结束点标记)
		uni.stopLocationUpdate({
			success: () => {
				console.log('暂停位置监听成功');
				// 移除位置变化监听
				uni.offLocationChange();
			},
			fail: (err) => {
				console.error('暂停位置监听失败:', err);
			},
		});

		// 停止定时器
		stopTimer();

		uni.showToast({
			title: '已暂停记录',
			icon: 'none',
		});

		isContinueAfterPause.value = true;

		pauseNum.value++;
		await getCurrentLocation();
		addMarker({
			latitude: mapCenter.value.latitude,
			longitude: mapCenter.value.longitude,
			id: new Date().getTime(),
			content: `暂停点${pauseNum.value}`,
		});

		pauseMarker.value = {
			latitude: mapCenter.value.latitude,
			longitude: mapCenter.value.longitude,
		};
	}

	// 继续记录
	async function resumeTracking() {
		console.log('调用继续记录函数,当前状态:', {
			isTracking: isTracking.value,
			isPause: isPause.value,
		});

		isPause.value = false;

		// 计算暂停时间并累加到总暂停时间
		if (pauseStartTime.value) {
			const pauseDuration = Date.now() - pauseStartTime.value;
			totalPauseTime.value += pauseDuration;
			pauseStartTime.value = null;
			console.log('暂停时长:', pauseDuration, 'ms, 总暂停时间:', totalPauseTime.value, 'ms');
		}

		// 立即更新当前时间,避免显示暂停前的时间
		currentTime.value = Date.now();

		// 重新开始位置监听(不重新初始化,只恢复监听)
		resumeLocationTracking();

		// 重新开始定时器
		startTimer();

		uni.showToast({
			title: '已继续记录',
			icon: 'none',
		});

		await getCurrentLocation();
		addMarker({
			latitude: mapCenter.value.latitude,
			longitude: mapCenter.value.longitude,
			id: new Date().getTime(),
			content: `继续点${pauseNum.value}`,
		});
	}

	// 恢复位置监听(不重新初始化)
	function resumeLocationTracking() {
		// 根据模式选择不同的位置监听方式
		const locationConfig = {
			success: () => {
				console.log('恢复位置监听');
				// 添加位置变化监听
				uni.onLocationChange((res) => {
					handleLocationChange(res);
				});
			},
			fail: (err) => {
				console.error('恢复位置监听失败:', err);
				uni.showToast({
					title: '恢复监听失败',
					icon: 'none',
				});
			},
		};

		if (isBackgroundMode.value) {
			// 使用后台定位
			console.log('恢复后台定位模式');
			uni.startLocationUpdateBackground(locationConfig);
		} else {
			// 使用前台定位
			console.log('恢复前台定位模式');
			uni.startLocationUpdate(locationConfig);
		}
	}

	// 开始/停止记录
	function toggleTracking() {
		console.log('调用切换记录函数,当前状态:', {
			isTracking: isTracking.value,
			isPause: isPause.value,
		});

		if (isTracking.value) {
			stopLocationTracking();
		} else {
			startLocationTracking();
		}
	}

	// 切换后台定位模式
	function toggleBackgroundMode(e) {
		isBackgroundMode.value = e.detail.value;
		console.log('切换定位模式:', isBackgroundMode.value ? '后台定位' : '前台定位');
	}

	// 开始位置监听
	function startLocationTracking() {
		console.log('调用开始位置监听函数,当前状态:', {
			isTracking: isTracking.value,
			isPause: isPause.value,
			trackPointsCount: trackPoints.value.length,
		});

		// 防止重复调用
		if (isTracking.value) {
			console.log('已经在记录状态,跳过重复调用');
			return;
		}

		// 如果是重新开始记录(之前已经停止过),重置所有时间相关状态
		// 判断条件:有轨迹点且当前不在记录状态且不是暂停状态,说明是重新开始
		if (trackPoints.value.length > 0 && !isTracking.value && !isPause.value) {
			// 重置所有状态
			resetState();

			// 延迟一帧确保状态更新后再开始监听
			nextTick(() => {
				initLocationTrackingInternal();
			});
			return;
		}

		// 正常开始记录
		initLocationTrackingInternal();
	}

	// 内部初始化位置监听函数
	async function initLocationTrackingInternal() {
		if (isBackgroundMode.value) {
			// 后台定位模式
			if (isLocationAuth.value && isLocationBackgroundAuth.value) {
				initLocationTracking();
			} else {
				if (!isLocationAuth.value) {
					uni.showModal({
						title: '需要地理位置权限',
						content: '请在设置中开启地理位置权限',
						showCancel: false,
					});
				} else if (!isLocationBackgroundAuth.value) {
					uni.showModal({
						title: '需要后台定位权限',
						content: '请在设置中开启后台定位权限',
						showCancel: false,
					});
				}
			}
		} else {
			// 前台定位模式
			if (isLocationAuth.value) {
				initLocationTracking();
			} else {
				uni.showModal({
					title: '需要地理位置权限',
					content: '请在设置中开启地理位置权限',
					showCancel: false,
				});
			}
		}
	}

	// 初始化位置监听
	async function initLocationTracking() {
		// 根据模式选择不同的位置监听方式
		const locationConfig = {
			success: async () => {
				console.log('开始监听位置变化');
				isTracking.value = true;

				// 只在第一次开始记录时设置开始时间
				if (!startTime.value) {
					startTime.value = Date.now();
					console.log('设置开始时间:', new Date(startTime.value).toLocaleString());
				}

				startTimer(); // 开始定时器

				// 添加位置变化监听
				uni.onLocationChange((res) => {
					handleLocationChange(res);
				});

				// 每次开始记录时都添加起始点标记
				await getCurrentLocation();
				addMarker({
					latitude: mapCenter.value.latitude,
					longitude: mapCenter.value.longitude,
					id: new Date().getTime(),
					content: '起始点',
				});

				uni.showToast({
					title: '已开始记录',
					icon: 'none',
				});
			},
			fail: (err) => {
				console.error('开始监听位置变化失败:', err);
				uni.showToast({
					title: '开始监听失败',
					icon: 'none',
				});
			},
		};

		if (isBackgroundMode.value) {
			// 使用后台定位
			console.log('使用后台定位模式');
			uni.startLocationUpdateBackground(locationConfig);
		} else {
			// 使用前台定位
			console.log('使用前台定位模式');
			uni.startLocationUpdate(locationConfig);
		}
	}

	// 处理位置变化
	function handleLocationChange(location) {
		console.log('位置变化', location);

		// 添加轨迹点
		const point = {
			latitude: location.latitude,
			longitude: location.longitude,
			timestamp: Date.now(),
		};

		if (checkLocation(point)) {
			trackPoints.value.push(point);

			// 更新轨迹线
			updatePolyline();
		}

		// 更新地图中心点(跟随用户位置)
		mapCenter.value = {
			latitude: location.latitude,
			longitude: location.longitude,
		};
	}

	// 校验返回的经纬度是否合法:和上一个经纬度相差不能超过100米
	function checkLocation(location) {
		if (trackPoints.value.length === 0) return true;
		const lastPoint = trackPoints.value[trackPoints.value.length - 1];
		const distance = calculateDistance(
			lastPoint.latitude,
			lastPoint.longitude,
			location.latitude,
			location.longitude
		);
		console.log('距离上次位置', distance + '米');
		let result = distance < 100;
		if (isContinueAfterPause.value) {
			isContinueAfterPause.value = false;
			pauseDistance.value += calculateDistance(
				pauseMarker.value.latitude,
				pauseMarker.value.longitude,
				location.latitude,
				location.longitude
			);
			pauseMarker.value = {};
			return true;
		}
		return result;
	}

	// 更新轨迹线
	function updatePolyline() {
		if (trackPoints.value.length < 2) return;

		polyline.value = [
			{
				points: trackPoints.value.map((point) => ({
					latitude: point.latitude,
					longitude: point.longitude,
				})),
				color: '#007AFF',
				width: 4,
				arrowLine: true,
			},
		];
	}

	// 重置状态
	function resetState() {
		trackPoints.value = [];
		polyline.value = [];
		markers.value = [];
		startTime.value = null;
		currentTime.value = Date.now(); // 清除时也重置当前时间

		// 重置暂停相关状态
		isPause.value = false;
		pauseStartTime.value = null;
		totalPauseTime.value = 0;
		isContinueAfterPause.value = false;
		pauseNum.value = 0;
		pauseDistance.value = 0;
		pauseMarker.value = {};
	}

	// 停止位置监听
	function stopLocationTracking() {
		console.log('调用停止位置监听函数,当前状态:', {
			isTracking: isTracking.value,
			isPause: isPause.value,
			markersCount: markers.value.length,
		});

		// 防止重复调用
		if (!isTracking.value) {
			console.log('已经在停止状态,跳过重复调用');
			return;
		}

		uni.stopLocationUpdate({
			success: async () => {
				console.log('停止监听位置变化成功');
				isTracking.value = false;
				stopTimer(); // 停止定时器

				// 移除位置变化监听
				uni.offLocationChange();

				// 重置暂停相关状态
				isPause.value = false;
				pauseStartTime.value = null;
				// 注意:这里不重置 totalPauseTime,因为停止记录时应该保留总暂停时间用于显示

				// 每次停止记录时都添加结束点标记
				await getCurrentLocation();
				addMarker({
					latitude: mapCenter.value.latitude,
					longitude: mapCenter.value.longitude,
					id: new Date().getTime(),
					content: '结束点',
				});

				uni.showToast({
					title: '已停止记录',
					icon: 'none',
				});
			},
			fail: (err) => {
				console.error('停止监听位置变化失败:', err);
			},
		});
	}

	// 保存轨迹记录
	function saveTrack() {
		let saveData = {
			id: new Date().getTime(),
			mapCenter: mapCenter.value,
			markers: markers.value,
			polyline: polyline.value,
			viewModeTotalDistance: totalDistance.value,
			viewModeFormatDuration: formatDuration.value,
			viewModeAverageSpeed: averageSpeed.value,
		};
		console.log('保存轨迹记录', saveData);
		let trackData = uni.getStorageSync('trackData') || [];
		trackData.push(saveData);
		uni.setStorageSync('trackData', trackData);
		uni.showToast({
			title: '轨迹记录已保存',
			icon: 'success',
		});
		setTimeout(() => {
			uni.reLaunch({
				url: `/pages/home/index`,
			});
		}, 1000);
	}
</script>

<style lang="scss" scoped>
	.container {
		position: relative;
		width: 100%;
		height: 100vh;
	}

	.map {
		width: 100%;
		height: 100%;
	}

	.operate-box {
		position: absolute;
		top: 0;
		left: 0;
		z-index: 100;
		width: 100%;
		padding: 20rpx;
		display: flex;
		justify-content: center;
		align-items: center;
		flex-direction: column;
	}

	.mode-panel {
		background: rgba(255, 255, 255, 0.95);
		border-radius: 12rpx;
		padding: 15rpx;
		box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.1);
		margin-bottom: 20rpx;
	}
	.mode-item {
		display: flex;
		align-items: center;
		margin-bottom: 10rpx;

		&:last-child {
			margin-bottom: 0;
		}
	}
	.mode-label {
		font-size: 26rpx;
		color: #666;
		margin-right: 10rpx;
	}
	.mode-switch {
		display: flex;
		align-items: center;
		background: #f0f0f0;
		border-radius: 15rpx;
		padding: 6rpx 10rpx;
		switch {
			margin: 0 10rpx;
		}
	}
	.mode-text {
		font-size: 24rpx;
		color: #333;
		padding: 4rpx 10rpx;
		border-radius: 10rpx;

		&.active {
			background: #007aff;
			color: white;
		}
	}

	.control-panel {
		display: flex;
		justify-content: center;
		flex-wrap: wrap;
		gap: 10rpx;
		width: 100%;
	}
	.control-btn {
		padding: 10rpx 20rpx;
		background: rgba(255, 255, 255, 0.9);
		border: 1px solid #ddd;
		border-radius: 22rpx;
		font-size: 32rpx;
		color: #333;
		display: flex;
		justify-content: center;
		align-items: center;

		&.active {
			background: #007aff;
			color: white;
			border-color: #007aff;
		}

		&:disabled {
			opacity: 0.5;
		}
	}

	.pause-btn {
		background: rgba(255, 193, 7, 0.9);
		color: #fff;
		border-color: #ffc107;

		&.pause-active {
			background: rgba(76, 175, 80, 0.9);
			color: white;
			border-color: #4caf50;
		}
	}

	.info-panel {
		position: absolute;
		bottom: 20rpx;
		left: 20rpx;
		right: 20rpx;
		background: rgba(255, 255, 255, 0.95);
		border-radius: 12rpx;
		padding: 15rpx;
		box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.1);
		z-index: 100;
	}

	.pause-status {
		text-align: center;
		margin-bottom: 10rpx;
		padding: 8rpx;
		background: rgba(255, 193, 7, 0.1);
		border-radius: 8rpx;
		border: 1px solid rgba(255, 193, 7, 0.3);
	}

	.pause-text {
		font-size: 26rpx;
		color: #ff9800;
		font-weight: 500;
	}
	.info-item {
		display: flex;
		justify-content: space-between;
		align-items: center;
		margin-bottom: 8rpx;

		&:last-child {
			margin-bottom: 0;
		}
	}
	.info-label {
		font-size: 28rpx;
		color: #666;
	}
	.info-value {
		font-size: 32rpx;
		color: #333;
		font-weight: 500;
	}
</style>

注意:getSetting 函数是封装的权限授权功能,查看我的这篇文章:juejin.cn/post/740096…