SEO 信息

  • SEO 标题:【三国志 App 实战系列 01】HarmonyOS NEXT ArkTS 实战:从 0 到 1 开发三国志单机 App
  • SEO 摘要:以三国志历史知识 App 为例,系统讲解 HarmonyOS NEXT、ArkTS、ArkUI 单机应用的需求拆解、工程分层、页面信息架构和本地数据设计。
  • 关键词:HarmonyOS NEXT, ArkTS, ArkUI, 鸿蒙应用开发, 单机 App, 三国志 App, 项目架构
  • 文章封面../generated_images/csdn-covers/01.png
  • 适合读者:HarmonyOS 初学者、ArkTS/ArkUI 开发者、正在做本地内容型 App 或音频播放功能的开发者

系列第 1 篇。本文以一个真实 HarmonyOS NEXT / ArkTS 项目《三国志鸿蒙 App》为例,复盘如何从需求、信息架构到工程落地,构建一款离线可用的历史知识应用。

首页与整体视觉

一、项目定位

这个项目的目标不是做一个联网百科,而是做一款单机版三国历史学习 App

  • 三国人物传记
  • 历史事件索引
  • 势力地图
  • 收藏与笔记
  • 本地 AI 听书
  • 深色/浅色主题
  • 手机和平板适配

核心原则是:所有数据本地可用,弱化网络依赖,让用户在通勤、阅读、学习时都能稳定使用。

这个定位会直接影响后面的工程决策。比如首页不是简单堆一组入口,而是要承担“今天读什么”“从哪里继续听”“哪些人物和事件有关联”的内容分发职责;收藏页也不是一个孤立列表,而是用户学习路径的沉淀。

1.1 首版目标与非目标

首版必须控制边界,否则一个历史内容类 App 很容易膨胀成百科、社区、AI 问答、地图和音频播放器的大杂烩。

反过来,首版不做评论、排行榜、云同步和在线百科搜索。这些能力并不是不重要,而是会引入服务端、审核、账号和数据质量问题,容易让第一个可运行版本迟迟无法闭环。

二、为什么选择单机架构

历史资料类 App 常见问题是内容获取链路复杂、网络不稳定、账号体系拖慢首个版本。这个项目采用本地 Mock 数据 + Preferences 持久化起步,优点明显:

  • 启动快
  • 审核风险低
  • 无 API Key 泄露风险
  • 离线可用
  • 便于把精力集中在 ArkUI 页面、听书体验和本地数据结构上

单机架构并不等于“没有架构”。更准确地说,它把复杂度从网络链路转移到了本地内容组织、本地状态管理和页面刷新策略上。尤其是 HarmonyOS / ArkUI 项目,如果一开始没有明确分层,后续很容易出现页面直接操作静态数据、收藏状态和内容模板相互污染、听书状态无法恢复等问题。

2.1 单机架构的数据边界

我把数据分成两条线:

  • 内容模板数据:人物、事件、势力、文章正文、听书文案,随 App 安装包发布。
  • 用户行为数据:收藏、笔记、播放进度、主题设置,保存在本地 Preferences。

这条边界很关键。内容模板应该是只读的,用户行为数据应该是可变的。页面渲染时再把两者组合起来,而不是把收藏状态直接写回人物模板。

export interface FavoriteRecord {
  targetId: string;
  targetType: 'person' | 'event' | 'map' | 'audio';
  title: string;
  summary?: string;
  createdAt: number;
}

export interface NoteRecord {
  targetId: string;
  content: string;
  updatedAt: number;
}

这样做的好处是:人物详情页、收藏页、听书页都可以围绕同一个 targetId 建立关联。后面如果增加云同步,也可以只同步用户行为数据,而不用同步整套历史内容模板。

三、核心功能分层

项目逻辑可以分为三类:

示例数据模型:

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

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

这个模型看似简单,但后续听书断点续播、列表刷新、后台播放都会依赖它。

3.1 功能分层到目录结构

为了避免页面文件越来越大,工程目录建议按“数据、服务、页面、组件、主题”组织。下面是一个适合中小型 ArkUI 单机应用的结构:

entry/src/main/ets/
├── data/
│   ├── PersonData.ets
│   ├── EventData.ets
│   └── MapData.ets
├── model/
│   ├── PersonModel.ets
│   ├── AudioRecord.ets
│   └── FavoriteRecord.ets
├── service/
│   ├── FavoriteService.ets
│   ├── NoteService.ets
│   └── AudioProgressService.ets
├── theme/
│   ├── AppTheme.ets
│   ├── AppColors.ets
│   └── AppSpacing.ets
├── pages/
│   ├── HomePage.ets
│   ├── PersonPage.ets
│   ├── AudioPage.ets
│   └── FavoritePage.ets
└── components/
    ├── PersonCard.ets
    ├── SectionTitle.ets
    └── EmptyState.ets

目录不必一开始就特别复杂,但至少要避免两种情况:

  • 把所有 Mock 数据、页面状态和 UI 组件都塞进 Index.ets
  • 页面直接读写 Preferences,导致数据格式散落在多个页面里。

3.2 页面只关心展示,服务层负责持久化

以收藏为例,页面需要知道“当前人物是否已收藏”,但不应该知道 Preferences 的 key 如何拼接、JSON 如何序列化。

export class FavoriteService {
  private static readonly STORE_NAME = 'user_favorites';
  private static readonly KEY = 'favorite_records';

  async list(): Promise<FavoriteRecord[]> {
    const store = await preferences.getPreferences(getContext(), FavoriteService.STORE_NAME);
    const raw = await store.get(FavoriteService.KEY, '[]') as string;
    return JSON.parse(raw) as FavoriteRecord[];
  }

  async toggle(record: FavoriteRecord): Promise<boolean> {
    const records = await this.list();
    const index = records.findIndex(item => item.targetId === record.targetId);
    const next = index >= 0
      ? records.filter(item => item.targetId !== record.targetId)
      : records.concat(record);

    const store = await preferences.getPreferences(getContext(), FavoriteService.STORE_NAME);
    await store.put(FavoriteService.KEY, JSON.stringify(next));
    await store.flush();
    return index < 0;
  }
}

页面侧只做状态更新:

private async toggleFavorite() {
  this.isFavorite = await this.favoriteService.toggle({
    targetId: this.person.id,
    targetType: 'person',
    title: this.person.name,
    summary: this.person.summary,
    createdAt: Date.now()
  });
}

这类拆分能让后续维护轻松很多:如果从 Preferences 迁移到关系型数据库,页面层不用跟着大改。

四、工程入口设计

module.json5 中,项目明确支持 phone / tablet,并声明后台音频能力:

{
  "module": {
    "deviceTypes": ["phone", "tablet"],
    "requestPermissions": [
      { "name": "ohos.permission.INTERNET" },
      { "name": "ohos.permission.KEEP_BACKGROUND_RUNNING" }
    ],
    "abilities": [
      {
        "name": "EntryAbility",
        "orientation": "auto_rotation",
        "backgroundModes": ["audioPlayback"]
      }
    ]
  }
}

这里的 backgroundModes 是后续实现听书后台播放的基础。

4.1 入口 Ability 的职责边界

EntryAbility 不应该承载业务逻辑,它更适合做三件事:

  • 初始化窗口和沉浸式状态。
  • 根据设备类型准备首屏容器。
  • 处理生命周期中的资源释放。
export default class EntryAbility extends UIAbility {
  onWindowStageCreate(windowStage: window.WindowStage): void {
    windowStage.loadContent('pages/Index', (err) => {
      if (err.code) {
        hilog.error(0x0000, 'EntryAbility', 'Failed to load content: %{public}s', JSON.stringify(err));
        return;
      }
      hilog.info(0x0000, 'EntryAbility', 'Content loaded');
    });
  }
}

如果把人物数据加载、听书队列初始化、收藏状态恢复都写进 Ability,后续会很难测试,也不利于页面复用。更好的方式是:Ability 只拉起页面,页面或服务按需加载自己的数据。

五、页面信息架构

首个版本规划为五个主 Tab:

  • 首页:聚合人物、事件与快捷入口
  • 人物:历史人物列表与详情
  • 听书:统一播放队列与控制面板
  • 收藏:收藏与笔记管理
  • 我的:统计、主题、设置

页面框架示例:

build() {
  Stack() {
    if (this.isTabletLayout()) {
      this.tabletFrame();
    } else {
      this.phoneFrame();
    }
  }
  .width('100%')
  .height('100%')
  .backgroundColor(this.palette().pageBg)
}

5.1 手机与平板不要只靠拉伸

内容型应用在平板上不能简单把手机布局等比例放大,否则会出现两侧空白过多、卡片过宽、阅读行长过长的问题。这个项目里,手机和平板使用同一套数据,但容器结构不同:

判断布局时不要只看屏幕宽度,也要结合窗口尺寸变化。DevEco 模拟器、折叠屏和多窗口都可能让宽度变化。

private isTabletLayout(): boolean {
  return this.windowWidth >= 840;
}

在 ArkUI 中,可以把布局分支控制在页面骨架层,业务组件尽量复用:

build() {
  if (this.isTabletLayout()) {
    Row() {
      this.sideNavigation()
      this.contentPanel()
    }
  } else {
    Column() {
      this.contentPanel()
      this.bottomTabs()
    }
  }
}

5.2 首页为什么要做内容聚合

历史类 App 的首页不是“导航页”,而是用户进入内容的第一层决策界面。一个有效首页至少应该回答三个问题:

  • 我可以从哪里开始读?
  • 当前有哪些重要人物或事件?
  • 我上次的学习进度在哪里?

因此首页首屏建议由 Banner、搜索框、快捷入口、人物卡片、事件索引和继续听书入口组成。这样用户既能探索新内容,也能回到上次的学习状态。

六、实践经验

  • 先做信息架构,再写 UI:历史类 App 内容复杂,没有清晰内容结构会很快失控。
  • 本地数据与用户数据分离:静态模板不能被用户操作破坏。
  • 听书入口要覆盖所有详情页:只依赖当前播放列表会导致清空列表后详情页不能播放。
  • 一开始就考虑平板:后补大屏适配成本远高于从框架层支持。

6.1 常见坑位复盘

6.2 发布前自检清单

在做第一个可发布版本前,我会按下面的清单检查:

  • 首页能否在无网络状态下完整展示。
  • 人物详情、事件详情和地图标记是否都能进入。
  • 收藏、取消收藏、重启 App 后状态是否一致。
  • 听书暂停、继续、切换条目后进度是否正确。
  • 深色模式下文字、卡片、分割线是否仍然可读。
  • 平板横屏下是否出现过宽文本和空白区域。
  • 模拟器截图是否来自当前版本,而不是旧设计图。

这些检查看起来琐碎,但它们决定了一个单机 App 是否真的“闭环”。功能演示能跑通不等于用户体验可靠,尤其是收藏、听书和主题这类跨页面状态,一定要反复验证。

七、工程验收与调试方法

前面讲的是架构设计,真正落地时还需要一套可执行的验收方法。对单机内容型 App 来说,我更关注四类问题:内容是否能完整访问、状态是否能正确持久化、页面是否能适配不同设备、关键能力是否能在模拟器或真机上复现。

7.1 内容链路验收

内容链路不是只看首页能不能打开,而是要从首页一路进入详情页、听书页、收藏页,再回到首页。每条内容都应该有稳定的 id,并且这个 id 能贯穿列表、详情、收藏、笔记和听书。

建议把验收用例写成下面这种清单:

  • 首页人物卡片点击后能进入人物详情。
  • 人物详情中的听书入口能创建或定位播放记录。
  • 收藏人物后,收藏页能立即出现同一条记录。
  • 取消收藏后,详情页和收藏页状态同步。
  • 重启 App 后,收藏和听书进度仍然存在。
  • 搜索或分类切换后,列表项 key 不发生错乱。

如果一个页面只能“看起来可用”,但不能经过这条链路回到用户数据,那它还没有真正完成。

7.2 数据完整性校验

本地内容数据虽然不走接口,但仍然需要校验。人物、事件、地图、听书模板之间一旦出现 targetId 对不上,页面就会表现为“入口存在,但点进去没有内容”。

可以在开发阶段写一个轻量校验函数:

interface ValidateResult {
  ok: boolean;
  message: string;
}

function validateAudioRecords(personIds: string[], audioRecords: AudioRecord[]): ValidateResult[] {
  return audioRecords.map((record) => {
    const exists = personIds.includes(record.targetId);
    return {
      ok: exists,
      message: exists
        ? `audio ${record.id} target ok`
        : `audio ${record.id} targetId ${record.targetId} not found`
    };
  });
}

这个函数不一定要打进正式包,但在开发期非常有价值。内容型 App 的 Bug 很多不是代码异常,而是数据关系断了。提前校验能减少大量“页面没报错但内容消失”的问题。

7.3 Preferences 读写验收

Preferences 适合保存少量用户偏好和轻量列表,但要注意序列化边界。不要把 class 实例直接塞进去,建议保存纯 JSON 对象。

async function saveRecords(key: string, records: object[]) {
  const store = await preferences.getPreferences(getContext(), 'user_store');
  await store.put(key, JSON.stringify(records));
  await store.flush();
}

async function readRecords<T>(key: string): Promise<T[]> {
  const store = await preferences.getPreferences(getContext(), 'user_store');
  const raw = await store.get(key, '[]') as string;
  try {
    return JSON.parse(raw) as T[];
  } catch (_) {
    return [];
  }
}

读写验收时至少覆盖三种情况:

  • 首次安装,没有任何本地数据。
  • 正常写入后重启 App。
  • 本地 JSON 异常时不让页面崩溃。

第三点很容易被忽略。即便 Preferences 数据通常由 App 自己写入,也要考虑版本升级、字段变更或调试阶段写入脏数据的情况。

7.4 模拟器调试命令

HarmonyOS 项目不要只依赖 DevEco Studio 的绿色运行按钮。发布前建议熟悉基本命令,尤其是在排查安装、启动和日志问题时很有用。

hdc list targets
hdc install -r .\entry\build\default\outputs\default\entry-default-signed.hap
hdc shell aa force-stop com.example.recordofthreekingdoms
hdc shell aa start -a EntryAbility -b com.example.recordofthreekingdoms
hdc shell hilog | Select-String -Pattern "EntryAbility|Audio|Favorite"

如果要截图做文章或宣发素材,必须使用当前版本真实页面:

hdc shell snapshot_display -f /data/local/tmp/home.png
hdc file recv /data/local/tmp/home.png .\doc\generated_images\promo\phone\01_home.png

这样文章中的截图和实际 App 一致,读者也更容易理解页面信息架构。

7.5 性能与体验检查

首版单机 App 的性能问题通常不是网络慢,而是页面一次性构建太多内容、列表 key 不稳定、状态变更触发大范围刷新。可以先从三个方向检查:

  • 首页首屏是否一次性渲染过多长文本。
  • 人物列表和事件列表是否使用稳定 id 作为 ForEach key。
  • 听书进度变化是否只刷新必要区域,而不是重建整个播放列表。

一个简单的列表写法如下:

ForEach(this.personList, (person: PersonModel) => {
  PersonCard({
    person,
    colors: this.palette(),
    onSelect: () => this.openPerson(person.id)
  })
}, (person: PersonModel) => person.id)

这里的关键是最后的 key 函数。不要为了强制刷新把时间戳、随机数或播放进度拼进 key,否则 ArkUI 会认为每一项都是新节点,列表很容易抖动。

7.6 为什么这些验收会影响后续迭代

当首版已经具备稳定的数据边界和验收清单后,后续加功能会轻松很多:

  • 增加云同步时,只需要同步用户行为数据。
  • 增加更多人物和事件时,先跑本地数据校验。
  • 增加后台听书时,可以复用现有 AudioRecord
  • 增加平板详情联动时,可以复用列表和详情组件。

这也是我建议先做单机闭环的原因。它不是“低配版本”,而是为后续复杂能力打基础的最小稳定版本。

八、小结

这篇文章介绍了项目的整体定位和架构:首版用单机架构降低复杂度,用本地内容模板承载历史资料,用 Preferences 保存用户行为,再通过首页、人物、听书、收藏和我的五个主 Tab 形成学习闭环。

如果你正在做 HarmonyOS NEXT / ArkTS 内容型应用,可以先从三件事开始:明确首版边界、拆清内容数据和用户数据、把页面信息架构画出来。下一篇会进入 ArkUI 主题系统,看看如何用 Token 管理颜色、字号、间距和深浅色模式,让多页面应用保持统一气质。

Logo

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

更多推荐