HarmonyOS开发:轨迹记录与运动轨迹回放
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 本文目标
构建一个完整的「运动追踪」应用,实现以下核心功能:
- 支持跑步/骑行/徒步三种运动模式
- 实时记录轨迹并计算运动数据
- 地图上实时绘制运动路径
- 运动结束后支持轨迹回放
二、核心原理
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
关键收获
- 三层过滤策略是轨迹质量的保障:精度过滤去漂移、速度过滤去跳跃、距离过滤去抖动
- Haversine公式是距离计算的基础,配合累计距离实现精确的运动统计
- 后台长驻任务确保运动记录不因应用切后台而中断
- 轨迹回放通过定时器逐帧推进,配合地图跟随实现沉浸式体验
- 性能优化贯穿始终:定位频率调节、轨迹点采样、Polyline增量绘制
下一步
- 第348篇将深入位置分享与实时位置同步,讲解如何将位置信息实时共享给其他用户
- 结合本篇的轨迹记录能力,可实现运动轨迹分享等社交功能
- 点赞
- 收藏
- 关注作者
评论(0)