【观止·诗史汇 HarmonyOS 实战系列 09】文脉纵览:体裁源流、流派演变与思潮脉络如何接入 App
【观止·诗史汇 HarmonyOS 实战系列 09】文脉纵览:体裁源流、流派演变与思潮脉络如何接入 App
前八篇分别走过了《观止·诗史汇》的工程骨架、首页、诗文内容包、诗文详情页、时间轴、兴替明鉴和古今地理。到这里,应用已经有了两种学习路径:一条是从诗文出发,读正文、译注、简析、作者;另一条是从历史出发,看朝代、事件、兴衰和地名。
但诗和史之间还缺一个“解释层”。
用户读到杜甫,不只是要知道《登高》的译文;还要知道安史之乱后士人精神如何变化。用户读到苏轼,不只是要知道黄州在哪里;还要知道宋代士大夫政治、词体发展和贬谪文学如何交织。用户读到《诗经》、楚辞、汉赋、唐诗、宋词、元曲、明清小说,也需要知道这些体裁为什么在那个时代成熟。
这就是第九篇要讲的“文脉纵览”。它不是诗文列表的换皮,也不是作者百科,而是要在应用中承接三条线:体裁源流、流派演变、思潮脉络。
当前项目已经实现了 LiteraturePage 的最小可用版本:按朝代分组,聚合内容包作者和作品,展示代表作者、代表作品,并跳转到诗文详情和兴替明鉴。与此同时,doc/文脉纵览设计逻辑.md 明确指出:真正的文脉内容包还没有完全落地,后续需要把“体裁源流、流派演变、思潮脉络”结构化。

上图来自本机 DevEco 模拟器的“文脉纵览”模块。第九篇的截图展示真实 LiteraturePage:顶部朝代筛选、先秦分组、文化模块入口、代表作者卡片。它不是首页截图,也不是诗文详情页截图。
本篇要解决什么问题
文脉纵览当前要解决两个层次的问题。
第一层是已经实现的工程问题:
| 问题 | 当前实现 |
|---|---|
| 文脉按什么组织 | 按朝代/时期分组:先秦、两汉、魏晋、南北朝、隋、唐、宋等 |
| 作者数据从哪里来 | `AuthorService` + `PoemPackRepo.listAuthors()` |
| 作品数据从哪里来 | `PoemService` + `PoemPackRepo.listAllBriefs()` |
| 如何避免重复 | 用 `Map` 和 `Set` 做作者、作品去重 |
| 如何跳转 | 作品跳 `POEM_DETAIL`,文化模块跳 `DYNASTY_INSIGHT` |
第二层是设计文档提出但还需要继续落地的问题:
| 目标 | 后续建议 |
|---|---|
| 体裁源流 | 新增 `LiteratureContext.genreLine` |
| 流派演变 | 新增 `LiteratureContext.schoolLine` |
| 思潮脉络 | 新增 `LiteratureContext.thoughtLine` |
| 事件/地理联动 | 增加 `eventIds`、`placeIds` |
| 学习闭环 | 记录文脉阅读行为,接入统计 |
这篇文章会把这两层都讲清楚:当前源码实现了什么,设计文档希望它继续长成什么。
源码对象总览
| 文件 | 职责 |
|---|---|
| `features/src/main/ets/literature/LiteraturePage.ets` | 文脉纵览页面,按朝代聚合作者和作品 |
| `doc/文脉纵览设计逻辑.md` | 文脉模块设计说明,定义体裁源流、流派演变、思潮脉络 |
| `features/src/main/ets/poem/PoemPackRepo.ets` | 诗文内容包仓储,提供作者和诗文索引 |
| `features/src/main/ets/services/DynastyService.ets` | 朝代数据,提供时期分组和文化模块跳转目标 |
| `features/src/main/ets/services/AuthorService.ets` | mock 作者数据,补充别号、生卒、成就、代表作 |
| `features/src/main/ets/services/PoemService.ets` | mock 诗文数据,用于与内容包互补 |
| `commons/src/main/ets/router/RouteNames.ets` | `LITERATURE`、`POEM_DETAIL`、`DYNASTY_INSIGHT` 等路由常量 |
第九篇不只是拆页面,还会结合设计文档说明:为什么当前实现是“骨架”,真正的文脉内容应该继续内容包化。
当前页面状态很轻
LiteraturePage 的状态只有两个字段:
interface LitState {
loading: boolean;
groups: DynastyGroup[];
}
@State state: LitState = { loading: true, groups: [] };
@State selectedDynasty: string = ALL_ID;
页面没有单独维护作者列表、作品列表、内容包列表、筛选结果等一堆状态,而是把所有数据装配到 DynastyGroup[] 里。
这说明页面的核心数据单位不是“作者”,也不是“诗文”,而是“一个文脉时期”。
DynastyGroup:文脉页的展示模型
DynastyGroup 是当前文脉页最关键的中间模型:
interface DynastyGroup {
id: string;
label: string;
fullName: string;
startYear: number;
endYear: number;
brief: string;
routeDynastyId: string;
representativeNames: string[];
representativeWorks: string[];
authors: AuthorRow[];
poems: PoemRow[];
}
它比 Dynasty 多了几个文脉页专用字段:
| 字段 | 作用 |
|---|---|
| `label` | 页面筛选 chip 和朝代标题,如“先秦”“两汉”“唐”“宋” |
| `routeDynastyId` | 点击“文化模块”时跳转到兴替明鉴 |
| `representativeNames` | 代表作者筛选依据 |
| `representativeWorks` | 代表作品筛选依据 |
| `authors` | 当前时期可展示作者 |
| `poems` | 当前时期可展示作品 |
这一步是文脉页最重要的工程取舍:页面没有直接渲染原始 Dynasty、Author、Poem、PoemBrief,而是先装成自己的展示模型。这样后续加入 LiteratureContext 时,也可以继续装配到 DynastyGroup 或新的 LiteratureGroup 里。
aboutToAppear:同时读取 mock 和内容包
文脉页进入时会读取四类数据:
async aboutToAppear() {
const dyn: Dynasty[] = await this.dynastySvc.list();
const mockAuthors: Author[] = await this.authorSvc.list();
const mockPoems: Poem[] = await this.poemSvc.list();
let packAuthors: PoemAuthor[] = [];
let packBriefs: PoemBrief[] = [];
try {
const repo: PoemPackRepo = PoemPackRepo.instance();
await repo.ensureReady();
packAuthors = repo.listAuthors();
packBriefs = await repo.listAllBriefs();
} catch (e) {
packAuthors = [];
packBriefs = [];
}
this.state = {
loading: false,
groups: this.buildGroups(dyn, mockAuthors, mockPoems, packAuthors, packBriefs)
};
}
这里的 try/catch 很关键。内容包读取失败时,页面不会直接崩掉,而是退回空内容包,继续用 mock 数据构建页面。
这和前几篇的本地优先原则一致:内容包是能力增强,不应该让页面失去基本可用性。
periodGroups:文脉不是逐朝代等价拆分
文脉页不是简单把所有朝代按时间线逐个展示,而是做了文学史分组:
private periodGroups(dyn: Dynasty[]): DynastyGroup[] {
return [
this.period('lit_xianqin', '先秦', -1046, -221, '先秦时期', 'd_chunqiu',
'诗经、楚辞与诸子散文共同奠定中国文学和思想表达的源头。',
this.representativeNamesFromDynasties(dyn, ['d_zhou', 'd_chunqiu', 'd_zhanguo', 'd_qin'])),
this.period('lit_lianghan', '两汉', -202, 220, '两汉', 'd_west_han',
'汉赋、乐府、史传文学兴盛,铺开大一统时代的文体格局。',
this.representativeNamesFromDynasties(dyn, ['d_west_han', 'd_east_han'])),
this.period('lit_weijin', '魏晋', 220, 420, '魏晋时期', 'd_jin',
'建安风骨、玄言清谈、田园诗和山水意识在动荡中舒展。',
this.representativeNamesFromDynasties(dyn, ['d_sanguo', 'd_jin'])),
this.periodFromDynasty(dyn, 'd_tang', 'lit_tang', '唐'),
this.period('lit_song', '宋', 960, 1279, '宋代', 'd_n_song',
'宋诗、宋词、古文与理学语境并进,士大夫文学高度成熟。',
this.representativeNamesFromDynasties(dyn, ['d_n_song', 'd_s_song']))
];
}
这很符合文学史语境。先秦、两汉、魏晋、南北朝、宋,往往不是单一王朝可以完整概括的文学时期。
例如:
- 先秦包含西周、春秋、战国和秦。
- 两汉包含西汉和东汉。
- 魏晋连接三国和晋。
- 宋代包含北宋和南宋。
如果直接按朝代逐个展示,就很难表达“汉赋和乐府”“魏晋风度”“宋词和士大夫文化”这种跨朝代文学脉络。
代表作者:从朝代 famousFigures 中提取候选
每个 DynastyGroup 会根据相关朝代的 famousFigures 生成代表人物候选:
private representativeNamesFromDynasties(dyn: Dynasty[], ids: string[]): string[] {
const out: string[] = [];
for (let i = 0; i < ids.length; i++) {
const d: Dynasty | undefined = dyn.find((it: Dynasty) => it.id === ids[i]);
if (!d || !d.famousFigures) {
continue;
}
for (let j = 0; j < d.famousFigures.length; j++) {
const name: string = d.famousFigures[j];
if (out.indexOf(name) < 0) {
out.push(name);
}
}
}
return out;
}
这体现了第七篇和第九篇的关联:兴替明鉴/朝代数据中的著名人物,可以成为文脉纵览筛选代表作者的依据。
但这也有边界。famousFigures 不一定都是文学人物,比如政治家、军事家、科学家也可能出现在朝代名人里。当前页面会再根据内容包作者进行匹配,最终展示与诗文内容相关的人。
buildGroups:把作者和作品装进时期分组
buildGroups() 是文脉页的数据装配中心。它先创建时期组和去重容器:
const groups: DynastyGroup[] = this.periodGroups(dyn);
const groupMap: Map<string, DynastyGroup> = new Map<string, DynastyGroup>();
const authorIds: Map<string, Set<string>> = new Map<string, Set<string>>();
const poemKeys: Map<string, Set<string>> = new Map<string, Set<string>>();
const authorWorks: Map<string, string[]> = this.buildAuthorWorks(packBriefs);
然后把内容包作者放进对应时期:
for (let i = 0; i < packAuthors.length; i++) {
const a: PoemAuthor = packAuthors[i];
const groupId: string = this.periodIdFromPackDynasty(a.dynasty);
const g: DynastyGroup | undefined = groupMap.get(groupId);
const ids: Set<string> | undefined = authorIds.get(groupId);
if (!g || !ids || ids.has(a.id)) {
continue;
}
const mock: Author | null = this.matchMockAuthor(mockAuthors, a.name, groupId);
g.authors.push(this.packAuthorRow(a, mock, authorWorks.get(this.authorKey(a.dynasty, a.name)) ?? []));
ids.add(a.id);
}
这段代码做了两件事:
第一,用内容包作者的 dynasty 字段映射到文脉时期。
第二,如果 mock 作者里有同名同组数据,就把别号、生卒、成就、代表作补进去。
这让内容包和 mock 数据互补,而不是互相覆盖。
内容包作品:按 poemId 去重
内容包作品使用 PoemBrief:
for (let i = 0; i < packBriefs.length; i++) {
const b: PoemBrief = packBriefs[i];
const groupId: string = this.periodIdFromPackDynasty(b.dynasty);
const g: DynastyGroup | undefined = groupMap.get(groupId);
const keys: Set<string> | undefined = poemKeys.get(groupId);
if (!g || !keys || keys.has(b.poemId)) {
continue;
}
g.poems.push({
id: b.poemId,
title: b.title,
subtitle: '',
authorId: '',
authorName: b.author,
firstLine: b.firstLine,
shard: b.shard
});
keys.add(b.poemId);
keys.add(this.poemKey(b.title, b.author));
}
这里保存 shard 很重要。点击作品时,文脉页可以带着 poemId + poemShard 进入诗文详情:
const params: NavigateParams = { poemId: p.id, poemShard: p.shard };
Navigator.push(AppRoutes.POEM_DETAIL, params);
这比第八篇地理详情中的 poemId 单参更完整,和第五篇诗文详情页的参数要求对齐。
mock 作品补充:避免内容包缺口
页面还会把 mock 诗文补进来:
for (let i = 0; i < mockPoems.length; i++) {
const p: Poem = mockPoems[i];
const groupId: string = this.periodIdFromDynastyId(p.dynastyId);
const g: DynastyGroup | undefined = groupMap.get(groupId);
const keys: Set<string> | undefined = poemKeys.get(groupId);
const author: Author | undefined = mockAuthors.find((a: Author) => a.id === p.authorId);
const authorName: string = author ? author.name : '';
if (!g || !keys || keys.has(this.poemKey(p.title, authorName))) {
continue;
}
const packed: PoemBrief | null = this.matchPoemBrief(packBriefs, p, authorName);
g.poems.push({
id: packed ? packed.poemId : p.id,
title: p.title,
subtitle: p.subtitle,
authorId: p.authorId,
authorName,
firstLine: packed ? packed.firstLine : (p.body.length > 0 ? p.body[0] : ''),
shard: packed ? packed.shard : 0
});
keys.add(this.poemKey(p.title, authorName));
}
如果 mock 诗文能在内容包中匹配到,就优先使用内容包的 poemId 和 shard。这样后续点击仍然能进入更完整的内容包详情页。
筛选条:按文脉时期过滤
页面顶部筛选条不是直接用朝代服务中的所有朝代,而是用 groups:
@Builder
DynastyFilter() {
Scroll() {
Row({ space: AppDimens.spaceSm }) {
this.FilterChip(ALL_ID, '全部')
ForEach(this.state.groups, (g: DynastyGroup) => {
this.FilterChip(g.id, g.label)
}, (g: DynastyGroup) => g.id)
}
.padding({ left: AppDimens.pagePadding, right: AppDimens.pagePadding })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.height(48)
.width('100%')
}
筛选也只筛 DynastyGroup:
private filteredGroups(): DynastyGroup[] {
if (this.selectedDynasty === ALL_ID) {
return this.state.groups;
}
return this.state.groups.filter((g: DynastyGroup) => g.id === this.selectedDynasty);
}
这意味着用户选择的是“文脉时期”,不是严格行政朝代。截图里的“先秦、两汉、魏晋、南北朝、隋”就是这个设计的结果。
页面渲染:组标题、作者、作品三段
文脉页先把分组拆成 LitListItem[]:
private listItems(): LitListItem[] {
const out: LitListItem[] = [];
const groups: DynastyGroup[] = this.filteredGroups();
for (let i = 0; i < groups.length; i++) {
const g: DynastyGroup = groups[i];
out.push({
key: `${g.id}_group`,
itemType: 'group',
group: g,
author: null,
poem: null,
title: '',
count: ''
});
const authors: AuthorRow[] = this.displayAuthors(g);
const poems: PoemRow[] = this.displayPoems(g);
}
return out;
}
LitItemView 再按类型分发:
@Builder
LitItemView(it: LitListItem) {
if (it.itemType === 'group' && it.group !== null) {
this.DynastyHeader(it.group)
} else if (it.itemType === 'section') {
this.SectionTitle(it.title, it.count)
} else if (it.itemType === 'author' && it.author !== null) {
this.AuthorCard(it.author)
} else if (it.itemType === 'poem' && it.poem !== null) {
this.PoemRowCard(it.poem)
}
}
这种做法让 List 只渲染一种 item 数据,而不是在 ArkUI 里嵌套过深的 ForEach。对长列表来说,这会更清晰。
文化模块入口:文脉回到兴替明鉴
每个时期标题里都有“文化模块”入口:
Text('文化模块 →')
.fontSize(AppText.bodySm).fontColor(AppColors.accent)
.onClick(() => {
const p: NavigateParams = { dynastyId: g.routeDynastyId };
Navigator.push(AppRoutes.DYNASTY_INSIGHT, p);
})
这正好把第九篇和第七篇连起来。
文脉纵览讲一个时期的文学作者和作品;兴替明鉴讲这个朝代或时期背后的政治、经济、文化、科技和对外环境。用户可以从文学回到历史解释,也可以从历史解释继续进入地理和事件。
设计文档中的三条文脉
doc/文脉纵览设计逻辑.md 对文脉模块的定位非常明确:
- 体裁源流:文学形式如何出现、成熟、转化。
- 流派演变:作者群体、审美风格和创作方向如何变化。
- 思潮脉络:文学背后的思想、制度、社会心理。
文档中给出了推荐模型:
export interface LiteratureContext {
dynastyId: string;
keywords: string[];
genreLine: string;
schoolLine: string;
thoughtLine: string;
summary: string;
authorIds: string[];
poemIds: string[];
eventIds: string[];
placeIds: string[];
}
这个模型正好弥补当前 LiteraturePage 的不足。
当前页面能回答“这一时期有哪些代表作者和作品”,但还不能完整回答:
- 这一时期的体裁为什么这样发展?
- 这一时期有哪些流派和审美风格?
- 这些文学现象背后的政治、思想、地理和事件是什么?
所以第九篇的工程结论很明确:LiteraturePage 已经完成最小可用的聚合页面,但文脉纵览要真正成立,还需要 LiteratureContextData 和 LiteratureContextService。
建议的下一步实现方式
后续不要把大量文学史文案直接写进 LiteraturePage.ets。更合适的做法是新增内容包:
export const LITERATURE_CONTEXTS: LiteratureContext[] = [
{
dynastyId: 'lit_xianqin',
keywords: ['诗经', '楚辞', '百家争鸣', '士阶层'],
genreLine: '四言诗、楚辞、诸子散文共同构成先秦文学的基本形态。',
schoolLine: '儒、道、墨、法等思想表达形成不同文风。',
thoughtLine: '礼崩乐坏和诸侯争霸推动士人游说、辩论与著述传统兴盛。',
summary: '先秦是中国文学和思想表达的源头时期。',
authorIds: [],
poemIds: [],
eventIds: [],
placeIds: []
}
];
然后新增服务:
export class LiteratureContextService {
list(): Promise<LiteratureContext[]> {
return Promise.resolve(LITERATURE_CONTEXTS);
}
getByDynastyId(id: string): Promise<LiteratureContext | null> {
const hit = LITERATURE_CONTEXTS.find((it) => it.dynastyId === id);
return Promise.resolve(hit ?? null);
}
}
页面只消费服务返回的结构化内容,不直接写长文案。
为什么不把文脉字段塞进 Poem
这是设计文档特别强调的点:不要污染 Poem 模型。
原因很简单。Poem 表达的是一篇作品,它应该关心标题、作者、朝代、正文、译注、赏析、主题等信息。文脉表达的是一个时期的文学生态,它应该关心体裁、流派、思潮、事件和地理。
如果把 genreLine、schoolLine、thoughtLine 之类字段塞进每一首诗,结果会有两个问题:
- 大量重复,同一时期的文脉解释会在许多诗里复制。
- 难以维护,改一段文学史说明要改很多条诗文记录。
更好的方式是:Poem 保持单篇作品模型,LiteratureContext 作为时期级内容包,通过 poemIds、authorIds、eventIds、placeIds 建立关系。
与第七篇、第八篇的关系
文脉纵览不是孤立模块,它应该和前两篇拆过的模块形成三角关系:
| 模块 | 负责什么 |
|---|---|
| 兴替明鉴 | 一个朝代为什么兴衰,文化模块处在政治经济背景中 |
| 古今地理 | 一个地名如何连接朝代、事件和诗文 |
| 文脉纵览 | 一个时期的文学形式、作者作品和思想风格如何发展 |
比如唐代:
- 兴替明鉴解释唐代政治、经济、文化和对外交流。
- 古今地理解释长安、陇右、安西四镇等空间。
- 文脉纵览解释唐诗、边塞诗、山水田园诗、浪漫主义和现实主义传统。
三者合起来,用户才不会只是在背诗句,而是在理解一个时代。
当前实现的边界
第九篇必须把边界讲清楚。
第一,页面副标题是“朝代·作者·作品”,而首页入口文案是“体裁源流、流派演变、思潮脉络”。这说明当前实现还停留在作者作品聚合阶段,三条文脉还没有完全可视化。
第二,periodGroups() 里已经写了一些时期 brief,但它只是简述,不等于完整文脉内容。后续需要把 brief 升级为结构化 LiteratureContext。
第三,当前代表作者依赖 famousFigures 和内容包作者匹配,可能漏掉重要文学群体。例如“初唐四杰”“边塞诗派”“豪放词派”这种群体不一定能由个人列表自然表达。
第四,当前页面没有展示关联事件和地理。设计文档中提到安史之乱、靖康之变、长安、洛阳、黄州、岳阳楼等都应该进入文脉解释,但当前页面还没有这一层。
第五,当前文脉页没有统计记录。用户阅读某个文脉块、点击某个文化模块或作品,后续都可以进入学习统计。
本地验收命令
本篇截图来自真实模拟器:
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_09_literature.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_09_literature.png .\screenshots\09_literature_emulator.png
页面验收清单:
- 首页下滑后点击“文脉纵览”可以进入
LiteraturePage。 - 顶部显示先秦、两汉、魏晋等文脉时期筛选。
- 默认展示“全部”文脉分组。
- 每个分组展示时期名、年代、简述和“文化模块”入口。
- 作者卡片展示姓名、简介、相关作品。
- 作品卡片可以带
poemId + poemShard进入诗文详情。 - 文化模块入口能带
dynastyId进入兴替明鉴。
常见问题复盘
1. 为什么文脉分组不是严格朝代?
文学史不总是按王朝边界发展。先秦、两汉、魏晋、宋代都可能跨越多个朝代或政权。文脉页按文学时期分组,比逐个朝代更符合用户理解。
2. 为什么当前页面还没有直接展示体裁源流、流派演变、思潮脉络?
因为当前实现先完成了最小可用的作者作品聚合。设计文档已经明确下一步要做结构化文脉内容包。把这一点写清楚,比在文章里把规划当成已完成更负责。
3. 为什么要同时读 mock 和内容包?
mock 数据提供作者别号、生卒、成就等补充信息,内容包提供更完整的诗文索引。两者合并后,页面比只用其中一个更丰富。
4. 为什么作品跳转要带 poemShard?
第五篇讲过诗文详情页依赖 poemId + poemShard 精准读取内容包详情。文脉页保存 shard 后,点击作品可以直接进入完整详情,而不是只靠标题或全局状态猜。
5. 为什么不把文脉内容写死在页面里?
因为文脉内容会持续扩展。写死在页面里会让 LiteraturePage.ets 变成文案仓库。结构化内容包加 service 才是长期可维护的方式。
本章小结
第九篇的核心是:文脉纵览已经完成了“按文学时期聚合作者和作品”的最小可用实现,但它真正的目标是成为诗文学习和历史理解之间的文化解释层。
当前 LiteraturePage 的价值在于:
- 通过
DynastyGroup建立文脉时期分组。 - 合并 mock 作者、mock 诗文和内容包作者/作品。
- 用
poemId + poemShard打通诗文详情。 - 用
routeDynastyId打通兴替明鉴文化模块。
下一步最值得做的是新增 LiteratureContextData 和 LiteratureContextService,把体裁源流、流派演变、思潮脉络真正结构化。这样文脉纵览才会从“作者作品列表”升级为“诗史之间的解释层”。
下一篇会进入练习模块:文试默写如何从诗词内容包动态生成默写、填空、连诗和史事练习,让阅读内容真正变成可训练、可反馈的学习行为。
更多推荐

所有评论(0)