【观止·诗史汇 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() 去重扁平化
页面如何承接内容包 PoemPackListPagePoemDetailPagePoemDataSource

当前内容包 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 的公共能力

内容包最终放在 entryrawfile 里,但读取能力不应该散落在每个业务页面。项目把读取逻辑收敛到 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:列表项和详情项必须分开

内容包的数据模型有一个关键点:PoemBriefPoemDetail 分开。

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;
}

这个实现有两个重要选择:

  1. 一个分类第一次打开时才读 index/<slug>.json
  2. 读过的分类索引缓存整份数组。

分类索引是轻量数据,缓存整份是合理的。比如唐诗三百、宋词三百这种索引几十 KB,换来的是用户来回切换分类时不再反复 IO。相比之下,详情分片接近 3 MB,总量更大,不应该全部常驻。

详情读取:poemId + shard 是关键参数

列表项里有 poemIdshard。点击列表项时,页面把这两个参数传给详情页:

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.titlepoem.dynastypoem.author
分类标签 poem.categories 映射 PoemCategory
原文 Tab poem.body
译文及注释 Tab poem.translationpoem.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)

Logo

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

更多推荐