鸿蒙开发系列 · 高级篇
前言
前几篇我们分别搞定了元服务、安全权限、推送后台、音视频播放。但如果你的产品是一个外卖配送App、一个运动健康应用,或者一个旅游攻略平台,地图能力就是绕不开的核心诉求。
地图开发看似简单——“调个SDK,传个经纬度,不就显示出来了?”——实际踩过坑的人都知道,定位精度漂移、地图卡顿、POI搜索不准确、轨迹绘制锯齿化、离线场景无图,每个问题都能消耗数天的调试时间。
HarmonyOS提供了Map Kit作为地图能力的官方解决方案。它基于华为自研地图数据,深度整合鸿蒙系统能力,支持手机、车机、手表等多设备形态。本文将从定位获取、地图显示、POI搜索、轨迹绘制四个维度,帮你建立完整的地图开发技术栈。
一、位置服务:定位获取与精度控制
1.1 定位架构与权限
在调用任何定位API之前,需要完成两件事:
权限声明(module.json5):
{
"module": {
"requestPermissions": [
{
"name": "ohos.permission.LOCATION",
"reason": "$string:perm_location_reason",
"usedScene": {
"abilities": ["EntryAbility"],
"when": "inuse"
}
},
{
"name": "ohos.permission.APPROXIMATELY_LOCATION",
"reason": "$string:perm_approx_location_reason",
"usedScene": {
"abilities": ["EntryAbility"],
"when": "inuse"
}
}
]
}
}
HarmonyOS NEXT引入了模糊定位权限(APPROXIMATELY_LOCATION),这是隐私合规的重要设计:
最佳实践:先申请模糊定位,只有在用户明确需要高精度场景时再申请精确定位。这样既能保护用户隐私,又能提升授权通过率。
1.2 单次定位与持续定位
// entry/src/main/ets/utils/LocationManager.ets
import { geoLocationManager } from '@kit.LocationKit';
import { BusinessError } from '@kit.BasicServicesKit';
export interface LocationInfo {
latitude: number;
longitude: number;
altitude: number;
accuracy: number;
speed: number;
time: number;
}
export class LocationManager {
private locationChangeCallback: ((location: LocationInfo) => void) | null = null;
// 单次定位
async getCurrentLocation(): Promise<LocationInfo> {
const requestInfo: geoLocationManager.CurrentLocationRequest = {
priority: geoLocationManager.LocationRequestPriority.FIRST_FIX,
scenario: geoLocationManager.LocationRequestScenario.UNSET
};
try {
const location = await geoLocationManager.getCurrentLocation(requestInfo);
return this.convertLocation(location);
} catch (err) {
const error = err as BusinessError;
console.error(`定位失败: ${error.code}, ${error.message}`);
throw error;
}
}
// 持续定位
startLocationUpdates(
callback: (location: LocationInfo) => void,
interval: number = 1000
): void {
this.locationChangeCallback = callback;
const requestInfo: geoLocationManager.LocationRequest = {
priority: geoLocationManager.LocationRequestPriority.PRIORITY_LOCATING,
scenario: geoLocationManager.LocationRequestScenario.NAVIGATION,
timeInterval: interval,
distanceInterval: 0
};
geoLocationManager.on('locationChange', requestInfo, (location) => {
const info = this.convertLocation(location);
this.locationChangeCallback?.(info);
});
}
// 停止持续定位
stopLocationUpdates(): void {
geoLocationManager.off('locationChange');
this.locationChangeCallback = null;
}
// 地理编码:地址 → 经纬度
async geoCode(address: string): Promise<LocationInfo[]> {
const result = await geoLocationManager.getAddressesFromLocationName({
description: address,
maxItems: 5
});
return result.map(item => ({
latitude: item.latitude,
longitude: item.longitude,
altitude: 0,
accuracy: 0,
speed: 0,
time: Date.now()
}));
}
// 逆地理编码:经纬度 → 地址
async reverseGeoCode(lat: number, lng: number): Promise<string> {
const result = await geoLocationManager.getAddressesFromLocation({
latitude: lat,
longitude: lng,
maxItems: 1
});
if (result.length > 0) {
const addr = result[0];
return `${addr.administrativeArea || ''}${addr.locality || ''}${addr.subLocality || ''}${addr.roadName || ''}${addr.subRoadName || ''}`;
}
return '';
}
private convertLocation(loc: geoLocationManager.Location): LocationInfo {
return {
latitude: loc.latitude,
longitude: loc.longitude,
altitude: loc.altitude ?? 0,
accuracy: loc.accuracy ?? 0,
speed: loc.speed ?? 0,
time: loc.timeStamp ?? Date.now()
};
}
}
1.3 定位精度与功耗权衡
关键建议:持续定位场景中,根据应用状态动态调整精度。前台用高精度,退后台后切换为低功耗模式。
// 前台高精度
startLocationUpdates(callback, 1000); // 每秒更新
// 退后台后降低频率
onBackgroundEnter() {
this.stopLocationUpdates();
this.startLocationUpdates(callback, 30000); // 30秒更新一次
}
二、Map Kit 地图集成
2.1 引入地图组件
Map Kit以组件形式提供,在页面中直接引用:
// entry/src/main/ets/pages/MapPage.ets
import { MapComponent, map } from '@kit.MapKit';
@Entry
@Component
struct MapPage {
private mapController: map.MapComponentController | null = null;
@State mapOptions: map.MapOptions = {
position: {
target: {
latitude: 39.9042, // 北京天安门
longitude: 116.4074
},
zoom: 12
}
};
onMapReady(controller: map.MapComponentController) {
this.mapController = controller;
// 地图就绪后可以进行操作
this.addMarkers();
}
private addMarkers() {
if (!this.mapController) return;
const markerOptions: map.MarkerOptions = {
position: {
latitude: 39.9042,
longitude: 116.4074
},
title: '当前位置',
snippet: '北京市东城区',
icon: $r('app.media.ic_location_marker')
};
this.mapController.addMarker(markerOptions);
}
// 移动到指定位置
moveToLocation(lat: number, lng: number) {
this.mapController?.moveCamera(
map.CameraUpdateFactory.newLatLngZoom({ latitude: lat, longitude: lng }, 15)
);
}
build() {
Stack() {
MapComponent({
mapOptions: this.mapOptions,
mapCallback: (err, controller) => {
if (!err && controller) {
this.onMapReady(controller);
}
}
})
.width('100%')
.height('100%')
// 叠加UI控件
Column({ space: 12 }) {
Button('定位到我')
.onClick(() => this.locateMe())
Button('放大')
.onClick(() => this.mapController?.moveCamera(
map.CameraUpdateFactory.zoomIn()
))
Button('缩小')
.onClick(() => this.mapController?.moveCamera(
map.CameraUpdateFactory.zoomOut()
))
}
.padding(16)
.alignSelf(ItemAlign.End)
.margin({ bottom: 32, right: 16 })
}
.width('100%')
.height('100%')
}
private async locateMe() {
const locManager = new LocationManager();
const location = await locManager.getCurrentLocation();
this.moveToLocation(location.latitude, location.longitude);
// 添加当前位置标记
this.mapController?.addMarker({
position: { latitude: location.latitude, longitude: location.longitude },
title: '我的位置',
icon: $r('app.media.ic_my_location')
});
}
}
2.2 地图核心配置
// 地图选项详解
const mapOptions: map.MapOptions = {
position: {
target: { latitude: 39.9, longitude: 116.4 },
zoom: 10, // 缩放级别 3-20
tilt: 0, // 倾斜角度 0-75
bearing: 0 // 旋转角度
},
mapType: map.MapType.STANDARD, // STANDARD / SATELLITE / TERRAIN
// 控件显示
compassControlsEnabled: true, // 指南针
scaleControlsEnabled: true, // 比例尺
zoomControlsEnabled: false, // 缩放按钮(通常自定义UI)
// 手势
scrollGesturesEnabled: true, // 拖拽
zoomGesturesEnabled: true, // 双指缩放
tiltGesturesEnabled: true, // 双指倾斜
rotationGesturesEnabled: true // 旋转
};
三、POI搜索与周边服务
3.1 文本搜索与周边搜索
// entry/src/main/ets/utils/PoiSearchManager.ets
import { site } from '@kit.MapKit';
export interface PoiResult {
name: string;
address: string;
latitude: number;
longitude: number;
distance?: number;
phone?: string;
poiTypes?: string[];
}
export class PoiSearchManager {
private searchService: site.SiteClient | null = null;
async initialize(): Promise<void> {
this.searchService = await site.createSiteClient();
}
// 文本搜索(如"附近的咖啡厅")
async textSearch(query: string, location?: { lat: number; lng: number }): Promise<PoiResult[]> {
if (!this.searchService) throw new Error('搜索服务未初始化');
const request: site.TextSearchRequest = {
query: query,
location: location ? {
lat: location.lat,
lng: location.lng
} : undefined,
radius: 5000, // 搜索半径5公里
pageSize: 20,
pageIndex: 1
};
const response = await this.searchService.textSearch(request);
return this.convertResults(response.sites);
}
// 周边搜索(搜索某位置附近的某类POI)
async nearbySearch(
lat: number,
lng: number,
keyword: string,
radius: number = 3000
): Promise<PoiResult[]> {
if (!this.searchService) throw new Error('搜索服务未初始化');
const request: site.NearbySearchRequest = {
location: { lat, lng },
query: keyword,
radius,
pageSize: 20,
pageIndex: 1
};
const response = await this.searchService.nearbySearch(request);
return this.convertResults(response.sites);
}
// 获取POI详情
async getPoiDetail(siteId: string): Promise<PoiResult | null> {
if (!this.searchService) throw new Error('搜索服务未初始化');
const request: site.DetailSearchRequest = { siteId };
const response = await this.searchService.detailSearch(request);
return response.site ? this.convertResult(response.site) : null;
}
// 搜索建议(自动补全)
async searchSuggestion(query: string): Promise<string[]> {
if (!this.searchService) throw new Error('搜索服务未初始化');
const request: site.QuerySuggestionRequest = {
query: query
};
const response = await this.searchService.querySuggestion(request);
return response.suggestions.map(s => s.name);
}
private convertResults(sites: site.Site[]): PoiResult[] {
return sites.map(s => this.convertResult(s));
}
private convertResult(s: site.Site): PoiResult {
return {
name: s.name,
address: s.formatAddress || '',
latitude: s.location?.lat ?? 0,
longitude: s.location?.lng ?? 0,
distance: s.distance,
phone: s.phone,
poiTypes: s.poiTypes
};
}
}
3.2 搜索页面实战
// entry/src/main/ets/pages/SearchPage.ets
import { PoiSearchManager, PoiResult } from '../utils/PoiSearchManager';
import { MapComponent, map } from '@kit.MapKit';
@Entry
@Component
struct SearchPage {
private searchManager: PoiSearchManager = new PoiSearchManager();
private mapController: map.MapComponentController | null = null;
@State searchQuery: string = '';
@State suggestions: string[] = [];
@State results: PoiResult[] = [];
@State selectedPoi: PoiResult | null = null;
@State showResults: boolean = false;
aboutToAppear() {
this.searchManager.initialize();
}
private async onSearch() {
if (!this.searchQuery.trim()) return;
this.showResults = true;
this.results = await this.searchManager.textSearch(this.searchQuery);
// 在地图上标记结果
this.mapController?.clearMarkers();
this.results.forEach((poi, index) => {
this.mapController?.addMarker({
position: { latitude: poi.latitude, longitude: poi.longitude },
title: poi.name,
snippet: poi.address,
icon: index === 0
? $r('app.media.ic_marker_primary')
: $r('app.media.ic_marker_secondary')
});
});
// 移动视角到第一个结果
if (this.results.length > 0) {
this.mapController?.moveCamera(
map.CameraUpdateFactory.newLatLng({
latitude: this.results[0].latitude,
longitude: this.results[0].longitude
})
);
}
}
private async onQueryChange(value: string) {
this.searchQuery = value;
if (value.length >= 2) {
this.suggestions = await this.searchManager.searchSuggestion(value);
} else {
this.suggestions = [];
}
}
build() {
Stack() {
MapComponent({
mapOptions: {
position: { target: { latitude: 39.9, longitude: 116.4 }, zoom: 12 }
},
mapCallback: (err, controller) => {
if (!err && controller) this.mapController = controller;
}
})
.width('100%')
.height('100%')
// 搜索栏
Column({ space: 0 }) {
Row({ space: 12 }) {
TextInput({ placeholder: '搜索地点、商家...', text: $$this.searchQuery })
.layoutWeight(1)
.height(44)
.backgroundColor('#F5F5F5')
.borderRadius(22)
.padding({ left: 16, right: 16 })
.onChange((value) => this.onQueryChange(value))
Button('搜索')
.height(44)
.onClick(() => this.onSearch())
}
.width('100%')
.padding(16)
.backgroundColor('#FFFFFF')
.shadow({ radius: 8, color: 'rgba(0,0,0,0.08)' })
// 搜索建议
if (this.suggestions.length > 0 && !this.showResults) {
List() {
ForEach(this.suggestions, (suggestion: string) => {
ListItem() {
Text(suggestion)
.fontSize(16)
.padding(16)
.width('100%')
}
.onClick(() => {
this.searchQuery = suggestion;
this.onSearch();
})
})
}
.width('100%')
.maxHeight(200)
.backgroundColor('#FFFFFF')
}
// 搜索结果
if (this.showResults) {
List() {
ForEach(this.results, (poi: PoiResult) => {
ListItem() {
Column({ space: 4 }) {
Text(poi.name)
.fontSize(16)
.fontWeight(FontWeight.Medium)
Text(poi.address)
.fontSize(13)
.fontColor('#888')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.padding(16)
.width('100%')
.backgroundColor(this.selectedPoi?.name === poi.name ? '#E3F2FD' : '#FFFFFF')
}
.onClick(() => {
this.selectedPoi = poi;
this.mapController?.moveCamera(
map.CameraUpdateFactory.newLatLngZoom(
{ latitude: poi.latitude, longitude: poi.longitude },
16
)
);
})
})
}
.width('100%')
.layoutWeight(1)
.backgroundColor('#FFFFFF')
}
}
.width('100%')
.height('100%')
.alignItems(HorizontalAlign.Start)
}
}
}
四、轨迹绘制与运动场景
4.1 轨迹线绘制
运动类应用(跑步、骑行、外卖配送)需要绘制用户移动的轨迹线:
// entry/src/main/ets/utils/TrackDrawer.ets
import { map } from '@kit.MapKit';
export interface TrackPoint {
latitude: number;
longitude: number;
timestamp: number;
speed?: number;
}
export class TrackDrawer {
private mapController: map.MapComponentController;
private polyline: map.Polyline | null = null;
private trackPoints: TrackPoint[] = [];
constructor(controller: map.MapComponentController) {
this.mapController = controller;
}
// 添加轨迹点
addPoint(point: TrackPoint): void {
this.trackPoints.push(point);
this.redrawTrack();
}
// 批量设置轨迹
setTrack(points: TrackPoint[]): void {
this.trackPoints = points;
this.redrawTrack();
}
// 重绘轨迹线
private redrawTrack(): void {
if (this.trackPoints.length < 2) return;
// 移除旧轨迹
if (this.polyline) {
this.mapController.removeOverlay(this.polyline);
}
// 创建新轨迹线
const options: map.PolylineOptions = {
points: this.trackPoints.map(p => ({
latitude: p.latitude,
longitude: p.longitude
})),
width: 6,
color: 0xFF2196F3, // 蓝色
// 可分段设置颜色表示速度
gradient: this.generateGradient()
};
this.polyline = this.mapController.addPolyline(options);
// 自动调整视角以完整显示轨迹
this.fitBounds();
}
// 根据速度生成渐变色
private generateGradient(): map.Gradient | undefined {
if (this.trackPoints.length < 2) return undefined;
const colors: map.GradientColor[] = [];
const speeds = this.trackPoints
.filter(p => p.speed !== undefined)
.map(p => p.speed!);
if (speeds.length === 0) return undefined;
const maxSpeed = Math.max(...speeds);
const minSpeed = Math.min(...speeds);
for (let i = 0; i < this.trackPoints.length; i++) {
const speed = this.trackPoints[i].speed ?? 0;
const ratio = maxSpeed > minSpeed
? (speed - minSpeed) / (maxSpeed - minSpeed)
: 0;
// 低速绿色 → 高速红色
const color = this.interpolateColor(0xFF4CAF50, 0xFFF44336, ratio);
colors.push({ color, ratio: i / (this.trackPoints.length - 1) });
}
return { colors };
}
private interpolateColor(color1: number, color2: number, ratio: number): number {
const r1 = (color1 >> 16) & 0xFF;
const g1 = (color1 >> 8) & 0xFF;
const b1 = color1 & 0xFF;
const r2 = (color2 >> 16) & 0xFF;
const g2 = (color2 >> 8) & 0xFF;
const b2 = color2 & 0xFF;
const r = Math.round(r1 + (r2 - r1) * ratio);
const g = Math.round(g1 + (g2 - g1) * ratio);
const b = Math.round(b1 + (b2 - b1) * ratio);
return (0xFF << 24) | (r << 16) | (g << 8) | b;
}
// 调整地图视角以显示完整轨迹
private fitBounds(): void {
if (this.trackPoints.length === 0) return;
const lats = this.trackPoints.map(p => p.latitude);
const lngs = this.trackPoints.map(p => p.longitude);
const bounds: map.LatLngBounds = {
southwest: {
latitude: Math.min(...lats),
longitude: Math.min(...lngs)
},
northeast: {
latitude: Math.max(...lats),
longitude: Math.max(...lngs)
}
};
this.mapController?.moveCamera(
map.CameraUpdateFactory.newLatLngBounds(bounds, 50) // 50px padding
);
}
// 清除轨迹
clear(): void {
if (this.polyline) {
this.mapController.removeOverlay(this.polyline);
this.polyline = null;
}
this.trackPoints = [];
}
}
4.2 运动记录页面
// entry/src/main/ets/pages/TrackPage.ets
import { MapComponent, map } from '@kit.MapKit';
import { LocationManager, LocationInfo } from '../utils/LocationManager';
import { TrackDrawer, TrackPoint } from '../utils/TrackDrawer';
@Entry
@Component
struct TrackPage {
private mapController: map.MapComponentController | null = null;
private trackDrawer: TrackDrawer | null = null;
private locationManager: LocationManager = new LocationManager();
@State isRecording: boolean = false;
@State duration: number = 0;
@State distance: number = 0;
@State speed: number = 0;
private timer: number = -1;
private lastLocation: LocationInfo | null = null;
onMapReady(controller: map.MapComponentController) {
this.mapController = controller;
this.trackDrawer = new TrackDrawer(controller);
}
startTracking() {
this.isRecording = true;
this.duration = 0;
this.distance = 0;
// 开始计时
this.timer = setInterval(() => {
this.duration++;
}, 1000);
// 开始定位
this.locationManager.startLocationUpdates((location) => {
const point: TrackPoint = {
latitude: location.latitude,
longitude: location.longitude,
timestamp: location.time,
speed: location.speed
};
this.trackDrawer?.addPoint(point);
// 计算距离
if (this.lastLocation) {
const dist = this.calculateDistance(this.lastLocation, location);
this.distance += dist;
}
this.lastLocation = location;
// 计算配速
if (this.duration > 0) {
this.speed = (this.distance / this.duration) * 3.6; // m/s → km/h
}
// 跟随当前位置
this.mapController?.moveCamera(
map.CameraUpdateFactory.newLatLng({
latitude: location.latitude,
longitude: location.longitude
})
);
}, 2000); // 每2秒记录一个点
}
stopTracking() {
this.isRecording = false;
this.locationManager.stopLocationUpdates();
if (this.timer !== -1) {
clearInterval(this.timer);
this.timer = -1;
}
}
// 简化的距离计算(Haversine公式)
private calculateDistance(loc1: LocationInfo, loc2: LocationInfo): number {
const R = 6371000; // 地球半径(米)
const lat1 = this.toRad(loc1.latitude);
const lat2 = this.toRad(loc2.latitude);
const deltaLat = this.toRad(loc2.latitude - loc1.latitude);
const deltaLng = this.toRad(loc2.longitude - loc1.longitude);
const a = Math.sin(deltaLat / 2) ** 2 +
Math.cos(lat1) * Math.cos(lat2) * Math.sin(deltaLng / 2) ** 2;
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
private toRad(deg: number): number {
return deg * Math.PI / 180;
}
private formatDuration(seconds: number): string {
const hrs = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
return `${hrs.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
build() {
Stack() {
MapComponent({
mapOptions: {
position: { target: { latitude: 39.9, longitude: 116.4 }, zoom: 15 }
},
mapCallback: (err, controller) => {
if (!err && controller) this.onMapReady(controller);
}
})
.width('100%')
.height('100%')
// 数据面板
Column({ space: 16 }) {
Row({ space: 24 }) {
Column({ space: 4 }) {
Text(this.formatDuration(this.duration))
.fontSize(28)
.fontWeight(FontWeight.Bold)
Text('时长')
.fontSize(12)
.fontColor('#888')
}
Column({ space: 4 }) {
Text(`${(this.distance / 1000).toFixed(2)}`)
.fontSize(28)
.fontWeight(FontWeight.Bold)
Text('公里')
.fontSize(12)
.fontColor('#888')
}
Column({ space: 4 }) {
Text(`${this.speed.toFixed(1)}`)
.fontSize(28)
.fontWeight(FontWeight.Bold)
Text('km/h')
.fontSize(12)
.fontColor('#888')
}
}
.width('100%')
.padding(20)
.backgroundColor('rgba(255,255,255,0.95)')
.borderRadius(16)
.justifyContent(FlexAlign.SpaceAround)
Button(this.isRecording ? '停止记录' : '开始记录')
.width(200)
.height(56)
.fontSize(18)
.backgroundColor(this.isRecording ? '#F44336' : '#4CAF50')
.onClick(() => {
if (this.isRecording) {
this.stopTracking();
} else {
this.startTracking();
}
})
}
.padding(16)
.alignSelf(ItemAlign.End)
.margin({ bottom: 32 })
}
.width('100%')
.height('100%')
}
}
五、小结与下篇预告
本文从四个维度建立了鸿蒙地图开发能力:
位置服务:单次/持续定位、模糊/精确定位权限、地理编码与逆编码、功耗优化
Map Kit集成:地图组件、标记、相机控制、手势交互
POI搜索:文本搜索、周边搜索、搜索建议、详情查询
轨迹绘制:实时轨迹线、速度渐变色、视角自动适配
地图开发的核心挑战在于精度与功耗的平衡、大量标记的性能优化、离线场景的降级策略。在实际项目中,建议结合Work Scheduler做后台轨迹记录,结合Push Kit做地理围栏触发。
地图解决了"在哪里"的问题,而现代应用还需要解决"怎么付钱"和"怎么登录"的问题。华为账号体系提供了统一的登录能力,应用内支付(IAP)提供了标准化的收银台。第16篇将深入鸿蒙支付与账号集成,讲解华为账号登录、OAuth授权、应用内商品配置和支付流程的完整实现。
参考阅读
本系列第12篇:《安全机制与权限管理:应用签名、ACL与隐私合规》
本系列第13篇:《推送服务与后台任务:Push Kit与Work Scheduler实战》