大家好,我是鸿蒙Jack。本期以我的《时光旅记》APP为例,讲一下旅记计划里的"行程地图"是怎么做的。
这个能力不是简单塞一个地图组件。我的目标是:用户在创建子行程时可以搜索地点、点选地图、自动带出当前位置和地址;保存后,旅行详情页能把每天的行程点位串成一条路线,并且支持全屏查看和一键拉起地图应用做路线规划。
场景拆解
在《时光旅记》里,一个旅行计划下面会有多个子行程,比如"到达酒店""西湖散步""夜游运河"。每个子行程都有自己的时间、地点、经纬度和备注。地图页面并不直接关心用户写了什么攻略,它只关心一个稳定的数据结构:
ts
export class TravelSubPlan {
id: string = '';
planId: string = '';
sortOrder: number = 0;
title: string = '';
content: string = '';
location: string = '';
latitude: number = 0;
longitude: number = 0;
fullAddress: string = '';
startTime: string = '';
endTime: string = '';
transportModeToNext: string = '未设置';
transportNoteToNext: string = '';
isCompleted: boolean = false;
calendarEventId: number = -1;
createdAt: string = '';
updatedAt: string = '';
}
export class TravelPlan {
id: string = '';
title: string = '';
description: string = '';
startDate: string = '';
endDate: string = '';
isTimePrecisionEnabled: boolean = true;
isDailyGroupingEnabled: boolean = true;
subPlans: Array<TravelSubPlan> = [];
}
只要子行程里有可用的 latitude 和 longitude,它就可以进入地图绘制。项目里判断坐标是否可用的逻辑很克制:
ts
private hasUsableCoordinates(latitude: number, longitude: number): boolean {
return Math.abs(latitude) > 0.000001 && Math.abs(longitude) > 0.000001;
}
我没有用 0, 0 作为真实地点,因为对旅行场景来说,空坐标更常见,不能让它误入地图路线。
用到的技术栈
这部分必须讲清楚,因为旅记计划地图不是单个 Kit 能独立完成的。
项目主体是 HarmonyOS Stage 模型,页面用 ArkTS + ArkUI 写。地图能力来自 Map Kit,里面我实际用到了四类能力:
MapComponent 用来把地图嵌进 ArkUI 页面;map.MapComponentController 是地图操作入口,负责 addMarker、addPolyline、clear、moveCamera、开启我的位置图层;mapCommon 提供 LatLng、LatLngBounds、MarkerOptions、PatternItemType 等类型;site 负责 POI 关键字搜索;petalMaps 负责拉起系统地图应用做路线规划。
定位和逆地理编码用的是 Location Kit。项目里通过 geoLocationManager.getCurrentLocation 获取当前位置,通过 geoLocationManager.getAddressesFromLocation 把经纬度反查成地点名和详细地址。
权限处理用 AbilityKit 的 Context、Permissions,再结合项目自己的 PermissionUtil 封装运行时权限申请。页面声明层面要在 module.json5 里加定位和网络权限。
UI 层面,项目使用 ArkUI 原生组件和 UIDesignKit 混合构建。地图 Marker 的序号气泡不是图片,而是通过 iconBuilder 直接用 ArkUI 画出来,这样它能跟随主题色,也不需要额外维护一批静态资源。
整体架构
#mermaid-svg-FYffQmUR0pmgJSrb{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-FYffQmUR0pmgJSrb .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-FYffQmUR0pmgJSrb .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-FYffQmUR0pmgJSrb .error-icon{fill:#552222;}#mermaid-svg-FYffQmUR0pmgJSrb .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-FYffQmUR0pmgJSrb .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-FYffQmUR0pmgJSrb .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-FYffQmUR0pmgJSrb .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-FYffQmUR0pmgJSrb .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-FYffQmUR0pmgJSrb .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-FYffQmUR0pmgJSrb .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-FYffQmUR0pmgJSrb .marker{fill:#333333;stroke:#333333;}#mermaid-svg-FYffQmUR0pmgJSrb .marker.cross{stroke:#333333;}#mermaid-svg-FYffQmUR0pmgJSrb svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-FYffQmUR0pmgJSrb p{margin:0;}#mermaid-svg-FYffQmUR0pmgJSrb .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-FYffQmUR0pmgJSrb .cluster-label text{fill:#333;}#mermaid-svg-FYffQmUR0pmgJSrb .cluster-label span{color:#333;}#mermaid-svg-FYffQmUR0pmgJSrb .cluster-label span p{background-color:transparent;}#mermaid-svg-FYffQmUR0pmgJSrb .label text,#mermaid-svg-FYffQmUR0pmgJSrb span{fill:#333;color:#333;}#mermaid-svg-FYffQmUR0pmgJSrb .node rect,#mermaid-svg-FYffQmUR0pmgJSrb .node circle,#mermaid-svg-FYffQmUR0pmgJSrb .node ellipse,#mermaid-svg-FYffQmUR0pmgJSrb .node polygon,#mermaid-svg-FYffQmUR0pmgJSrb .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-FYffQmUR0pmgJSrb .rough-node .label text,#mermaid-svg-FYffQmUR0pmgJSrb .node .label text,#mermaid-svg-FYffQmUR0pmgJSrb .image-shape .label,#mermaid-svg-FYffQmUR0pmgJSrb .icon-shape .label{text-anchor:middle;}#mermaid-svg-FYffQmUR0pmgJSrb .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-FYffQmUR0pmgJSrb .rough-node .label,#mermaid-svg-FYffQmUR0pmgJSrb .node .label,#mermaid-svg-FYffQmUR0pmgJSrb .image-shape .label,#mermaid-svg-FYffQmUR0pmgJSrb .icon-shape .label{text-align:center;}#mermaid-svg-FYffQmUR0pmgJSrb .node.clickable{cursor:pointer;}#mermaid-svg-FYffQmUR0pmgJSrb .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-FYffQmUR0pmgJSrb .arrowheadPath{fill:#333333;}#mermaid-svg-FYffQmUR0pmgJSrb .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-FYffQmUR0pmgJSrb .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-FYffQmUR0pmgJSrb .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-FYffQmUR0pmgJSrb .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-FYffQmUR0pmgJSrb .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-FYffQmUR0pmgJSrb .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-FYffQmUR0pmgJSrb .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-FYffQmUR0pmgJSrb .cluster text{fill:#333;}#mermaid-svg-FYffQmUR0pmgJSrb .cluster span{color:#333;}#mermaid-svg-FYffQmUR0pmgJSrb div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-FYffQmUR0pmgJSrb .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-FYffQmUR0pmgJSrb rect.text{fill:none;stroke-width:0;}#mermaid-svg-FYffQmUR0pmgJSrb .icon-shape,#mermaid-svg-FYffQmUR0pmgJSrb .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-FYffQmUR0pmgJSrb .icon-shape p,#mermaid-svg-FYffQmUR0pmgJSrb .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-FYffQmUR0pmgJSrb .icon-shape .label rect,#mermaid-svg-FYffQmUR0pmgJSrb .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-FYffQmUR0pmgJSrb .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-FYffQmUR0pmgJSrb .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-FYffQmUR0pmgJSrb :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 用户创建或编辑子行程
SubPlanDialog
选择地点方式
Location Kit 获取当前位置
Map Kit site.searchByText 搜索 POI
MapComponent 地图点击选点
逆地理编码生成地点名和详细地址
选中 POI 写入经纬度
TravelSubPlan 保存 location fullAddress latitude longitude
TravelPlanDetailPage
筛选当前日期可展示点位
MapComponentController.clear
addPolyline 绘制行程路线
addMarker 绘制序号点
moveCamera 适配地图视野
用户全屏查看或拉起地图应用规划路线
这条链路有两个重点。第一个是地点采集发生在 SubPlanDialog,地图展示发生在 TravelPlanDetailPage,不要把搜索逻辑和展示逻辑混在一起。第二个是地图每次刷新都先清空覆盖物,然后根据当前数据重画,这比维护 Marker 增删改状态更直接,也更适合旅行计划这种低频编辑场景。
配置权限
Map Kit 的地图展示和 POI 搜索需要网络能力,当前位置和我的位置图层需要定位权限。项目的 entry/src/main/module.json5 中已经声明了这些权限,核心配置如下:
json5
{
"module": {
"requestPermissions": [
{
"name": "ohos.permission.INTERNET",
"reason": "$string:permission_internet_reason",
"usedScene": {
"abilities": [
"EntryAbility"
],
"when": "always"
}
},
{
"name": "ohos.permission.APPROXIMATELY_LOCATION",
"reason": "$string:permission_location_reason",
"usedScene": {
"abilities": [
"EntryAbility"
],
"when": "inuse"
}
},
{
"name": "ohos.permission.LOCATION",
"reason": "$string:permission_location_reason",
"usedScene": {
"abilities": [
"EntryAbility"
],
"when": "inuse"
}
}
]
}
}
代码里不要默认用户已经授权。我的处理方式是先判断有没有权限,没有就请求;请求失败时地图仍然可用,只是不显示当前位置,也不会自动填充当前地点。
ts
private async requestLocationPermissions(context: Context): Promise<boolean> {
return ensurePermissionsGranted(
context,
['ohos.permission.APPROXIMATELY_LOCATION', 'ohos.permission.LOCATION'] as Array<Permissions>
);
}
地点采集:搜索、定位和地图选点
子行程编辑时,我给了用户三条路:自动使用当前位置、搜索地点、直接点地图。
当前位置来自 Location Kit:
ts
this.currentLocation = await geoLocationManager.getCurrentLocation({
priority: geoLocationManager.LocationRequestPriority.FIRST_FIX,
scenario: geoLocationManager.LocationRequestScenario.NAVIGATION
});
拿到经纬度后,我会调用逆地理编码,把坐标变成用户看得懂的地点名:
ts
private async reverseGeocode(position: mapCommon.LatLng): Promise<ResolvedLocationInfo> {
let resolved: ResolvedLocationInfo = new ResolvedLocationInfo();
try {
let addresses = await geoLocationManager.getAddressesFromLocation({
latitude: position.latitude,
longitude: position.longitude,
maxItems: 1,
locale: 'zh-CN'
});
if (addresses && addresses.length > 0) {
resolved.name = this.buildLocationLabel(addresses[0]);
resolved.fullAddress = this.buildFullAddressLabel(addresses[0]);
}
} catch (error) {
console.error(`Failed to reverse geocode location: ${JSON.stringify(error)}`);
}
return resolved;
}
POI 搜索使用 Map Kit 的 site.searchByText。我在 LocationSearchUtil.ets 里做了一层封装:如果有当前位置或已选点,就优先在 50 公里范围内搜;如果搜不到,再退回全局关键字搜索。
ts
import { mapCommon, site } from '@kit.MapKit';
const LOCATION_SEARCH_RADIUS_METERS: number = 50000;
export function hasUsableLocationSearchAnchor(anchor: mapCommon.LatLng | undefined): boolean {
if (anchor === undefined) {
return false;
}
return Math.abs(anchor.latitude) > 0.000001 && Math.abs(anchor.longitude) > 0.000001;
}
export async function searchSitesByTextNearLocation(
query: string,
anchor: mapCommon.LatLng | undefined,
language: string = 'zh-CN'
): Promise<Array<site.Site>> {
let trimmedQuery: string = query.trim();
if (trimmedQuery.length === 0) {
return [];
}
if (hasUsableLocationSearchAnchor(anchor)) {
let nearbyParams: site.SearchByTextParams = {
query: trimmedQuery,
location: anchor,
radius: LOCATION_SEARCH_RADIUS_METERS,
language: language
};
let nearbyResult = await site.searchByText(nearbyParams);
let nearbySites: Array<site.Site> = nearbyResult.sites || [];
if (nearbySites.length > 0) {
return nearbySites;
}
}
let fallbackParams: site.SearchByTextParams = {
query: trimmedQuery,
language: language
};
let fallbackResult = await site.searchByText(fallbackParams);
return fallbackResult.sites || [];
}
地图选点则是 MapComponent 加 mapClick 事件。用户点一下地图,我移动或创建一个 Marker,先记录坐标;点"确定"时,如果没有地点名,再做一次逆地理编码兜底。
ts
MapComponent({
mapOptions: {
position: {
target: this.getPickerTarget(),
zoom: 14
}
},
mapCallback: async (err, mapController) => {
if (!err) {
this.pickerMapController = mapController;
await this.ensureCurrentLocationLoaded();
this.applyCurrentLocationToPickerMap();
let initialPosition: mapCommon.LatLng | undefined = this.getPickerSelectionPosition();
if (initialPosition !== undefined) {
await this.setPickerMarkerPosition(initialPosition, true);
}
this.pickerMapController.getEventManager().on('mapClick', async (position: mapCommon.LatLng) => {
this.pickerLocationName = '';
this.pickerFullAddress = '';
this.searchResults = [];
await this.setPickerMarkerPosition(position, false);
});
}
}
})
.width('100%')
.height('100%')
这里有个细节:搜索结果选中时我会 moveCamera 到目标位置,但地图点击时不移动相机。因为点击本身已经发生在当前视野内,再移动一次会显得跳。
地图展示:路线和序号 Marker
旅行详情页里,地图渲染入口是 buildMapSection。只要当前视图里存在带坐标的子行程,就渲染小地图;否则展示空状态。
ts
@Builder
private buildMapSection(plan: TravelPlan): void {
Column({ space: 14 }) {
this.buildSectionHeader('行程地图', this.getMapSectionSubtitle(plan))
if (this.getVisibleMappedSubPlanCount(plan) > 0) {
Stack({ alignContent: Alignment.TopEnd }) {
MapComponent({
mapOptions: {
position: {
target: this.getPreferredMapTarget(),
zoom: 10
}
},
mapCallback: async (err, mapController) => {
if (!err) {
this.mapController = mapController;
void this.prepareControllerLocationLayer(mapController);
void this.updateMapMarkers();
}
}
})
.width('100%')
.height(220)
.borderRadius(24)
Button() {
SymbolGlyph($r('sys.symbol.arrow_up_left_and_arrow_down_right'))
.fontSize(16)
.fontColor([ThemePalette.textPrimary()])
}
.type(ButtonType.Circle)
.width(38)
.height(38)
.margin({ top: 14, right: 14 })
.backgroundColor(ThemePalette.surfaceTertiary())
.onClick(() => {
this.openFullscreenMap();
})
}
.width('100%')
.height(220)
.backgroundColor(ThemePalette.inputSurface())
.borderRadius(24)
} else {
Column({ space: 8 }) {
SymbolGlyph($r('sys.symbol.map'))
.fontSize(38)
.fontColor([$r('app.color.text_disabled')])
Text(this.isDailyGroupingEnabled ? '当前日期还没有可展示的地点' : '子行程还没有带地点')
.fontSize(15)
.fontWeight(FontWeight.Medium)
.fontColor(ThemePalette.textSecondary())
Text(this.isDailyGroupingEnabled ? '切换日期或补充坐标后这里会显示地图' : '添加带地点的行程后这里会显示地图')
.fontSize(13)
.lineHeight(20)
.fontColor(ThemePalette.textTertiary())
}
.height(160)
.width('100%')
.justifyContent(FlexAlign.Center)
.backgroundColor(ThemePalette.inputSurface())
.borderRadius(24)
}
}
.width('100%')
}
地图刷新逻辑集中在 updateMapMarkersOnController。这段是整个旅记计划地图的核心。
ts
private async updateMapMarkersOnController(controller?: map.MapComponentController) {
if (!controller) {
return;
}
controller.clear();
let mappedPlans: Array<NumberedSubPlan> = this.getMappedSubPlansWithOrder(this.plan);
if (mappedPlans.length === 0) {
return;
}
let boundsOptions: mapCommon.LatLngBounds = {
northeast: { latitude: -90, longitude: -180 },
southwest: { latitude: 90, longitude: 180 }
};
let routePoints: Array<mapCommon.LatLng> = [];
for (let i: number = 0; i < mappedPlans.length; i++) {
let plan: TravelSubPlan = mappedPlans[i].subPlan;
routePoints.push({
latitude: plan.latitude,
longitude: plan.longitude
});
}
if (routePoints.length >= 2) {
let polyline: map.MapPolyline = await controller.addPolyline({
points: routePoints,
clickable: false,
startCap: mapCommon.CapStyle.BUTT,
endCap: mapCommon.CapStyle.BUTT,
geodesic: false,
jointType: mapCommon.JointType.BEVEL,
visible: true,
width: 6,
zIndex: 1,
gradient: false
});
polyline.setColor(0xCC5A89E6);
polyline.setPatterns([
{ type: mapCommon.PatternItemType.DASH, length: 36 },
{ type: mapCommon.PatternItemType.GAP, length: 18 }
]);
}
for (let i: number = 0; i < mappedPlans.length; i++) {
let item: NumberedSubPlan = mappedPlans[i];
let plan: TravelSubPlan = item.subPlan;
let pos: mapCommon.LatLng = { latitude: plan.latitude, longitude: plan.longitude };
let order: number = item.order;
await controller.addMarker({
position: pos,
title: `${order}. ${plan.title}`,
snippet: '',
anchorU: 0.5,
anchorV: 1,
alpha: 0.8,
zIndex: 20,
clickable: true,
iconBuilder: () => {
this.buildMapOrderMarker(order);
}
});
boundsOptions.northeast.latitude = Math.max(boundsOptions.northeast.latitude, pos.latitude);
boundsOptions.northeast.longitude = Math.max(boundsOptions.northeast.longitude, pos.longitude);
boundsOptions.southwest.latitude = Math.min(boundsOptions.southwest.latitude, pos.latitude);
boundsOptions.southwest.longitude = Math.min(boundsOptions.southwest.longitude, pos.longitude);
}
if (mappedPlans.length === 1) {
controller.moveCamera(map.newCameraPosition({
target: {
latitude: mappedPlans[0].subPlan.latitude,
longitude: mappedPlans[0].subPlan.longitude
},
zoom: 12
}));
} else {
controller.moveCamera(map.newLatLngBounds(boundsOptions, 50));
}
}
我这里用了虚线 Polyline,因为旅行计划通常是"计划路线",不一定等于真实步行或驾车轨迹。虚线能表达"行程顺序",但不会误导用户以为这是实际导航路径。
序号 Marker 用 iconBuilder 画:
ts
@Builder
private buildMapOrderMarker(order: number): void {
Column({ space: -4 }) {
Stack({ alignContent: Alignment.Center }) {
Circle({ width: 38, height: 38 })
.fill($r('app.color.travel_primary'))
Text(order.toString())
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_inverse'))
}
.shadow({ radius: 10, color: $r('app.color.shadow_medium'), offsetY: 4 })
Circle({ width: 10, height: 10 })
.fill($r('app.color.travel_primary'))
}
.opacity(0.8)
.width(40)
.alignItems(HorizontalAlign.Center)
}
这比准备 marker_1.png、marker_2.png 这种图片更适合我的项目,因为行程序号是动态的,颜色也要跟随 APP 的旅行主题。
时序图
petalMaps MapComponentController TravelPlanDetailPage Map Kit site Location Kit SubPlanDialog 用户 petalMaps MapComponentController TravelPlanDetailPage Map Kit site Location Kit SubPlanDialog 用户 #mermaid-svg-p7RIX2DgmRPQaUwq{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-p7RIX2DgmRPQaUwq .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-p7RIX2DgmRPQaUwq .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-p7RIX2DgmRPQaUwq .error-icon{fill:#552222;}#mermaid-svg-p7RIX2DgmRPQaUwq .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-p7RIX2DgmRPQaUwq .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-p7RIX2DgmRPQaUwq .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-p7RIX2DgmRPQaUwq .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-p7RIX2DgmRPQaUwq .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-p7RIX2DgmRPQaUwq .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-p7RIX2DgmRPQaUwq .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-p7RIX2DgmRPQaUwq .marker{fill:#333333;stroke:#333333;}#mermaid-svg-p7RIX2DgmRPQaUwq .marker.cross{stroke:#333333;}#mermaid-svg-p7RIX2DgmRPQaUwq svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-p7RIX2DgmRPQaUwq p{margin:0;}#mermaid-svg-p7RIX2DgmRPQaUwq .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-p7RIX2DgmRPQaUwq text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-p7RIX2DgmRPQaUwq .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-p7RIX2DgmRPQaUwq .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-p7RIX2DgmRPQaUwq .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-p7RIX2DgmRPQaUwq .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-p7RIX2DgmRPQaUwq #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-p7RIX2DgmRPQaUwq .sequenceNumber{fill:white;}#mermaid-svg-p7RIX2DgmRPQaUwq #sequencenumber{fill:#333;}#mermaid-svg-p7RIX2DgmRPQaUwq #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-p7RIX2DgmRPQaUwq .messageText{fill:#333;stroke:none;}#mermaid-svg-p7RIX2DgmRPQaUwq .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-p7RIX2DgmRPQaUwq .labelText,#mermaid-svg-p7RIX2DgmRPQaUwq .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-p7RIX2DgmRPQaUwq .loopText,#mermaid-svg-p7RIX2DgmRPQaUwq .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-p7RIX2DgmRPQaUwq .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-p7RIX2DgmRPQaUwq .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-p7RIX2DgmRPQaUwq .noteText,#mermaid-svg-p7RIX2DgmRPQaUwq .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-p7RIX2DgmRPQaUwq .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-p7RIX2DgmRPQaUwq .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-p7RIX2DgmRPQaUwq .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-p7RIX2DgmRPQaUwq .actorPopupMenu{position:absolute;}#mermaid-svg-p7RIX2DgmRPQaUwq .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-p7RIX2DgmRPQaUwq .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-p7RIX2DgmRPQaUwq .actor-man circle,#mermaid-svg-p7RIX2DgmRPQaUwq line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-p7RIX2DgmRPQaUwq :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 新增或编辑子行程请求定位权限并获取当前位置返回 latitude / longitude逆地理编码返回地点名和详细地址搜索 POI 或点击地图选点searchByText返回地点列表保存 TravelSubPlanclearaddPolylineaddMarkermoveCamera点击路线规划openMapRoutePlan
全屏地图
详情页小地图高度只有 220vp,适合快速扫一眼当天路线。用户需要看细节时,我用 showMapFullscreen 打开全屏覆盖层,并给全屏地图单独维护一个 fullscreenMapController。
ts
private openFullscreenMap(): void {
this.showMapFullscreen = true;
this.fullscreenMapUpdateVersion++;
}
private closeFullscreenMap(): void {
this.showMapFullscreen = false;
this.fullscreenMapController = undefined;
}
@Builder
private buildFullscreenMapOverlay(): void {
Stack({ alignContent: Alignment.TopStart }) {
MapComponent({
mapOptions: {
position: {
target: this.getPreferredMapTarget(),
zoom: 10
}
},
mapCallback: async (err, mapController) => {
if (!err) {
this.fullscreenMapController = mapController;
void this.prepareControllerLocationLayer(mapController);
void this.updateFullscreenMapMarkers();
}
}
})
.width('100%')
.height('100%')
Column() {
Row() {
Button() {
SymbolGlyph($r('sys.symbol.xmark'))
.fontSize(18)
.fontColor([$r('app.color.text_inverse')])
}
.type(ButtonType.Circle)
.width(40)
.height(40)
.backgroundColor($r('app.color.travel_overlay_mid'))
.onClick(() => {
this.closeFullscreenMap();
})
Text('行程地图')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_inverse'))
.layoutWeight(1)
.textAlign(TextAlign.Center)
Blank()
.width(40)
}
.width('100%')
}
.padding({ left: 20, right: 20, top: 12, bottom: 6 })
.width('100%')
}
.width('100%')
.height('100%')
.backgroundColor($r('app.color.travel_overlay_bottom'))
.zIndex(20)
}
小地图和全屏地图共用同一套 updateMapMarkersOnController,差异只在 Controller。这样不会出现"小地图路线对,全屏地图路线错"的问题。
拉起地图应用做路线规划
APP 内的行程地图负责"看计划",真正出发时还是要交给地图应用做路线规划。这里用的是 Map Kit 的 petalMaps.openMapRoutePlan。
ts
private async startSubPlanNavigation(subPlan: TravelSubPlan): Promise<void> {
if (!this.hasUsableCoordinates(subPlan.latitude, subPlan.longitude)) {
this.showToast('该行程还没有精确坐标,暂时无法导航');
return;
}
let hostContext: common.UIAbilityContext | undefined =
this.getUIContext().getHostContext() as common.UIAbilityContext | undefined;
if (hostContext === undefined) {
this.showToast('当前无法打开路线规划');
return;
}
try {
const params: petalMaps.RoutePlanParams = {
destinationPosition: {
latitude: subPlan.latitude,
longitude: subPlan.longitude
}
};
await petalMaps.openMapRoutePlan(hostContext, params);
} catch (error) {
console.error(`Failed to open route plan: ${JSON.stringify(error)}`);
this.showToast('打开路线规划失败,请确认设备已安装地图应用');
}
}
这里我只传了终点坐标,起点交给地图应用默认使用用户当前位置。对旅行 APP 来说,这个交互更自然,因为用户真正点开路线规划时,当前位置才是有效起点。
完整可迁移代码
下面这份代码把《时光旅记》里的地图能力抽成了一个完整组件。真实项目中我拆成了 TravelPlanDetailPage.ets、SubPlanDialog.ets、LocationSearchUtil.ets 和模型文件;如果你要在自己的页面里快速复用,可以先用这份组件跑通,再按业务拆分。
ts
import { common, Context, Permissions } from '@kit.AbilityKit';
import { MapComponent, map, mapCommon, petalMaps, site } from '@kit.MapKit';
import { geoLocationManager } from '@kit.LocationKit';
import { ensurePermissionsGranted, arePermissionsGranted } from '../utils/PermissionUtil';
export class TravelSubPlan {
id: string = '';
sortOrder: number = 0;
title: string = '';
location: string = '';
fullAddress: string = '';
latitude: number = 0;
longitude: number = 0;
startTime: string = '';
createdAt: string = '';
}
export class TravelPlan {
id: string = '';
title: string = '';
isTimePrecisionEnabled: boolean = true;
subPlans: Array<TravelSubPlan> = [];
}
class NumberedSubPlan {
order: number = 0;
subPlan: TravelSubPlan = new TravelSubPlan();
}
class ResolvedLocationInfo {
name: string = '';
fullAddress: string = '';
}
const LOCATION_SEARCH_RADIUS_METERS: number = 50000;
@Component
export struct TravelPlanMapFeature {
@Prop plan: TravelPlan = new TravelPlan();
@State showPicker: boolean = false;
@State searchQuery: string = '';
@State searchResults: Array<site.Site> = [];
@State pickerLocationName: string = '';
@State pickerFullAddress: string = '';
@State pickerLatitude: number = 0;
@State pickerLongitude: number = 0;
@State selectedSubPlanId: string = '';
private mapController?: map.MapComponentController;
private pickerMapController?: map.MapComponentController;
private pickerMarker?: map.Marker;
private currentLocation?: geoLocationManager.Location;
private hasRequestedLocationPermission: boolean = false;
private hasLocationPermission: boolean = false;
private isLoadingCurrentLocation: boolean = false;
onLocationPicked: (subPlanId: string, location: string, fullAddress: string, latitude: number, longitude: number) => void =
() => {};
private hasUsableCoordinates(latitude: number, longitude: number): boolean {
return Math.abs(latitude) > 0.000001 && Math.abs(longitude) > 0.000001;
}
private async requestLocationPermissions(context: Context): Promise<boolean> {
return ensurePermissionsGranted(
context,
['ohos.permission.APPROXIMATELY_LOCATION', 'ohos.permission.LOCATION'] as Array<Permissions>
);
}
private async ensureCurrentLocationLoaded(): Promise<void> {
if (this.currentLocation || this.isLoadingCurrentLocation) {
return;
}
let hostContext: Context | undefined = this.getUIContext().getHostContext() as Context | undefined;
if (hostContext === undefined) {
return;
}
this.hasLocationPermission = this.hasRequestedLocationPermission
? await arePermissionsGranted(['ohos.permission.APPROXIMATELY_LOCATION', 'ohos.permission.LOCATION'] as Array<Permissions>)
: await this.requestLocationPermissions(hostContext);
this.hasRequestedLocationPermission = true;
if (!this.hasLocationPermission) {
return;
}
this.isLoadingCurrentLocation = true;
try {
this.currentLocation = await geoLocationManager.getCurrentLocation({
priority: geoLocationManager.LocationRequestPriority.FIRST_FIX,
scenario: geoLocationManager.LocationRequestScenario.NAVIGATION
});
this.applyCurrentLocationToController(this.mapController);
this.applyCurrentLocationToController(this.pickerMapController);
} catch (error) {
console.error(`Failed to load current location: ${JSON.stringify(error)}`);
}
this.isLoadingCurrentLocation = false;
}
private applyCurrentLocationToController(controller?: map.MapComponentController): void {
if (!controller || !this.hasLocationPermission) {
return;
}
controller.setMyLocationEnabled(true);
controller.setMyLocationControlsEnabled(true);
if (this.currentLocation) {
controller.setMyLocation(this.currentLocation);
}
}
private buildLocationLabel(address: geoLocationManager.GeoAddress): string {
if (address.placeName !== undefined && address.placeName.length > 0) {
return address.placeName;
}
if (address.roadName !== undefined && address.roadName.length > 0) {
return address.roadName;
}
let parts: Array<string> = [];
if (address.locality !== undefined && address.locality.length > 0) {
parts.push(address.locality);
}
if (address.subLocality !== undefined && address.subLocality.length > 0) {
parts.push(address.subLocality);
}
return parts.join(' · ');
}
private buildFullAddressLabel(address: geoLocationManager.GeoAddress): string {
let parts: Array<string> = [];
if (address.administrativeArea !== undefined && address.administrativeArea.length > 0) {
parts.push(address.administrativeArea);
}
if (address.locality !== undefined && address.locality.length > 0) {
parts.push(address.locality);
}
if (address.subLocality !== undefined && address.subLocality.length > 0) {
parts.push(address.subLocality);
}
if (address.roadName !== undefined && address.roadName.length > 0) {
parts.push(address.roadName);
}
if (address.placeName !== undefined && address.placeName.length > 0) {
parts.push(address.placeName);
}
return parts.join(' · ');
}
private async reverseGeocode(position: mapCommon.LatLng): Promise<ResolvedLocationInfo> {
let resolved: ResolvedLocationInfo = new ResolvedLocationInfo();
try {
let addresses = await geoLocationManager.getAddressesFromLocation({
latitude: position.latitude,
longitude: position.longitude,
maxItems: 1,
locale: 'zh-CN'
});
if (addresses && addresses.length > 0) {
resolved.name = this.buildLocationLabel(addresses[0]);
resolved.fullAddress = this.buildFullAddressLabel(addresses[0]);
}
} catch (error) {
console.error(`Failed to reverse geocode: ${JSON.stringify(error)}`);
}
return resolved;
}
private getSearchAnchor(): mapCommon.LatLng | undefined {
if (this.currentLocation) {
return {
latitude: this.currentLocation.latitude,
longitude: this.currentLocation.longitude
};
}
if (this.hasUsableCoordinates(this.pickerLatitude, this.pickerLongitude)) {
return {
latitude: this.pickerLatitude,
longitude: this.pickerLongitude
};
}
return undefined;
}
private async searchLocations(): Promise<void> {
let trimmedQuery: string = this.searchQuery.trim();
if (trimmedQuery.length === 0) {
this.searchResults = [];
return;
}
await this.ensureCurrentLocationLoaded();
let anchor: mapCommon.LatLng | undefined = this.getSearchAnchor();
if (anchor !== undefined) {
let nearbyParams: site.SearchByTextParams = {
query: trimmedQuery,
location: anchor,
radius: LOCATION_SEARCH_RADIUS_METERS,
language: 'zh-CN'
};
let nearbyResult = await site.searchByText(nearbyParams);
let nearbySites: Array<site.Site> = nearbyResult.sites || [];
if (nearbySites.length > 0) {
this.searchResults = nearbySites;
return;
}
}
let fallbackResult = await site.searchByText({
query: trimmedQuery,
language: 'zh-CN'
});
this.searchResults = fallbackResult.sites || [];
}
private getSortedSubPlans(): Array<TravelSubPlan> {
return this.plan.subPlans.slice(0).sort((left: TravelSubPlan, right: TravelSubPlan) => {
if (!this.plan.isTimePrecisionEnabled) {
if (left.sortOrder !== right.sortOrder) {
return left.sortOrder - right.sortOrder;
}
return left.createdAt.localeCompare(right.createdAt);
}
let leftTime: number = new Date(left.startTime).getTime();
let rightTime: number = new Date(right.startTime).getTime();
if (isNaN(leftTime) && isNaN(rightTime)) {
return 0;
}
if (isNaN(leftTime)) {
return 1;
}
if (isNaN(rightTime)) {
return -1;
}
return leftTime - rightTime;
});
}
private getMappedSubPlansWithOrder(): Array<NumberedSubPlan> {
let result: Array<NumberedSubPlan> = [];
let sorted: Array<TravelSubPlan> = this.getSortedSubPlans();
let order: number = 1;
for (let i: number = 0; i < sorted.length; i++) {
let subPlan: TravelSubPlan = sorted[i];
if (!this.hasUsableCoordinates(subPlan.latitude, subPlan.longitude)) {
continue;
}
let item: NumberedSubPlan = new NumberedSubPlan();
item.order = order;
item.subPlan = subPlan;
result.push(item);
order++;
}
return result;
}
private getPreferredMapTarget(): mapCommon.LatLng {
let mappedPlans: Array<NumberedSubPlan> = this.getMappedSubPlansWithOrder();
if (mappedPlans.length > 0) {
return {
latitude: mappedPlans[0].subPlan.latitude,
longitude: mappedPlans[0].subPlan.longitude
};
}
if (this.currentLocation) {
return {
latitude: this.currentLocation.latitude,
longitude: this.currentLocation.longitude
};
}
return { latitude: 39.9, longitude: 116.4 };
}
private async updateMapMarkers(): Promise<void> {
if (!this.mapController) {
return;
}
let controller: map.MapComponentController = this.mapController;
controller.clear();
let mappedPlans: Array<NumberedSubPlan> = this.getMappedSubPlansWithOrder();
if (mappedPlans.length === 0) {
return;
}
let routePoints: Array<mapCommon.LatLng> = [];
let boundsOptions: mapCommon.LatLngBounds = {
northeast: { latitude: -90, longitude: -180 },
southwest: { latitude: 90, longitude: 180 }
};
for (let i: number = 0; i < mappedPlans.length; i++) {
let subPlan: TravelSubPlan = mappedPlans[i].subPlan;
routePoints.push({ latitude: subPlan.latitude, longitude: subPlan.longitude });
}
if (routePoints.length >= 2) {
let polyline: map.MapPolyline = await controller.addPolyline({
points: routePoints,
clickable: false,
startCap: mapCommon.CapStyle.BUTT,
endCap: mapCommon.CapStyle.BUTT,
geodesic: false,
jointType: mapCommon.JointType.BEVEL,
visible: true,
width: 6,
zIndex: 1,
gradient: false
});
polyline.setColor(0xCC5A89E6);
polyline.setPatterns([
{ type: mapCommon.PatternItemType.DASH, length: 36 },
{ type: mapCommon.PatternItemType.GAP, length: 18 }
]);
}
for (let i: number = 0; i < mappedPlans.length; i++) {
let item: NumberedSubPlan = mappedPlans[i];
let subPlan: TravelSubPlan = item.subPlan;
let pos: mapCommon.LatLng = { latitude: subPlan.latitude, longitude: subPlan.longitude };
await controller.addMarker({
position: pos,
title: `${item.order}. ${subPlan.title}`,
anchorU: 0.5,
anchorV: 1,
alpha: 0.85,
zIndex: 20,
clickable: true,
iconBuilder: () => {
this.buildOrderMarker(item.order);
}
});
boundsOptions.northeast.latitude = Math.max(boundsOptions.northeast.latitude, pos.latitude);
boundsOptions.northeast.longitude = Math.max(boundsOptions.northeast.longitude, pos.longitude);
boundsOptions.southwest.latitude = Math.min(boundsOptions.southwest.latitude, pos.latitude);
boundsOptions.southwest.longitude = Math.min(boundsOptions.southwest.longitude, pos.longitude);
}
if (mappedPlans.length === 1) {
controller.moveCamera(map.newCameraPosition({
target: {
latitude: mappedPlans[0].subPlan.latitude,
longitude: mappedPlans[0].subPlan.longitude
},
zoom: 12
}));
} else {
controller.moveCamera(map.newLatLngBounds(boundsOptions, 50));
}
}
private async setPickerMarkerPosition(position: mapCommon.LatLng, moveCamera: boolean): Promise<void> {
this.pickerLatitude = position.latitude;
this.pickerLongitude = position.longitude;
if (!this.pickerMapController) {
return;
}
if (this.pickerMarker) {
this.pickerMarker.setPosition(position);
} else {
this.pickerMarker = await this.pickerMapController.addMarker({
position: position,
clickable: true
});
}
if (moveCamera) {
this.pickerMapController.moveCamera(map.newCameraPosition({ target: position, zoom: 16 }));
}
}
private async confirmPickerSelection(): Promise<void> {
if (!this.hasUsableCoordinates(this.pickerLatitude, this.pickerLongitude)) {
this.showPicker = false;
return;
}
let position: mapCommon.LatLng = {
latitude: this.pickerLatitude,
longitude: this.pickerLongitude
};
let resolvedName: string = this.pickerLocationName.trim();
let resolvedAddress: string = this.pickerFullAddress.trim();
if (resolvedName.length === 0 || resolvedAddress.length === 0) {
let resolved: ResolvedLocationInfo = await this.reverseGeocode(position);
if (resolvedName.length === 0) {
resolvedName = resolved.name;
}
if (resolvedAddress.length === 0) {
resolvedAddress = resolved.fullAddress;
}
}
if (resolvedName.length === 0) {
resolvedName = `${position.latitude.toFixed(5)}, ${position.longitude.toFixed(5)}`;
}
if (resolvedAddress.length === 0) {
resolvedAddress = resolvedName;
}
this.onLocationPicked(this.selectedSubPlanId, resolvedName, resolvedAddress, position.latitude, position.longitude);
this.showPicker = false;
}
private async openRoutePlan(subPlan: TravelSubPlan): Promise<void> {
if (!this.hasUsableCoordinates(subPlan.latitude, subPlan.longitude)) {
this.getUIContext().getPromptAction().showToast({ message: '该行程还没有精确坐标' });
return;
}
let hostContext: common.UIAbilityContext | undefined =
this.getUIContext().getHostContext() as common.UIAbilityContext | undefined;
if (hostContext === undefined) {
return;
}
const params: petalMaps.RoutePlanParams = {
destinationPosition: {
latitude: subPlan.latitude,
longitude: subPlan.longitude
},
destinationName: subPlan.location.length > 0 ? subPlan.location : subPlan.title
};
await petalMaps.openMapRoutePlan(hostContext, params);
}
private openPicker(subPlan: TravelSubPlan): void {
this.selectedSubPlanId = subPlan.id;
this.pickerLocationName = subPlan.location;
this.pickerFullAddress = subPlan.fullAddress;
this.pickerLatitude = subPlan.latitude;
this.pickerLongitude = subPlan.longitude;
this.searchQuery = '';
this.searchResults = [];
this.pickerMapController = undefined;
this.pickerMarker = undefined;
this.showPicker = true;
void this.ensureCurrentLocationLoaded();
}
@Builder
private buildOrderMarker(order: number): void {
Column({ space: -4 }) {
Stack({ alignContent: Alignment.Center }) {
Circle({ width: 38, height: 38 })
.fill('#5A89E6')
Text(order.toString())
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(Color.White)
}
.shadow({ radius: 10, color: '#33000000', offsetY: 4 })
Circle({ width: 10, height: 10 })
.fill('#5A89E6')
}
.opacity(0.85)
.width(40)
.alignItems(HorizontalAlign.Center)
}
@Builder
private buildPickerOverlay(): void {
Stack({ alignContent: Alignment.Bottom }) {
Column({ space: 10 }) {
Row({ space: 8 }) {
TextInput({ placeholder: '搜索地点', text: this.searchQuery })
.layoutWeight(1)
.height(44)
.onChange((value: string) => {
this.searchQuery = value;
})
Button('搜索')
.height(44)
.onClick(() => {
void this.searchLocations();
})
}
.width('100%')
.padding({ left: 12, right: 12, top: 12 })
Stack({ alignContent: Alignment.Top }) {
MapComponent({
mapOptions: {
position: {
target: this.getPreferredMapTarget(),
zoom: 14
}
},
mapCallback: async (err, mapController) => {
if (!err) {
this.pickerMapController = mapController;
this.applyCurrentLocationToController(mapController);
if (this.hasUsableCoordinates(this.pickerLatitude, this.pickerLongitude)) {
await this.setPickerMarkerPosition({
latitude: this.pickerLatitude,
longitude: this.pickerLongitude
}, true);
}
mapController.getEventManager().on('mapClick', async (position: mapCommon.LatLng) => {
this.pickerLocationName = '';
this.pickerFullAddress = '';
this.searchResults = [];
await this.setPickerMarkerPosition(position, false);
});
}
}
})
.width('100%')
.height('100%')
if (this.searchResults.length > 0) {
List() {
ForEach(this.searchResults, (item: site.Site) => {
ListItem() {
Column({ space: 4 }) {
Text(item.name || '未命名地点')
.fontSize(15)
.fontWeight(FontWeight.Medium)
Text(item.formatAddress || '')
.fontSize(12)
.fontColor('#666666')
}
.alignItems(HorizontalAlign.Start)
.padding(12)
.width('100%')
.onClick(() => {
if (!item.location) {
return;
}
this.pickerLocationName = item.name || item.formatAddress || '';
this.pickerFullAddress = item.formatAddress || this.pickerLocationName;
this.searchResults = [];
void this.setPickerMarkerPosition({
latitude: item.location.latitude,
longitude: item.location.longitude
}, true);
})
}
})
}
.width('92%')
.constraintSize({ maxHeight: 280 })
.backgroundColor(Color.White)
.borderRadius(12)
.margin({ top: 10 })
}
}
.layoutWeight(1)
.width('100%')
Row({ space: 12 }) {
Button('取消')
.layoutWeight(1)
.height(46)
.onClick(() => {
this.showPicker = false;
})
Button('使用这个地点')
.layoutWeight(1)
.height(46)
.onClick(() => {
void this.confirmPickerSelection();
})
}
.padding({ left: 12, right: 12, bottom: 12 })
}
.width('100%')
.height('100%')
.backgroundColor(Color.White)
}
.width('100%')
.height('100%')
.zIndex(99)
}
build() {
Stack() {
Column({ space: 14 }) {
if (this.getMappedSubPlansWithOrder().length > 0) {
MapComponent({
mapOptions: {
position: {
target: this.getPreferredMapTarget(),
zoom: 10
}
},
mapCallback: async (err, mapController) => {
if (!err) {
this.mapController = mapController;
await this.ensureCurrentLocationLoaded();
this.applyCurrentLocationToController(mapController);
await this.updateMapMarkers();
}
}
})
.width('100%')
.height(220)
.borderRadius(24)
} else {
Text('添加带地点的行程后这里会显示地图')
.fontSize(14)
.fontColor('#666666')
.width('100%')
.height(160)
.textAlign(TextAlign.Center)
}
List({ space: 8 }) {
ForEach(this.getSortedSubPlans(), (subPlan: TravelSubPlan) => {
ListItem() {
Row({ space: 10 }) {
Column({ space: 4 }) {
Text(subPlan.title)
.fontSize(16)
.fontWeight(FontWeight.Medium)
Text(subPlan.location.length > 0 ? subPlan.location : '未设置地点')
.fontSize(12)
.fontColor('#666666')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Button('选点')
.height(36)
.onClick(() => {
this.openPicker(subPlan);
})
Button('路线')
.height(36)
.enabled(this.hasUsableCoordinates(subPlan.latitude, subPlan.longitude))
.onClick(() => {
void this.openRoutePlan(subPlan);
})
}
.padding(12)
.width('100%')
}
})
}
.layoutWeight(1)
}
.width('100%')
.height('100%')
.padding(16)
if (this.showPicker) {
this.buildPickerOverlay()
}
}
.width('100%')
.height('100%')
}
}
这份代码里的 PermissionUtil 是我项目里的封装。如果你的项目还没有,可以直接加一个同名工具文件:
ts
import { abilityAccessCtrl, bundleManager, Context, Permissions } from '@kit.AbilityKit';
let cachedAccessTokenId: number = -1;
async function getSelfAccessTokenId(): Promise<number> {
if (cachedAccessTokenId > 0) {
return cachedAccessTokenId;
}
const bundleInfo = await bundleManager.getBundleInfoForSelf(
bundleManager.BundleFlag.GET_BUNDLE_INFO_WITH_APPLICATION
);
cachedAccessTokenId = bundleInfo.appInfo.accessTokenId;
return cachedAccessTokenId;
}
export async function arePermissionsGranted(permissions: Array<Permissions>): Promise<boolean> {
if (permissions.length === 0) {
return true;
}
try {
const atManager: abilityAccessCtrl.AtManager = abilityAccessCtrl.createAtManager();
const accessTokenId: number = await getSelfAccessTokenId();
for (let i: number = 0; i < permissions.length; i++) {
const grantStatus: abilityAccessCtrl.GrantStatus =
await atManager.checkAccessToken(accessTokenId, permissions[i]);
if (grantStatus !== abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED) {
return false;
}
}
return true;
} catch (_error) {
return false;
}
}
export async function ensurePermissionsGranted(context: Context, permissions: Array<Permissions>): Promise<boolean> {
if (await arePermissionsGranted(permissions)) {
return true;
}
try {
await abilityAccessCtrl.createAtManager().requestPermissionsFromUser(context, permissions);
} catch (_error) {
}
return await arePermissionsGranted(permissions);
}
效果图

我踩过的几个点
第一,地图 Controller 必须在 MapComponent 的 mapCallback 之后再用。不要在 aboutToAppear 里直接调用 addMarker,那时地图还没初始化完成。
第二,列表数据变化后要刷新地图覆盖物。我的做法是子行程新增、编辑、删除后同时调用 updateMapMarkers() 和 updateFullscreenMapMarkers(),保证小地图和全屏地图一致。
第三,路线图和导航不是一回事。addPolyline 只是把行程点按顺序连起来,适合表达计划;真正的驾车、步行、公交路线规划应该交给 petalMaps.openMapRoutePlan。
第四,POI 搜索最好带一个当前位置或已选点作为 anchor。旅行计划里用户搜"酒店""机场""博物馆"这类词时,如果没有地理范围,结果会很发散。
总结
《时光旅记》的旅记计划地图,本质上是"地点采集"和"路线呈现"两条链路的组合。
采集侧用 Location Kit 获取当前位置、逆地理编码,用 Map Kit site.searchByText 做 POI 搜索,再用 MapComponent 的点击事件支持手动选点。展示侧用 MapComponentController 清空覆盖物、绘制虚线路线、添加自定义序号 Marker,并根据点位范围移动相机。真正出发时,再用 petalMaps.openMapRoutePlan 把终点交给地图应用。
这样做的好处是边界清楚:APP 内负责旅记计划的表达,地图应用负责专业导航。对旅行场景来说,这个分工刚好。
参考资料:
- MapComponentController:
https://developer.huawei.com/consumer/cn/doc/harmonyos-references/map-map-mapcomponentcontroller - site 地点搜索:
https://developer.huawei.com/consumer/cn/doc/harmonyos-references/map-site - petalMaps 拉起地图应用:
https://developer.huawei.com/consumer/cn/doc/harmonyos-references/map-petal-maps - POI 搜索指南:
https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/map-site-search