【观止·诗史汇 HarmonyOS 实战系列 08】古今地理:从历史地名到诗文、事件、朝代的空间关联
【观止·诗史汇 HarmonyOS 实战系列 08】古今地理:从历史地名到诗文、事件、朝代的空间关联
第七篇拆了“兴替明鉴”:它把朝代兴衰拆成六类详细分析,并通过 dynastyId 跳到古今地理和朝代详情。到了第八篇,我们顺着这个入口继续往下看:当用户知道一个朝代的兴衰线索之后,怎样把这个朝代放回真实空间?
诗文和历史里有大量地名:长安、洛阳、汴梁、金陵、黄州、襄州、白帝城、岳阳楼。它们不是孤立名词。长安既是秦汉隋唐的政治中心,也是无数诗文的情感坐标;汴梁既是北宋都城,也是靖康之变的历史伤口;黄州既是苏轼被贬之地,也是《赤壁赋》与东坡词的文学现场。
因此古今地理模块要解决的不是“给地名配一个现代地址”这么简单,而是把一个地名和朝代、历史事件、诗文作品串起来。当前实现的核心对象就是 GeoPlace,页面入口是 GeoPage,服务层由 GeoService 负责合并 MockData 和 HistoryGeoData。

上图来自本机 DevEco 模拟器中的“古今地理”模块。第八篇的正文和封面截图展示地名列表与朝代筛选,不再复用首页图,也不使用时间轴截图代替地理模块。
本篇要解决什么问题
古今地理模块至少要处理这些工程问题:
| 问题 | 当前实现 |
|---|---|
| 地名数据从哪里来 | `GeoService.list()` 合并 `MOCK_GEO` 与 `HISTORY_GEO_PLACES` |
| 如何按朝代筛选 | `selectedDynasty` + 顶部 `FilterChip()` |
| 一个页面如何支持列表和详情 | `NavigateParams.geoId` 存在时展示详情,否则展示列表 |
| 地名如何关联历史 | `GeoPlace.dynastyIds / eventIds / poemIds` |
| 详情页如何跳转 | 朝代跳 `DYNASTY_INSIGHT`,事件跳 `TIMELINE_EVENT_DETAIL`,诗文跳 `POEM_DETAIL` |
| 地名如何进入学习状态 | 收藏和笔记使用 `FavoriteStore`、`NoteStore`,类型为 `place` |
这说明古今地理不是一个地图控件,而是项目里的空间索引层。地图以后可以加,但关系模型必须先稳定。
源码对象总览
| 文件 | 职责 |
|---|---|
| `features/src/main/ets/geo/GeoPage.ets` | 古今地理页面,列表、筛选、详情、收藏、笔记和跨模块跳转 |
| `features/src/main/ets/services/GeoService.ets` | 地名服务,合并基础 mock 与历史地理扩展包 |
| `features/src/main/ets/services/HistoryGeoData.ets` | 历史地名扩展数据包,来自 `doc/lishi` 资料整理 |
| `features/src/main/ets/services/MockData.ets` | 初始地名数据,含诗文和事件联动基础记录 |
| `features/src/main/ets/domain/Models.ets` | `GeoPlace`、`Dynasty`、`HistoryEvent`、`Poem` 等领域模型 |
| `features/src/main/ets/state/AppStores.ets` | 收藏、笔记等本地状态仓 |
这篇重点看 GeoPage 的双态页面设计和 GeoService 的数据合并逻辑。
GeoPlace:一条地名记录连接四类对象
古今地理的核心不是地图坐标,而是 GeoPlace 的关系字段。当前页面会使用这些属性:
GeoPlace {
id: string;
ancientName: string;
modernName: string;
region: string;
history: string;
dynastyIds: string[];
eventIds: string[];
poemIds: string[];
}
字段可以分成三类:
| 类型 | 字段 | 作用 |
|---|---|---|
| 展示字段 | `ancientName`、`modernName`、`region`、`history` | 列表和详情页直接展示 |
| 关系字段 | `dynastyIds`、`eventIds`、`poemIds` | 关联朝代、事件和诗文 |
| 稳定标识 | `id` | 收藏、笔记、跳转和合并的基础 |
这个模型很适合当前项目。它没有急着接经纬度,也没有把地图服务放进第一版,而是先把诗史学习中最重要的“关系”建起来。
GeoService:合并两个地理数据源
地名服务的公开接口非常小:
export class GeoService {
list(): Promise<GeoPlace[]> {
return Promise.resolve(mergeGeoPlaces());
}
getById(id: string): Promise<GeoPlace | null> {
const p: GeoPlace | undefined = mergeGeoPlaces().find((it: GeoPlace) => it.id === id);
return Promise.resolve(p ?? null);
}
}
真正的关键在 mergeGeoPlaces():
function mergeGeoPlaces(): GeoPlace[] {
const merged: GeoPlace[] = [];
appendUnique(merged, MOCK_GEO);
appendUnique(merged, HISTORY_GEO_PLACES);
return merged;
}
它先放入基础 MOCK_GEO,再把 HistoryGeoData 中的扩展地名合进去。这样做的好处是:
- 原有 mock 数据不用删除。
- 历史扩展包可以逐步补充。
- 相同 ID 的记录可以在服务层合并,而不是在页面层去重。
对内容型项目来说,这是一种很实用的渐进扩容方式。页面只问 GeoService.list(),至于数据来自早期 mock 还是后续内容包,页面不需要知道。
appendUnique:按 id 合并,不按名称猜测
合并逻辑没有按 ancientName 或 modernName 去重,而是按 id:
function appendUnique(target: GeoPlace[], source: GeoPlace[]): void {
for (let i = 0; i < source.length; i++) {
const next: GeoPlace = source[i];
const existedIndex: number = target.findIndex((it: GeoPlace) => it.id === next.id);
if (existedIndex >= 0) {
target[existedIndex] = mergeSameIdGeo(target[existedIndex], next);
} else {
target.push(next);
}
}
}
为什么不用名称?
因为古今地名天然复杂:一个古地名可能对应多个现代区域,一个现代城市也可能承载多个古名。例如“金陵”“建康”“南京”在不同历史时期有不同含义;“北京”也可能是辽南京、金中都、元大都、明清京师、民国北平等多个层次。
所以稳定的 id 是必须的。页面和服务都不应靠名称猜测同一性。
mergeSameIdGeo:文本和数组都要去重
同一个 id 出现在两个数据源时,服务层会合并:
function mergeSameIdGeo(oldItem: GeoPlace, newItem: GeoPlace): GeoPlace {
return {
id: newItem.id,
ancientName: newItem.ancientName || oldItem.ancientName,
modernName: newItem.modernName || oldItem.modernName,
region: newItem.region || oldItem.region,
history: mergeText(oldItem.history, newItem.history),
dynastyIds: mergeStringList(oldItem.dynastyIds, newItem.dynastyIds),
eventIds: mergeStringList(oldItem.eventIds, newItem.eventIds),
poemIds: mergeStringList(oldItem.poemIds, newItem.poemIds)
};
}
这里有两个小取舍。
第一,标题类字段优先取新数据。HistoryGeoData 是后续整理的历史地理扩展包,通常比早期 mock 更完整。
第二,关系数组用 mergeStringList() 合并。一个地名可能先在 mock 中关联诗文,后来又在历史包里关联朝代或事件,不能互相覆盖。
合并文本时也做了包含关系判断:
function mergeText(a: string, b: string): string {
if (!a || a.length === 0) {
return b;
}
if (!b || b.length === 0 || a === b || a.indexOf(b) >= 0) {
return a;
}
if (b.indexOf(a) >= 0) {
return b;
}
return `${a}${b}`;
}
这个实现比较朴素,但能避免最常见的重复:如果新旧文本完全相同,或者一段已经包含另一段,就保留更完整的那段。
GeoPage 是一个双态页面
GeoPage 既是列表页,也是详情页。它通过路由参数判断当前状态:
async aboutToAppear() {
const params: NavigateParams = Navigator.getParams();
const id: string = params.geoId ?? '';
const dynastyId: string = params.dynastyId ?? '';
if (id) {
await this.loadDetail(id);
} else {
const all: GeoPlace[] = await this.geoSvc.list();
if (dynastyId) {
this.selectedDynasty = dynastyId;
} else {
this.selectedDynasty = GEO_ALL_ID;
}
this.state = {
loading: false,
list: all,
dynastiesForFilter: MOCK_DYNASTIES_TIMELINE.slice(),
detail: null,
events: [],
poems: [],
dynasties: [],
favored: false,
folders: this.favStore.listFolders(),
favoriteFolderId: ''
};
}
}
这里的两个参数语义不同:
| 参数 | 页面行为 |
|---|---|
| `geoId` | 展示某个地名详情 |
| `dynastyId` | 展示列表,并按朝代筛选 |
所以从首页点“古今地理”时,没有参数,展示全部地名;从兴替明鉴点“上古·古今地理”时,传 dynastyId,页面展示该朝代相关地名;从列表点某个地名时,传 geoId,进入地名详情。
这种“一页双态”的做法很适合轻量模块,避免过早拆成 GeoListPage 和 GeoDetailPage 两套文件。但它也要求状态设计清楚,不能让列表状态和详情状态互相污染。
GeoState:列表字段和详情字段并存
GeoState 直接体现了双态页面的结构:
interface GeoState {
loading: boolean;
list: GeoPlace[];
dynastiesForFilter: Dynasty[];
detail: GeoPlace | null;
events: RelEvent[];
poems: RelPoem[];
dynasties: RelDyn[];
favored: boolean;
folders: FavoriteFolder[];
favoriteFolderId: string;
}
列表态使用:
listdynastiesForFilterselectedDynasty
详情态使用:
detaileventspoemsdynastiesfavoredfoldersfavoriteFolderId
这不是最“纯”的建模方式,但在 ArkUI 页面中很直观。只要 build 阶段根据 detail 是否为空切换渲染,状态就不会乱。
if (!this.state.detail) {
this.DynastyFilter()
}
if (this.state.loading) {
LoadingView({ message: '加载…' }).layoutWeight(1)
} else if (this.state.detail) {
this.DetailScroll()
} else if (this.filteredPlaces().length === 0) {
EmptyView({ message: '暂无地名数据' }).layoutWeight(1)
} else {
this.ListView()
}
注意:筛选条只在列表态出现。详情页不再展示朝代筛选,避免用户以为筛选会影响当前详情。
朝代筛选:从时间线复用朝代集合
古今地理列表顶部的筛选条来自 MOCK_DYNASTIES_TIMELINE:
dynastiesForFilter: MOCK_DYNASTIES_TIMELINE.slice()
页面构造筛选 chip:
@Builder
DynastyFilter() {
Scroll() {
Row({ space: AppDimens.spaceSm }) {
this.FilterChip(GEO_ALL_ID, '全部')
ForEach(this.state.dynastiesForFilter, (d: Dynasty) => {
this.FilterChip(d.id, d.name)
}, (d: Dynasty) => d.id)
}
.padding({ left: AppDimens.pagePadding, right: AppDimens.pagePadding })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.height(48)
.width('100%')
}
筛选逻辑很简单:
private filteredPlaces(): GeoPlace[] {
if (this.selectedDynasty === GEO_ALL_ID) {
return this.state.list;
}
return this.state.list.filter((p: GeoPlace) => {
return p.dynastyIds.indexOf(this.selectedDynasty) >= 0;
});
}
这种筛选是基于关系字段完成的。只要 GeoPlace.dynastyIds 稳定,页面不需要知道“长安属于哪些朝代”,也不需要写一堆 if 判断。
列表卡片:地名先给古今对照,再给历史说明
列表项 GeoCard 的信息层级很清楚:
@Builder
GeoCard(p: GeoPlace) {
Column({ space: 6 }) {
Row({ space: AppDimens.spaceSm }) {
Text(p.ancientName)
Text(`今 · ${p.modernName}`)
}
Text(p.region)
Text(p.history)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.onClick(() => {
const params: NavigateParams = { geoId: p.id };
Navigator.push(AppRoutes.GEO_DETAIL, params);
})
}
古名是主标题,今名是副信息,地区用于快速定位,历史沿革用两行摘要。点击后用 geoId 打开同一个页面的详情态。
这个列表体验比较适合学习应用。用户先看到“长安 今·西安”,再通过历史摘要判断是否继续深入。
详情页:按关系反查朝代、事件、诗文
进入详情时,页面不只是展示 GeoPlace 本身,还会把关系 ID 转成可读对象:
private async loadDetail(id: string) {
const p: GeoPlace | null = await this.geoSvc.getById(id);
if (!p) {
this.state = {
loading: false, list: [], dynastiesForFilter: [], detail: null,
events: [], poems: [], dynasties: [], favored: false,
folders: this.favStore.listFolders(), favoriteFolderId: ''
};
return;
}
const events: RelEvent[] = [];
for (let i = 0; i < p.eventIds.length; i++) {
const e: HistoryEvent | null = await this.eventSvc.getById(p.eventIds[i]);
if (e) events.push({ id: e.id, title: e.title, year: e.year });
}
}
诗文和朝代也用同样方式加载:
const poems: RelPoem[] = [];
for (let i = 0; i < p.poemIds.length; i++) {
const po: Poem | null = await this.poemSvc.getById(p.poemIds[i]);
if (po) poems.push({ id: po.id, title: po.title });
}
const dynasties: RelDyn[] = [];
for (let i = 0; i < p.dynastyIds.length; i++) {
const d: Dynasty | null = await this.dynastySvc.getById(p.dynastyIds[i]);
if (d) dynasties.push({ id: d.id, name: d.name });
}
这里的设计思路是:GeoPlace 只保存关系 ID,页面进入详情后再通过 service 转成展示模型。这避免了地理数据重复保存朝代名、事件标题和诗文标题。
详情页跳转:地名变成诗史交通枢纽
详情页有三组关系。
相关朝代跳兴替明鉴:
Text(d.name)
.onClick(() => {
const p: NavigateParams = { dynastyId: d.id };
Navigator.push(AppRoutes.DYNASTY_INSIGHT, p);
})
相关事件跳事件详情:
Row() {
Text(`${e.year}`)
Text(e.title)
Text('→')
}
.onClick(() => {
const p: NavigateParams = { eventId: e.id };
Navigator.push(AppRoutes.TIMELINE_EVENT_DETAIL, p);
})
相关诗文跳诗文详情:
Row() {
Text(po.title)
Text('→')
}
.onClick(() => {
const pp: NavigateParams = { poemId: po.id };
Navigator.push(AppRoutes.POEM_DETAIL, pp);
})
注意这里有一个后续优化点:诗文详情页在第五篇中依赖 poemId + poemShard 更稳,而这里目前只传了 poemId。如果 PoemService 返回的是 mock 诗文,这样没问题;但如果要跳转到内容包中的完整诗文详情,后续最好补充 poemShard 或通过仓储按 poemId 查出 shard。
收藏和笔记:地名也进入本地学习状态
古今地理详情页也接入了收藏和笔记:
private onFavoriteTap(): void {
if (!this.state.detail) return;
if (this.state.favored) {
this.favStore.removeFavorite('place', this.state.detail.id);
this.choosingFolder = false;
this.updateFavoriteState(false, '');
return;
}
this.choosingFolder = !this.choosingFolder;
}
添加收藏时使用类型 place:
private addFavToFolder(folderId: string): void {
if (!this.state.detail) return;
this.favStore.addToFolder('place', this.state.detail.id, this.state.detail.ancientName, folderId);
this.choosingFolder = false;
this.updateFavoriteState(true, folderId);
}
笔记也围绕 place 建草稿:
private openNote(): void {
if (!this.state.detail) return;
const t: FavoriteType = 'place';
let note = this.noteStore.getByTarget(t, this.state.detail.id);
if (!note) {
note = this.noteStore.newDraft(t, this.state.detail.id, this.state.detail.ancientName);
this.noteStore.upsert(note);
}
const p: NavigateParams = { noteId: note.id };
Navigator.push(AppRoutes.NOTE_DETAIL, p);
}
这和第五篇诗文详情页、第七篇朝代详情页的模式一致:不同内容类型共享同一套本地学习状态,只是 FavoriteType 不同。
| 内容类型 | 收藏类型 |
|---|---|
| 诗文 | `poem` |
| 朝代 | `dynasty` |
| 地名 | `place` |
| 事件 | 可继续扩展为 `event` |
统一收藏和笔记模型的价值在第十一篇会继续展开。
HistoryGeoData:地理内容包的特点
HistoryGeoData.ets 不是简单的城市列表,而是按朝代扩展地理名词。例如:
{
id: 'g_bianzhou',
ancientName: '汴州',
modernName: '河南开封',
region: '中原',
history: '五代后梁、后晋等政权都城所在地,也是北宋东京的前身,依托汴河漕运成为中原政治中心。',
dynastyIds: ['d_wudai', 'd_n_song'],
eventIds: ['e_chenqiao'],
poemIds: []
}
这条记录就把三个模块连在一起:
- 地名:汴州/开封
- 朝代:五代、北宋
- 事件:陈桥兵变
后续如果再补诗文 ID,它还可以连接到宋诗宋词和城市书写。
再比如:
{
id: 'g_baidicheng',
ancientName: '白帝城',
modernName: '重庆奉节白帝山',
region: '川东',
history: '三峡瞿塘峡口名城,刘备托孤故事使其成为三国记忆的重要地标,后世亦为唐宋诗文名胜。',
dynastyIds: ['d_sanguo', 'd_tang', 'd_n_song'],
eventIds: [],
poemIds: []
}
白帝城横跨三国记忆和唐宋诗文,这类地名最能体现“诗史汇”的价值:同一个空间,在不同朝代被不断重写。
为什么先做关系模型,而不是先上地图
很多人看到“古今地理”会第一时间想到地图,但当前项目没有先接地图组件,这是合理的。
原因有三点。
第一,诗史学习的核心不是导航,而是理解。用户不是要去长安怎么走,而是要知道“长安”在不同朝代和诗文中的意义。
第二,地名存在区域型表达。比如“九边重镇”“岭北行省”“通商口岸”“租界”都不是单点坐标,强行上地图容易产生误导。
第三,关系数据先稳定,地图只是展示升级。只要 GeoPlace 的 dynastyIds / eventIds / poemIds 稳定,未来可以新增 lat/lng、polygon、mapLevel,但不会破坏现有页面和文章体系。
当前实现的边界
第八篇也需要诚实记录当前实现边界。
第一,详情页跳诗文时只传 poemId,没有传 poemShard。如果目标诗文来自内容包,后续需要补 shard 查询或在 GeoPlace.poemIds 中使用更完整的引用结构。
第二,mergeText() 直接拼接文本时没有自动加标点或换行。如果两个文本都不包含对方,当前结果可能显得紧。后续可以改成段落数组或用分隔符合并。
第三,列表筛选只支持朝代,没有支持地区、关键词、诗文/事件关联状态。随着地名达到 119 处,后续需要增加搜索。
第四,地名详情目前没有统计记录。用户阅读地点也应进入学习统计,这可以和第十二篇统计闭环一起做。
第五,区域型地名没有区分展示类型。后续可以给 GeoPlace 增加 placeType: 'point' | 'region' | 'route' | 'system',让页面文案更准确。
本地验收命令
本篇同样使用真实模拟器截图:
git status --short
& "D:\Program Files\HuaWei\DevEco Studio\sdk\default\openharmony\toolchains\hdc.exe" list targets
& "D:\Program Files\HuaWei\DevEco Studio\sdk\default\openharmony\toolchains\hdc.exe" shell aa start -a EntryAbility -b com.example.app_project02
& "D:\Program Files\HuaWei\DevEco Studio\sdk\default\openharmony\toolchains\hdc.exe" shell snapshot_display -i 0 -f /data/local/tmp/guanzhi_08_geo.png -w 1080 -h 2400 -t png
& "D:\Program Files\HuaWei\DevEco Studio\sdk\default\openharmony\toolchains\hdc.exe" file recv /data/local/tmp/guanzhi_08_geo.png .\screenshots\08_geo_emulator.png
页面验收清单:
- 首页“古今地理”入口可以进入
GeoPage。 - 默认展示全部地名,并显示总数。
- 顶部朝代 chip 可以筛选地名。
- 列表卡片展示古名、今名、地区和历史摘要。
- 点击地名能进入详情页。
- 详情页能展示历史沿革、相关朝代、事件、诗文。
- 收藏和记笔记可以围绕
place类型工作。
常见问题复盘
1. 为什么地名列表里有“通商口岸”“九边重镇”这种非单点?
因为本项目不是地图导航,而是诗史学习。很多历史地理概念本来就是区域、制度或路线,不能强行压成一个点。先保留区域型地名,有助于后续更准确地表达历史空间。
2. 为什么同一个地名要关联多个朝代?
地名的意义会随朝代变化。长安在西周、秦汉、隋唐意义不同,汴梁在五代和北宋意义不同,金陵在六朝、明初和民国意义也不同。dynastyIds 让一个地名可以承载多层历史。
3. 为什么不直接在 GeoPlace 里保存朝代名和诗文标题?
因为那会导致数据重复。朝代名、事件标题、诗文标题应该由对应 service 提供,GeoPlace 保存 ID 就够了。这样改一个朝代名或诗文标题,不需要同步改地理数据。
4. 为什么从兴替明鉴进入古今地理时用 dynastyId 而不是 geoId?
因为用户此时关心的是“这个朝代有哪些地名”,不是某一个地点。传 dynastyId 可以直接打开筛选后的地名列表。
5. 为什么地理模块也要收藏和笔记?
因为地名是学习对象。用户可能想收藏“黄州”“汴梁”“白帝城”,也可能为某个地名记录诗文和事件关联。统一进入收藏笔记体系,才能形成长期学习闭环。
本章小结
第八篇的核心是:古今地理不是地图优先,而是关系优先。GeoPlace 用一条记录同时连接古名、今名、历史沿革、朝代、事件和诗文;GeoService 把早期 mock 与历史地理扩展包合并;GeoPage 通过同一个页面处理列表和详情两种状态。
这个模块和第七篇兴替明鉴互相补位:兴替明鉴解释一个朝代为什么兴衰,古今地理解释这个朝代发生在什么空间。
下一篇会继续往“诗”和“史”的中间层走:文脉纵览如何按朝代聚合作者和作品,当前实现为什么还只是最小可用,以及“体裁源流、流派演变、思潮脉络”应该如何从设计文档走向结构化内容包。
更多推荐



所有评论(0)