【观止·诗史汇 HarmonyOS 实战系列 04】诗文内容包:从 Markdown 到可检索的本地诗库
【观止·诗史汇 HarmonyOS 实战系列 04】诗文内容包:从 Markdown 到可检索的本地诗库
前三篇把《观止·诗史汇》的工程底座、三层边界和首页组织方式讲清楚了。第一篇解决“这个 App 为什么是一个本地优先的学习闭环”,第二篇解决 entry / features / commons 的边界,第三篇把首页的 hero、入口网格、每日一文和一日一史落到了 ArkUI 页面上。到了第四篇,就要回到一个更底层但更决定体验的问题:诗文内容从哪里来,如何在 App 里被稳定、快速、可检索地读取?
诗文学习类 App 最容易低估内容工程。界面上看到的只是一个列表、一首诗、几个 Tab,但背后如果没有合适的内容包结构,页面很快会被拖慢:列表需要千级条目,详情需要正文、译文、注释、简析和作者信息,搜索又要同时命中标题、作者、朝代和首句。把所有 Markdown 运行时临时解析一遍并不可取,把所有详情一次性塞进内存也不可取。本篇就沿着真实源码拆解:项目如何把原始诗文整理成 rawfile/poempack 内容包,如何用 PoemPackRepo 做仓储,如何让列表页和详情页只拿自己需要的数据。

上图应来自本机 DevEco 模拟器中打开《观止·诗史汇》的“诗文时空”相关模块。第四篇的截图不能复用首页首屏,因为主题已经从首页编排进入到“内容包、索引、列表、详情和检索”这条链路。后续封面也会沿用第二篇的山水手机模板,但手机屏幕需要展示诗文模块,而不是首页。
本篇要解决什么问题
本篇不是泛泛讨论“如何解析 Markdown”,而是围绕当前项目里的真实内容包实现回答几个工程问题:
| 问题 | 项目里的落点 |
|---|---|
| 诗文内容包放在哪里 | entry/src/main/resources/rawfile/poempack |
| 运行时如何读 JSON | commons/src/main/ets/data/RawJsonLoader.ets |
| 列表为什么只读轻量索引 | poempack/index/<slug>.json + PoemBrief |
| 详情为什么按分片加载 | poempack/detail/shard-xxx.json + PoemDetail |
| 搜索如何避免重复数据 | PoemPackRepo.ensureAllFlat() 去重扁平化 |
| 页面如何承接内容包 | PoemPackListPage、PoemDetailPage、PoemDataSource |
当前内容包 manifest 信息如下:
{
"version": 1,
"generatedAt": "2026-05-18T23:43:54.496Z",
"totalPoems": 1219,
"totalAuthors": 399,
"totalCategories": 11,
"shardSize": 50,
"shardCount": 25
}
这组数字说明项目已经不是几条 mock 数据。1219 篇诗文、399 位作者、11 个分类、25 个详情分片,如果没有轻量索引、按需加载和缓存策略,页面体验会很快变差。
内容包目录:manifest、分类索引、详情分片
内容包位于:
entry/src/main/resources/rawfile/poempack
目录结构可以概括成五类文件:
| 文件或目录 | 作用 | 运行时读取时机 |
|---|---|---|
manifest.json |
内容包版本、总数、分片大小 | 仓储初始化 |
categories.json |
分类列表、顺序、数量 | 仓储初始化 |
authors.json |
作者信息、朝代、简介 | 仓储初始化 |
index/<slug>.json |
某个分类下的轻量诗文列表 | 打开分类列表时按需加载 |
detail/shard-xxx.json |
诗文完整详情分片 | 打开详情页时按需加载 |
项目当前有 11 个分类索引文件,例如:
| 索引文件 | 大小 |
|---|---|
tangshisanbai.json |
约 51 KB |
songcisanbai.json |
约 49 KB |
gushisanbai.json |
约 43 KB |
songcijingxuan.json |
约 36 KB |
xiaoxuegushi.json |
约 20 KB |
详情分片共有 25 个,总大小约 2.98 MB。这个规模对本地应用并不夸张,但如果首屏一次性全部读入,就会把启动、首页和列表页都拖进不必要的 IO 和 JSON 解析里。项目的做法是:元数据常驻,分类索引按需缓存,详情分片按需 LRU。
RawJsonLoader:从 rawfile 读取 JSON 的公共能力
内容包最终放在 entry 的 rawfile 里,但读取能力不应该散落在每个业务页面。项目把读取逻辑收敛到 commons:
export class RawJsonLoader {
private static ctxRef: common.UIAbilityContext | null = null;
static bind(ctx: common.UIAbilityContext): void {
RawJsonLoader.ctxRef = ctx;
}
static async load<T>(relativePath: string): Promise<T> {
const ctx = RawJsonLoader.ctxRef;
if (!ctx) {
throw new Error('RawJsonLoader not bound. Call RawJsonLoader.bind(ctx) first.');
}
const rm: resourceManager.ResourceManager = ctx.resourceManager;
const buf: Uint8Array = await rm.getRawFileContent(relativePath);
const decoder: util.TextDecoder = util.TextDecoder.create('utf-8');
const text: string = decoder.decodeToString(buf);
return JSON.parse(text) as T;
}
}
这个设计接住了第二篇的分层边界:RawJsonLoader 是公共基础能力,它只知道“给我一个 rawfile 相对路径,我返回 JSON 对象”。它不理解诗文、作者、朝代、分类,也不参与页面跳转。诗文业务语义留在 features/poem,底层读取能力沉到 commons/data。
需要注意的是,RawJsonLoader.bind(ctx) 必须在 Ability 上下文准备好之后调用。否则仓储读取 rawfile 时拿不到 ResourceManager,会直接报错。这个约束也应该写进工程验收里。
PoemPackTypes:列表项和详情项必须分开
内容包的数据模型有一个关键点:PoemBrief 和 PoemDetail 分开。
export interface PoemBrief {
poemId: string;
order: number;
title: string;
author: string;
dynasty: string;
firstLine: string;
shard: number;
}
export interface PoemDetail {
id: string;
title: string;
author: string;
authorId: string;
dynasty: string;
body: string;
translation: string;
annotation: string;
brief: string;
categories: string[];
}
列表页只需要标题、作者、朝代、首句和所属分片,不需要正文、译文、注释、简析。详情页才需要完整内容。因此 index/<slug>.json 只保存 PoemBrief[],detail/shard-xxx.json 才保存 PoemDetail[]。
这个拆分让页面性能边界非常清楚:
| 页面 | 需要的数据 | 不应该提前加载的数据 |
|---|---|---|
| 分类列表页 | PoemBrief[] |
所有正文、译文、注释 |
| 全局搜索 | 去重后的 PoemBrief[] |
详情分片 |
| 诗文详情页 | 当前诗的 PoemDetail |
其他分片里的详情 |
| 首页每日一文 | 一个 PoemBrief + 必要时的详情摘句 |
全量详情 |
很多内容型 App 变慢,不是因为 ArkUI 列表不会写,而是因为数据模型没有分层。把详情塞进列表项,页面渲染再怎么优化也会很吃力。
PoemPackRepo:内容包仓储只做三件事
PoemPackRepo 是第四篇的核心对象。源码注释已经把职责写得很清楚:
/**
* 古诗内容包仓储(单例)
* - manifest / categories / authors:一次性加载后常驻
* - index/<slug>.json:按需加载 + 缓存
* - detail shard:按需加载 + LRU(最多 4 片,约 200 首详情)
*/
export class PoemPackRepo {
private manifest: PoemPackManifest | null = null;
private categories: PoemCategory[] = [];
private authors: PoemAuthor[] = [];
private authorById: Map<string, PoemAuthor> = new Map<string, PoemAuthor>();
private indexByCat: Map<string, PoemBrief[]> = new Map<string, PoemBrief[]>();
private shardCache: LRU<number, PoemDetail[]> = new LRU<number, PoemDetail[]>(4);
private detailById: Map<string, PoemDetail> = new Map<string, PoemDetail>();
}
仓储不是页面,也不是全局状态库。它只负责把内容包变成可查询的运行时对象:
| 仓储能力 | 方法 | 说明 |
|---|---|---|
| 初始化元数据 | ensureReady() |
加载 manifest、categories、authors |
| 分类读取 | listByCategory(slug) |
按分类加载轻量索引并缓存 |
| 详情读取 | getDetail(poemId, shard) |
按分片加载详情,使用 LRU |
| 全局扁平索引 | ensureAllFlat() |
首次搜索或每日推荐时加载所有索引并去重 |
| 全局搜索 | searchAll(kw, limit) |
标题、作者、朝代、首句匹配 |
| 分类搜索 | search(catSlug, kw, limit) |
当前分类内轻量过滤 |
这个职责划分让页面层可以保持简单:页面只问仓储要数据,不关心 rawfile 路径、分片文件名、作者映射和缓存淘汰。
初始化:元数据常驻,但详情不常驻
ensureReady() 只加载三类元数据:
async ensureReady(): Promise<void> {
if (this.ready) return;
this.manifest = await RawJsonLoader.load<PoemPackManifest>('poempack/manifest.json');
this.categories = await RawJsonLoader.load<PoemCategory[]>('poempack/categories.json');
this.authors = await RawJsonLoader.load<PoemAuthor[]>('poempack/authors.json');
this.authors.forEach((a: PoemAuthor) => this.authorById.set(a.id, a));
this.ready = true;
}
这里没有加载 index,也没有加载 detail。原因很直接:元数据量小,且会被多个页面复用;分类索引和详情分片量更大,应该等用户真正打开对应页面时再读。
初始化后可以立即支持这些能力:
getManifest(): PoemPackManifest | null
listCategories(): PoemCategory[]
getCategory(slug: string): PoemCategory | null
getAuthor(id: string): PoemAuthor | null
listAuthors(): PoemAuthor[]
首页、分类入口、详情页作者信息都能复用这批元数据。
分类列表:只加载当前分类索引
分类列表使用:
async listByCategory(slug: string): Promise<PoemBrief[]> {
await this.ensureReady();
const cached: PoemBrief[] | undefined = this.indexByCat.get(slug);
if (cached) return cached;
const arr: PoemBrief[] = await RawJsonLoader.load<PoemBrief[]>(`poempack/index/${slug}.json`);
this.indexByCat.set(slug, arr);
return arr;
}
这个实现有两个重要选择:
- 一个分类第一次打开时才读
index/<slug>.json。 - 读过的分类索引缓存整份数组。
分类索引是轻量数据,缓存整份是合理的。比如唐诗三百、宋词三百这种索引几十 KB,换来的是用户来回切换分类时不再反复 IO。相比之下,详情分片接近 3 MB,总量更大,不应该全部常驻。
详情读取:poemId + shard 是关键参数
列表项里有 poemId 和 shard。点击列表项时,页面把这两个参数传给详情页:
const params: NavigateParams = {
poemId: it.poemId,
poemShard: it.shard
};
Navigator.push(AppRoutes.POEM_DETAIL, params);
详情页再用它们读取完整内容:
const params: NavigateParams = Navigator.getParams();
const poemId: string = params.poemId ?? '';
const shard: number = params.poemShard ?? 0;
const repo: PoemPackRepo = PoemPackRepo.instance();
await repo.ensureReady();
const p: PoemDetail | null = await repo.getDetail(poemId, shard);
getDetail() 的核心逻辑如下:
async getDetail(poemId: string, shard: number): Promise<PoemDetail | null> {
await this.ensureReady();
const hit: PoemDetail | undefined = this.detailById.get(poemId);
if (hit) return hit;
let arr: PoemDetail[] | undefined = this.shardCache.get(shard);
if (!arr) {
const name: string = `shard-${String(shard).padStart(3, '0')}.json`;
arr = await RawJsonLoader.load<PoemDetail[]>(`poempack/detail/${name}`);
this.shardCache.put(shard, arr);
arr.forEach((p: PoemDetail) => this.detailById.set(p.id, p));
}
return this.detailById.get(poemId) ?? null;
}
这里不是每点一首诗都读一个独立文件,而是按 shard 读一组详情。当前配置 shardSize: 50,所以一个详情分片最多约 50 首。用户连续浏览相邻诗文时,很可能命中同一个分片或近几个分片;LRU 缓存可以减少反复读取。
LRU:详情分片只保留最近 4 片
仓储里实现了一个很小的 LRU:
class LRU<K, V> {
private map: Map<K, V> = new Map<K, V>();
private cap: number;
get(k: K): V | undefined {
const v: V | undefined = this.map.get(k);
if (v !== undefined) {
this.map.delete(k);
this.map.set(k, v);
}
return v;
}
put(k: K, v: V): void {
if (this.map.has(k)) this.map.delete(k);
this.map.set(k, v);
if (this.map.size > this.cap) {
const first: K = this.map.keys().next().value as K;
this.map.delete(first);
}
}
}
PoemPackRepo 使用的是:
private shardCache: LRU<number, PoemDetail[]> = new LRU<number, PoemDetail[]>(4);
按照 shardSize: 50 估算,最多保留约 200 首详情。这个数字比较克制:它能覆盖用户短时间连续阅读和返回上一首/下一首的场景,又不会让所有详情常驻内存。
需要注意的是,源码还维护了 detailById:
private detailById: Map<string, PoemDetail> = new Map<string, PoemDetail>();
当某个 shard 被加载后,里面每首诗都会进入 detailById。这让同一首诗二次打开可以直接命中。后续如果内容包继续扩大,可以考虑给 detailById 也加上上限,或者让它和 shard LRU 的生命周期绑定得更严格。
全局搜索:首次加载所有轻量索引并去重
全局搜索和每日推荐都需要跨分类选诗。项目没有去加载所有详情,而是加载所有分类索引并去重:
private async ensureAllFlat(): Promise<void> {
await this.ensureReady();
if (!this.allFlatLoaded) {
const seen: Set<string> = new Set<string>();
const flat: PoemBrief[] = [];
for (let i = 0; i < this.categories.length; i++) {
const slug: string = this.categories[i].slug;
const arr: PoemBrief[] = await this.listByCategory(slug);
for (let j = 0; j < arr.length; j++) {
const it: PoemBrief = arr[j];
if (!seen.has(it.poemId)) {
seen.add(it.poemId);
flat.push(it);
}
}
}
this.allFlat = flat;
this.allFlatLoaded = true;
}
}
为什么需要去重?因为同一首诗可能同时属于多个分类,例如“唐诗三百”和“古诗三百”。如果不按 poemId 去重,全局搜索、每日推荐、统计都可能出现重复项。
全局搜索实现如下:
async searchAll(kw: string, limit: number): Promise<PoemBrief[]> {
await this.ensureAllFlat();
const q: string = kw.trim();
if (!q) return [];
const out: PoemBrief[] = [];
for (let i = 0; i < this.allFlat.length && out.length < limit; i++) {
const it: PoemBrief = this.allFlat[i];
if (it.title.indexOf(q) >= 0
|| it.author.indexOf(q) >= 0
|| it.dynasty.indexOf(q) >= 0
|| it.firstLine.indexOf(q) >= 0) {
out.push(it);
}
}
return out;
}
这不是全文搜索,而是轻量字段搜索。对于移动端本地内容包,这是合理的第一阶段:速度快、实现简单、不会把详情全部拉进内存。后续如果要升级,可以在构建内容包时额外生成倒排索引,而不是让运行时扫描完整正文。
每日推荐:同一天稳定,而不是每次随机
第三篇讲首页时已经提到每日一文。它依赖:
async pickBriefBySeed(seed: number): Promise<PoemBrief | null> {
await this.ensureAllFlat();
if (this.allFlat.length === 0) return null;
const idx: number = this.positiveMod(seed, this.allFlat.length);
return this.allFlat[idx];
}
这说明内容包不仅服务诗文列表,也服务首页的每日内容。用 seed 取模的方式让同一天内容稳定,避免用户每次打开 App 都看到不同诗文。稳定的每日推荐,比纯随机更适合学习类应用,因为它能形成“今天读这一篇”的记忆点。
列表页:LazyForEach 承接千级索引
PoemPackListPage 打开时从路由参数里拿分类:
const params: NavigateParams = Navigator.getParams();
const slug: string = params.poemCatSlug ?? '';
然后读取分类和列表:
const repo: PoemPackRepo = PoemPackRepo.instance();
await repo.ensureReady();
const cat: PoemCategory | null = repo.getCategory(slug);
this.allItems = await repo.listByCategory(slug);
this.dataSource.setAll(this.allItems);
this.filteredCount = this.allItems.length;
列表渲染使用 LazyForEach:
List({ space: AppDimens.spaceSm }) {
LazyForEach(this.dataSource, (it: PoemBrief) => {
ListItem() {
this.PoemRow(it)
}
}, (it: PoemBrief) => it.poemId)
}
.cachedCount(20)
PoemDataSource 很轻:
export class PoemDataSource implements IDataSource {
private data: PoemBrief[] = [];
private listeners: DataChangeListener[] = [];
setAll(arr: PoemBrief[]): void {
this.data = arr;
this.listeners.forEach((l: DataChangeListener) => l.onDataReloaded());
}
totalCount(): number { return this.data.length; }
getData(index: number): PoemBrief { return this.data[index]; }
}
这里的关键不是数据源多复杂,而是职责正好够用:底层已经加载了当前分类索引,DataSource 负责告诉 ArkUI 当前有多少项、某个 index 对应哪条数据、数据改变时通知刷新。配合 LazyForEach,即使分类里有几百首诗,也不需要一次性创建几百个可见节点。
列表搜索:当前分类内即时过滤
列表页顶部有搜索框:
TextInput({ placeholder: '搜索标题 / 作者 / 首句', text: this.keyword })
.onChange((v: string) => this.applyFilter(v))
过滤逻辑是:
private applyFilter(kw: string): void {
this.keyword = kw;
const q: string = kw.trim();
if (!q) {
this.dataSource.setAll(this.allItems);
this.filteredCount = this.allItems.length;
return;
}
const out: PoemBrief[] = this.allItems.filter((it: PoemBrief) =>
it.title.indexOf(q) >= 0 || it.author.indexOf(q) >= 0 || it.firstLine.indexOf(q) >= 0
);
this.dataSource.setAll(out);
this.filteredCount = out.length;
}
这和仓储里的 search(catSlug, kw, limit) 是同一类轻量检索思路:在当前已加载的索引里查标题、作者、首句。它不查详情正文,所以响应快,也不会因为输入框每次变化都触发大量 IO。
详情页:四个 Tab 消费同一个 PoemDetail
PoemDetailPage 读取到 PoemDetail 后,把页面拆成四个 Tab:
private tabs: TabDef[] = [
{ key: 't_body', label: '原文' },
{ key: 't_tran', label: '译文及注释' },
{ key: 't_brief', label: '简析' },
{ key: 't_author', label: '作者介绍' }
];
详情页的核心读取链路是:
const p: PoemDetail | null = await repo.getDetail(poemId, shard);
const a: PoemAuthor | null = p.authorId ? repo.getAuthor(p.authorId) : null;
const cats: PoemCategory[] = p.categories
.map((slug: string) => repo.getCategory(slug))
.filter((c: PoemCategory | null) => c !== null) as PoemCategory[];
可以看到,详情页只在一个地方拿完整详情,然后把作者、分类补齐。Tab 本身不再读文件,也不再查仓储。这样页面状态更稳定:
| 区域 | 数据来源 |
|---|---|
| 顶部标题 | poem.title、poem.dynasty、poem.author |
| 分类标签 | poem.categories 映射 PoemCategory |
| 原文 Tab | poem.body |
| 译文及注释 Tab | poem.translation、poem.annotation |
| 简析 Tab | poem.brief |
| 作者介绍 Tab | repo.getAuthor(poem.authorId) |
这也是内容包结构设计的价值:详情页的 UI 可以专注展示,不需要理解 Markdown 原始格式。
收藏、笔记、朗读为什么也能接上
第四篇虽然讲内容包,但详情页已经把内容和后续学习动作接起来了:
this.statsStore.recordPoem(poemId);
收藏和笔记使用 poemId 作为目标 ID:
this.favStore.addToFolder('poem', this.state.poemId, this.favoriteTitle(), folderId);
let note = this.noteStore.getByTarget('poem', this.state.poemId);
朗读则把当前 PoemDetail 组装成可读文本:
await TextReaderService.start(
PoemReadInfoBuilder.buildFullText(this.state.poem, this.state.author)
);
这说明内容包的 ID 设计非常关键。只要 poemId 稳定,收藏、笔记、统计、朗读、每日推荐都可以围绕同一个目标建立关系。反过来,如果内容包每次构建都改变 ID,用户学习状态就很难长期保存。
从 Markdown 到内容包的工程取舍
当前文章标题写的是“从 Markdown 到可检索的本地诗库”,但运行时看到的已经不是 Markdown,而是构建后的 JSON 内容包。这是一个重要取舍:
| 阶段 | 适合做什么 |
|---|---|
| 构建前 Markdown | 方便维护原始诗文、译注、简析、作者信息 |
| 构建脚本 | 解析 Markdown、生成 ID、分类、作者、索引和分片 |
| rawfile 内容包 | 适合 App 运行时读取 |
| ArkTS 仓储 | 提供按需加载、搜索、缓存和 ID 查询 |
| ArkUI 页面 | 展示列表、详情和学习动作 |
也就是说,Markdown 是内容生产格式,JSON 内容包是 App 运行格式。把这两者分开,项目才能同时兼顾可维护性和运行时性能。
本地验收命令
第四篇的验收不只看页面,还要看内容包结构是否真实存在:
Get-Content .\entry\src\main\resources\rawfile\poempack\manifest.json -Encoding UTF8
Get-ChildItem .\entry\src\main\resources\rawfile\poempack\index
Get-ChildItem .\entry\src\main\resources\rawfile\poempack\detail
读取仓储和页面实现:
Get-Content .\features\src\main\ets\poem\PoemPackRepo.ets -Encoding UTF8
Get-Content .\features\src\main\ets\poem\PoemPackTypes.ets -Encoding UTF8
Get-Content .\features\src\main\ets\poem\PoemPackListPage.ets -Encoding UTF8
Get-Content .\features\src\main\ets\poem\PoemDetailPage.ets -Encoding UTF8
模拟器验收建议进入“诗文时空”模块截图,而不是停留在首页:
& "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/poem_pack.png -w 1080 -h 2400 -t png
页面验收清单
| 检查项 | 期望结果 |
|---|---|
| 诗文分类入口 | 能进入具体分类列表 |
| 分类列表标题 | 显示分类名和当前数量 |
| 搜索框 | 能按标题、作者、首句过滤 |
| 列表性能 | 使用 LazyForEach,滚动顺畅 |
| 列表项 | 显示序号、标题、朝代作者、首句 |
| 详情跳转 | 点击列表项携带 poemId + poemShard |
| 详情页 | 原文、译文及注释、简析、作者介绍四个 Tab 正常 |
| 作者信息 | 通过 authorId 从作者表补齐 |
| 收藏笔记 | 使用稳定 poemId 关联 |
| 每日推荐 | 同一天 seed 结果稳定 |
常见问题复盘
1. 为什么不直接把 Markdown 放进 App 运行时解析?
Markdown 适合编辑和维护,不适合作为移动端运行时主数据结构。运行时解析 Markdown 会把 IO、解析、结构化和页面渲染混在一起。项目选择构建期转 JSON,让运行时只做读取和展示。
2. 为什么列表不直接读取详情分片?
列表页只需要 PoemBrief。如果列表阶段读取详情,用户只是浏览分类也会付出正文、译文、注释的加载成本。把详情推迟到点击某首诗之后,才符合移动端按需加载的原则。
3. 为什么详情分片大小是 50?
shardSize: 50 是一个折中。太小会产生很多文件,用户连续阅读时频繁 IO;太大又会让一次详情加载变重。50 首一片配合 4 片 LRU,能覆盖常见连续阅读路径。
4. 为什么全局搜索不是全文搜索?
当前全局搜索只查标题、作者、朝代、首句。这样首次加载只需要轻量索引,不需要全部详情。对于第一阶段的学习 App,这已经覆盖了最常见的检索入口。全文搜索可以留给后续构建期倒排索引。
5. 为什么作者表单独存在?
详情里只有 authorId 和作者名还不够。作者简介、朝代信息可能被多首诗复用,单独作者表可以避免重复,也方便后续做作者页、朝代页和人物关联。
本章小结
第四篇的核心不是“我有很多诗文数据”,而是“这些诗文数据如何被组织成一个适合 HarmonyOS App 读取的内容包”。当前项目把原始 Markdown 的维护友好性留在构建前,把运行时需要的稳定结构落到 rawfile/poempack:元数据常驻、分类索引按需缓存、详情分片 LRU、全局轻量索引去重、列表用 LazyForEach 承接千级数据、详情页用 poemId + shard 精准加载。
这套设计把内容工程和页面工程接起来了。首页的每日一文、诗文时空的分类列表、诗文详情页的四个 Tab、收藏笔记统计和朗读能力,都围绕稳定的 poemId 和轻量索引运行。下一篇可以在这个基础上继续拆诗文详情页,看看正文、注释、译文、简析、作者信息、收藏、笔记和朗读如何组织成一个完整的阅读体验。
[#HarmonyOS](https://so.csdn.net/so/search/s.do?q=HarmonyOS&t=all&o=vip&s=&l=&f=&viparticle=&from_tracking_code=tag_word&from_code=app_blog_art) [#ArkTS](https://so.csdn.net/so/search/s.do?q=ArkTS&t=all&o=vip&s=&l=&f=&viparticle=&from_tracking_code=tag_word&from_code=app_blog_art) [#ArkUI](https://so.csdn.net/so/search/s.do?q=ArkUI&t=all&o=vip&s=&l=&f=&viparticle=&from_tracking_code=tag_word&from_code=app_blog_art) [#DevEco Studio](https://so.csdn.net/so/search/s.do?q=DevEco+Studio&t=all&o=vip&s=&l=&f=&viparticle=&from_tracking_code=tag_word&from_code=app_blog_art) [#鸿蒙开发](https://so.csdn.net/so/search/s.do?q=%E9%B8%BF%E8%92%99%E5%BC%80%E5%8F%91&t=all&o=vip&s=&l=&f=&viparticle=&from_tracking_code=tag_word&from_code=app_blog_art)
更多推荐


所有评论(0)