【观止·诗史汇 HarmonyOS 实战系列 04】诗文内容包:从 Markdown 到可检索的本地诗库

📅 2026/6/26 1:37:43
【观止·诗史汇 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运行时如何读 JSONcommons/src/main/ets/data/RawJsonLoader.ets列表为什么只读轻量索引poempack/index/slug.jsonPoemBrief详情为什么按分片加载poempack/detail/shard-xxx.jsonPoemDetail搜索如何避免重复数据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 KBsongcisanbai.json约 49 KBgushisanbai.json约 43 KBsongcijingxuan.json约 36 KBxiaoxuegushi.json约 20 KB详情分片共有 25 个总大小约 2.98 MB。这个规模对本地应用并不夸张但如果首屏一次性全部读入就会把启动、首页和列表页都拖进不必要的 IO 和 JSON 解析里。项目的做法是元数据常驻分类索引按需缓存详情分片按需 LRU。RawJsonLoader从 rawfile 读取 JSON 的公共能力内容包最终放在entry的rawfile里但读取能力不应该散落在每个业务页面。项目把读取逻辑收敛到commonsexport class RawJsonLoader { private static ctxRef: common.UIAbilityContext | null null; static bind(ctx: common.UIAbilityContext): void { RawJsonLoader.ctxRef ctx; } static async loadT(relativePath: string): PromiseT { 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: Mapstring, PoemAuthor new Mapstring, PoemAuthor(); private indexByCat: Mapstring, PoemBrief[] new Mapstring, PoemBrief[](); private shardCache: LRUnumber, PoemDetail[] new LRUnumber, PoemDetail[](4); private detailById: Mapstring, PoemDetail new Mapstring, PoemDetail(); }仓储不是页面也不是全局状态库。它只负责把内容包变成可查询的运行时对象仓储能力方法说明初始化元数据ensureReady()加载 manifest、categories、authors分类读取listByCategory(slug)按分类加载轻量索引并缓存详情读取getDetail(poemId, shard)按分片加载详情使用 LRU全局扁平索引ensureAllFlat()首次搜索或每日推荐时加载所有索引并去重全局搜索searchAll(kw, limit)标题、作者、朝代、首句匹配分类搜索search(catSlug, kw, limit)当前分类内轻量过滤这个职责划分让页面层可以保持简单页面只问仓储要数据不关心 rawfile 路径、分片文件名、作者映射和缓存淘汰。初始化元数据常驻但详情不常驻ensureReady()只加载三类元数据async ensureReady(): Promisevoid { if (this.ready) return; this.manifest await RawJsonLoader.loadPoemPackManifest(poempack/manifest.json); this.categories await RawJsonLoader.loadPoemCategory[](poempack/categories.json); this.authors await RawJsonLoader.loadPoemAuthor[](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): PromisePoemBrief[] { await this.ensureReady(); const cached: PoemBrief[] | undefined this.indexByCat.get(slug); if (cached) return cached; const arr: PoemBrief[] await RawJsonLoader.loadPoemBrief[](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): PromisePoemDetail | 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.loadPoemDetail[](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 片仓储里实现了一个很小的 LRUclass LRUK, V { private map: MapK, V new MapK, 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: LRUnumber, PoemDetail[] new LRUnumber, PoemDetail[](4);按照shardSize: 50估算最多保留约 200 首详情。这个数字比较克制它能覆盖用户短时间连续阅读和返回上一首/下一首的场景又不会让所有详情常驻内存。需要注意的是源码还维护了detailByIdprivate detailById: Mapstring, PoemDetail new Mapstring, PoemDetail();当某个 shard 被加载后里面每首诗都会进入detailById。这让同一首诗二次打开可以直接命中。后续如果内容包继续扩大可以考虑给detailById也加上上限或者让它和 shard LRU 的生命周期绑定得更严格。全局搜索首次加载所有轻量索引并去重全局搜索和每日推荐都需要跨分类选诗。项目没有去加载所有详情而是加载所有分类索引并去重private async ensureAllFlat(): Promisevoid { await this.ensureReady(); if (!this.allFlatLoaded) { const seen: Setstring new Setstring(); 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): PromisePoemBrief[] { 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): PromisePoemBrief | 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;列表渲染使用LazyForEachList({ 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 消费同一个 PoemDetailPoemDetailPage读取到PoemDetail后把页面拆成四个 Tabprivate 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原文 Tabpoem.body译文及注释 Tabpoem.translation、poem.annotation简析 Tabpoem.brief作者介绍 Tabrepo.getAuthor(poem.authorId)这也是内容包结构设计的价值详情页的 UI 可以专注展示不需要理解 Markdown 原始格式。收藏、笔记、朗读为什么也能接上第四篇虽然讲内容包但详情页已经把内容和后续学习动作接起来了this.statsStore.recordPoem(poemId);收藏和笔记使用poemId作为目标 IDthis.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. 为什么详情分片大小是 50shardSize: 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?qHarmonyOStallovipslfviparticlefrom_tracking_codetag_wordfrom_codeapp_blog_art) [#ArkTS](https://so.csdn.net/so/search/s.do?qArkTStallovipslfviparticlefrom_tracking_codetag_wordfrom_codeapp_blog_art) [#ArkUI](https://so.csdn.net/so/search/s.do?qArkUItallovipslfviparticlefrom_tracking_codetag_wordfrom_codeapp_blog_art) [#DevEco Studio](https://so.csdn.net/so/search/s.do?qDevEcoStudiotallovipslfviparticlefrom_tracking_codetag_wordfrom_codeapp_blog_art) [#鸿蒙开发](https://so.csdn.net/so/search/s.do?q%E9%B8%BF%E8%92%99%E5%BC%80%E5%8F%91tallovipslfviparticlefrom_tracking_codetag_wordfrom_codeapp_blog_art)