HarmonyOS开发:轨迹记录与运动轨迹回放

举报
Jack20 发表于 2026/06/22 14:07:46 2026/06/22
【摘要】 HarmonyOS开发:轨迹记录与运动轨迹回放核心要点:本文深入讲解HarmonyOS平台轨迹记录与运动轨迹回放的完整技术实现,涵盖持续定位追踪、轨迹点采集与过滤、运动数据计算(距离/速度/配速/卡路里)、地图轨迹绘制、轨迹回放动画等核心能力,构建一个完整的运动追踪应用。项目说明核心KitLocation Kit、Map Kit、Background Task Kit难度等级⭐⭐⭐⭐☆ 一...

HarmonyOS开发:轨迹记录与运动轨迹回放

核心要点:本文深入讲解HarmonyOS平台轨迹记录与运动轨迹回放的完整技术实现,涵盖持续定位追踪、轨迹点采集与过滤、运动数据计算(距离/速度/配速/卡路里)、地图轨迹绘制、轨迹回放动画等核心能力,构建一个完整的运动追踪应用。

项目 说明
核心Kit Location Kit、Map Kit、Background Task Kit
难度等级 ⭐⭐⭐⭐☆

一、背景与动机

1.1 运动追踪的市场需求

随着全民健身意识的提升,运动类APP已成为智能手机的标配应用。跑步、骑行、徒步等运动场景中,轨迹记录是最核心的功能——用户期望精确记录运动路径、实时查看运动数据、事后回放运动轨迹。

在HarmonyOS生态中,Location Kit提供了持续定位能力,Map Kit提供了轨迹绘制能力,Background Task Kit保障了后台运行时的定位持续性。三大Kit的协同,使得构建高性能运动追踪应用成为可能。

1.2 轨迹记录的技术挑战

轨迹记录远不止"每隔几秒获取一次位置"这么简单,其核心技术挑战包括:

  • 精度与功耗的平衡:高频定位耗电,低频定位丢失细节
  • 轨迹点过滤:GPS漂移、信号遮挡导致异常点需剔除
  • 后台持续运行:应用切到后台后定位不能中断
  • 运动数据计算:距离、配速、卡路里等数据的实时计算
  • 轨迹回放:将历史轨迹以动画形式重现

1.3 本文目标

构建一个完整的「运动追踪」应用,实现以下核心功能:

  1. 支持跑步/骑行/徒步三种运动模式
  2. 实时记录轨迹并计算运动数据
  3. 地图上实时绘制运动路径
  4. 运动结束后支持轨迹回放

二、核心原理

2.1 轨迹记录技术架构

flowchart TB
    classDef kit fill:#4ECDC4,stroke:#2C3E50,color:#fff,font-weight:bold
    classDef service fill:#FF6B6B,stroke:#2C3E50,color:#fff,font-weight:bold
    classDef data fill:#45B7D1,stroke:#2C3E50,color:#fff,font-weight:bold
    classDef ui fill:#96CEB4,stroke:#2C3E50,color:#fff,font-weight:bold

    A[用户开始运动]:::ui --> B[启动持续定位]:::service
    B --> C[Location Kit]:::kit
    C --> D[位置回调]:::service

    D --> E[轨迹点过滤]:::service
    E --> F{精度检查}:::service
    F -->|精度合格| G[加入轨迹队列]:::data
    F -->|精度不足| H[丢弃/等待]:::service

    G --> I[运动数据计算]:::service
    I --> J[距离累加]:::data
    I --> K[速度/配速计算]:::data
    I --> L[卡路里估算]:::data

    G --> M[地图轨迹绘制]:::ui
    M --> N[Map Kit Polyline]:::kit

    I --> O[UI数据刷新]:::ui

    P[Background Task Kit]:::kit --> Q[后台长驻任务]:::service
    Q --> B

    style A fill:#FFE66D,stroke:#2C3E50,color:#2C3E50
    style F fill:#FFE66D,stroke:#2C3E50,color:#2C3E50

2.2 关键算法:轨迹点过滤

GPS定位存在漂移问题,特别是在高楼密集区、隧道、地下通道等场景。轨迹点过滤是保证轨迹质量的关键环节。

卡尔曼滤波简化版

flowchart LR
    classDef input fill:#FFE66D,stroke:#2C3E50,color:#2C3E50
    classDef process fill:#4ECDC4,stroke:#2C3E50,color:#fff,font-weight:bold
    classDef output fill:#FF6B6B,stroke:#2C3E50,color:#fff,font-weight:bold

    A[原始GPS]:::input --> B[精度阈值过滤]:::process
    B --> C[速度阈值过滤]:::process
    C --> D[距离阈值过滤]:::process
    D --> E[平滑处理]:::process
    E --> F[过滤后轨迹点]:::output

    style A fill:#FFE66D,stroke:#2C3E50,color:#2C3E50
    style F fill:#FFE66D,stroke:#2C3E50,color:#2C3E50

三层过滤策略

过滤层 条件 说明
精度过滤 accuracy ≤ 30m 精度太差的点直接丢弃
速度过滤 speed ≤ maxSpeed 超过合理速度的点视为漂移
距离过滤 distance ≥ 3m 距离太近的点视为静止抖动

2.3 运动数据计算模型

距离计算:Haversine公式

两点之间的球面距离:

d = 2R × arcsin((sin²((φ2-φ1)/2) + cos(φ1)×cos(φ2)×sin²((λ2-λ1)/2)))

其中R为地球半径(6371km),φ为纬度,λ为经度。

卡路里估算

不同运动类型的卡路里消耗公式:

运动类型 公式 参数说明
跑步 Cal = MET × 体重(kg) × 时间(h) MET≈9.0(8km/h)
骑行 Cal = MET × 体重(kg) × 时间(h) MET≈6.8(15km/h)
徒步 Cal = MET × 体重(kg) × 时间(h) MET≈5.3(4km/h)

三、代码实战

3.1 轨迹点数据模型

// TrackModels.ets

/**
 * 轨迹点数据模型
 * 记录运动过程中每个采样点的完整信息
 */
export interface TrackPoint {
  latitude: number;       // 纬度
  longitude: number;      // 经度
  altitude: number;       // 海拔(米)
  accuracy: number;       // 定位精度(米)
  speed: number;          // 瞬时速度(m/s)
  timestamp: number;      // 时间戳(毫秒)
  distance: number;       // 累计距离(米)
  duration: number;       // 累计时长(秒)
  heartRate: number;      // 心率(bpm),可选
}

/**
 * 运动类型枚举
 */
export enum SportType {
  RUNNING = 'running',     // 跑步
  CYCLING = 'cycling',     // 骑行
  HIKING = 'hiking'        // 徒步
}

/**
 * 运动统计数据
 */
export interface SportStats {
  totalDistance: number;    // 总距离(米)
  totalDuration: number;   // 总时长(秒)
  avgSpeed: number;        // 平均速度(m/s)
  avgPace: number;         // 平均配速(秒/公里)
  maxSpeed: number;        // 最高速度(m/s)
  calories: number;        // 卡路里消耗(千卡)
  elevationGain: number;   // 累计爬升(米)
  elevationLoss: number;   // 累计下降(米)
}

/**
 * 轨迹记录数据模型
 * 一条完整的运动轨迹
 */
export interface TrackRecord {
  id: string;              // 轨迹ID
  sportType: SportType;    // 运动类型
  startTime: number;       // 开始时间
  endTime: number;         // 结束时间
  points: TrackPoint[];    // 轨迹点列表
  stats: SportStats;       // 运动统计
  totalDistance: number;    // 总距离
  totalDuration: number;   // 总时长
}

/**
 * 运动类型对应的MET值
 */
export class SportMetValue {
  static readonly RUNNING: number = 9.0;
  static readonly CYCLING: number = 6.8;
  static readonly HIKING: number = 5.3;

  static getMet(sportType: SportType): number {
    switch (sportType) {
      case SportType.RUNNING:
        return this.RUNNING;
      case SportType.CYCLING:
        return this.CYCLING;
      case SportType.HIKING:
        return this.HIKING;
      default:
        return 5.0;
    }
  }
}

3.2 轨迹点过滤器

// TrackPointFilter.ets
import { TrackPoint } from './TrackModels';

/**
 * 过滤配置参数
 */
export interface FilterConfig {
  minAccuracy: number;     // 最小精度阈值(米),默认30
  maxSpeed: number;        // 最大速度阈值(m/s),默认50
  minDistance: number;     // 最小距离阈值(米),默认3
  smoothingFactor: number; // 平滑因子(0-1),默认0.5
}

/**
 * 轨迹点过滤器
 * 实现三层过滤策略:精度过滤、速度过滤、距离过滤
 * 以及简单的指数平滑处理
 */
export class TrackPointFilter {
  private config: FilterConfig;
  private lastValidPoint: TrackPoint | null = null;
  private smoothedLat: number = 0;
  private smoothedLng: number = 0;
  private isInitialized: boolean = false;

  constructor(config?: Partial<FilterConfig>) {
    this.config = {
      minAccuracy: config?.minAccuracy ?? 30,
      maxSpeed: config?.maxSpeed ?? 50,
      minDistance: config?.minDistance ?? 3,
      smoothingFactor: config?.smoothingFactor ?? 0.5
    };
  }

  /**
   * 过滤轨迹点
   * @param point 原始轨迹点
   * @returns 过滤后的轨迹点,如果被过滤则返回null
   */
  filter(point: TrackPoint): TrackPoint | null {
    // 第一层:精度过滤
    if (point.accuracy > this.config.minAccuracy) {
      console.debug(`[Filter] 精度不足: ${point.accuracy}m > ${this.config.minAccuracy}m`);
      return null;
    }

    // 第一个点直接通过
    if (!this.lastValidPoint) {
      this.lastValidPoint = point;
      this.smoothedLat = point.latitude;
      this.smoothedLng = point.longitude;
      this.isInitialized = true;
      return point;
    }

    // 第二层:速度过滤
    const timeDelta = (point.timestamp - this.lastValidPoint.timestamp) / 1000;
    if (timeDelta > 0) {
      const distance = this.calculateDistance(
        this.lastValidPoint.latitude, this.lastValidPoint.longitude,
        point.latitude, point.longitude
      );
      const speed = distance / timeDelta;

      if (speed > this.config.maxSpeed) {
        console.debug(`[Filter] 速度异常: ${speed.toFixed(2)}m/s > ${this.config.maxSpeed}m/s`);
        return null;
      }
    }

    // 第三层:距离过滤
    const distFromLast = this.calculateDistance(
      this.lastValidPoint.latitude, this.lastValidPoint.longitude,
      point.latitude, point.longitude
    );
    if (distFromLast < this.config.minDistance) {
      console.debug(`[Filter] 距离过近: ${distFromLast.toFixed(2)}m < ${this.config.minDistance}m`);
      return null;
    }

    // 平滑处理:指数移动平均
    const alpha = this.config.smoothingFactor;
    this.smoothedLat = alpha * point.latitude + (1 - alpha) * this.smoothedLat;
    this.smoothedLng = alpha * point.longitude + (1 - alpha) * this.smoothedLng;

    // 应用平滑后的坐标
    const filteredPoint: TrackPoint = {
      ...point,
      latitude: this.smoothedLat,
      longitude: this.smoothedLng
    };

    this.lastValidPoint = filteredPoint;
    return filteredPoint;
  }

  /**
   * 重置过滤器状态
   */
  reset(): void {
    this.lastValidPoint = null;
    this.isInitialized = false;
  }

  /**
   * Haversine公式计算两点距离
   */
  private calculateDistance(
    lat1: number, lng1: number,
    lat2: number, lng2: number
  ): number {
    const R = 6371000; // 地球半径(米)
    const dLat = this.toRadians(lat2 - lat1);
    const dLng = this.toRadians(lng2 - lng1);

    const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
              Math.cos(this.toRadians(lat1)) * Math.cos(this.toRadians(lat2)) *
              Math.sin(dLng / 2) * Math.sin(dLng / 2);

    const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
    return R * c;
  }

  private toRadians(degrees: number): number {
    return degrees * Math.PI / 180;
  }
}

3.3 轨迹记录管理器

// TrackRecorder.ets
import { geoLocationManager } from '@kit.LocationKit';
import { backgroundTaskManager } from '@kit.BackgroundTaskKit';
import { BusinessError } from '@kit.BasicServicesKit';
import {
  TrackPoint, TrackRecord, SportType, SportStats, SportMetValue
} from './TrackModels';
import { TrackPointFilter } from './TrackPointFilter';

/**
 * 记录状态枚举
 */
export enum RecordState {
  IDLE = 'idle',           // 空闲
  RECORDING = 'recording', // 记录中
  PAUSED = 'paused',      // 暂停
  FINISHED = 'finished'   // 已完成
}

/**
 * 轨迹记录回调
 */
export interface TrackRecorderCallback {
  onPointAdded?: (point: TrackPoint) => void;          // 新轨迹点
  onStatsUpdated?: (stats: SportStats) => void;        // 统计更新
  onStateChanged?: (state: RecordState) => void;       // 状态变更
  onError?: (error: Error) => void;                    // 错误
}

/**
 * 轨迹记录管理器
 * 核心职责:持续定位、轨迹点采集、运动数据计算
 */
export class TrackRecorder {
  private static instance: TrackRecorder;
  private state: RecordState = RecordState.IDLE;
  private currentTrack: TrackRecord | null = null;
  private filter: TrackPointFilter;
  private callback: TrackRecorderCallback | null = null;
  private locationChangeId: number = -1;
  private bgTaskId: number = -1;
  private userWeight: number = 70; // 用户体重(kg),默认70kg

  // 运动统计中间值
  private totalDistance: number = 0;
  private maxSpeed: number = 0;
  private elevationGain: number = 0;
  private elevationLoss: number = 0;
  private lastAltitude: number = 0;

  private constructor() {
    this.filter = new TrackPointFilter({
      minAccuracy: 25,
      maxSpeed: 45,
      minDistance: 3,
      smoothingFactor: 0.6
    });
  }

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

  /**
   * 注册回调
   */
  setCallback(callback: TrackRecorderCallback): void {
    this.callback = callback;
  }

  /**
   * 设置用户体重(用于卡路里计算)
   */
  setUserWeight(weight: number): void {
    this.userWeight = weight;
  }

  /**
   * 开始记录
   * @param sportType 运动类型
   */
  async startRecording(sportType: SportType): Promise<void> {
    if (this.state === RecordState.RECORDING) {
      console.warn('[TrackRecorder] 已在记录中');
      return;
    }

    try {
      // 申请后台长驻任务
      await this.requestBackgroundTask();

      // 初始化轨迹记录
      this.currentTrack = {
        id: `track_${Date.now()}`,
        sportType: sportType,
        startTime: Date.now(),
        endTime: 0,
        points: [],
        stats: this.createEmptyStats(),
        totalDistance: 0,
        totalDuration: 0
      };

      // 重置统计值
      this.totalDistance = 0;
      this.maxSpeed = 0;
      this.elevationGain = 0;
      this.elevationLoss = 0;
      this.lastAltitude = 0;
      this.filter.reset();

      // 启动持续定位
      await this.startContinuousLocation();

      this.state = RecordState.RECORDING;
      this.callback?.onStateChanged?.(this.state);
      console.info('[TrackRecorder] 开始记录');
    } catch (error) {
      const err = error as BusinessError;
      console.error(`[TrackRecorder] 启动失败: ${err.code} - ${err.message}`);
      this.callback?.onError?.(new Error(`启动记录失败: ${err.message}`));
    }
  }

  /**
   * 暂停记录
   */
  pauseRecording(): void {
    if (this.state !== RecordState.RECORDING) return;

    this.stopContinuousLocation();
    this.state = RecordState.PAUSED;
    this.callback?.onStateChanged?.(this.state);
    console.info('[TrackRecorder] 暂停记录');
  }

  /**
   * 恢复记录
   */
  async resumeRecording(): Promise<void> {
    if (this.state !== RecordState.PAUSED) return;

    await this.startContinuousLocation();
    this.state = RecordState.RECORDING;
    this.callback?.onStateChanged?.(this.state);
    console.info('[TrackRecorder] 恢复记录');
  }

  /**
   * 停止记录
   */
  stopRecording(): TrackRecord | null {
    if (this.state === RecordState.IDLE) return null;

    this.stopContinuousLocation();
    this.cancelBackgroundTask();

    if (this.currentTrack) {
      this.currentTrack.endTime = Date.now();
      this.currentTrack.totalDistance = this.totalDistance;
      this.currentTrack.totalDuration = this.calculateDuration();
      this.currentTrack.stats = this.calculateStats();
    }

    const result = this.currentTrack;
    this.state = RecordState.FINISHED;
    this.callback?.onStateChanged?.(this.state);
    console.info(`[TrackRecorder] 记录完成: ${(this.totalDistance / 1000).toFixed(2)}km`);

    return result;
  }

  /**
   * 启动持续定位
   */
  private async startContinuousLocation(): Promise<void> {
    const requestInfo: geoLocationManager.ContinuousLocationRequest = {
      interval: 1,                    // 1秒更新一次
      locationScenario: geoLocationManager.LocationScenario.NAVIGATION, // 导航场景
      locationTimeout: 10             // 定位超时10秒
    };

    try {
      this.locationChangeId = geoLocationManager.on('locationChange', requestInfo,
        (location: geoLocationManager.Location) => {
          this.onLocationReceived(location);
        }
      );
      console.info('[TrackRecorder] 持续定位已启动');
    } catch (error) {
      const err = error as BusinessError;
      console.error(`[TrackRecorder] 启动定位失败: ${err.code} - ${err.message}`);
      throw error;
    }
  }

  /**
   * 停止持续定位
   */
  private stopContinuousLocation(): void {
    if (this.locationChangeId !== -1) {
      try {
        geoLocationManager.off('locationChange', this.locationChangeId);
        this.locationChangeId = -1;
        console.info('[TrackRecorder] 持续定位已停止');
      } catch (error) {
        console.error('[TrackRecorder] 停止定位失败:', JSON.stringify(error));
      }
    }
  }

  /**
   * 位置回调处理
   */
  private onLocationReceived(location: geoLocationManager.Location): void {
    if (this.state !== RecordState.RECORDING || !this.currentTrack) return;

    // 构建原始轨迹点
    const rawPoint: TrackPoint = {
      latitude: location.latitude,
      longitude: location.longitude,
      altitude: location.altitude ?? 0,
      accuracy: location.accuracy,
      speed: location.speed ?? 0,
      timestamp: Date.now(),
      distance: 0,
      duration: 0
    };

    // 过滤轨迹点
    const filteredPoint = this.filter.filter(rawPoint);
    if (!filteredPoint) return;

    // 计算与上一个点的距离
    if (this.currentTrack.points.length > 0) {
      const lastPoint = this.currentTrack.points[this.currentTrack.points.length - 1];
      const segmentDistance = this.haversineDistance(
        lastPoint.latitude, lastPoint.longitude,
        filteredPoint.latitude, filteredPoint.longitude
      );
      this.totalDistance += segmentDistance;

      // 海拔变化
      const altitudeDelta = filteredPoint.altitude - lastPoint.altitude;
      if (altitudeDelta > 1) {
        this.elevationGain += altitudeDelta;
      } else if (altitudeDelta < -1) {
        this.elevationLoss += Math.abs(altitudeDelta);
      }
    }

    // 更新轨迹点数据
    filteredPoint.distance = this.totalDistance;
    filteredPoint.duration = this.calculateDuration();

    // 更新最高速度
    if (filteredPoint.speed > this.maxSpeed) {
      this.maxSpeed = filteredPoint.speed;
    }

    // 加入轨迹
    this.currentTrack.points.push(filteredPoint);

    // 通知回调
    this.callback?.onPointAdded?.(filteredPoint);
    this.callback?.onStatsUpdated?.(this.calculateStats());
  }

  /**
   * 计算当前运动统计
   */
  private calculateStats(): SportStats {
    const duration = this.calculateDuration();
    const avgSpeed = duration > 0 ? this.totalDistance / duration : 0;
    const avgPace = this.totalDistance > 0 ? (duration / (this.totalDistance / 1000)) : 0;

    // 卡路里计算:MET × 体重(kg) × 时间(h)
    const metValue = this.currentTrack ?
      SportMetValue.getMet(this.currentTrack.sportType) : 5.0;
    const calories = metValue * this.userWeight * (duration / 3600);

    return {
      totalDistance: this.totalDistance,
      totalDuration: duration,
      avgSpeed: avgSpeed,
      avgPace: avgPace,
      maxSpeed: this.maxSpeed,
      calories: Math.round(calories),
      elevationGain: Math.round(this.elevationGain),
      elevationLoss: Math.round(this.elevationLoss)
    };
  }

  /**
   * 计算运动时长(秒)
   */
  private calculateDuration(): number {
    if (!this.currentTrack) return 0;
    return Math.floor((Date.now() - this.currentTrack.startTime) / 1000);
  }

  /**
   * 创建空统计对象
   */
  private createEmptyStats(): SportStats {
    return {
      totalDistance: 0,
      totalDuration: 0,
      avgSpeed: 0,
      avgPace: 0,
      maxSpeed: 0,
      calories: 0,
      elevationGain: 0,
      elevationLoss: 0
    };
  }

  /**
   * Haversine距离计算
   */
  private haversineDistance(lat1: number, lng1: number, lat2: number, lng2: number): number {
    const R = 6371000;
    const dLat = (lat2 - lat1) * Math.PI / 180;
    const dLng = (lng2 - lng1) * Math.PI / 180;
    const a = Math.sin(dLat / 2) ** 2 +
              Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
              Math.sin(dLng / 2) ** 2;
    return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
  }

  /**
   * 申请后台长驻任务
   */
  private async requestBackgroundTask(): Promise<void> {
    try {
      this.bgTaskId = await backgroundTaskManager.requestSuspendDelay(
        '运动轨迹记录',
        () => {
          console.warn('[TrackRecorder] 后台任务即将被取消');
        }
      );
      console.info(`[TrackRecorder] 后台任务已申请: ${this.bgTaskId}`);
    } catch (error) {
      console.error('[TrackRecorder] 后台任务申请失败:', JSON.stringify(error));
    }
  }

  /**
   * 取消后台长驻任务
   */
  private cancelBackgroundTask(): void {
    if (this.bgTaskId !== -1) {
      backgroundTaskManager.cancelSuspendDelay(this.bgTaskId);
      this.bgTaskId = -1;
    }
  }

  /**
   * 获取当前状态
   */
  getState(): RecordState {
    return this.state;
  }

  /**
   * 获取当前轨迹
   */
  getCurrentTrack(): TrackRecord | null {
    return this.currentTrack;
  }
}

3.4 轨迹回放控制器

// TrackReplayController.ets
import { TrackPoint, TrackRecord } from './TrackModels';
import { map } from '@kit.MapKit';

/**
 * 回放状态
 */
export enum ReplayState {
  IDLE = 'idle',
  PLAYING = 'playing',
  PAUSED = 'paused',
  FINISHED = 'finished'
}

/**
 * 回放配置
 */
export interface ReplayConfig {
  speed: number;            // 回放速度倍率,默认1
  interval: number;         // 回放间隔(毫秒),默认100
  showMarker: boolean;      // 是否显示移动标记,默认true
  showTrail: boolean;       // 是否显示轨迹尾迹,默认true
}

/**
 * 回放进度回调
 */
export interface ReplayCallback {
  onProgress?: (progress: number, point: TrackPoint) => void;
  onStateChanged?: (state: ReplayState) => void;
  onFinished?: () => void;
}

/**
 * 轨迹回放控制器
 * 将历史轨迹以动画形式回放
 */
export class TrackReplayController {
  private state: ReplayState = ReplayState.IDLE;
  private config: ReplayConfig;
  private callback: ReplayCallback | null = null;
  private currentIndex: number = 0;
  private timerId: number = -1;
  private trackPoints: TrackPoint[] = [];
  private mapController: map.MapComponentController | null = null;
  private replayMarker: map.Marker | null = null;
  private replayPolyline: map.MapPolyline | null = null;

  constructor(config?: Partial<ReplayConfig>) {
    this.config = {
      speed: config?.speed ?? 1,
      interval: config?.interval ?? 100,
      showMarker: config?.showMarker ?? true,
      showTrail: config?.showTrail ?? true
    };
  }

  /**
   * 设置地图控制器
   */
  setMapController(controller: map.MapComponentController): void {
    this.mapController = controller;
  }

  /**
   * 设置回放回调
   */
  setCallback(callback: ReplayCallback): void {
    this.callback = callback;
  }

  /**
   * 加载轨迹数据
   */
  loadTrack(track: TrackRecord): void {
    this.trackPoints = track.points;
    this.currentIndex = 0;
    this.state = ReplayState.IDLE;

    // 地图移动到轨迹起点
    if (this.trackPoints.length > 0 && this.mapController) {
      const startPoint = this.trackPoints[0];
      this.mapController.moveTo({
        latitude: startPoint.latitude,
        longitude: startPoint.longitude
      });
    }

    this.callback?.onStateChanged?.(this.state);
  }

  /**
   * 开始回放
   */
  start(): void {
    if (this.trackPoints.length === 0) {
      console.warn('[TrackReplay] 没有轨迹数据');
      return;
    }

    if (this.state === ReplayState.PAUSED) {
      // 从暂停恢复
      this.resumeReplay();
      return;
    }

    this.currentIndex = 0;
    this.state = ReplayState.PLAYING;
    this.callback?.onStateChanged?.(this.state);

    // 绘制完整轨迹线(半透明)
    this.drawFullTrack();

    // 开始逐步回放
    this.scheduleNextFrame();
  }

  /**
   * 暂停回放
   */
  pause(): void {
    if (this.state !== ReplayState.PLAYING) return;

    this.state = ReplayState.PAUSED;
    if (this.timerId !== -1) {
      clearTimeout(this.timerId);
      this.timerId = -1;
    }
    this.callback?.onStateChanged?.(this.state);
  }

  /**
   * 恢复回放
   */
  private resumeReplay(): void {
    this.state = ReplayState.PLAYING;
    this.callback?.onStateChanged?.(this.state);
    this.scheduleNextFrame();
  }

  /**
   * 停止回放
   */
  stop(): void {
    if (this.timerId !== -1) {
      clearTimeout(this.timerId);
      this.timerId = -1;
    }

    this.state = ReplayState.IDLE;
    this.currentIndex = 0;
    this.removeReplayMarker();
    this.callback?.onStateChanged?.(this.state);
  }

  /**
   * 设置回放速度
   */
  setSpeed(speed: number): void {
    this.config.speed = speed;
  }

  /**
   * 调度下一帧
   */
  private scheduleNextFrame(): void {
    if (this.state !== ReplayState.PLAYING) return;

    this.timerId = setTimeout(() => {
      this.replayFrame();
    }, this.config.interval / this.config.speed) as unknown as number;
  }

  /**
   * 回放一帧
   */
  private replayFrame(): void {
    if (this.currentIndex >= this.trackPoints.length) {
      this.state = ReplayState.FINISHED;
      this.callback?.onStateChanged?.(this.state);
      this.callback?.onFinished?.();
      return;
    }

    const point = this.trackPoints[this.currentIndex];
    const progress = this.currentIndex / (this.trackPoints.length - 1);

    // 更新移动标记位置
    this.updateReplayMarker(point);

    // 地图跟随移动
    if (this.mapController) {
      this.mapController.moveTo({
        latitude: point.latitude,
        longitude: point.longitude
      });
    }

    // 通知进度
    this.callback?.onProgress?.(progress, point);

    this.currentIndex++;
    this.scheduleNextFrame();
  }

  /**
   * 绘制完整轨迹线
   */
  private drawFullTrack(): void {
    if (!this.mapController || this.trackPoints.length < 2) return;

    // 移除旧轨迹线
    if (this.replayPolyline) {
      this.replayPolyline.remove();
    }

    const points: map.LatLng[] = this.trackPoints.map(p => ({
      latitude: p.latitude,
      longitude: p.longitude
    }));

    const polylineOptions: map.MapPolylineOptions = {
      points: points,
      width: 6,
      color: 0x804ECDC4  // 半透明青色
    };

    this.replayPolyline = this.mapController.addPolyline(polylineOptions);
  }

  /**
   * 更新回放标记位置
   */
  private updateReplayMarker(point: TrackPoint): void {
    if (!this.mapController || !this.config.showMarker) return;

    // 移除旧标记
    this.removeReplayMarker();

    // 添加新标记
    const markerOptions: map.MarkerOptions = {
      position: {
        latitude: point.latitude,
        longitude: point.longitude
      },
      title: '当前位置',
      anchor: { x: 0.5, y: 0.5 }
    };

    this.replayMarker = this.mapController.addMarker(markerOptions);
  }

  /**
   * 移除回放标记
   */
  private removeReplayMarker(): void {
    if (this.replayMarker) {
      this.replayMarker.remove();
      this.replayMarker = null;
    }
  }
}

3.5 运动追踪主页面

// TrackRecordingPage.ets
import { map, mapCommon } from '@kit.MapKit';
import {
  TrackRecorder, RecordState, TrackPoint, TrackRecord, SportType, SportStats
} from '../service/TrackRecorder';
import { TrackReplayController, ReplayState, ReplayConfig } from '../service/TrackReplayController';
import { promptAction } from '@kit.ArkUI';

@Entry
@Component
struct TrackRecordingPage {
  // 记录状态
  @State recordState: RecordState = RecordState.IDLE;
  @State currentSport: SportType = SportType.RUNNING;
  @State stats: SportStats = {
    totalDistance: 0, totalDuration: 0, avgSpeed: 0, avgPace: 0,
    maxSpeed: 0, calories: 0, elevationGain: 0, elevationLoss: 0
  };
  @State trackPoints: TrackPoint[] = [];
  @State mapController: map.MapComponentController | null = null;

  // 回放状态
  @State replayState: ReplayState = ReplayState.IDLE;
  @State replayProgress: number = 0;
  @State showReplayPanel: boolean = false;
  @State replaySpeed: number = 1;

  // 服务实例
  private recorder = TrackRecorder.getInstance();
  private replayController = new TrackReplayController();
  private completedTrack: TrackRecord | null = null;
  private mapCallback = async () => {};

  aboutToAppear(): void {
    // 注册记录回调
    this.recorder.setCallback({
      onPointAdded: (point: TrackPoint) => {
        this.trackPoints = [...this.trackPoints, point];
        this.updateMapPolyline();
      },
      onStatsUpdated: (stats: SportStats) => {
        this.stats = { ...stats };
      },
      onStateChanged: (state: RecordState) => {
        this.recordState = state;
      },
      onError: (error: Error) => {
        promptAction.showToast({ message: error.message });
      }
    });
  }

  /**
   * 更新地图轨迹线
   */
  updateMapPolyline(): void {
    if (!this.mapController || this.trackPoints.length < 2) return;

    const points: map.LatLng[] = this.trackPoints.map(p => ({
      latitude: p.latitude,
      longitude: p.longitude
    }));

    // 简化:每次重新绘制(生产环境应增量更新)
    const polylineOptions: map.MapPolylineOptions = {
      points: points,
      width: 8,
      color: 0xFF4ECDC4
    };

    this.mapController.addPolyline(polylineOptions);
  }

  /**
   * 开始运动
   */
  async startSport(): Promise<void> {
    this.trackPoints = [];
    await this.recorder.startRecording(this.currentSport);
  }

  /**
   * 暂停运动
   */
  pauseSport(): void {
    this.recorder.pauseRecording();
  }

  /**
   * 恢复运动
   */
  async resumeSport(): Promise<void> {
    await this.recorder.resumeRecording();
  }

  /**
   * 结束运动
   */
  finishSport(): void {
    this.completedTrack = this.recorder.stopRecording();
    if (this.completedTrack && this.completedTrack.points.length > 0) {
      this.showReplayPanel = true;
    }
  }

  /**
   * 开始回放
   */
  startReplay(): void {
    if (!this.completedTrack || !this.mapController) return;

    this.replayController.setMapController(this.mapController!);
    this.replayController.setCallback({
      onProgress: (progress: number) => {
        this.replayProgress = progress;
      },
      onStateChanged: (state: ReplayState) => {
        this.replayState = state;
      },
      onFinished: () => {
        promptAction.showToast({ message: '回放完成' });
      }
    });

    this.replayController.loadTrack(this.completedTrack);
    this.replayController.start();
  }

  build() {
    Column() {
      // 顶部运动数据面板
      this.StatsPanel()

      // 地图区域
      Stack() {
        MapComponent({
          mapOptions: {
            position: {
              target: { latitude: 39.9042, longitude: 116.4074 },
              zoom: 16
            }
          },
          mapCallback: this.mapCallback
        })
        .width('100%')
        .height('100%')
        .onControllerReady((controller: map.MapComponentController) => {
          this.mapController = controller;
        })

        // 回放控制面板
        if (this.showReplayPanel) {
          this.ReplayPanel()
        }
      }
      .layoutWeight(1)

      // 底部控制栏
      this.ControlBar()
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#1A1A2E')
  }

  /**
   * 运动数据面板
   */
  @Builder
  StatsPanel() {
    Column() {
      // 核心数据:距离
      Text(this.formatDistance(this.stats.totalDistance))
        .fontSize(42)
        .fontColor('#4ECDC4')
        .fontWeight(FontWeight.Bold)
        .fontFamily('HarmonyOS Sans')

      Text(this.recordState === RecordState.RECORDING ? '运动中' : '已暂停')
        .fontSize(13)
        .fontColor('#8B8B9E')
        .margin({ top: 4 })

      // 次要数据行
      Row() {
        this.StatItem('时长', this.formatDuration(this.stats.totalDuration))
        this.StatItem('配速', this.formatPace(this.stats.avgPace))
        this.StatItem('卡路里', `${this.stats.calories}kcal`)
        this.StatItem('爬升', `${this.stats.elevationGain}m`)
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceAround)
      .margin({ top: 16 })
    }
    .width('100%')
    .padding({ left: 24, right: 24, top: 20, bottom: 16 })
    .backgroundColor('rgba(26, 26, 46, 0.95)')
    .borderRadius({ bottomLeft: 20, bottomRight: 20 })
  }

  /**
   * 统计项组件
   */
  @Builder
  StatItem(label: string, value: string) {
    Column() {
      Text(value)
        .fontSize(16)
        .fontColor('#FFFFFF')
        .fontWeight(FontWeight.Medium)
      Text(label)
        .fontSize(11)
        .fontColor('#8B8B9E')
        .margin({ top: 2 })
    }
    .alignItems(HorizontalAlign.Center)
  }

  /**
   * 回放控制面板
   */
  @Builder
  ReplayPanel() {
    Column() {
      // 进度条
      Progress({ value: this.replayProgress * 100, total: 100, type: ProgressType.Linear })
        .width('80%')
        .color('#4ECDC4')
        .backgroundColor('rgba(78, 205, 196, 0.2)')
        .margin({ bottom: 12 })

      // 速度选择
      Row({ space: 12 }) {
        ForEach([1, 2, 4, 8], (speed: number) => {
          Text(`${speed}x`)
            .fontSize(13)
            .fontColor(this.replaySpeed === speed ? '#4ECDC4' : '#8B8B9E')
            .padding({ left: 8, right: 8, top: 4, bottom: 4 })
            .backgroundColor(this.replaySpeed === speed ? 'rgba(78, 205, 196, 0.15)' : 'transparent')
            .borderRadius(8)
            .onClick(() => {
              this.replaySpeed = speed;
              this.replayController.setSpeed(speed);
            })
        })
      }

      // 播放/暂停按钮
      Row({ space: 16 }) {
        Button(this.replayState === ReplayState.PLAYING ? '暂停' : '播放')
          .fontSize(14)
          .fontColor('#FFFFFF')
          .backgroundColor('#4ECDC4')
          .borderRadius(20)
          .width(80)
          .height(36)
          .onClick(() => {
            if (this.replayState === ReplayState.PLAYING) {
              this.replayController.pause();
            } else {
              this.startReplay();
            }
          })

        Button('停止')
          .fontSize(14)
          .fontColor('#8B8B9E')
          .backgroundColor('rgba(139, 139, 158, 0.2)')
          .borderRadius(20)
          .width(80)
          .height(36)
          .onClick(() => {
            this.replayController.stop();
            this.showReplayPanel = false;
          })
      }
      .margin({ top: 12 })
    }
    .width('90%')
    .padding(16)
    .backgroundColor('rgba(26, 26, 46, 0.95)')
    .borderRadius(16)
    .alignItems(HorizontalAlign.Center)
    .position({ x: '5%', y: '70%' })
  }

  /**
   * 底部控制栏
   */
  @Builder
  ControlBar() {
    Row() {
      // 运动类型选择
      if (this.recordState === RecordState.IDLE) {
        Row({ space: 8 }) {
          ForEach([
            { type: SportType.RUNNING, label: '跑步' },
            { type: SportType.CYCLING, label: '骑行' },
            { type: SportType.HIKING, label: '徒步' }
          ], (item: { type: SportType; label: string }) => {
            Text(item.label)
              .fontSize(14)
              .fontColor(this.currentSport === item.type ? '#4ECDC4' : '#8B8B9E')
              .padding({ left: 16, right: 16, top: 8, bottom: 8 })
              .backgroundColor(this.currentSport === item.type ? 'rgba(78, 205, 196, 0.15)' : 'rgba(30, 30, 60, 0.8)')
              .borderRadius(20)
              .onClick(() => { this.currentSport = item.type; })
          })
        }
      }

      // 开始/暂停/恢复/结束按钮
      if (this.recordState === RecordState.IDLE) {
        Button('开始运动')
          .fontSize(16)
          .fontColor('#FFFFFF')
          .backgroundColor('#4ECDC4')
          .borderRadius(24)
          .width(160)
          .height(48)
          .onClick(() => this.startSport())
      } else if (this.recordState === RecordState.RECORDING) {
        Row({ space: 12 }) {
          Button('暂停')
            .fontSize(14)
            .fontColor('#FFFFFF')
            .backgroundColor('#FF6B6B')
            .borderRadius(20)
            .width(100)
            .height(40)
            .onClick(() => this.pauseSport())

          Button('结束')
            .fontSize(14)
            .fontColor('#FFFFFF')
            .backgroundColor('rgba(139, 139, 158, 0.4)')
            .borderRadius(20)
            .width(100)
            .height(40)
            .onClick(() => this.finishSport())
        }
      } else if (this.recordState === RecordState.PAUSED) {
        Row({ space: 12 }) {
          Button('继续')
            .fontSize(14)
            .fontColor('#FFFFFF')
            .backgroundColor('#4ECDC4')
            .borderRadius(20)
            .width(100)
            .height(40)
            .onClick(() => this.resumeSport())

          Button('结束')
            .fontSize(14)
            .fontColor('#FFFFFF')
            .backgroundColor('rgba(139, 139, 158, 0.4)')
            .borderRadius(20)
            .width(100)
            .height(40)
            .onClick(() => this.finishSport())
        }
      }
    }
    .width('100%')
    .justifyContent(FlexAlign.Center)
    .padding({ top: 12, bottom: 12 })
    .backgroundColor('rgba(26, 26, 46, 0.95)')
  }

  // 格式化工具方法
  private formatDistance(meters: number): string {
    if (meters < 1000) return `${Math.round(meters)}m`;
    return `${(meters / 1000).toFixed(2)}km`;
  }

  private formatDuration(seconds: number): string {
    const h = Math.floor(seconds / 3600);
    const m = Math.floor((seconds % 3600) / 60);
    const s = seconds % 60;
    if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
    return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
  }

  private formatPace(secondsPerKm: number): string {
    if (secondsPerKm <= 0) return '--:--';
    const min = Math.floor(secondsPerKm / 60);
    const sec = Math.round(secondsPerKm % 60);
    return `${min}'${String(sec).padStart(2, '0')}"`;
  }
}

四、踩坑与注意事项

4.1 后台定位中断问题

问题:应用切到后台后,系统可能为节省电量而暂停定位服务,导致轨迹断裂。

解决方案

// 申请后台长驻任务(长时任务)
import { backgroundTaskManager } from '@kit.BackgroundTaskKit';
import { wantAgent, WantAgent } from '@kit.AbilityKit';

// 在module.json5中声明后台任务类型
// "backgroundModes": ["location"]

async function requestContinuousTask(): Promise<number> {
  // 创建WantAgent用于通知栏展示
  const wantAgentInfo: wantAgent.WantAgentInfo = {
    wants: [{ bundleName: 'com.example.sport', abilityName: 'EntryAbility' }],
    requestCode: 0,
    operationType: wantAgent.OperationType.START_ABILITY,
    wantAgentFlags: [wantAgent.WantAgentFlags.UPDATE_PRESENT_FLAG]
  };
  
  const agent = await wantAgent.getWantAgent(wantAgentInfo);
  
  // 申请长时任务
  return backgroundTaskManager.requestSuspendDelay('运动轨迹记录', () => {
    console.warn('后台任务即将被系统取消');
  });
}

4.2 GPS漂移处理

问题:在高楼密集区或隧道中,GPS信号反射导致定位点跳跃,轨迹出现尖角。

优化策略

策略 实现方式 效果
精度过滤 丢弃accuracy>30m的点 去除明显漂移
速度过滤 丢弃速度异常的点 去除跳跃点
平滑处理 指数移动平均 减少抖动
道路吸附 将点吸附到最近道路 消除偏移(需路网数据)

4.3 轨迹点存储优化

问题:长时间运动(如马拉松)可能产生数千个轨迹点,全部存储到内存和数据库会有性能问题。

优化方案

// 轨迹点采样策略:运动中只保留关键点
function downsampleTrack(points: TrackPoint[], maxPoints: number = 500): TrackPoint[] {
  if (points.length <= maxPoints) return points;

  const step = Math.ceil(points.length / maxPoints);
  const sampled: TrackPoint[] = [];

  // 始终保留起点和终点
  sampled.push(points[0]);

  for (let i = step; i < points.length - 1; i += step) {
    sampled.push(points[i]);
  }

  sampled.push(points[points.length - 1]);
  return sampled;
}

4.4 Polyline绘制性能

问题:频繁调用addPolyline会导致地图渲染卡顿。

最佳实践

  • 避免每新增一个点就重新绘制整条轨迹线
  • 使用增量更新:只绘制新增的线段
  • 设置合理的绘制间隔(如每5个点绘制一次)
  • 超长轨迹考虑分段绘制

4.5 电量优化

运动类应用是耗电大户,以下是关键优化点:

优化项 策略 节电效果
定位频率 匀速时降低到2-3秒/次 ⭐⭐⭐⭐
屏幕控制 提供黑屏模式,降低刷新率 ⭐⭐⭐
网络请求 运动中不上传数据,结束后批量上传 ⭐⭐
后台任务 合理管理长时任务生命周期 ⭐⭐⭐

五、HarmonyOS 6适配

5.1 Location Kit增强

HarmonyOS 6对持续定位进行了重要优化:

特性 HarmonyOS 5 HarmonyOS 6
定位精度 5-10m 1-3m(支持RTK)
功耗优化 标准功耗 智能功耗模式(运动场景降低30%功耗)
室内定位 不支持 支持Wi-Fi/蓝牙融合定位
轨迹预测 不支持 支持基于AI的轨迹预测补全

5.2 Map Kit 3D轨迹渲染

// HarmonyOS 6新增:3D轨迹渲染
const trackStyle3D: map.MapPolyline3DOptions = {
  points: trackPoints.map(p => ({ latitude: p.latitude, longitude: p.longitude })),
  width: 10,
  color: 0xFF4ECDC4,
  elevation: 50,           // 轨迹线离地高度
  shadowEnabled: true,     // 启用阴影
  gradientEnabled: true,   // 启用速度渐变色
  gradientColors: [
    { color: 0xFF4ECDC4, speed: 0 },    // 低速:青色
    { color: 0xFFFF6B6B, speed: 5 },    // 中速:红色
    { color: 0xFFFFD700, speed: 10 }    // 高速:金色
  ]
};

5.3 后台任务管理增强

HarmonyOS 6引入了更精细的后台任务管理:

// HarmonyOS 6:精细化后台任务配置
const bgTaskConfig: backgroundTaskManager.BackgroundTaskConfig = {
  taskName: '运动轨迹记录',
  taskIcon: $r('app.media.ic_sport_notification'),
  taskDescription: '正在记录您的运动轨迹',
  notificationId: 1001,
  // 新增:电量阈值,低于此值自动停止
  batteryThreshold: 10,
  // 新增:最大运行时长
  maxDuration: 8 * 3600  // 最长8小时
};

六、总结

本文系统讲解了HarmonyOS平台轨迹记录与回放的完整实现方案,核心要点如下:

flowchart TB
    classDef core fill:#FF6B6B,stroke:#2C3E50,color:#fff,font-weight:bold
    classDef key fill:#4ECDC4,stroke:#2C3E50,color:#fff,font-weight:bold
    classDef tip fill:#FFE66D,stroke:#2C3E50,color:#2C3E50

    A[轨迹记录核心能力]:::core --> B[持续定位]:::key
    A --> C[轨迹点过滤]:::key
    A --> D[运动数据计算]:::key
    A --> E[轨迹回放]:::key

    B --> B1[Location Kit]:::tip
    B --> B2[后台长驻任务]:::tip
    B --> B3[智能功耗模式]:::tip

    C --> C1[精度过滤]:::tip
    C --> C2[速度过滤]:::tip
    C --> C3[平滑处理]:::tip

    D --> D1[Haversine距离]:::tip
    D --> D2[配速/速度计算]:::tip
    D --> D3[MET卡路里估算]:::tip

    E --> E1[逐帧回放]:::tip
    E --> E2[速度倍率控制]:::tip
    E --> E3[地图跟随]:::tip

    style A fill:#FF6B6B,stroke:#2C3E50,color:#fff

关键收获

  1. 三层过滤策略是轨迹质量的保障:精度过滤去漂移、速度过滤去跳跃、距离过滤去抖动
  2. Haversine公式是距离计算的基础,配合累计距离实现精确的运动统计
  3. 后台长驻任务确保运动记录不因应用切后台而中断
  4. 轨迹回放通过定时器逐帧推进,配合地图跟随实现沉浸式体验
  5. 性能优化贯穿始终:定位频率调节、轨迹点采样、Polyline增量绘制

下一步

  • 第348篇将深入位置分享与实时位置同步,讲解如何将位置信息实时共享给其他用户
  • 结合本篇的轨迹记录能力,可实现运动轨迹分享等社交功能
【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。