注意:封装的地图运动轨迹只测试了微信小程序,其他平台未测试
不过实现功能的核心API就是这几个:uniapp.dcloud.net.cn/api/locatio…
展示的核心组件是:uniapp.dcloud.net.cn/component/m…
不同平台根据文档中的差异说明修改代码即可
实现的功能:
- 获取当前位置信息并展示
- 在前/后台实时获取运动轨迹并渲染
- 支持暂停/继续记录轨迹
功能效果:
轨迹效果如下图(由于开发的时候不方便肉身移动,所以就贴一张静态的轨迹图吧)
开始前先在配置文件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…