【观止·诗史汇 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` 当前时期可展示作品

这一步是文脉页最重要的工程取舍:页面没有直接渲染原始 DynastyAuthorPoemPoemBrief,而是先装成自己的展示模型。这样后续加入 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 诗文能在内容包中匹配到,就优先使用内容包的 poemIdshard。这样后续点击仍然能进入更完整的内容包详情页。

筛选条:按文脉时期过滤

页面顶部筛选条不是直接用朝代服务中的所有朝代,而是用 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 已经完成最小可用的聚合页面,但文脉纵览要真正成立,还需要 LiteratureContextDataLiteratureContextService

建议的下一步实现方式

后续不要把大量文学史文案直接写进 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 表达的是一篇作品,它应该关心标题、作者、朝代、正文、译注、赏析、主题等信息。文脉表达的是一个时期的文学生态,它应该关心体裁、流派、思潮、事件和地理。

如果把 genreLineschoolLinethoughtLine 之类字段塞进每一首诗,结果会有两个问题:

  • 大量重复,同一时期的文脉解释会在许多诗里复制。
  • 难以维护,改一段文学史说明要改很多条诗文记录。

更好的方式是:Poem 保持单篇作品模型,LiteratureContext 作为时期级内容包,通过 poemIdsauthorIdseventIdsplaceIds 建立关系。

与第七篇、第八篇的关系

文脉纵览不是孤立模块,它应该和前两篇拆过的模块形成三角关系:

模块 负责什么
兴替明鉴 一个朝代为什么兴衰,文化模块处在政治经济背景中
古今地理 一个地名如何连接朝代、事件和诗文
文脉纵览 一个时期的文学形式、作者作品和思想风格如何发展

比如唐代:

  • 兴替明鉴解释唐代政治、经济、文化和对外交流。
  • 古今地理解释长安、陇右、安西四镇等空间。
  • 文脉纵览解释唐诗、边塞诗、山水田园诗、浪漫主义和现实主义传统。

三者合起来,用户才不会只是在背诗句,而是在理解一个时代。

当前实现的边界

第九篇必须把边界讲清楚。

第一,页面副标题是“朝代·作者·作品”,而首页入口文案是“体裁源流、流派演变、思潮脉络”。这说明当前实现还停留在作者作品聚合阶段,三条文脉还没有完全可视化。

第二,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 打通兴替明鉴文化模块。

下一步最值得做的是新增 LiteratureContextDataLiteratureContextService,把体裁源流、流派演变、思潮脉络真正结构化。这样文脉纵览才会从“作者作品列表”升级为“诗史之间的解释层”。

下一篇会进入练习模块:文试默写如何从诗词内容包动态生成默写、填空、连诗和史事练习,让阅读内容真正变成可训练、可反馈的学习行为。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐