鸿蒙运动健康实战:手把手打造高清顺滑的运动轨迹(基于Map Kit + Location Kit)
本文基于鸿蒙Map Kit和Location Kit,实现了一个高精度、顺滑美观的运动轨迹记录功能。通过坐标转换(WGS84→GCJ02)解决轨迹偏移问题,采用点稀释和移动平均平滑算法优化轨迹线条,并支持后台持续定位保证数据完整。系统设计了异常点过滤机制,有效消除GPS漂移,同时实时展示运动数据(时长、距离、配速等)。测试显示轨迹平滑准确,性能良好。项目采用模块化设计,包含权限管理、定位服务、轨迹
集成地图、高精度定位、实时轨迹绘制、点稀释、移动平均平滑、坐标纠偏、后台持续定位、异常点过滤、运动数据实时展示,附关键代码与踩坑总结。
完整源码:SportTrackDemo
一、为什么需要这个功能?
运动健康类App中,运动轨迹是用户最直观的数据呈现。很多App记录的轨迹都做的很好,如何解决以下问题是本篇内容的关键。程序我已经测试过了,有点费腿跑一圈下来绘制基本无大问题。
- 轨迹偏移:GPS信号漂移(高楼)或坐标系不一致(WGS84 vs GCJ02)导致位置偏离实际路线。
- 线条毛糙:点太密或未做平滑处理,锯齿感严重。
- 性能差:存储了过多冗余点,导致地图绘制卡顿。
- 后台中断:锁屏或切换应用后定位停止,轨迹不完整。
本文基于鸿蒙 Map Kit 和 Location 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方法:- 若已移动过则返回。
- 检查设备位置服务是否开启。
- 尝试获取缓存位置,若有则直接移动相机。
- 否则发起单次定位请求(速度优先),成功后移动相机。
代码片段:
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 事件。每次回调执行以下处理流程:
- 坐标转换:
MapManager.convertWgs84ToGcj02 - 异常点检测:计算与上一个有效点的距离和时间差,若距离 > 80 米且时间 < 3 秒则丢弃。
- 点稀释:若与上一个记录点距离 < 5 米则丢弃。
- 移动平均平滑:对最近5个原始点取平均,存入平滑点数组。
- 距离累计:基于原始点使用 Haversine 公式累加。
- 更新运动数据(时长、配速、速度)。
- 增量绘制:取平滑点数组的最后两个点,调用
MapManager.addPolylineSegment添加线段。 - 相机跟随:移动相机到最新平滑点。
运动数据计算说明:
- 时长:从运动开始到当前的时间差,每秒更新一次。
- 距离:累加所有相邻有效原始点之间的球面距离(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: ROUND 和 capType: 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绘制动态折线(圆角连接、分段颜色) - 模块化设计(权限、前台定位、后台定位、轨迹、地图分离)
- 后台持续定位(长时任务 + 后台权限 + 状态栏通知)
- 首次加载地图时自动定位到当前位置(含缓存降级)
- 异常点检测算法(距离跳变 + 速度异常)有效防漂移
- 增量绘制折线段解决轨迹线与蓝点不同步的空白问题
九、扩展方向
- 分段配速热力图:根据每公里配速改变折线颜色,让用户直观看到速度变化。
- 轨迹分享与生成海报:支持将轨迹导出海报。
- 运动数据统计图表:展示周/月/年里程趋势、配速曲线等。
- 语音播报:每公里自动播报配速、距离等信息。
拓展内容有时间我会一一实现,如果觉得有用,请点赞、收藏、转发支持!
更多推荐

所有评论(0)