骑行App怎么做GPS轨迹记录?距离计算详解
上一篇我们聊了踏轮记的定位服务,这篇来聊点更实用的——GPS轨迹记录和距离计算。如果你还没体验过踏轮记,可以去鸿蒙应用市场搜一下**「踏轮记」**,下载下来骑一圈,看看你的骑行轨迹是怎么被记录下来的。体验完了再回来看这篇文章,你会更清楚轨迹记录的完整流程。
写在前面
大家好,我是一名写了十多年Web前端的老兵。从jQuery时代一路走到React/Vue,CSS3动画、requestAnimationFrame、Web Animation API这些都算是看家本领。去年开始转战鸿蒙生态,用ArkTS开发App,这一路踩了不少坑,也积累了不少心得。
上一篇我们解决了"怎么获取GPS坐标"的问题,这篇来解决"怎么把坐标变成轨迹"的问题。
这个需求在Web端也有实现,比如用Leaflet、Mapbox这些地图库。但鸿蒙端没有这些现成的库,你需要自己处理坐标点的存储、距离计算、轨迹平滑等逻辑。不过好处是,完全可控,不用担心第三方库的兼容性问题。
这篇文章聊什么
踏轮记的轨迹记录功能,核心要解决的问题是:
- 轨迹怎么存 — 把一连串坐标点存下来
- 距离怎么算 — 根据坐标计算总距离
- 轨迹怎么优化 — 过滤噪点、平滑轨迹
第一步:轨迹数据结构
首先设计轨迹的数据结构:
// ArkTS - 轨迹数据结构
interface TrackPoint {
latitude: number; // 纬度
longitude: number; // 经度
altitude: number; // 海拔(米)
speed: number; // 速度(米/秒)
timestamp: number; // 时间戳
accuracy: number; // 精度(米)
}
interface Track {
id: string;
startTime: number;
endTime: number;
points: TrackPoint[];
totalDistance: number; // 总距离(米)
totalDuration: number; // 总时长(秒)
avgSpeed: number; // 平均速度(km/h)
maxSpeed: number; // 最高速度(km/h)
}
React对应版本:
// React - 轨迹数据结构
const createTrack = () => ({
id: Date.now().toString(),
startTime: null,
endTime: null,
points: [],
totalDistance: 0,
totalDuration: 0,
avgSpeed: 0,
maxSpeed: 0
});
第二步:轨迹点采集
在骑行过程中,每秒采集一个轨迹点:
// ArkTS - 轨迹点采集
class TrackRecorder {
private points: TrackPoint[] = [];
private startTime: number = 0;
private isRecording: boolean = false;
// 开始记录
start() {
this.points = [];
this.startTime = Date.now();
this.isRecording = true;
}
// 添加轨迹点
addPoint(point: TrackPoint) {
if (!this.isRecording) return;
// 过滤精度差的点
if (point.accuracy > 50) return;
// 过滤距离太近的点(防止原地不动也记录)
if (this.points.length > 0) {
const lastPoint = this.points[this.points.length - 1];
const distance = this.calculateDistance(lastPoint, point);
if (distance < 1) return; // 距离小于1米,忽略
}
this.points.push(point);
}
// 停止记录
stop(): Track {
this.isRecording = false;
const totalDistance = this.calculateTotalDistance();
const totalDuration = (Date.now() - this.startTime) / 1000;
return {
id: Date.now().toString(),
startTime: this.startTime,
endTime: Date.now(),
points: this.points,
totalDistance,
totalDuration,
avgSpeed: totalDuration > 0 ? (totalDistance / totalDuration) * 3.6 : 0,
maxSpeed: this.calculateMaxSpeed()
};
}
}
React对应版本:
// React - 轨迹点采集 Hook
function useTrackRecorder() {
const [points, setPoints] = useState([]);
const [isRecording, setIsRecording] = useState(false);
const startTimeRef = useRef(null);
const start = useCallback(() => {
setPoints([]);
startTimeRef.current = Date.now();
setIsRecording(true);
}, []);
const addPoint = useCallback((point) => {
if (!isRecording) return;
if (point.accuracy > 50) return;
setPoints(prev => {
if (prev.length > 0) {
const lastPoint = prev[prev.length - 1];
const distance = calculateDistance(lastPoint, point);
if (distance < 1) return prev;
}
return [...prev, point];
});
}, [isRecording]);
const stop = useCallback(() => {
setIsRecording(false);
const totalDistance = calculateTotalDistance(points);
const totalDuration = (Date.now() - startTimeRef.current) / 1000;
return {
id: Date.now().toString(),
startTime: startTimeRef.current,
endTime: Date.now(),
points,
totalDistance,
totalDuration,
avgSpeed: totalDuration > 0 ? (totalDistance / totalDuration) * 3.6 : 0,
maxSpeed: calculateMaxSpeed(points)
};
}, [points]);
return { points, isRecording, start, addPoint, stop };
}
第三步:距离计算(Haversine公式)
计算两个经纬度之间的距离,需要用Haversine公式:
// ArkTS - Haversine公式计算距离
function calculateDistance(p1: TrackPoint, p2: TrackPoint): number {
const R = 6371000; // 地球半径(米)
const lat1 = p1.latitude * Math.PI / 180;
const lat2 = p2.latitude * Math.PI / 180;
const deltaLat = (p2.latitude - p1.latitude) * Math.PI / 180;
const deltaLng = (p2.longitude - p1.longitude) * Math.PI / 180;
const a = Math.sin(deltaLat / 2) * Math.sin(deltaLat / 2) +
Math.cos(lat1) * Math.cos(lat2) *
Math.sin(deltaLng / 2) * Math.sin(deltaLng / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c; // 距离(米)
}
// 计算总距离
function calculateTotalDistance(points: TrackPoint[]): number {
let total = 0;
for (let i = 1; i < points.length; i++) {
total += calculateDistance(points[i - 1], points[i]);
}
return total;
}
React对应版本:
// React - Haversine公式计算距离
function calculateDistance(p1, p2) {
const R = 6371000;
const lat1 = p1.latitude * Math.PI / 180;
const lat2 = p2.latitude * Math.PI / 180;
const deltaLat = (p2.latitude - p1.latitude) * Math.PI / 180;
const deltaLng = (p2.longitude - p1.longitude) * Math.PI / 180;
const a = Math.sin(deltaLat / 2) * Math.sin(deltaLat / 2) +
Math.cos(lat1) * Math.cos(lat2) *
Math.sin(deltaLng / 2) * Math.sin(deltaLng / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
function calculateTotalDistance(points) {
let total = 0;
for (let i = 1; i < points.length; i++) {
total += calculateDistance(points[i - 1], points[i]);
}
return total;
}
为什么用Haversine公式而不是简单的勾股定理?
- 因为地球是圆的,不是平面
- 经纬度是球面坐标,不是平面坐标
- 简单的勾股定理在长距离下误差很大
第四步:轨迹优化
GPS返回的坐标有误差,直接画出来会有很多噪点。需要做一些优化:
4.1 过滤漂移点
// 过滤漂移点(突然跳到很远的位置)
function filterDriftPoints(points: TrackPoint[], maxSpeed: number = 50): TrackPoint[] {
if (points.length < 2) return points;
const filtered: TrackPoint[] = [points[0]];
for (let i = 1; i < points.length; i++) {
const prev = filtered[filtered.length - 1];
const curr = points[i];
const distance = calculateDistance(prev, curr);
const timeDiff = (curr.timestamp - prev.timestamp) / 1000;
if (timeDiff === 0) continue;
const speed = (distance / timeDiff) * 3.6; // km/h
// 如果速度超过阈值,认为是漂移点
if (speed <= maxSpeed) {
filtered.push(curr);
}
}
return filtered;
}
4.2 轨迹平滑(移动平均)
// 轨迹平滑(移动平均)
function smoothTrack(points: TrackPoint[], windowSize: number = 3): TrackPoint[] {
if (points.length < windowSize) return points;
const smoothed: TrackPoint[] = [];
for (let i = 0; i < points.length; i++) {
const start = Math.max(0, i - Math.floor(windowSize / 2));
const end = Math.min(points.length - 1, i + Math.floor(windowSize / 2));
let lat = 0, lng = 0, count = 0;
for (let j = start; j <= end; j++) {
lat += points[j].latitude;
lng += points[j].longitude;
count++;
}
smoothed.push({
...points[i],
latitude: lat / count,
longitude: lng / count
});
}
return smoothed;
}
React对应版本:
// React - 轨迹优化函数
const filterDriftPoints = (points, maxSpeed = 50) => {
if (points.length < 2) return points;
const filtered = [points[0]];
for (let i = 1; i < points.length; i++) {
const prev = filtered[filtered.length - 1];
const curr = points[i];
const distance = calculateDistance(prev, curr);
const timeDiff = (curr.timestamp - prev.timestamp) / 1000;
if (timeDiff === 0) continue;
const speed = (distance / timeDiff) * 3.6;
if (speed <= maxSpeed) filtered.push(curr);
}
return filtered;
};
const smoothTrack = (points, windowSize = 3) => {
if (points.length < windowSize) return points;
const smoothed = [];
for (let i = 0; i < points.length; i++) {
const start = Math.max(0, i - Math.floor(windowSize / 2));
const end = Math.min(points.length - 1, i + Math.floor(windowSize / 2));
let lat = 0, lng = 0, count = 0;
for (let j = start; j <= end; j++) {
lat += points[j].latitude;
lng += points[j].longitude;
count++;
}
smoothed.push({ ...points[i], latitude: lat / count, longitude: lng / count });
}
return smoothed;
};
第五步:轨迹存储
骑行结束后,把轨迹存下来:
// ArkTS - 存储轨迹
import { preferences } from '@kit.ArkData';
async function saveTrack(track: Track) {
const pref = await preferences.getPreferences(context, 'talunji');
const tracks: Track[] = JSON.parse(
await pref.get('tracks', '[]') as string
);
tracks.push(track);
await pref.put('tracks', JSON.stringify(tracks));
await pref.flush();
}
// React - 存储轨迹
function saveTrack(track) {
const tracks = JSON.parse(localStorage.getItem('app_talunji_tracks') || '[]');
tracks.push(track);
localStorage.setItem('app_talunji_tracks', JSON.stringify(tracks));
}
第六步:轨迹统计
有了轨迹数据,可以计算各种统计信息:
// ArkTS - 轨迹统计
function calculateTrackStats(track: Track): TrackStats {
const points = track.points;
// 计算爬升
let totalAscent = 0;
let totalDescent = 0;
for (let i = 1; i < points.length; i++) {
const altDiff = points[i].altitude - points[i - 1].altitude;
if (altDiff > 0) totalAscent += altDiff;
else totalDescent += Math.abs(altDiff);
}
// 计算最高速度
let maxSpeed = 0;
for (const point of points) {
const speed = point.speed * 3.6; // km/h
if (speed > maxSpeed) maxSpeed = speed;
}
return {
totalDistance: track.totalDistance,
totalDuration: track.totalDuration,
avgSpeed: track.avgSpeed,
maxSpeed,
totalAscent,
totalDescent,
pointCount: points.length
};
}
React对应版本:
// React - 轨迹统计
function calculateTrackStats(track) {
const points = track.points;
let totalAscent = 0, totalDescent = 0, maxSpeed = 0;
for (let i = 1; i < points.length; i++) {
const altDiff = points[i].altitude - points[i - 1].altitude;
if (altDiff > 0) totalAscent += altDiff;
else totalDescent += Math.abs(altDiff);
}
for (const point of points) {
const speed = point.speed * 3.6;
if (speed > maxSpeed) maxSpeed = speed;
}
return {
totalDistance: track.totalDistance,
totalDuration: track.totalDuration,
avgSpeed: track.avgSpeed,
maxSpeed,
totalAscent,
totalDescent,
pointCount: points.length
};
}
踩坑提醒
-
GPS漂移:GPS在静止时也会有漂移,导致距离虚增。建议加一个最小移动距离(如1米)过滤。
-
存储空间:轨迹点数据量很大,一小时骑行可能有3600个点。建议定期清理旧轨迹,或者压缩存储。
-
轨迹断裂:GPS信号丢失会导致轨迹断裂。建议在信号恢复后,用直线连接断点,而不是跳过。
-
电量消耗:持续GPS定位很耗电,建议在页面不可见时降低更新频率(如从1秒变为5秒)。
-
精度与电量的平衡:导航模式精度最高但最省电,省电模式精度低但省电。骑行App建议用导航模式。
总结
这篇文章带你走了一遍GPS轨迹记录的完整流程:
- 轨迹数据结构:设计合理的数据结构存储轨迹点
- 轨迹点采集:每秒采集一个点,过滤精度差的点
- 距离计算:用Haversine公式计算两个经纬度之间的距离
- 轨迹优化:过滤漂移点、平滑轨迹
- 轨迹存储:把轨迹存下来,方便后续查看
- 轨迹统计:计算爬升、最高速度等统计信息
核心公式就一个:Haversine公式。其他的都是业务逻辑,跟Web开发没太大区别。
两篇文章下来,踏轮记的核心功能——定位和轨迹记录——就讲完了。如果你对骑行App开发感兴趣,可以去鸿蒙应用市场下载踏轮记体验一下,看看实际效果。