系列背景:这个系列记录一个本地优先的三国历史知识 App 从工程骨架、主题、内容模型、听书、地图、资源目录到发布体验的完整实现。第 16 篇处理了首页搜索,本篇继续向内容层推进:专题文章不只是静态阅读页,它还应该能收藏、记笔记、加入听书,并和人物、事件共享一套可维护的数据语义。

一、真实工程问题:文章页不能只是一个列表

在历史知识类 App 里,人物、事件、地图和专题文章看起来是不同入口,但用户真正使用时并不会按代码模块来理解内容。他可能先读诸葛亮人物页,再收藏赤壁之战事件,然后在专题文章里听一篇“三分天下为何形成”。如果每类内容各自维护一套收藏、笔记和播放逻辑,后续扩展会越来越脆。

当前项目的目标不是做一个联网 CMS,而是做一个离线可用、内容稳定、可长时间维护的 HarmonyOS NEXT App。因此内容模型需要满足几个边界:

目标 具体要求 不这么做的风险
离线可读 专题文章在本地静态目录中可直接展示 依赖网络后会破坏无账号、无服务器基线
可收藏 文章和人物、事件共用收藏结构 收藏页需要为每种内容写分支页面
可记笔记 用户随读札记能挂到文章 id 上 笔记无法反向打开原文
可听 文章能进入 TTS 播放列表 听书页只能听人物传记,内容价值被限制
可扩展 后续可继续新增专题,不改页面主流程 每加一篇文章都要改业务逻辑

这个问题的关键不是“加一个文章数组”这么简单,而是要给所有内容对象一套共同定位方式。项目里最终选择的是 targetId + targetTypetargetId 指向具体内容对象,targetType 说明它属于 personeventmap 还是 article

二、源码对象总览:四类记录如何协作

本篇主要涉及三个文件:

  • library1/src/main/ets/models/RecordsModels.ets
  • library2/src/main/ets/data/MockRecords.ets
  • library2/src/main/ets/pages/MainFrame.ets

从对象职责看,文章系统并不是孤立的一张表,而是和收藏、笔记、音频模板形成协作。

对象 位置 职责
ArticleRecord RecordsModels.ets 专题文章元数据、摘要、标签和正文段落
AudioRecord RecordsModels.ets 可播放条目的 id、targetId、标题和进度
FavoriteRecord RecordsModels.ets 用户收藏,统一挂载到 targetId
NoteRecord RecordsModels.ets 用户笔记,统一挂载到 targetId
MockRecords.articles() MockRecords.ets 本地专题文章静态目录
MockRecords.audios() MockRecords.ets 可听内容模板,包括文章音频
MainFrame.ets 页面主控 列表、详情、收藏、札记、听书入口和播放队列

2.1 ArticleRecord 的最小可用字段

ArticleRecord 没有把正文拆成复杂富文本结构,而是使用 sections: string[]。这很朴素,但符合当前 App 的边界:本地静态内容、纯文字 TTS、无需服务端排版。

export class ArticleRecord {
  id: string;
  title: string;
  category: string;
  summary: string;
  readTime: string;
  tags: string[];
  sections: string[];

  constructor(
    id: string,
    title: string,
    category: string,
    summary: string,
    readTime: string,
    tags: string[],
    sections: string[]
  ) {
    this.id = id;
    this.title = title;
    this.category = category;
    this.summary = summary;
    this.readTime = readTime;
    this.tags = tags;
    this.sections = sections;
  }
}

这里没有引入 contentHtml,也没有引入远程 markdown 解析。原因很直接:当前产品形态需要稳定、可离线、可朗读。sections 对页面展示和 TTS 拼接都足够友好。

2.2 AudioRecord 通过 targetId 指向原始内容

听书目录里真正关键的字段不是 id,而是 targetIdid 是音频记录自身的唯一标识,targetId 则指向人物、事件或文章。

export class AudioRecord {
  id: string;
  targetId: string;
  title: string;
  durationText: string;
  durationSeconds: number;
  listenedSeconds: number;

  constructor(
    id: string,
    targetId: string,
    title: string,
    durationText: string,
    durationSeconds: number,
    listenedSeconds: number
  ) {
    this.id = id;
    this.targetId = targetId;
    this.title = title;
    this.durationText = durationText;
    this.durationSeconds = durationSeconds;
    this.listenedSeconds = listenedSeconds;
  }
}

这让文章加入听书目录时不需要新增 ArticleAudioRecord。只要音频模板的 targetId 等于文章 id,播放页就能在 audioTextFor 里找到原始文章并生成朗读文本。

三、静态目录:MockRecords 里如何放专题文章

MockRecords.articles() 是专题目录的来源。当前已有三篇示例文章,覆盖格局解析、战役解析和人物方法。

static articles(): ArticleRecord[] {
  return [
    new ArticleRecord(
      'article_three_powers',
      '三分天下为何能够形成',
      '格局解析',
      '从地理、豪强、人口与军事资源看魏蜀吴长期对峙的基础。',
      '约 8 分钟',
      ['魏蜀吴', '赤壁', '战略'],
      [
        '三分天下不是一场战役之后自然出现的结果,而是长期资源分布、政治整合和军事边界共同作用的局面。',
        '曹操统一北方后拥有最完整的人口与屯田基础,但南方水网、地方士族和远距离补给使得继续南下并不容易。',
        '刘备集团在荆益之间寻找立足点,孙权集团则依托江东水军与地方经营,三方都拥有可以防守的纵深。',
        '理解三国格局,要把人物选择放回地理和制度条件之中。'
      ]
    )
  ];
}

我比较喜欢这个写法的一点是:它把文章看作“内容对象”,而不是“页面专属变量”。只要文章有稳定 id,后面的收藏、笔记、听书都可以复用同一套定位机制。

3.1 文章音频模板放在 audios()

文章要能听,除了文章目录本身,还需要在音频模板里声明对应条目。

static audios(): AudioRecord[] {
  return [
    new AudioRecord('audio_three_powers', 'article_three_powers', '三分天下为何形成', '按内容估算', 0, 0),
    new AudioRecord('audio_chibi_logic', 'article_chibi_logic', '赤壁之战的胜负逻辑', '按内容估算', 0, 0),
    new AudioRecord('audio_heroes_reading', 'article_heroes_reading', '读三国人物要看什么', '按内容估算', 0, 0)
  ];
}

这里的工程边界非常重要:MockRecords.audios() 是静态模板目录,不等于用户当前播放队列。模板解决“有哪些内容可以听”,用户队列解决“用户现在正在听哪些、听到哪里”。如果把两者混成一个数组,重置列表、恢复进度、模板新增都会互相影响。

四、页面入口:文章列表和详情如何不破坏主框架

MainFrame.ets 里通过 articles()selectedArticle() 把静态目录接入页面状态。

private articles(): ArticleRecord[] {
  return MockRecords.articles();
}

private selectedArticle(): ArticleRecord {
  return this.articles().find((item: ArticleRecord) => item.id === this.selectedArticleId) ?? this.articles()[0];
}

这里有一个细节:selectedArticle() 用 fallback 返回第一篇文章。这样即使状态恢复时 selectedArticleId 已经过期,页面也不会崩掉。对本地内容 App 来说,后续增删静态目录是常见维护动作,fallback 能减少空对象异常。

文章目录与首页入口

4.1 ForEach 必须用稳定 id

专题文章列表使用 ArticleRecord.id 作为 ForEach key,这一点和前面搜索、人物卡片的经验一致:内容项有稳定业务 id 时,不要用数组下标当 key。

ForEach(
  this.visibleArticles(),
  (item: ArticleRecord) => {
    this.articleRow(item);
  },
  (item: ArticleRecord) => item.id
)

如果后面加入搜索、分类筛选或排序,用下标做 key 很容易让 ArkUI 复用错旧组件状态。文章这种会进入详情页、收藏页和听书页的内容,更应该从一开始就把 id 设计稳。

五、详情页:收藏、札记、听这篇如何围绕同一个 articleId

文章详情页最有价值的地方,不是展示标题和正文,而是把三个行为接到同一个 articleId 上:

  • 点击收藏:创建 FavoriteRecordtargetType = 'article'
  • 添加札记:创建 NoteRecordtargetType = 'article'
  • 听这篇:根据 article.id 查找或插入音频条目
this.addFavorite(
  this.selectedArticle().id,
  'article',
  this.selectedArticle().title,
  this.selectedArticle().summary
)
this.addQuickNote(
  this.selectedArticle().id,
  'article',
  this.selectedArticle().title,
  this.selectedArticle().summary
)
Button('听这篇')
  .onClick(() => {
    this.playAudioByTarget(this.selectedArticle().id);
  })

这三个行为看起来简单,但它们决定了内容模型是否能闭环。如果收藏只存标题,笔记只存页面路径,听书只存音频 id,后面从收藏页反向打开文章时就会很麻烦。现在统一保存 targetId,所有入口都能回到同一个内容对象。

六、听书文本:audioTextFor 怎样识别人、事件和文章

听书页拿到的是 AudioRecord,它不知道自己对应的是人物、事件还是文章。MainFrame.ets 里通过 audioTextFor(item)targetId 查找不同内容目录。

private audioTextFor(item: AudioRecord): string {
  const people = this.people().filter((person: Person) => person.id === item.targetId);
  if (people.length > 0) {
    return this.personAudioText(people[0]);
  }

  const events = this.events().filter((event: HistoryEvent) => event.id === item.targetId);
  if (events.length > 0) {
    return this.eventAudioText(events[0]);
  }

  const articles = this.articles().filter((article: ArticleRecord) => article.id === item.targetId);
  if (articles.length > 0) {
    return this.articleAudioText(articles[0]);
  }

  return item.title;
}

文章朗读文本的拼接也保持克制:标题、摘要、正文段落依次拼起来。

private articleAudioText(article: ArticleRecord): string {
  return article.title + '。' + article.summary + '\n\n' + article.sections.join('\n\n');
}

这套实现有一个好处:TTS 层不需要知道 ArticleRecord 的 UI 结构,也不需要拿页面组件文本。朗读文本来自数据模型,页面只是消费模型。后续如果文章详情页增加标签、插图或推荐阅读,也不会污染 TTS 文本。

文章进入听书播放队列

七、模板目录和用户播放队列必须分开

这一点是本篇最容易踩坑的地方。MockRecords.audios() 是模板目录,audioRecords 是用户播放队列。模板目录是静态的,用户队列是运行时状态。

private audioTemplateByTarget(targetId: string): AudioRecord | null {
  const templates = this.audioCatalog().filter((item: AudioRecord) => item.targetId === targetId);
  return templates.length > 0 ? templates[0] : null;
}

private ensureAudioInListByTarget(targetId: string): AudioRecord | null {
  const exists = this.audioRecords.filter((item: AudioRecord) => item.targetId === targetId);
  if (exists.length > 0) {
    return exists[0];
  }

  const template = this.audioTemplateByTarget(targetId);
  if (template === null) {
    return null;
  }

  this.audioRecords = [template, ...this.audioRecords];
  return template;
}

这个函数的职责很清楚:

  1. 用户队列里已有该内容,就复用已有条目。
  2. 用户队列里没有,就从模板目录查。
  3. 模板目录也没有,说明该内容暂时不可听。
  4. 查到模板后插入用户队列,并返回给播放逻辑。

这样做的好处是“可听能力”和“正在听的列表”分层。新增一篇可听文章只需要扩充模板,不会强制打乱用户已经维护的播放列表。

八、从收藏页反向打开文章

收藏页不是只展示收藏文本,还要能反向打开原对象。openFavorite 里针对 targetType === 'article' 设置文章详情状态。

private openFavorite(item: FavoriteRecord): void {
  if (item.targetType === 'article') {
    this.selectedArticleId = item.targetId;
    this.homeMode = 'articles';
    this.showArticleDetail = true;
    this.showEventDetail = false;
    this.activeTab = 0;
  }
}

这段代码体现了 targetType 的价值。如果收藏记录只有一个标题,页面就无法可靠判断应该打开人物详情、事件详情还是文章详情。targetType 是跨页面导航的最低成本协议。

8.1 targetType 显示文案只做展示,不做业务判断

项目里还有一个 targetTypeText,用于把类型转成页面文案。

private targetTypeText(targetType: string): string {
  if (targetType === 'person') {
    return '人物';
  }
  if (targetType === 'event') {
    return '事件';
  }
  if (targetType === 'map') {
    return '地图';
  }
  return '文章';
}

注意这个函数只负责显示,不应该让业务逻辑依赖中文文案。业务判断继续用 person/event/map/article 这样的稳定枚举值。否则一旦文案从“文章”改成“专题”,收藏打开逻辑可能被间接影响。

九、调试命令与日志:先确认对象链路,再确认页面

本次排查我先从源码对象入手,避免直接在 UI 上猜状态。常用命令如下:

git status --short

这一步用于确认工作区已有改动,避免覆盖用户正在做的文件。

rg -n "class ArticleRecord|class AudioRecord|class FavoriteRecord|class NoteRecord" library1/src/main/ets/models/RecordsModels.ets

用于确认模型定义位置。

rg -n "static articles|static audios|article_three_powers|article_chibi_logic" library2/src/main/ets/data/MockRecords.ets

用于确认专题目录和音频模板是否对齐。

rg -n "selectedArticle|articleAudioText|audioTextFor|playAudioByTarget|openFavorite" library2/src/main/ets/pages/MainFrame.ets

用于确认页面入口、TTS 文本、播放队列和收藏反跳是否串起来。

如果要进一步做 HarmonyOS 构建验证,可以在项目根目录执行:

.\hvigorw.bat --mode module -p module=entry@default assembleHap

当前这篇文章分析的是既有实现,没有改 ArkTS 源码;如果后续改动内容模型或页面逻辑,再运行 Hvigor 构建更有意义。

十、问题复盘:为什么不能给文章单独写一套收藏和播放

我一开始看到专题文章时,很容易直觉地把它当成“首页下面的一个新列表”。但从真实 App 使用路径看,文章不是列表项,它是一个和人物、事件同级的内容对象。

如果给文章单独写 articleFavoritesarticleNotesarticleAudios,短期可能更快,长期会出现几个问题:

踩坑点 表现 更稳的做法
收藏体系分裂 收藏页要分别渲染人物收藏和文章收藏 统一 FavoriteRecord.targetId
笔记无法回跳 札记只知道标题,不知道原文 id 统一 NoteRecord.targetId
听书列表重复 模板目录和用户队列混在一起 MockRecords.audios()audioRecords 分层
TTS 文本依赖 UI 页面改版会影响朗读内容 使用模型生成 articleAudioText
扩展成本变高 后续新增地图、专题、时间线都要复制分支 保持 targetType 协议

这也是本地知识 App 很常见的分界线:一开始内容少,写死几个数组看起来没问题;当收藏、札记、搜索、听书、统计都开始围绕内容对象运转,模型就必须先稳住。

十一、验收清单:第 17 篇对应的工程检查点

这篇文章对应的本地实现可以按下面清单验收:

  • ArticleRecord 有稳定 id,并包含标题、分类、摘要、阅读时长、标签和正文段落。
  • MockRecords.articles() 中的文章 id 不和人物、事件 id 冲突。
  • MockRecords.audios() 中的文章音频模板 targetId 能匹配文章 id。
  • 文章列表 ForEach 使用 item.id 作为 key。
  • 文章详情页收藏时传入 targetType = 'article'
  • 文章详情页添加札记时传入 targetType = 'article'
  • 文章详情页的“听这篇”按钮通过 playAudioByTarget(article.id) 进入播放逻辑。
  • audioTextFor 能从 targetId 识别文章,并调用 articleAudioText
  • articleAudioText 不依赖 UI 文本,而是从数据模型拼接标题、摘要和段落。
  • 收藏页打开文章收藏时能切回首页文章模式,并显示文章详情。
  • 静态音频模板和用户播放队列没有被混成同一个概念。

十二、小结:内容扩展的核心是统一定位协议

第 17 篇看似是在讲专题文章,实质是在讲一个本地知识 App 的内容对象协议。ArticleRecord 负责描述文章,FavoriteRecordNoteRecord 负责保存用户行为,AudioRecord 负责把内容接入听书目录,而它们之间靠 targetId + targetType 关联。

这套设计的价值在于:页面入口可以变化,视觉样式可以变化,文章数量可以增长,但收藏、札记和听书不需要重新发明一遍。对于历史知识类 App 来说,这比单纯多做几个页面更重要。

下一篇会继续沿着“内容如何被用户长期使用”往下走,拆解收藏、笔记、听书进度这些本地状态如何在 Preferences 和页面状态之间保持清晰边界。

Logo

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

更多推荐