集成地图、高精度定位、实时轨迹绘制、点稀释、移动平均平滑、坐标纠偏、后台持续定位、异常点过滤、运动数据实时展示,附关键代码与踩坑总结。

完整源码:SportTrackDemo

一、为什么需要这个功能?

运动健康类App中,运动轨迹是用户最直观的数据呈现。很多App记录的轨迹都做的很好,如何解决以下问题是本篇内容的关键。程序我已经测试过了,有点费腿跑一圈下来绘制基本无大问题。

  • 轨迹偏移:GPS信号漂移(高楼)或坐标系不一致(WGS84 vs GCJ02)导致位置偏离实际路线。
  • 线条毛糙:点太密或未做平滑处理,锯齿感严重。
  • 性能差:存储了过多冗余点,导致地图绘制卡顿。
  • 后台中断:锁屏或切换应用后定位停止,轨迹不完整。

本文基于鸿蒙 Map KitLocation Kit,从零实现一个高精度、顺滑、美观、后台持续的运动轨迹记录功能,核心包括:

  • 单次定位获取我的位置-不记录绘制点
  • 高精度定位配置(GPS优先,轨迹追踪场景)
  • 坐标转换(WGS84 → GCJ02,适配中国大陆地图)
  • 点稀释(减少冗余点,提升性能)
  • 移动平均平滑(消除漂移,线条更顺滑)
  • 后台持续定位(长时任务 + 后台权限,锁屏/切应用不中断)
  • 异常点检测(过滤GPS漂移,轨迹不乱画)
  • 运动数据实时展示(时长、距离、配速、速度)

实际效果

测试设备:华为 Mate 70Pro
结果

  • 轨迹平滑,无毛刺,与道路基本吻合。
  • 点稀释后,地图绘制流畅。
  • 坐标转换后位置准确,无偏移。
  • 移动平均滤波有效消除了瞬时漂移。
  • 首次加载地图后自动移动到用户当前位置(优先使用缓存,约1秒)。
  • 异常点检测有效过滤了GPS漂移,轨迹不再乱画。
  • 运动数据实时更新准确,增量绘制消除了轨迹线与蓝点之间的延迟。

地图轨迹

注意:除了代码之外还需手动配置调试证书,创建应用,开启地图服务。否则地图不加载,且需要真机设备。

二、技术选型

能力 鸿蒙官方 API 说明
定位 geoLocationManager 支持高精度GPS、运动场景优化、后台定位
地图 Map Kit 系统级集成,支持折线、轨迹动画、坐标转换
坐标纠偏 map.convertCoordinateSync WGS84 → GCJ02 坐标转换
折线绘制 MapPolyline 支持分段颜色、纹理、圆角连接
长时任务 backgroundTaskManager 后台持续运行

为什么用Map Kit? 鸿蒙原生地图组件,无需引入第三方SDK,与系统深度集成,性能好,且提供坐标纠偏能力。

三、整体设计

3.1 核心流程(前台 + 后台)

用户点击“开始运动”
       ↓
申请定位权限(精确+模糊+后台+长时任务)
       ↓
初始化地图,设置中心点
       ↓
启动前台高精度定位(1秒/3米回调)
       ↓
同时启动后台长时任务(5秒/5米回调)
       ↓
每次收到原始定位(WGS84)
       ├─ 坐标转换:WGS84 → GCJ02
       ├─ 异常点检测(距离跳变>80米且时间<3秒 → 丢弃)
       ├─ 点稀释:与上一个记录点距离≥5米才保留
       ├─ 移动平均平滑:对最近5个原始点取平均
       ├─ 存入平滑后的轨迹数组
       ├─ 累计运动距离(基于原始点)
       ├─ 更新运动数据(时长、配速、速度)
       └─ 更新地图折线 + 移动相机到最新点
       ↓
应用切到后台 → 前台定位自动停止,后台定位继续
应用切回前台 → 恢复地图绘制,后台定位继续
       ↓
点击“暂停” → 停止所有定位
点击“继续” → 恢复前台+后台定位
点击“结束” → 停止所有定位,长时任务结束,展示运动数据

3.2 代码结构

SportTrackDemo/
├── entry/src/main/ets/
│   ├── common/
│   │   ├── constants/SportConstants.ets        # 常量配置
│   │   ├── managers/
│   │   │   ├── PermissionManager.ets           # 通用权限管理
│   │   │   ├── LocationManager.ets             # 前台高精度定位
│   │   │   ├── TrackManager.ets                # 轨迹平滑、距离累计、路网纠偏
│   │   │   ├── MapManager.ets                  # 地图初始化、折线绘制、坐标转换
│   │   │   └── BackgroundManager.ets           # 后台定位与长时任务
│   │   ├── models/
│   │   │   ├── LocationPoint.ets               # 位置点数据模型
│   │   │   └── SportSession.ets                # 运动记录数据模型
│   │   └── utils/GeoUtils.ets                  # 地理计算、平滑算法
│   └── pages/Index.ets                         # 主页面

四、权限配置(必须)

4.0 权限声明(module.json5)

运动轨迹记录需要以下权限,请在 entry/src/main/module.json5 中声明:

{
  "module": {
    "requestPermissions": [
      {
        "name": "ohos.permission.LOCATION",
        "reason": "$string:location_permission_reason",
        "usedScene": {
          "abilities": ["EntryAbility"],
          "when": "inuse"
        }
      },
      {
        "name": "ohos.permission.APPROXIMATELY_LOCATION",
        "reason": "$string:approximately_location_permission_reason",
        "usedScene": {
          "abilities": ["EntryAbility"],
          "when": "inuse"
        }
      },
      {
        "name": "ohos.permission.LOCATION_IN_BACKGROUND",
        "reason": "$string:location_background_permission_reason",
        "usedScene": {
          "abilities": ["EntryAbility"],
          "when": "always"
        }
      },
      {
        "name": "ohos.permission.KEEP_BACKGROUND_RUNNING",
        "reason": "$string:keep_background_running_reason"
      }
    ],
    "abilities": [
      {
        "name": "EntryAbility",
        "backgroundModes": ["location"]
      }
    ]
  }
}

同时,在 resources/base/element/string.json 中添加权限说明字符串:

{
  "string": [
    {
      "name": "location_permission_reason",
      "value": "用于记录运动轨迹、计算距离和配速"
    },
    {
      "name": "approximately_location_permission_reason",
      "value": "用于在地图上展示您的大致位置"
    },
    {
      "name": "location_background_permission_reason",
      "value": "用于锁屏后持续记录运动轨迹"
    },
    {
      "name": "keep_background_running_reason",
      "value": "用于后台持续定位,保证轨迹不中断"
    }
  ]
}

权限说明

权限名称 用途 申请时机
ohos.permission.LOCATION 获取精确GPS位置 运动开始前(动态申请)
ohos.permission.APPROXIMATELY_LOCATION 获取模糊网络位置(辅助定位) 运动开始前(动态申请)
ohos.permission.LOCATION_IN_BACKGROUND 后台持续定位 静态声明即可,无需动态申请
ohos.permission.KEEP_BACKGROUND_RUNNING 申请长时任务,防止后台进程被杀死 运动开始时(动态申请)

注意backgroundModes: ["location"] 必须在 abilities 中配置,否则后台定位无法生效。

五、关键实现与代码片段

5.1 权限申请(通用封装)

为了避免重复代码,将权限检查与请求封装在 PermissionManager 中。使用时只需传入权限列表和拒绝回调。

核心代码

// PermissionManager.ets
 /**
   * 检查并申请指定的权限列表
   * @param context 上下文
   * @param permissions 权限数组
   * @param onDenied 可选回调,当权限被用户拒绝或请求失败时调用,参数为被拒绝的权限列表
   * @returns true 所有权限均已授权,false 有权限被拒绝
   */
  static async checkAndRequestPermissions(
    context: Context,
    permissions: Permissions[],
    onDenied?: (deniedPermissions: Permissions[]) => void
  ): Promise<boolean> {
    // 1. 先检查所有权限是否已授权
    let allGranted = true;
    for (const perm of permissions) {
      const isGranted = await PermissionManager.checkPermission(context, perm);
      console.info(`${TAG} 权限 ${perm} 当前状态: ${isGranted ? '已授权' : '未授权'}`);
      if (!isGranted) {
        allGranted = false;
      }
    }
    if (allGranted) {
      console.info(`${TAG} 所有权限均已授权,无需请求`);
      return true;
    }

    // 2. 未全部授权,发起请求
    const atManager = abilityAccessCtrl.createAtManager();
    try {
      console.info(`${TAG} 发起权限请求: ${permissions.join(', ')}`);
      const result = await atManager.requestPermissionsFromUser(context, permissions);
      const authResults = result.authResults;
      let finalAllGranted = true;
      const deniedList: Permissions[] = [];
      for (let i = 0; i < permissions.length; i++) {
        const authResult = authResults[i];
        const isGranted = authResult === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED;
        console.info(`${TAG} 权限 ${permissions[i]} 授权结果: ${authResult} (${isGranted ? '已授权' : authResult === -1 ? '用户拒绝' : authResult === 2 ? '权限无效' : '未知错误'})`);
        if (!isGranted) {
          finalAllGranted = false;
          deniedList.push(permissions[i]);
        }
      }
      if (!finalAllGranted && onDenied) {
        onDenied(deniedList);
      }
      return finalAllGranted;
    } catch (err) {
      console.error(`${TAG} 权限请求失败: ${(err as BusinessError).code}`);
      if (onDenied) {
        onDenied(permissions); // 请求异常时,将所有请求的权限视为被拒绝
      }
      return false;
    }
  }

在Index页面启动时调用:

   async aboutToAppear() {
    this.context = this.getUIContext().getHostContext() as common.UIAbilityContext;
    this.bgLocationManager.initialize(this.context);

    const permissions: Permissions[] = [
      'ohos.permission.LOCATION',
      'ohos.permission.APPROXIMATELY_LOCATION'
    ];

    const hasFrontPerm = await PermissionManager.checkAndRequestPermissions(
      this.context,
      permissions,
      (deniedList) => {
        console.info(`前台权限被拒绝: ${deniedList.join(', ')}`);
        this.showPermissionDeniedDialog();
        this.hasLocationPermission = false;
      }
    );

    if (hasFrontPerm) {
      this.hasLocationPermission = true;
      this.setupMapWithLocation();
    } else {
      this.hasLocationPermission = false;
      this.showToast('需要定位权限才能记录轨迹', SportConstants.TOAST_DURATION_LONG);
    }

    this.bgLocationManager.onLocationUpdate = (rawLoc) => {
      this.onLocationUpdate(rawLoc);
    };
  }

5.2 地图初始化与自动定位

地图组件 MapComponent 通过 mapOptions 设置初始中心点、缩放范围、控件等。为了提升首次加载速度,我们优先使用系统缓存位置 getLastLocation(),若无缓存则使用速度优先的单次定位请求。但还有一个问题,首次运行没有上次缓存的坐标点,所以获取授权之后才能单次定位和我的位置展示。否则会定位失败,我的位置’蓝点’无法展示。

关键逻辑

  • mapCallback 中获取地图控制器,初始化地图管理器。
  • 调用 moveToMyLocationIfNeeded 方法:
    1. 若已移动过则返回。
    2. 检查设备位置服务是否开启。
    3. 尝试获取缓存位置,若有则直接移动相机。
    4. 否则发起单次定位请求(速度优先),成功后移动相机。

代码片段

  private async moveToMyLocationIfNeeded() {
    if (this.hasMovedToMyLocation) return;
    if (!this.hasLocationPermission) {
      console.warn('无定位权限,跳过移动相机');
      return;
    }

    try {
      if (!geoLocationManager.isLocationEnabled()) {
        this.showToast('请开启设备位置服务');
        return;
      }
      // 优先使用缓存位置
      const lastLocation = geoLocationManager.getLastLocation();
      if (lastLocation && lastLocation.latitude && lastLocation.longitude) {
        const gcj = MapManager.convertWgs84ToGcj02(lastLocation.latitude, lastLocation.longitude);
        this.moveCameraToPoint(gcj.latitude, gcj.longitude, SportConstants.MAP_ZOOM_LEVEL);
        this.hasMovedToMyLocation = true;
        console.info('使用缓存位置移动相机');
        return;
      }

      const request: geoLocationManager.SingleLocationRequest = {
        locatingPriority: geoLocationManager.LocatingPriority.PRIORITY_LOCATING_SPEED,
        locatingTimeoutMs: SportConstants.SINGLE_LOCATION_TIMEOUT_MS
      };
      const location = await geoLocationManager.getCurrentLocation(request);
      this.mapController?.setMyLocation(location);
      const gcj = MapManager.convertWgs84ToGcj02(location.latitude, location.longitude);
      this.moveCameraToPoint(gcj.latitude, gcj.longitude, SportConstants.MAP_ZOOM_LEVEL);
      this.hasMovedToMyLocation = true;
      console.info('已移动到用户当前位置');
    } catch (error) {
      console.error('定位失败', error);
      this.showToast('无法获取当前位置,请点击开始运动后自动跟随', SportConstants.TOAST_DURATION_LONG);
    }
  }

5.3 实时定位与轨迹绘制

为了解决“定位点在前、绘制在后”的空白问题,我们采用增量添加折线段的方式,每次只绘制最后两个相邻点之间的线段,而不是全量重绘整条折线。这样可以大幅降低绘制延迟,使轨迹线与蓝点几乎同步。

前台定位管理器 LocationManager 使用 ACCURACY 优先级和 TRAJECTORY_TRACKING 场景,订阅 locationChange 事件。每次回调执行以下处理流程:

  1. 坐标转换MapManager.convertWgs84ToGcj02
  2. 异常点检测:计算与上一个有效点的距离和时间差,若距离 > 80 米且时间 < 3 秒则丢弃。
  3. 点稀释:若与上一个记录点距离 < 5 米则丢弃。
  4. 移动平均平滑:对最近5个原始点取平均,存入平滑点数组。
  5. 距离累计:基于原始点使用 Haversine 公式累加。
  6. 更新运动数据(时长、配速、速度)。
  7. 增量绘制:取平滑点数组的最后两个点,调用 MapManager.addPolylineSegment 添加线段。
  8. 相机跟随:移动相机到最新平滑点。

运动数据计算说明

  • 时长:从运动开始到当前的时间差,每秒更新一次。
  • 距离:累加所有相邻有效原始点之间的球面距离(Haversine),实时更新。
  • 配速:时长(秒) ÷ 距离(公里),单位:秒/公里。若距离为0则配速显示 0’00"。
  • 速度:(距离(米) ÷ 时长(秒))× 3.6,单位:公里/小时。也可直接使用GPS提供的瞬时速度 speed 乘以 3.6。

定位与轨迹核心代码

  private onLocationUpdate(rawLoc: geoLocationManager.Location) {
    if (!rawLoc.latitude || !rawLoc.longitude) return;

    // 立即更新地图上的蓝点
    this.mapController?.setMyLocation(rawLoc);
    // 仅在运动进行中(且未暂停)时记录轨迹
    if (!this.isTracking || this.isPaused) {
      return;
    }
    // 坐标转换
    const gcj = MapManager.convertWgs84ToGcj02(rawLoc.latitude, rawLoc.longitude);
    const now = Date.now();
    const point: LocationPoint = {
      lat: gcj.latitude,
      lng: gcj.longitude,
      timestamp: now,
      speed: rawLoc.speed
    };

    // 异常点检测
    if (this.lastValidPoint) {
      const deltaDist = GeoUtils.calculateDistance(
        this.lastValidPoint.lat, this.lastValidPoint.lng,
        point.lat, point.lng
      );
      const deltaTime = (now - this.lastTimestamp) / 1000;
      const isJumpAbnormal = (deltaDist > SportConstants.JUMP_DISTANCE_THRESHOLD &&
        deltaTime < SportConstants.JUMP_TIME_THRESHOLD);
      if (isJumpAbnormal) {
        console.warn(`丢弃异常点: 距离跳变 ${deltaDist.toFixed(1)}米, 时间差 ${deltaTime}s`);
        return;
      }
    }

    this.trackManager.addPoint(point);
    this.lastValidPoint = point;
    this.lastTimestamp = now;
    this.distance = this.trackManager.getTotalDistance();

    const smoothed = this.trackManager.getSmoothedPoints();
    if (smoothed.length >= 2) {
      const lastTwo = smoothed.slice(-2);
      this.mapManager.addPolylineSegment(lastTwo[0], lastTwo[1]);
      const last = smoothed[smoothed.length - 1];
      this.moveCameraToPoint(last.lat, last.lng);
    } else if (smoothed.length === 1) {
      this.moveCameraToPoint(smoothed[0].lat, smoothed[0].lng);
    }
  }

运动数据计算核心代码


    private async startTracking() {
    if (this.isTracking) return;

    this.mapManager.clearPolylineSegments();

    this.isTracking = true;
    this.isPaused = false;
    this.trackManager.reset();
    this.duration = 0;
    this.distance = 0;
    this.avgPace = 0;
    this.startTime = Date.now();
    this.lastValidPoint = undefined;
    this.lastTimestamp = 0;

    this.locationManager.start((rawLoc) => {
      this.onLocationUpdate(rawLoc);
    });
    this.bgLocationManager.startBackgroundLocation();

    if (this.timerId){
      clearInterval(this.timerId);
    }
    this.timerId = setInterval(() => {
      if (!this.isTracking || this.isPaused) return;
      this.duration = Math.floor((Date.now() - this.startTime) / 1000);
      if (this.distance > 0) {
        this.avgPace = this.duration / (this.distance / 1000);
        this.currentSpeed = (this.distance / this.duration) * 3.6;
      }
    }, SportConstants.UI_UPDATE_INTERVAL_MS);

    this.showToast('开始运动');
  }

地图管理核心完整代码

import { map, mapCommon } from '@kit.MapKit';
import { SportConstants } from '../constants/SportConstants';
import { LocationPoint } from '../models/LocationPoint';

export class MapManager {
  private mapController?: map.MapComponentController;
  private currentPolyline?: map.MapPolyline;
  private segments: map.MapPolyline[] = []; // 存储所有折线段(用于增量添加)

  init(controller: map.MapComponentController): void {
    this.mapController = controller;
    controller.setMapType(mapCommon.MapType.STANDARD);
    controller.setMyLocationEnabled(true);
    controller.setMyLocationControlsEnabled(true);
  }

  /**
   * 坐标转换:WGS84 → GCJ02(中国大陆使用)
   */
  static convertWgs84ToGcj02(lat: number, lng: number): mapCommon.LatLng {
    const wgsPoint: mapCommon.LatLng = { latitude: lat, longitude: lng };
    return map.convertCoordinateSync(
      mapCommon.CoordinateType.WGS84,
      mapCommon.CoordinateType.GCJ02,
      wgsPoint
    );
  }

  /**
   * 全量绘制折线(用于运动结束后或初始化时)
   */
  async drawPolyline(points: LocationPoint[], colors?: number[]): Promise<void> {
    if (!this.mapController || points.length < 2) return;

    const latLngs: mapCommon.LatLng[] = points.map(p => {
     const LatLng:mapCommon.LatLng =  { latitude: p.lat, longitude: p.lng }
      return LatLng;
    });

    const options: mapCommon.MapPolylineOptions = {
      points: latLngs,
      width: SportConstants.POLYLINE_WIDTH,
      color: SportConstants.POLYLINE_COLOR_DEFAULT,
      visible: true,
      geodesic: false,
      jointType: mapCommon.JointType.ROUND,
      startCap: mapCommon.CapStyle.ROUND,
      endCap: mapCommon.CapStyle.ROUND
    };

    if (colors && colors.length === points.length - 1) {
      options.colors = colors;
      options.gradient = true;
    }

    try {
      if (this.currentPolyline) {
        this.currentPolyline.remove();
      }
      this.currentPolyline = await this.mapController.addPolyline(options);
    } catch (err) {
      console.error(`绘制折线失败: ${JSON.stringify(err)}`);
    }
  }

  /**
   * 增量添加折线段(每次只添加两个相邻点之间的线段)
   * @param p1 起点
   * @param p2 终点
   */
  async addPolylineSegment(p1: LocationPoint, p2: LocationPoint): Promise<void> {
    if (!this.mapController) return;
    const points: mapCommon.LatLng[] = [
      { latitude: p1.lat, longitude: p1.lng },
      { latitude: p2.lat, longitude: p2.lng }
    ];
    const options: mapCommon.MapPolylineOptions = {
      points: points,
      width: SportConstants.POLYLINE_WIDTH,
      color: SportConstants.POLYLINE_COLOR_DEFAULT,
      visible: true,
      geodesic: false,
      jointType: mapCommon.JointType.ROUND,
      startCap: mapCommon.CapStyle.ROUND,
      endCap: mapCommon.CapStyle.ROUND
    };
    try {
      const segment = await this.mapController.addPolyline(options);
      this.segments.push(segment);
    } catch (err) {
      console.error(`添加折线段失败: ${JSON.stringify(err)}`);
    }
  }

  /**
   * 清除所有增量添加的折线段(运动结束时调用)
   */
  clearPolylineSegments(): void {
    for (const segment of this.segments) {
      segment.remove();
    }
    this.segments = [];
    // 同时清除全量折线(如果有)
    if (this.currentPolyline) {
      this.currentPolyline.remove();
      this.currentPolyline = undefined;
    }
  }

  /**
   * 移动相机到指定点
   */
  moveToPoint(lat: number, lng: number, zoom: number = SportConstants.MAP_ZOOM_LEVEL): void {
    if (!this.mapController) {
      console.warn('地图控制器未初始化,无法移动相机');
      return;
    }
    const target: mapCommon.LatLng = { latitude: lat, longitude: lng };
    let cameraUpdate = map.newLatLng(target, zoom);
    this.mapController?.animateCamera(cameraUpdate, 1000);
  }
}

5.4 后台持续定位

应用退到后台后,前台定位会自动停止,需要启动长时任务来保持后台定位。BackgroundManager 负责:

  • 动态申请 KEEP_BACKGROUND_RUNNING 权限。
  • 创建 WantAgent 使状态栏通知可点击拉起应用。
  • 调用 backgroundTaskManager.startBackgroundRunning 申请长时任务。
  • 使用省电模式定位参数(5秒/5米),订阅 locationChange 并将位置通过回调传递给主页面。

后台管理


import { common, wantAgent, WantAgent } from '@kit.AbilityKit';
import { geoLocationManager } from '@kit.LocationKit';
import { backgroundTaskManager } from '@kit.BackgroundTasksKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { SportConstants } from '../constants/SportConstants';
import { PermissionManager } from './PermissionManager';

const TAG = 'BackgroundLocationManager';

export class BackgroundManager {
  private static instance: BackgroundManager;
  private context: common.UIAbilityContext | null = null;
  private isRunning: boolean = false;
  private locationCallback?: (location: geoLocationManager.Location) => void;

  // 外部可设置此回调来处理后台定位数据
  public onLocationUpdate?: (location: geoLocationManager.Location) => void;

  private constructor() {}

  static getInstance(): BackgroundManager {
    if (!BackgroundManager.instance) {
      BackgroundManager.instance = new BackgroundManager();
    }
    return BackgroundManager.instance;
  }

  initialize(context: common.UIAbilityContext): void {
    this.context = context;
  }

  /**
   * 启动后台定位
   */
  async startBackgroundLocation(): Promise<void> {
    if (!this.context) {
      console.warn(`${TAG} context 未初始化,请先调用 initialize`);
      return;
    }
    if (this.isRunning) {
      console.info(`${TAG} 后台定位已在运行中`);
      return;
    }

    // 1. 动态申请长时任务权限
    const hasKeepPerm = await PermissionManager.checkAndRequestPermissions(this.context, [
      'ohos.permission.KEEP_BACKGROUND_RUNNING'
    ]);
    if (!hasKeepPerm) {
      console.warn(`${TAG} 保持后台长时间运行权限不足,无法启动后台定位`);
      return;
    }


    try {
      // 3. 创建 WantAgent(用于点击通知返回应用)
      const wantAgentInfo: wantAgent.WantAgentInfo = {
        wants: [
          {
            bundleName: this.context.abilityInfo.bundleName,
            abilityName: this.context.abilityInfo.name  // 动态获取 Ability 名称
          }
        ],
        actionType: wantAgent.OperationType.START_ABILITY,
        requestCode: 0,
        actionFlags: [wantAgent.WantAgentFlags.UPDATE_PRESENT_FLAG]
      };
      const  wantAgentObj = await wantAgent.getWantAgent(wantAgentInfo);

      // 4. 申请长时任务
      await backgroundTaskManager.startBackgroundRunning(
        this.context,
        backgroundTaskManager.BackgroundMode.LOCATION,
        wantAgentObj
      );
      console.info(`${TAG} 长时任务已申请`);

      // 5. 配置后台定位参数
      const requestInfo: geoLocationManager.LocationRequest = {
        priority: geoLocationManager.LocationRequestPriority.ACCURACY,
        scenario: geoLocationManager.LocationRequestScenario.TRAJECTORY_TRACKING,
        timeInterval: SportConstants.BACKGROUND_LOCATION_TIME_INTERVAL_SEC,
        distanceInterval: SportConstants.BACKGROUND_LOCATION_DISTANCE_INTERVAL_M,
        maxAccuracy: 0
      };

      // 6. 注册定位回调
      this.locationCallback = (location: geoLocationManager.Location) => {
        if (this.onLocationUpdate) {
          this.onLocationUpdate(location);
        }
      };
      geoLocationManager.on('locationChange', requestInfo, this.locationCallback);

      this.isRunning = true;
      AppStorage.setOrCreate('isBackgroundLocationRunning', true);
      console.info(`${TAG} 后台定位已启动`);

    } catch (err) {
      const error = err as BusinessError;
      console.error(`${TAG} 启动后台定位失败: code=${error.code}, message=${error.message}`);
      // 失败时清理资源
      await this.stopBackgroundLocation();
    }
  }

  /**
   * 停止后台定位
   */
  async stopBackgroundLocation(): Promise<void> {
    if (!this.isRunning || !this.context) {
      this.cleanupInternal();
      return;
    }

    // 1. 取消定位监听
    if (this.locationCallback) {
      try {
        geoLocationManager.off('locationChange', this.locationCallback);
      } catch (err) {
        console.error(`${TAG} 取消定位监听失败: ${(err as BusinessError).code}`);
      }
      this.locationCallback = undefined;
    }

    // 2. 停止长时任务
    try {
      await backgroundTaskManager.stopBackgroundRunning(this.context);
    } catch (err) {
      console.error(`${TAG} 停止长时任务失败: ${(err as BusinessError).code}`);
    }

    this.isRunning = false;
    AppStorage.setOrCreate('isBackgroundLocationRunning', false);
    console.info(`${TAG} 后台定位已停止`);
  }

  /**
   * 内部清理资源
   */
  private cleanupInternal(): void {
    if (this.locationCallback) {
      try {
        geoLocationManager.off('locationChange', this.locationCallback);
      } catch (err) {
        // 忽略
      }
      this.locationCallback = undefined;
    }
  }

  isBackgroundRunning(): boolean {
    return this.isRunning;
  }
}

运动开始时调用 startBackgroundLocation(),结束时调用 stopBackgroundLocation()

5.5 运动结束后的路网纠偏

运动结束后,我们对整条轨迹调用 TrackManager.snapToRoad 进行路网吸附,将轨迹点修正到最近的道路上。对点分批处理,navi.snapToRoads 系统提供的方法。

代码片段


  /**
   * 路网纠偏:将平滑后的轨迹点吸附到道路上
   * @param points 待纠偏的轨迹点数组(GCJ02坐标)
   * @returns 纠偏后的轨迹点数组
   */
  static async snapToRoad(points: LocationPoint[]): Promise<LocationPoint[]> {
    if (!points || points.length === 0) return [];
    const batchSize = 100;
    const results: LocationPoint[] = [];
    for (let i = 0; i < points.length; i += batchSize) {
      const batch = points.slice(i, i + batchSize);
      const params: navi.SnapToRoadsParams = {
        points: batch.map(p => {
          const point: mapCommon.LatLng = { latitude: p.lat, longitude: p.lng };
          return point;
        })
      };
      try {
        const snapped = await navi.snapToRoads(params);
        for (let idx = 0; idx < snapped.snappedPoints.length; idx++) {
          const s =  snapped.snappedPoints[idx];
          results.push({
            lat: s.latitude,
            lng: s.longitude,
            timestamp: batch[idx].timestamp,
            speed: batch[idx].speed
          });
        }
      } catch (err) {
        console.error(`路网纠偏失败: ${(err as BusinessError).code}`);
        results.push(...batch);
      }
    }
    return results;
  }

六、关键算法详解

6.1 球面距离计算(Haversine公式)

GPS坐标是球面经纬度,不能用平面几何计算距离。Haversine公式通过地球半径和经纬度差值,计算出两点之间的大圆距离,精度满足运动需求。

公式

a = sin²(Δlat/2) + cos(lat1) * cos(lat2) * sin²(Δlng/2)
c = 2 * atan2(√a, √(1-a))
d = R * c

R = 6371000 米。

代码实现(GeoUtils.ets)

  static calculateDistance(lat1: number, lng1: number, lat2: number, lng2: number): number {
    const R = 6371000;
    const rad = Math.PI / 180;
    const dLat = (lat2 - lat1) * rad;
    const dLng = (lng2 - lng1) * rad;
    const a = Math.sin(dLat/2) * Math.sin(dLat/2) +
      Math.cos(lat1 * rad) * Math.cos(lat2 * rad) *
      Math.sin(dLng/2) * Math.sin(dLng/2);
    const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
    return R * c;
  }

6.2 移动平均平滑

取最近 N 个原始点的经纬度平均值作为平滑点。窗口大小 N=5,兼顾平滑度和实时性。平滑后的轨迹消除了瞬时漂移,线条更顺滑。

代码实现(GeoUtils.ets)

  /**
   * 移动平均平滑:对最近 N 个原始点取经纬度平均
   */
  static smoothPoints(points: LocationPoint[], windowSize: number): LocationPoint {
    const len = points.length;
    if (len === 0) throw new Error('points cannot be empty');
    const start = Math.max(0, len - windowSize);
    let sumLat = 0, sumLng = 0;
    for (let i = start; i < len; i++) {
      sumLat += points[i].lat;
      sumLng += points[i].lng;
    }
    const count = len - start;
    return {
      lat: sumLat / count,
      lng: sumLng / count,
      timestamp: points[len-1].timestamp
    };
  }

6.3 点稀释

只有与上一个记录点距离 ≥ 5 米时才记录新点,有效减少 80% 的冗余点,降低内存和绘图开销。

代码实现(TrackManager.ets 中的 addPoint 方法)

  addPoint(point: LocationPoint): void {
    if (this.lastRecordedPoint) {
      const dist = GeoUtils.calculateDistance(
        this.lastRecordedPoint.lat, this.lastRecordedPoint.lng,
        point.lat, point.lng
      );
      if (dist < SportConstants.MIN_RECORD_DISTANCE_M) {
        return;
      }
    }
    this.rawPoints.push(point);
    this.lastRecordedPoint = point;

    const smoothed = GeoUtils.smoothPoints(this.rawPoints, SportConstants.SMOOTH_WINDOW_SIZE);
    this.smoothedPoints.push(smoothed);

    if (this.rawPoints.length >= 2) {
      const prev = this.rawPoints[this.rawPoints.length - 2];
      const delta = GeoUtils.calculateDistance(prev.lat, prev.lng, point.lat, point.lng);
      this.totalDistance += delta;
    }
  }

6.4 两点之间的距离

  static calculateDistance(lat1: number, lng1: number, lat2: number, lng2: number): number {
    const R = 6371000;
    const rad = Math.PI / 180;
    const dLat = (lat2 - lat1) * rad;
    const dLng = (lng2 - lng1) * rad;
    const a = Math.sin(dLat/2) * Math.sin(dLat/2) +
      Math.cos(lat1 * rad) * Math.cos(lat2 * rad) *
      Math.sin(dLng/2) * Math.sin(dLng/2);
    const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
    return R * c;
  }

6.5 异常点检测

利用距离跳变和时间间隔判断:如果当前点与上一个有效点的距离超过 80 米且时间间隔小于 3 秒,则判定为GPS漂移,直接丢弃。该阈值对跑步、骑行、驾车均适用,不依赖运动类型。同时,也可以结合瞬时速度检测:若 GPS 提供的速度 > 15 m/s(54 km/h)且距离跳变较大,也判定为异常。

代码实现(Index.ets 中的 onLocationUpdate 方法)

if (this.lastValidPoint) {
    const deltaDist = GeoUtils.calculateDistance(
      this.lastValidPoint.lat, this.lastValidPoint.lng,
      point.lat, point.lng
    );
    const deltaTime = (now - this.lastTimestamp) / 1000;
    const isJumpAbnormal = (deltaDist > SportConstants.JUMP_DISTANCE_THRESHOLD &&
      deltaTime < SportConstants.JUMP_TIME_THRESHOLD);
    if (isJumpAbnormal) {
      console.warn(`丢弃异常点: 距离跳变 ${deltaDist.toFixed(1)}米, 时间差 ${deltaTime}s`);
      return;
    }
  }

七、踩坑与经验总结

1. 地图显示位置与实际位置偏移
原因:WGS84坐标直接叠加在GCJ02地图上。
解决方法:使用 convertCoordinateSync 将WGS84转换为GCJ02坐标。

2. 轨迹线条有锯齿
原因:点之间直线连接,未做圆角处理。
解决方法:设置折线的 jointType: ROUNDcapType: ROUND

3. 静止时产生大量重复点
原因:即使位置未变,系统仍在回调。
解决方法:点稀释阈值设为5米,只记录位移足够大的点。

4. 手机发热、耗电快
原因:定位频率过高。
解决方法:前台定位间隔1秒/3米,后台定位间隔5秒/5米,运动结束及时停止定位。

5. 地图绘制卡顿
原因:存储了过多冗余点(>5000)。
解决方法:使用点稀释控制点数,或改用增量添加折线方式绘制。

6. “我的位置”按钮不显示
原因:未开启 setMyLocationControlsEnabled(true)
解决方法:同时需要定位权限和 setMyLocationEnabled(true)

7. 后台定位中断(锁屏后停止)
原因:未申请后台定位权限或未配置长时任务。
解决方法:在 module.json5 中声明后台权限,添加 backgroundModes: ["location"],并调用 startBackgroundRunning

8. 后台定位仍被系统杀死
原因:未创建 WantAgent 或长时任务参数错误。
解决方法:正确创建 WantAgent,传入有效的 WantAgent 对象。

9. 首次定位不移动相机
原因:getCurrentLocation 未传入参数或超时。
解决方法:配置 SingleLocationRequest,增加重试和缓存位置降级(优先使用 getLastLocation())。

10. 轨迹乱画(漂移)
原因:GPS信号瞬间跳变。
解决方法:首次定位未开始运动只显示位置,不存储数据。开始运动后增加异常点检测,丢弃距离跳变 >80米且时间 <3秒的点,或瞬时速度 >15m/s的点。

11. 首次地图加载定位慢
原因:GPS冷启动需要时间。
解决方法:优先使用 getLastLocation() 缓存位置,再异步请求最新位置。

12. 定位点在前、绘制在后(轨迹线滞后)
原因:全量重绘折线有延迟。
解决方法:改用增量添加折线段,每次只绘制最后两点之间的线段,避免清除整条折线。

13. 坐标转换失败
原因:未导入 map 模块或未正确调用同步接口。
解决方法:使用 map.convertCoordinateSync,确保参数类型正确。

八、总结

通过本文,你学会了:

  • 高精度定位参数配置(ACCURACY + TRAJECTORY_TRACKING
  • 坐标纠偏(WGS84 → GCJ02)的原理与系统API使用
  • 点稀释与移动平均平滑的数学原理及代码实现
  • Haversine公式计算球面距离
  • 速度、配速的计算公式与UI实时更新
  • 使用 Map Kit 绘制动态折线(圆角连接、分段颜色)
  • 模块化设计(权限、前台定位、后台定位、轨迹、地图分离)
  • 后台持续定位(长时任务 + 后台权限 + 状态栏通知)
  • 首次加载地图时自动定位到当前位置(含缓存降级)
  • 异常点检测算法(距离跳变 + 速度异常)有效防漂移
  • 增量绘制折线段解决轨迹线与蓝点不同步的空白问题

九、扩展方向

  • 分段配速热力图:根据每公里配速改变折线颜色,让用户直观看到速度变化。
  • 轨迹分享与生成海报:支持将轨迹导出海报。
  • 运动数据统计图表:展示周/月/年里程趋势、配速曲线等。
  • 语音播报:每公里自动播报配速、距离等信息。

拓展内容有时间我会一一实现,如果觉得有用,请点赞、收藏、转发支持!

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐