【三国志 App 实战系列 06】ArkUI 离线地图交互实战:三国势力地图、年份切换与标记收藏
介绍如何在 HarmonyOS 单机 App 中用 ArkUI 实现离线三国势力地图,包括年份切换、势力区域、标记点击与收藏。
【三国志 App 实战系列 06】离线三国势力地图与 ArkUI 交互实现:年份、势力与标记收藏
系列第 6 篇。本文讲一个更容易“看起来简单、做起来很碎”的页面: 离线三国势力地图。真正难点不在地图底图,而在年份状态、势力区域、标记点击、全屏查看和收藏链路怎么收口。

一、地图页真正要解决什么问题
这个页面不是 GIS,也不是在线地图 SDK,而是一个服务于历史学习场景的离线示意交互地图。它至少要同时满足六件事:
- 年份切换后,势力范围和关键标记同步变化
- 点击势力区域后,用户能知道当前看的到底是哪一方
- 点击标记后,能补充地点、战役或事件说明
- 地图标记能加入本地收藏,复用收藏体系
- 手机、横屏、平板下都不变形
- 无网络时仍然能正常浏览
如果只把一张图片塞进页面,用户看到的只是“地图素材”;只有把时间、区域、标记、详情、收藏串起来,它才是一个完整功能页。
二、先拆三类数据:年份、势力区域、地图标记
这类页面一开始最容易犯的错误,是把所有信息都塞进一个大对象里,后面年份切换、收藏状态和详情浮层会越来越难维护。更稳的做法是拆成三层数据:
MapPeriodData: 描述某一年或某个历史阶段MapRegionData: 描述当前阶段里一块势力区域MapMarker: 描述地图上的一个交互点
export class MapPeriodData {
year: string = '';
title: string = '';
summary: string = '';
regionIds: string[] = [];
markerIds: string[] = [];
constructor(year: string, title: string, summary: string,
regionIds: string[], markerIds: string[]) {
this.year = year;
this.title = title;
this.summary = summary;
this.regionIds = regionIds;
this.markerIds = markerIds;
}
}
export class MapRegionData {
factionId: string = '';
name: string = '';
subtitle: string = '';
color: string = '';
xPercent: number = 0;
yPercent: number = 0;
widthPercent: number = 0;
heightPercent: number = 0;
constructor(factionId: string, name: string, subtitle: string, color: string,
xPercent: number, yPercent: number, widthPercent: number, heightPercent: number) {
this.factionId = factionId;
this.name = name;
this.subtitle = subtitle;
this.color = color;
this.xPercent = xPercent;
this.yPercent = yPercent;
this.widthPercent = widthPercent;
this.heightPercent = heightPercent;
}
}
export class MapMarker {
id: string = '';
title: string = '';
year: string = '';
factionId: string = '';
summary: string = '';
xPercent: number = 0;
yPercent: number = 0;
}
这样拆开的好处是很直接的:
- 年份切换只影响当前可见
regionIds和markerIds - 势力详情只围绕
factionId - 收藏系统只关心
targetType + targetId - 平板和横屏只是换布局,不用改业务数据结构
三、年份切换不要重建整页,只切当前地图状态
地图页常见的低级问题,是切一次年份就把整个页面从头重建,结果造成闪烁、选中状态丢失、浮层关闭、滚动位置重置。更合理的状态拆分应该像这样:
interface ForceMapState {
selectedYear: string;
selectedFactionId?: string;
selectedMarkerId?: string;
visibleRegions: MapRegionData[];
visibleMarkers: MapMarker[];
favoriteMarkerIds: string[];
}
年份变化时,只计算当前阶段可见的数据:
private switchPeriod(year: string): void {
const period = this.periods.find((item: MapPeriodData) => item.year === year);
if (!period) {
return;
}
this.selectedYear = year;
this.selectedFactionId = undefined;
this.selectedMarkerId = undefined;
this.visibleRegions = this.allRegions.filter((item: MapRegionData) =>
period.regionIds.includes(item.factionId));
this.visibleMarkers = this.allMarkers.filter((item: MapMarker) =>
period.markerIds.includes(item.id));
}
这里有两个工程判断值得固定下来:
- 年份切换时重置当前选中的势力和标记,避免上一阶段的浮层残留到下一阶段。
- 可见数据用过滤结果缓存,不要在
build()里每次都重新做复杂筛选。
四、ArkUI 绘制地图时,坐标最好存百分比
地图页面最怕屏幕一变,标记全跑偏。无论底图最终是 Image、Canvas 还是 Stack 叠加,标记和势力区域都建议保存为百分比坐标,而不是绝对像素。
private pxX(region: MapRegionData, mapWidth: number): number {
return mapWidth * region.xPercent;
}
private pxY(region: MapRegionData, mapHeight: number): number {
return mapHeight * region.yPercent;
}
页面绘制可以先走一个稳定、容易维护的 Stack 版本:
Stack() {
Image($r('app.media.map_period_bg'))
.width('100%')
.aspectRatio(0.78)
.objectFit(ImageFit.Contain)
ForEach(this.visibleRegions, (region: MapRegionData) => {
Text(region.name)
.fontSize(12)
.fontWeight(FontWeight.Medium)
.fontColor(Color.White)
.padding({ left: 8, right: 8, top: 6, bottom: 6 })
.backgroundColor(region.color)
.opacity(this.selectedFactionId === region.factionId ? 0.92 : 0.72)
.borderRadius(14)
.position({
x: `${region.xPercent * 100}%`,
y: `${region.yPercent * 100}%`
})
.onClick(() => {
this.selectedFactionId = region.factionId;
})
}, (region: MapRegionData) => region.factionId)
ForEach(this.visibleMarkers, (marker: MapMarker) => {
this.MarkerDot(marker)
}, (marker: MapMarker) => marker.id)
}
这里有一个细节很重要: 底图尽量使用 Contain 或固定纵横比,不要为了“铺满”直接拉伸。 一旦底图比例被改,所有百分比坐标都会变成错位坐标。
五、地图标记详情和收藏,不要另起一套状态系统
地图标记本质上和人物、事件一样,都是一个可收藏目标。如果前面 04、05 已经有 targetType + targetId 体系,这里应该直接复用,而不是给地图单独做一套“已收藏标记表”。
private toggleMarkerFavorite(marker: MapMarker): void {
const exists = this.favoriteMarkerIds.includes(marker.id);
const next = this.favoriteMarkerIds.slice();
if (exists) {
this.favoriteMarkerIds = next.filter((id: string) => id !== marker.id);
this.favoriteService.remove('map_marker', marker.id);
} else {
next.unshift(marker.id);
this.favoriteMarkerIds = next;
this.favoriteService.add({
targetType: 'map_marker',
targetId: marker.id,
title: marker.title,
summary: marker.summary
});
}
}
对应的详情浮层可以保持很轻:
@Builder
private markerPanel(marker: MapMarker) {
Column({ space: 8 }) {
Text(marker.title)
.fontSize(18)
.fontWeight(FontWeight.Bold)
Text(marker.summary)
.fontSize(14)
.lineHeight(22)
Button(this.favoriteMarkerIds.includes(marker.id) ? '取消收藏' : '收藏标记')
.onClick(() => this.toggleMarkerFavorite(marker))
}
.width('100%')
.padding(16)
}
这样做的收益是:
- 收藏页能直接显示地图标记
- 搜索结果和详情页可以共用
targetId - 重启 App 后只需要恢复一份收藏数据
六、全屏查看、横屏和平板适配才是地图页最容易翻车的地方
地图不是列表页,手机上能看不代表横屏和平板上还能看。真正容易翻车的是这三种情况:

| 场景 | 常见问题 | 更稳的做法 |
|---|---|---|
| 手机竖屏 | 底图过高,说明区被顶下去 | 地图区固定纵横比,说明区单独滚动 |
| 手机横屏 | 地图被裁切,顶部按钮遮挡 | 使用横向全屏布局,操作按钮放侧边 |
| 平板 | 直接把手机页放大,信息显得稀 | 左侧筛选/年份,右侧地图和详情双栏 |
一个比较实用的布局判断方式:
private isWideLayout(areaWidth: number): boolean {
return areaWidth >= 840;
}
再按宽度切结构,而不是写死“手机/平板”:
if (this.isWideLayout(this.windowWidth)) {
Row() {
this.periodSidebar()
this.mapCanvas()
this.detailPanel()
}
} else {
Column() {
this.periodTabs()
this.mapCanvas()
this.detailPanel()
}
}
对知识内容型 App 来说,平板不是“变大的手机页”,而是应该给地图更多横向空间,同时把年份、图例和详情拆到独立区域。
七、离线地图最常见的 4 个坑
为了把这篇文章提到可复用的程度,我把这类页面里最值得提前规避的坑集中列一下。
1. 标记坐标跟着图片变形
根因通常不是标记数据错了,而是底图在某个场景被 Cover 拉伸,百分比坐标对应的实际像素发生了变化。处理顺序应该是:
- 先固定底图纵横比
- 再校准百分比坐标
- 最后再做横屏和平板适配
不要反过来先调坐标,不然不同设备会越修越乱。
2. 点击区域太小,用户以为页面失效
地图标记视觉上可以很小,但点击热区不能太小。哪怕点位是一个 10px 的圆点,也建议外面包一层更大的透明点击区域:
Stack() {
Circle()
.width(10)
.height(10)
.fill(Color.White)
Blank()
.width(36)
.height(36)
}
.onClick(() => {
this.selectedMarkerId = marker.id;
})
3. 年份切换后,上一阶段详情浮层还留着
这类 Bug 很影响体验。比如 190 年选中了“董卓”,切到 208 年后浮层还显示旧势力说明,用户会误以为地图内容没更新。解决方式很简单,但要明确写进状态切换逻辑里:
- 清空
selectedFactionId - 清空
selectedMarkerId - 重新计算
visibleRegions - 重新计算
visibleMarkers
4. 收藏状态更新了,地图页没跟着刷新
如果只在收藏服务里写成功,但页面还握着旧数组引用,标记按钮状态就会错。这里继续沿用 ArkUI 的基本经验: 复制数组,在副本上改,再整体赋值回页面状态。
八、调试命令与发布前自检
地图页问题很多都不是编译错误,而是布局错位、点击失效或状态不同步。所以发布前我更看重这组调试检查:
hdc list targets
hdc install -r .\entry\build\default\outputs\default\entry-default-signed.hap
hdc shell aa force-stop com.example.recordofthreekingdoms
hdc shell aa start -a EntryAbility -b com.example.recordofthreekingdoms
hdc shell hilog | Select-String -Pattern "Map|Marker|Favorite|Period"
如果地图页表现不对,我会优先排查:
- 年份切换后是否真的重算了
visibleRegions/visibleMarkers - 底图是否在某个断点下被拉伸
- 标记点击事件是否只绑在视觉圆点上,导致热区太小
- 收藏写回后页面是否仍在使用旧数组引用
最小日志建议至少保留当前年份、可见区域数和标记数:
hilog.info(0x0000, 'ForceMap', 'year=%{public}s regions=%{public}d markers=%{public}d',
this.selectedYear, this.visibleRegions.length, this.visibleMarkers.length);
九、工程实现与验收清单
离线地图的关键不是“画出来”,而是它能不能稳定承载历史内容浏览、时间切换和收藏复用。工程上建议把数据、状态和布局拆开维护:
interface ForceMapPageState {
selectedYear: string;
selectedFactionId?: string;
selectedMarkerId?: string;
isFullscreen: boolean;
visibleRegions: MapRegionData[];
visibleMarkers: MapMarker[];
favoriteMarkerIds: string[];
}
底图、势力区域和标记点尽量都围绕同一套百分比坐标体系:
interface MapMarkerPosition {
markerId: string;
xPercent: number;
yPercent: number;
}
验收清单我会按下面这张表走,而不是只看“页面打开了没有”:
| 场景 | 通过标准 |
|---|---|
| 年份切换 | 标记和势力说明同步变化 |
| 标记点击 | 能展示名称、势力、简介 |
| 收藏标记 | 重启后仍能保留 |
| 年份切换后 | 上一阶段浮层不会残留 |
| 横屏 | 地图不被裁切,操作区可用 |
| 平板 | 年份区、地图区、详情区信息密度合理 |
| 离线 | 无网络时地图仍可浏览 |
| 长时间浏览 | 多次切换年份后无明显闪烁或错位 |
离线势力地图的重点不是追求 GIS 复杂度,而是围绕学习场景提供清晰的时间、势力和事件关系。下一篇会进入 AI 听书模块: 如何用 Core Speech Kit 做长文本朗读,而不是简单调用一次 speak。
更多推荐


所有评论(0)