【观止·诗史汇 HarmonyOS 实战系列 02】三层架构落地:entry、features、commons 的边界设计
【观止·诗史汇 HarmonyOS 实战系列 02】三层架构落地:entry、features、commons 的边界设计
很多 HarmonyOS 项目一开始都能把页面跑起来,但真正写到第二周、第三周,问题往往不是某个按钮不会写,而是工程边界开始变糊:入口页顺手读取业务数据,业务页顺手写路由字符串,公共层又悄悄知道了具体页面。代码还能运行,却越来越难改。
《观止·诗史汇》是一个本地优先的诗文与历史学习 App,首页要展示每日诗句和每日史事,后续还要继续接入诗文时空、兴替解意、古今地图、练习、收藏笔记、统计和设置。这个体量不算巨大,但已经足够暴露一个真实问题:如果不早一点把 entry、features、commons 的职责讲清楚,后面每增加一个页面,都会给维护留下债。
这一篇不只讲“目录长什么样”,而是复盘项目里三层架构真正落地时的判断:哪些东西应该留在产品入口,哪些东西属于业务模块,哪些能力可以沉到公共层,以及这些边界怎么通过代码和验证命令被固定下来。

上图是当前应用首页。看起来只是一个诗史学习 App 的入口,但背后已经涉及五个底部 Tab、响应式断点、公共主题 token、业务服务初始化、路由常量和本地状态仓。本文就沿着这些对象拆开看。
| 维度 | 内容 |
|---|---|
| 应用 | 观止·诗史汇 |
| 技术栈 | HarmonyOS NEXT、ArkTS、ArkUI、Stage 模型 |
| 本篇主题 | 三层架构落地:entry、features、commons 的边界设计 |
| 核心文件 | build-profile.json5、entry/src/main/ets/pages/Index.ets、features/Index.ets、commons/Index.ets |
| 验收目标 | 入口只做产品装配,业务能力收束到 features,公共能力不反向依赖业务模块 |
本章导读
这一篇会按工程流来讲,而不是按概念堆定义。
先看为什么学习类 App 一开始就需要分层,而不是等代码变多再整理。然后拆根目录 build-profile.json5 和各模块 oh-package.json5,确认模块关系是不是清楚。接着分别看 entry、features、commons 三层的真实代码职责。最后给出验证路径、问题复盘和验收清单,方便后续文章继续沿用这套标准。
当前验证环境
| 项目 | 版本或说明 |
|---|---|
| DevEco Studio | 6.x 系列 |
| HarmonyOS SDK | targetSdkVersion: 6.1.0(23) |
| 兼容版本 | compatibleSdkVersion: 6.0.2(22) |
| 应用模型 | Stage 模型 |
| 工程模块 | entry、commons、features |
| 当前验证页面 | 首页、时间轴、收藏、统计、设置五个一级入口 |
这篇文章的验收重点不是“首页好不好看”,而是三件事:模块依赖是不是单向、入口层是不是足够轻、公共层是不是足够稳定。只要这三件事成立,后面做诗文详情、朝代解读、练习闭环和收藏笔记时,代码就不容易互相拖住。
为什么三层架构不能只停留在目录
HarmonyOS 的工程经常会被拆成入口模块、公共模块和业务模块。目录拆开只是第一步,真正难的是长期遵守这些目录背后的规则。
《观止·诗史汇》当前已经包含这些能力:
| 能力 | 页面或服务 |
|---|---|
| 首页 | HomePage、每日诗句、每日史事、功能入口 |
| 诗文 | PoemListPage、PoemPackListPage、PoemDetailPage |
| 历史 | TimelinePage、DynastyInsightPage、DynastyOverviewPage |
| 地理与文脉 | GeoPage、LiteraturePage |
| 练习 | PracticeHomePage、PracticeRunPage |
| 收藏笔记 | FavoritePage、FavoriteFolderPage、NoteDetailPage |
| 统计设置 | StatsPage、SettingPage 与多个设置子页 |
如果这些页面都各自保存路由字符串、各自处理主题颜色、各自打开 Preferences,短期看很快,长期看会出现三个问题:
| 问题 | 直接后果 | 当前设计 |
|---|---|---|
| 路由散落在页面里 | 改一个页面名要全局搜索 | AppRoutes 集中管理 |
| 主题 token 写在业务页里 | 暗色、高对比、字号缩放难以统一 | commons/theme 统一提供 |
| 状态仓到处初始化 | 首屏、收藏、统计容易不同步 | AppBootstrap.hydrateAll(ctx) 统一水合 |
| 公共层引用业务层 | 模块边界倒置,后续拆包困难 | commons 只向上提供能力 |
所以第二篇要解决的不是“把代码放进三个文件夹”,而是让每一层都知道自己的边界。
根 build-profile:先把工程模块定下来
根目录 build-profile.json5 是三层架构的第一道证据。它明确声明了当前工程只暴露三个主要模块:
"modules": [
{
"name": "entry",
"srcPath": "./entry"
},
{
"name": "commons",
"srcPath": "./commons"
},
{
"name": "features",
"srcPath": "./features"
}
]
这里先不急着拆更多 HAR/HSP。对一个学习 App 的第一阶段来说,过早拆太细会带来额外的构建和引用成本。当前更合适的做法是:根工程只声明三层,业务内部再按目录继续收束,等某个业务域真正稳定以后,再考虑独立成更细的模块。
这也是本篇文章的一个关键判断:架构不是越碎越好,而是要能承接当前复杂度,并给未来扩展留出清晰方向。
依赖关系:entry 可以装配,features 可以依赖 commons
只看根模块还不够,还要看模块之间的依赖方向。当前 entry/oh-package.json5 依赖两个模块:
"dependencies": {
"commons": "file:../commons",
"features": "file:../features"
}
这说明 entry 是产品入口层,它可以使用公共能力,也可以装配业务页面。它不应该变成业务仓库,也不应该直接保存诗文、历史事件、收藏笔记这些业务数据。
再看 features/oh-package.json5:
"dependencies": {
"commons": "file:../commons"
}
features 只依赖 commons,不依赖 entry。这条规则非常重要:业务模块可以使用公共主题、路由、工具和数据封装,但不能知道入口层怎么组织 Tab,也不能反过来控制应用壳。
最终形成的依赖关系应该是:
entry
├─ uses commons
└─ uses features
features
└─ uses commons
commons
└─ no entry / features dependency
这条链路越简单,后续越容易定位问题。比如收藏页刷新不及时,就先看 features/state 和页面订阅;如果 Tab 切换异常,就先看 entry;如果路由参数丢失,就先看 commons/router。
entry:产品入口只负责装配,不沉淀业务逻辑
entry/src/main/ets/pages/Index.ets 是应用第一屏的入口。它做的事情很克制:维护当前 Tab、装配五个一级页面、监听断点变化、启动时触发业务状态水合。
核心结构如下:
import { AppColors, AppDimens, AppText, AppRoutes, AppBp } from 'commons';
import {
HomePage, TimelinePage, FavoritePage, StatsPage, SettingPage, AppBootstrap
} from 'features';
@Entry
@Component
struct Index {
@State currentKey: string = AppRoutes.TAB_HOME;
@State favoriteRefreshSignal: number = 0;
@State statsRefreshSignal: number = 0;
@StorageLink('curBp') curBp: string = AppBp.SM;
aboutToAppear(): void {
const ctx: common.UIAbilityContext = getContext(this) as common.UIAbilityContext;
AppBootstrap.hydrateAll(ctx);
}
}
这段代码有几个值得保留的边界:
| 入口层行为 | 为什么合理 |
|---|---|
currentKey 只保存 Tab key |
入口只需要知道当前展示哪个一级页面 |
favoriteRefreshSignal 和 statsRefreshSignal 只做刷新信号 |
不直接读取收藏或统计数据 |
AppBootstrap.hydrateAll(ctx) 在入口触发 |
应用启动时统一恢复状态,但具体 store 仍在 features |
主题、尺寸、断点来自 commons |
产品壳只消费 token,不自己定义 token |
这让 entry 更像一个“装配层”。它知道首页、时间轴、收藏、统计、设置这些一级入口,但不知道诗词列表如何加载,也不知道收藏项如何持久化,更不知道统计趋势怎么计算。
Tabs:一级页面不走路由,减少入口复杂度
当前五个一级入口通过 Tabs 组件直接装配:
Tabs({
barPosition: AppBp.isLg(this.curBp) ? BarPosition.Start : BarPosition.End,
index: this.indexOfKey(this.currentKey)
}) {
TabContent() {
HomePage()
}.tabBar(this.TabBarItem(AppRoutes.TAB_HOME, '首页'))
TabContent() {
TimelinePage()
}.tabBar(this.TabBarItem(AppRoutes.TAB_TIMELINE, '时间轴'))
TabContent() {
FavoritePage({ refreshSignal: this.favoriteRefreshSignal })
}.tabBar(this.TabBarItem(AppRoutes.TAB_FAVORITE, '收藏'))
}
这里有一个容易忽略的设计:一级 Tab 没有通过 router.pushUrl 跳转,而是由 Tabs 容器直接切换。原因很简单,首页、时间轴、收藏、统计、设置属于应用壳的固定入口,不是临时详情页。它们和底部导航强绑定,由入口层直接装配更稳定。
对应地,诗文详情、朝代解读、练习运行、笔记详情这类子页面才适合通过路由进入。这样页面层级会更清楚:
| 页面类型 | 处理方式 | 示例 |
|---|---|---|
| 一级入口 | Tabs 直接装配 |
首页、时间轴、收藏、统计、设置 |
| 子页面 | Navigator.push 跳转 |
诗文详情、朝代解读、练习运行 |
| 设置子页 | 路由进入 | 外观、字号、无障碍、关于 |
这套规则后面会继续影响页面设计。比如第三篇写首页时,首页只负责展示和入口触达;进入诗文列表或练习页时,再交给 commons/router 和具体业务页。
响应式断点也应该属于入口装配的一部分
Index.ets 里还有一段断点逻辑:
GridRow({ columns: 1 }) {
GridCol({ span: 1 }) {
this.TabsContainer()
}
}
.onBreakpointChange((bp: string) => {
this.curBp = bp;
AppStorage.setOrCreate(AppBp.STORAGE_KEY, bp);
})
断点状态保存在 AppStorage,并通过 @StorageLink('curBp') 连接到入口组件。这样做的好处是:入口层决定导航栏在大屏时放左侧、小屏时放底部,而业务页面只需要消费当前布局上下文。
这也是 entry 可以承担的职责。它不处理具体业务数据,但它可以处理产品级布局:导航栏位置、Tab 容器、首屏初始化、一级页面装配。这些都是应用壳该做的事情。
features:业务模块先用 Index.ets 收口
features/Index.ets 是业务模块的出口文件。它把当前 App 的业务页面和状态仓集中导出:
export { HomePage } from './src/main/ets/home/HomePage';
export { TimelinePage } from './src/main/ets/timeline/TimelinePage';
export { FavoritePage } from './src/main/ets/favorite/FavoritePage';
export { StatsPage } from './src/main/ets/stats/StatsPage';
export { SettingPage } from './src/main/ets/setting/SettingPage';
export {
FavoriteStore, NoteStore, StatsStore, SettingsStore, WrongStore,
PracticeAggResult, WrongQuestion, AppBootstrap
} from './src/main/ets/state/AppStores';
这里的价值不是少写几行 import,而是给 entry 一个稳定入口。入口层不需要知道 HomePage 在 src/main/ets/home/HomePage,也不需要知道 AppBootstrap 在 state/AppStores。它只从 features 拿能力。
这相当于给业务层做了一层门面:
| 外部看见 | 内部真实目录 |
|---|---|
HomePage |
features/src/main/ets/home/HomePage.ets |
TimelinePage |
features/src/main/ets/timeline/TimelinePage.ets |
FavoritePage |
features/src/main/ets/favorite/FavoritePage.ets |
AppBootstrap |
features/src/main/ets/state/AppStores.ets |
后续如果首页内部拆组件、统计页换目录、收藏状态仓拆文件,只要 features/Index.ets 的导出保持稳定,入口层就不用改。
features 内部按业务域分目录,而不是按组件类型分堆
当前 features/src/main/ets 下的目录是按业务域组织的:
features/src/main/ets
├─ home
├─ poem
├─ timeline
├─ dynasty
├─ geo
├─ literature
├─ practice
├─ favorite
├─ stats
├─ setting
├─ services
├─ state
└─ domain
这种组织方式比“components / pages / services”更适合这个项目。因为诗文、历史、练习、收藏这些能力会长期扩展,每个业务域都有自己的页面、局部组件、模型和服务。先按业务收束,能让维护者更快找到上下文。
比如首页相关组件放在 home/components:
| 文件 | 职责 |
|---|---|
DailyPoemCard.ets |
首页每日诗句卡片 |
DailyEventCard.ets |
首页每日史事卡片 |
EntryCard.ets |
首页功能入口卡片 |
而跨多个页面共享的诗文、历史、地理服务,则放到 services。这样业务目录不会无限膨胀,公共业务服务也不会被误放到 commons。
这里要特别注意:services 仍然属于 features,不是 commons。因为诗文服务、历史服务、练习服务都带有明确业务语义,不能因为“多个页面会用”就下沉到公共层。
commons:公共能力只能向上提供,不能知道业务
commons/Index.ets 是公共层出口:
// Theme tokens
export { AppColors } from './src/main/ets/theme/AppColors';
export { AppDimens } from './src/main/ets/theme/AppDimens';
export { AppText } from './src/main/ets/theme/AppText';
export { AppBp } from './src/main/ets/theme/Breakpoints';
// Router
export { AppRoutes } from './src/main/ets/router/RouteNames';
export { Navigator, NavigateParams } from './src/main/ets/router/Navigator';
// Data
export { PrefsStore } from './src/main/ets/data/PrefsStore';
export { RawJsonLoader } from './src/main/ets/data/RawJsonLoader';
这个文件暴露的是稳定基础能力:主题、路由、工具、数据封装、通用 UI。它不能 import features,也不能 import entry。
判断一个文件能不能放进 commons,可以用下面这张表:
| 能力 | 是否适合 commons | 原因 |
|---|---|---|
| 颜色、字号、尺寸 token | 适合 | 全应用通用,不带业务语义 |
| 路由常量和导航封装 | 适合 | 页面跳转基础设施 |
| Preferences JSON 封装 | 适合 | 存储基础能力 |
| Loading / Empty / Error 通用视图 | 适合 | 通用 UI 状态 |
| 诗文列表加载 | 不适合 | 属于诗文业务 |
| 历史事件分析 | 不适合 | 属于历史业务 |
| 收藏文件夹模型 | 不适合 | 带具体业务含义 |
| 练习题统计规则 | 不适合 | 属于学习闭环 |
这张表很朴素,但实战里特别有用。公共层一旦塞进业务对象,就会越来越像“全能工具箱”,最后反而谁都不敢改。
AppRoutes:一级 Tab key 和子页面路由分开
commons/src/main/ets/router/RouteNames.ets 里把一级 Tab key 和子页面路由放在一起集中管理,但语义上做了区分:
export class AppRoutes {
// 一级 Tab key(用于 Tabs 组件)
static readonly TAB_HOME: string = 'tab_home';
static readonly TAB_TIMELINE: string = 'tab_timeline';
static readonly TAB_FAVORITE: string = 'tab_favorite';
static readonly TAB_STATS: string = 'tab_stats';
static readonly TAB_SETTING: string = 'tab_setting';
// 子页面(router.pushUrl 用)
static readonly POEM_DETAIL: string = 'pages/PoemDetailPage';
static readonly DYNASTY_INSIGHT: string = 'pages/DynastyInsightPage';
static readonly PRACTICE_RUN: string = 'pages/PracticeRunPage';
}
这比在页面里写字符串更稳。因为路由名本质上是一种跨模块契约,只要散落到页面里,就会变成隐形耦合。集中到 AppRoutes 后,新增页面时就有一个固定步骤:先加路由常量,再在页面里引用。
在《观止·诗史汇》里,路由常量还承担了一个隐含作用:提醒开发者哪些页面属于一级入口,哪些页面属于二级详情。一级入口的 key 不直接传给 router.pushUrl,子页面才走路由。
Navigator:跨页参数要有名字,失败也要可观测
Navigator.ets 封装了 router.pushUrl、replaceUrl、back 和参数读取。这里最值得看的不是封装本身,而是 NavigateParams:
export interface NavigateParams {
dynastyId?: string;
poemId?: string;
eventId?: string;
geoId?: string;
practiceType?: string;
practiceMode?: string;
folderId?: string;
noteId?: string;
poemCatSlug?: string;
poemShard?: number;
}
参数有名字以后,页面之间的协作会稳定很多。比如诗文详情需要 poemId,时间轴详情需要 eventId,收藏文件夹需要 folderId。如果直接传一个松散对象,调用方和接收方很容易各写各的字段名。
导航失败也没有被吞掉:
static async push(url: string, params?: NavigateParams): Promise<void> {
try {
const opts: router.RouterOptions = { url: url, params: params ?? ({} as NavigateParams) };
await router.pushUrl(opts);
} catch (err) {
hilog.error(DOMAIN, TAG, 'pushUrl failed url=%{public}s err=%{public}s',
url, JSON.stringify(err));
}
}
这个设计对后续排查很关键。页面跳转失败时,调用页面不一定有明显报错,但日志里能看到 url 和错误对象,至少不会陷入“点了没反应”的盲猜。
AppBootstrap:状态水合从入口触发,具体实现留在业务层
entry 的 aboutToAppear 会调用 AppBootstrap.hydrateAll(ctx),但 AppBootstrap 本身定义在 features/state/AppStores.ets。这就是一个很好的边界示例:入口层负责触发应用启动流程,业务层负责具体状态恢复。
核心逻辑如下:
export class AppBootstrap {
private static hydrated: boolean = false;
private static hydrating: Promise<void> | null = null;
static async hydrateAll(ctx: Ctx): Promise<void> {
if (AppBootstrap.hydrated) return;
if (!AppBootstrap.hydrating) {
AppBootstrap.hydrating = AppBootstrap.doHydrateAll(ctx);
}
await AppBootstrap.hydrating;
}
private static async doHydrateAll(ctx: Ctx): Promise<void> {
await Promise.all([
SettingsStore.instance().hydrate(ctx),
FavoriteStore.instance().hydrate(ctx),
NoteStore.instance().hydrate(ctx),
StatsStore.instance().hydrate(ctx),
WrongStore.instance().hydrate(ctx)
]);
AppBootstrap.hydrated = true;
}
}
这里有三个工程价值:
| 设计 | 价值 |
|---|---|
hydrated 防重复 |
页面重建时不会反复恢复状态 |
hydrating 复用 Promise |
并发触发时不会重复水合 |
Promise.all 并行恢复 |
设置、收藏、笔记、统计、错题互不阻塞 |
这也解释了为什么 AppBootstrap 不适合放到 commons。它虽然被入口调用,但它恢复的是收藏、笔记、统计、错题这些业务状态,属于学习 App 的业务闭环。
PrefsStore:公共存储封装,业务 store 自己定义 key
commons 中的 PrefsStore 适合做基础存储封装,但具体存什么应该由业务 store 决定。比如 FavoriteStore 打开的是 favorites,StatsStore 打开的是 stats,SettingsStore 打开的是 settings。
这种分工可以这样理解:
| 层级 | 负责什么 |
|---|---|
PrefsStore |
打开 Preferences、读写 JSON、读写基础类型 |
FavoriteStore |
收藏项、文件夹、收藏状态 |
StatsStore |
学习时长、练习正确率、趋势、徽章 |
SettingsStore |
主题、字号、高对比、减少动效 |
公共层提供“怎么存”,业务层决定“存什么”。这样公共层就不会知道诗文、收藏、统计这些业务概念,也不会被后续业务变化拖着改。
分层边界的一个实用判断法
写 HarmonyOS 页面时,经常会遇到一个问题:这个函数到底放哪里?下面这张表是当前项目采用的判断方式。
| 想放的代码 | 推荐位置 | 判断依据 |
|---|---|---|
| 五个一级 Tab 的装配 | entry |
属于应用壳 |
| 首页每日诗句卡片 | features/home |
属于首页业务 |
| 诗文详情数据读取 | features/poem 或 features/services |
带诗文业务语义 |
| 路由常量 | commons/router |
跨模块基础契约 |
| 跳转失败日志封装 | commons/router |
通用导航能力 |
| 颜色、字号、断点 | commons/theme |
全局视觉 token |
| 收藏/笔记状态仓 | features/state |
属于学习闭环 |
| Empty / Loading / Error | commons/ui |
通用 UI 状态 |
这个判断法并不复杂,但能避免很多“顺手”。工程变差往往不是因为一次大错误,而是很多个“这个先放这里吧”叠起来。
工程验收记录
本篇对应的本机验证可以分成四类。
第一类,看工作区和模块文件是否存在:
git status --short
Get-ChildItem -LiteralPath . -Directory
Get-Content -LiteralPath .\build-profile.json5 -Encoding UTF8
第二类,看模块依赖方向:
Get-Content -LiteralPath .\entry\oh-package.json5 -Encoding UTF8
Get-Content -LiteralPath .\features\oh-package.json5 -Encoding UTF8
Get-Content -LiteralPath .\commons\oh-package.json5 -Encoding UTF8
验收时重点确认:
| 检查项 | 期望 |
|---|---|
entry |
依赖 commons 和 features |
features |
只依赖 commons |
commons |
不依赖 entry 或 features |
第三类,看模拟器里应用是否能打开并展示入口页:
& "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/app.png -w 1080 -h 2400 -t png
第四类,看源码层是否遵守边界。比如公共层不应该 import 业务模块,可以用搜索确认:
Select-String -Path .\commons\**\*.ets -Pattern "from 'features'","from 'entry'" -CaseSensitive
Select-String -Path .\features\**\*.ets -Pattern "from 'entry'" -CaseSensitive
这类命令不会替代人工判断,但能快速发现最危险的反向依赖。
常见问题复盘
1. 为什么不把所有页面都放在 entry?
因为 entry 是应用入口,不是业务容器。如果所有页面都堆在 entry,短期会少一个模块引用,长期会让入口层越来越重。入口层一旦混入诗文、历史、练习、收藏、统计逻辑,后续做多端布局或入口改版时,风险会被放大。
2. 为什么 features 现在还是一个模块,没有拆成多个 HAR?
当前项目还处在系列实战的前半段,业务边界正在稳定。先用一个 features 模块承接所有业务,再用目录区分业务域,是比较稳的选择。等诗文、历史、练习、收藏等域的接口稳定后,再拆成独立 HAR 会更自然。
3. 为什么路由常量放 commons,不放 features?
路由名是跨页面协作契约。entry 和多个业务页面都会用到它,所以放在 commons/router 更合适。但要注意,AppRoutes 只保存字符串契约,不应该在里面塞页面实例或业务逻辑。
4. 为什么 AppBootstrap 放 features,不放 commons?
AppBootstrap 虽然被 entry 调用,但它水合的是 FavoriteStore、NoteStore、StatsStore、WrongStore 这些业务状态。它不是通用基础设施,所以不应该下沉到 commons。
5. 为什么一级 Tab 不走 router?
一级 Tab 是应用壳的一部分,它和底部导航、侧栏导航强绑定。直接用 Tabs 装配更简单,也更符合入口层职责。子页面才通过 Navigator.push 进入。
文件职责整理
| 文件 | 职责 |
|---|---|
build-profile.json5 |
声明 entry、commons、features 三个主要模块 |
entry/oh-package.json5 |
声明入口层依赖公共层和业务层 |
features/oh-package.json5 |
声明业务层只依赖公共层 |
entry/src/main/ets/pages/Index.ets |
五 Tab 装配、断点监听、启动水合触发 |
features/Index.ets |
业务页面和状态仓统一导出 |
features/src/main/ets/state/AppStores.ets |
收藏、笔记、统计、设置、错题等状态仓 |
commons/Index.ets |
公共主题、路由、工具、数据、UI 能力统一导出 |
commons/src/main/ets/router/RouteNames.ets |
一级 Tab key 和子页面路由常量 |
commons/src/main/ets/router/Navigator.ets |
路由跳转、参数读取、失败日志封装 |
commons/src/main/ets/theme/* |
颜色、尺寸、文字、断点 token |
验收清单
- 根工程只声明
entry、commons、features三个核心模块。 entry可以依赖commons和features,但不直接沉淀业务数据。features可以依赖commons,但不能依赖entry。commons不 importfeatures或entry。- 一级 Tab 由
entry的Tabs容器装配。 - 子页面路由常量集中在
AppRoutes。 - 跨页参数通过
NavigateParams传递。 - 业务页面通过
features/Index.ets暴露给入口层。 - 主题 token、通用 UI、基础存储封装放在
commons。 - 收藏、笔记、统计、错题、设置这些业务状态留在
features/state。
本章小结
第二篇真正想解决的不是“项目里有几个目录”,而是让《观止·诗史汇》的工程边界从一开始就可解释、可验证、可扩展。
entry 是产品入口,负责五个一级 Tab、响应式导航和启动触发。features 是业务能力层,承接诗文、历史、地理、练习、收藏、统计和设置。commons 是公共基础层,提供主题、路由、工具、存储封装和通用 UI。
这套分层让后续文章可以继续往下拆:第三篇写首页时,只需要关注首页如何组织每日诗句、每日史事和入口卡片;第四篇写内容包时,只需要关注数据如何进入业务服务;后面写练习、收藏、统计时,也能沿着同一条边界继续推进。工程一开始稳住,后面的功能才不会越写越散。
更多推荐


所有评论(0)