滑雪App怎么做GPS轨迹记录?鸿蒙定位服务实战

如果你正在做一款滑雪App,或者对运动类应用的定位开发感兴趣,可以去鸿蒙应用市场搜一下**「雪痕」**,下载下来滑一趟体验体验。实时速度、距离、滑行记录,滑完还能看详细总结。体验完了再回来看这篇文章,你会更清楚这些功能背后的定位服务是怎么工作的。


写在前面

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

很多人觉得"前端转鸿蒙"应该很容易——都是写UI嘛,组件化、状态管理、生命周期,概念都差不多。但真正上手之后你会发现,相似的地方让你觉得亲切,不同的地方让你抓狂

比如:

  • 定位服务:Web上有navigator.geolocation,但浏览器里定位精度一般,鸿蒙的@ohos.location是系统级API,精度更高、功耗更可控。
  • 后台定位:Web页面切到后台定位就停了,鸿蒙可以申请后台长时任务,滑雪时切到其他App也能持续记录轨迹。
  • 速度计算:GPS返回的速度单位是米/秒,需要转换成公里/小时,还要处理速度抖动。

但别担心,核心思想是一样的:都是获取经纬度坐标,都是把坐标转换成用户能看懂的信息。


这篇文章聊什么

雪痕这个App,核心要解决的问题是:

  1. GPS怎么定位 — 用@ohos.location获取GPS坐标
  2. 轨迹怎么记录 — 把一连串坐标存下来
  3. 速度怎么计算 — 根据坐标变化计算实时速度

第一步:权限申请

使用定位服务之前,需要在module.json5里声明权限:

{
  "requestPermissions": [
    {
      "name": "ohos.permission.LOCATION",
      "reason": "用于获取GPS定位,记录滑雪轨迹和速度",
      "usedScene": {
        "abilities": ["EntryAbility"],
        "when": "always"
      }
    },
    {
      "name": "ohos.permission.APPROXIMATELY_LOCATION",
      "reason": "用于获取大致位置,辅助定位",
      "usedScene": {
        "abilities": ["EntryAbility"],
        "when": "always"
      }
    }
  ]
}

然后在代码里动态申请:

import { abilityAccessCtrl } from '@kit.AbilityKit';

async function requestLocationPermission(): Promise<boolean> {
  try {
    const atManager = abilityAccessCtrl.createAtManager();
    const result = await atManager.requestPermissionsFromUser(
      getContext(),
      ['ohos.permission.LOCATION', 'ohos.permission.APPROXIMATELY_LOCATION']
    );
    return result.authResults[0] === 0 && result.authResults[1] === 0;
  } catch (err) {
    console.error('权限申请失败:', err);
    return false;
  }
}

第二步:封装定位服务

实际开发中,我们不会把定位逻辑直接写在页面组件里。封装一个独立的服务类:

// LocationService.ets
import { location } from '@kit.LocationKit';

export class LocationService {
  private callback: ((location: LocationData) => void) | null = null;
  private isRunning: boolean = false;

  start(onLocation: (location: LocationData) => void) {
    this.callback = onLocation;
    this.isRunning = true;

    location.enableLocation((err) => {
      if (err) {
        console.error('启用定位失败:', err);
        return;
      }

      location.on('locationChange', {
        interval: 1000,
        distanceInterval: 0,
        locationScenario: location.LocationScenario.NAVIGATION
      }, (err, data) => {
        if (err) {
          console.error('定位失败:', err);
          return;
        }

        if (this.isRunning && this.callback) {
          this.callback({
            latitude: data.latitude,
            longitude: data.longitude,
            speed: data.speed,
            altitude: data.altitude,
            accuracy: data.accuracy,
            timestamp: data.timeStamp
          });
        }
      });
    });
  }

  stop() {
    this.isRunning = false;
    location.off('locationChange');
    location.disableLocation();
    this.callback = null;
  }
}

interface LocationData {
  latitude: number;
  longitude: number;
  speed: number;
  altitude: number;
  accuracy: number;
  timestamp: number;
}

React对应版本(模拟数据):

// React - 模拟定位服务
function useLocation() {
  const [location, setLocation] = useState(null);
  const [isRunning, setIsRunning] = useState(false);
  const intervalRef = useRef(null);
  const baseLat = useRef(46.5197);  // 瑞士阿尔卑斯
  const baseLng = useRef(6.6323);

  const start = () => {
    setIsRunning(true);
    intervalRef.current = setInterval(() => {
      baseLat.current += (Math.random() - 0.5) * 0.0002;
      baseLng.current += (Math.random() - 0.5) * 0.0002;

      setLocation({
        latitude: baseLat.current,
        longitude: baseLng.current,
        speed: 30 + Math.random() * 40,  // 30-70 km/h
        altitude: 1500 + Math.random() * 500,
        accuracy: 5,
        timestamp: Date.now()
      });
    }, 1000);
  };

  const stop = () => {
    setIsRunning(false);
    clearInterval(intervalRef.current);
  };

  return { location, isRunning, start, stop };
}

第三步:轨迹记录

把GPS坐标存下来,形成轨迹:

// ArkTS - 轨迹记录
class TrackRecorder {
  private points: LocationData[] = [];
  private startTime: number = 0;
  private isRecording: boolean = false;

  start() {
    this.points = [];
    this.startTime = Date.now();
    this.isRecording = true;
  }

  addPoint(point: LocationData) {
    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;
    }

    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()
    };
  }

  calculateDistance(p1: LocationData, p2: LocationData): 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;
  }

  calculateTotalDistance(): number {
    let total = 0;
    for (let i = 1; i < this.points.length; i++) {
      total += this.calculateDistance(this.points[i - 1], this.points[i]);
    }
    return total;
  }

  calculateMaxSpeed(): number {
    let maxSpeed = 0;
    for (const point of this.points) {
      const speed = point.speed * 3.6;
      if (speed > maxSpeed) maxSpeed = speed;
    }
    return maxSpeed;
  }
}

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

第四步:速度计算

GPS返回的速度单位是米/秒,需要转换成公里/小时:

// 速度单位转换
function mpsToKmh(mps: number): number {
  return mps * 3.6;
}

// 实时速度(从GPS数据)
const speedKmh = mpsToKmh(location.speed);

// 低通滤波(平滑速度)
class SpeedFilter {
  private filtered: number = 0;
  private alpha: number = 0.3; // 滤波系数

  update(raw: number): number {
    this.filtered = this.alpha * raw + (1 - this.alpha) * this.filtered;
    return this.filtered;
  }
}

React对应版本:

// React - 速度计算
const mpsToKmh = (mps) => mps * 3.6;

function useSpeedFilter() {
  const filteredRef = useRef(0);
  const alpha = 0.3;

  const update = useCallback((raw) => {
    filteredRef.current = alpha * raw + (1 - alpha) * filteredRef.current;
    return filteredRef.current;
  }, []);

  return update;
}

第五步:在滑雪页面集成

把所有逻辑整合到滑雪页面:

// ArkTS - 滑雪页面
@Component
struct SkiActive {
  @State speed: number = 0;
  @State distance: number = 0;
  @State duration: number = 0;
  @State isRunning: boolean = false;

  private locationService: LocationService = new LocationService();
  private trackRecorder: TrackRecorder = new TrackRecorder();
  private speedFilter: SpeedFilter = new SpeedFilter();
  private timer: number = 0;

  startSki() {
    this.isRunning = true;
    this.trackRecorder.start();

    this.locationService.start((loc) => {
      const filteredSpeed = this.speedFilter.update(loc.speed);
      this.speed = mpsToKmh(filteredSpeed);

      this.trackRecorder.addPoint(loc);
      const track = this.trackRecorder.stop();
      this.distance = track.totalDistance / 1000;
    });

    this.timer = setInterval(() => {
      this.duration++;
    }, 1000);
  }

  stopSki() {
    this.isRunning = false;
    clearInterval(this.timer);
    this.locationService.stop();
    const track = this.trackRecorder.stop();

    router.pushUrl({
      url: 'pages/SkiSummary',
      params: track
    });
  }
}

React对应版本:

// React - 滑雪页面
function SkiActive() {
  const [speed, setSpeed] = useState(0);
  const [distance, setDistance] = useState(0);
  const [duration, setDuration] = useState(0);
  const [isRunning, setIsRunning] = useState(false);
  const { location, start, stop } = useLocation();
  const { points, isRecording, start: startTrack, addPoint, stop: stopTrack } = useTrackRecorder();
  const speedFilter = useSpeedFilter();
  const intervalRef = useRef(null);

  useEffect(() => {
    if (location && isRunning) {
      const filteredSpeed = speedFilter(location.speed);
      setSpeed(mpsToKmh(filteredSpeed));
      addPoint(location);
    }
  }, [location, isRunning]);

  const startSki = () => {
    setIsRunning(true);
    start();
    startTrack();
    intervalRef.current = setInterval(() => {
      setDuration(prev => prev + 1);
    }, 1000);
  };

  const stopSki = () => {
    setIsRunning(false);
    stop();
    clearInterval(intervalRef.current);
    const track = stopTrack();
    navigate('/ski/summary', { state: track });
  };

  return (
    <div className="flex flex-col items-center justify-center h-full">
      <p className="text-6xl font-bold">{speed.toFixed(1)}</p>
      <p className="text-sm text-gray-500">km/h</p>
      <p className="text-2xl mt-4">{distance.toFixed(2)}</p>
      <p className="text-sm text-gray-500">公里</p>
    </div>
  );
}

踩坑提醒

  1. GPS精度:GPS在室内、隧道、高楼密集区精度很差。建议加一个精度阈值(如accuracy > 50米时忽略数据)。

  2. 电量消耗:GPS持续运行很耗电,建议在页面不可见时降低更新频率,或者暂停定位。

  3. 后台运行:鸿蒙默认会限制后台应用的GPS访问,需要申请长时任务(backgroundTaskManager)才能在后台持续定位。

  4. 速度抖动:GPS返回的速度值可能有抖动,建议加一个低通滤波平滑处理。

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


总结

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

  1. 权限申请LOCATIONAPPROXIMATELY_LOCATION权限
  2. 定位服务:用@ohos.location获取GPS坐标
  3. 轨迹记录:把坐标存下来,形成轨迹
  4. 速度计算:GPS返回米/秒,转换为公里/小时
  5. 页面集成:把速度、距离、时长展示出来

核心公式就一个:Haversine公式计算两个经纬度之间的距离。其他的都是业务逻辑,跟Web开发没太大区别。

下一篇文章,我会聊聊雪痕的传感器融合——怎么用加速度计和陀螺仪计算坡度。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐