【三国志 App 实战系列 17】HarmonyOS ArkTS 内容模型扩展:从人物事件到专题文章的可听目录
系列背景:这个系列记录一个本地优先的三国历史知识 App 从工程骨架、主题、内容模型、听书、地图、资源目录到发布体验的完整实现。第 16 篇处理了首页搜索,本篇继续向内容层推进:专题文章不只是静态阅读页,它还应该能收藏、记笔记、加入听书,并和人物、事件共享一套可维护的数据语义。
一、真实工程问题:文章页不能只是一个列表
在历史知识类 App 里,人物、事件、地图和专题文章看起来是不同入口,但用户真正使用时并不会按代码模块来理解内容。他可能先读诸葛亮人物页,再收藏赤壁之战事件,然后在专题文章里听一篇“三分天下为何形成”。如果每类内容各自维护一套收藏、笔记和播放逻辑,后续扩展会越来越脆。
当前项目的目标不是做一个联网 CMS,而是做一个离线可用、内容稳定、可长时间维护的 HarmonyOS NEXT App。因此内容模型需要满足几个边界:
| 目标 | 具体要求 | 不这么做的风险 |
|---|---|---|
| 离线可读 | 专题文章在本地静态目录中可直接展示 | 依赖网络后会破坏无账号、无服务器基线 |
| 可收藏 | 文章和人物、事件共用收藏结构 | 收藏页需要为每种内容写分支页面 |
| 可记笔记 | 用户随读札记能挂到文章 id 上 | 笔记无法反向打开原文 |
| 可听 | 文章能进入 TTS 播放列表 | 听书页只能听人物传记,内容价值被限制 |
| 可扩展 | 后续可继续新增专题,不改页面主流程 | 每加一篇文章都要改业务逻辑 |
这个问题的关键不是“加一个文章数组”这么简单,而是要给所有内容对象一套共同定位方式。项目里最终选择的是 targetId + targetType:targetId 指向具体内容对象,targetType 说明它属于 person、event、map 还是 article。
二、源码对象总览:四类记录如何协作
本篇主要涉及三个文件:
library1/src/main/ets/models/RecordsModels.etslibrary2/src/main/ets/data/MockRecords.etslibrary2/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,而是 targetId。id 是音频记录自身的唯一标识,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 上:
- 点击收藏:创建
FavoriteRecord,targetType = 'article' - 添加札记:创建
NoteRecord,targetType = '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;
}
这个函数的职责很清楚:
- 用户队列里已有该内容,就复用已有条目。
- 用户队列里没有,就从模板目录查。
- 模板目录也没有,说明该内容暂时不可听。
- 查到模板后插入用户队列,并返回给播放逻辑。
这样做的好处是“可听能力”和“正在听的列表”分层。新增一篇可听文章只需要扩充模板,不会强制打乱用户已经维护的播放列表。
八、从收藏页反向打开文章
收藏页不是只展示收藏文本,还要能反向打开原对象。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 使用路径看,文章不是列表项,它是一个和人物、事件同级的内容对象。
如果给文章单独写 articleFavorites、articleNotes、articleAudios,短期可能更快,长期会出现几个问题:
| 踩坑点 | 表现 | 更稳的做法 |
|---|---|---|
| 收藏体系分裂 | 收藏页要分别渲染人物收藏和文章收藏 | 统一 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 负责描述文章,FavoriteRecord 和 NoteRecord 负责保存用户行为,AudioRecord 负责把内容接入听书目录,而它们之间靠 targetId + targetType 关联。
这套设计的价值在于:页面入口可以变化,视觉样式可以变化,文章数量可以增长,但收藏、札记和听书不需要重新发明一遍。对于历史知识类 App 来说,这比单纯多做几个页面更重要。
下一篇会继续沿着“内容如何被用户长期使用”往下走,拆解收藏、笔记、听书进度这些本地状态如何在 Preferences 和页面状态之间保持清晰边界。
更多推荐



所有评论(0)