【三国志 App 实战系列 01】HarmonyOS 三国志 App 从 0 到 1:项目设计与单机架构
以三国志历史知识 App 为例,系统讲解 HarmonyOS NEXT、ArkTS、ArkUI 单机应用的需求拆解、工程分层、页面信息架构和本地数据设计。
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 作为
ForEachkey。 - 听书进度变化是否只刷新必要区域,而不是重建整个播放列表。
一个简单的列表写法如下:
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 管理颜色、字号、间距和深浅色模式,让多页面应用保持统一气质。
更多推荐

所有评论(0)