如何用Map Kit和Location Kit制作高清流畅的运动轨迹图?

摘要:集成地图、高精度定位、实时轨迹绘制、点稀释、移动平均平滑、坐标纠偏、后台持续定位、异常点过滤、运动数据实时展示,附关键代码与踩坑总结。 完整源码:SportTrackDemo 一、为什么需要这个功能? 运动健康类App中,运动轨迹是用户最直
集成地图、高精度定位、实时轨迹绘制、点稀释、移动平均平滑、坐标纠偏、后台持续定位、异常点过滤、运动数据实时展示,附关键代码与踩坑总结。 完整源码: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 绘制动态折线(圆角连接、分段颜色) 模块化设计(权限、前台定位、后台定位、轨迹、地图分离) 后台持续定位(长时任务 + 后台权限 + 状态栏通知) 首次加载地图时自动定位到当前位置(含缓存降级) 异常点检测算法(距离跳变 + 速度异常)有效防漂移 增量绘制折线段解决轨迹线与蓝点不同步的空白问题 九、扩展方向 分段配速热力图:根据每公里配速改变折线颜色,让用户直观看到速度变化。 轨迹分享与生成海报:支持将轨迹导出海报。 运动数据统计图表:展示周/月/年里程趋势、配速曲线等。 语音播报:每公里自动播报配速、距离等信息。 拓展内容有时间我会一一实现,如果觉得有用,请点赞、收藏、转发支持!