鸿蒙APP开发:踏轮记怎么做GPS轨迹记录?距离计算详解

5 阅读7分钟

骑行App怎么做GPS轨迹记录?距离计算详解

上一篇我们聊了踏轮记的定位服务,这篇来聊点更实用的——GPS轨迹记录和距离计算。如果你还没体验过踏轮记,可以去鸿蒙应用市场搜一下**「踏轮记」**,下载下来骑一圈,看看你的骑行轨迹是怎么被记录下来的。体验完了再回来看这篇文章,你会更清楚轨迹记录的完整流程。


写在前面

大家好,我是一名写了十多年Web前端的老兵。从jQuery时代一路走到React/Vue,CSS3动画、requestAnimationFrame、Web Animation API这些都算是看家本领。去年开始转战鸿蒙生态,用ArkTS开发App,这一路踩了不少坑,也积累了不少心得。

上一篇我们解决了"怎么获取GPS坐标"的问题,这篇来解决"怎么把坐标变成轨迹"的问题。

这个需求在Web端也有实现,比如用Leaflet、Mapbox这些地图库。但鸿蒙端没有这些现成的库,你需要自己处理坐标点的存储、距离计算、轨迹平滑等逻辑。不过好处是,完全可控,不用担心第三方库的兼容性问题。


这篇文章聊什么

踏轮记的轨迹记录功能,核心要解决的问题是:

  1. 轨迹怎么存 — 把一连串坐标点存下来
  2. 距离怎么算 — 根据坐标计算总距离
  3. 轨迹怎么优化 — 过滤噪点、平滑轨迹

第一步:轨迹数据结构

首先设计轨迹的数据结构:

// 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
  };
}

踩坑提醒

  1. GPS漂移:GPS在静止时也会有漂移,导致距离虚增。建议加一个最小移动距离(如1米)过滤。

  2. 存储空间:轨迹点数据量很大,一小时骑行可能有3600个点。建议定期清理旧轨迹,或者压缩存储。

  3. 轨迹断裂:GPS信号丢失会导致轨迹断裂。建议在信号恢复后,用直线连接断点,而不是跳过。

  4. 电量消耗:持续GPS定位很耗电,建议在页面不可见时降低更新频率(如从1秒变为5秒)。

  5. 精度与电量的平衡:导航模式精度最高但最省电,省电模式精度低但省电。骑行App建议用导航模式。


总结

这篇文章带你走了一遍GPS轨迹记录的完整流程:

  1. 轨迹数据结构:设计合理的数据结构存储轨迹点
  2. 轨迹点采集:每秒采集一个点,过滤精度差的点
  3. 距离计算:用Haversine公式计算两个经纬度之间的距离
  4. 轨迹优化:过滤漂移点、平滑轨迹
  5. 轨迹存储:把轨迹存下来,方便后续查看
  6. 轨迹统计:计算爬升、最高速度等统计信息

核心公式就一个:Haversine公式。其他的都是业务逻辑,跟Web开发没太大区别。

两篇文章下来,踏轮记的核心功能——定位和轨迹记录——就讲完了。如果你对骑行App开发感兴趣,可以去鸿蒙应用市场下载踏轮记体验一下,看看实际效果。