前言
在 HarmonyOS 应用开发中,架构分层往往比写好单个组件更关键。一个分层清晰、职责明确的项目,能在后续迭代中保持稳定性和可维护性。本文将以开源鸿蒙笔友通信应用xiexin为蓝本,详细剖析其“入口-页面-模型-数据“四层解耦设计,讲解每个层级的职责边界、依赖关系与最佳实践。
xiexin 是一款基于 HarmonyOS ArkTS/ArkUI 的社交通信类应用,围绕“笔友关系与书信往来“的核心场景提供完整体验。整个项目代码量约 3500 行,麻雀虽小却五脏俱全,是学习 HarmonyOS Stage 模型架构设计的绝佳案例。
提示:本文假设你已经了解 HarmonyOS Stage 模型的基本概念。如果还不熟悉,建议先阅读HarmonyOS Stage 模型概述。
一、项目整体目录结构
xiexin 项目采用标准 HarmonyOS 工程组织方式,根目录下的关键文件和目录如下:
xiexin/ ├── AppScope/ # 应用级全局配置 │ ├── app.json5 # 应用清单(bundleName/versionCode/icon/label) │ └── resources/ # 应用级资源(图标、字符串) ├── entry/ # 主模块,包含所有业务代码 │ ├── src/ │ │ └── main/ │ │ ├── ets/ # ArkTS 源码(核心业务) │ │ ├── resources/ # 模块级资源(字符串、颜色、媒体、路由表) │ │ └── module.json5 # 模块清单(abilities、permissions) │ ├── build-profile.json5 # 模块构建配置 │ └── oh-package.json5 # 模块包描述 ├── build-profile.json5 # 工程级构建配置(签名、products、modules) ├── oh-package.json5 # 工程级包描述(devDependencies) └── hvigorfile.ts # hvigor 构建脚本这种组织方式遵循 HarmonyOS 官方推荐的工程结构,AppScope负责应用全局属性,entry模块承载具体业务,而build-profile.json5系列文件控制构建行为。
1.1 entry/src/main/ets 的内部分层
进入主模块的 ArkTS 源码目录,可以看到一个清晰的内部分层:
entry/src/main/ets/ ├── common/ # 常量与公共配置层 │ └── Constants.ets # AppColors 设计令牌、LetterStatus/Frequency 等枚举、日期工具 ├── components/ # 表现层复用组件 │ └── CommonComponents.ets # Avatar/LetterCard/PenPalItem/EmptyState 等 ├── database/ # 数据访问层 │ └── DataStore.ets # AppStorage 状态管理、CRUD 封装、mock 数据 ├── entryability/ # 入口与能力层 │ └── EntryAbility.ets # UIAbility 子类、生命周期回调 ├── model/ # 领域模型层 │ └── Models.ets # Letter/PenPal/UserProfile/WriteStats 等领域实体 └── pages/ # 页面层 ├── Index.ets # 首页 Tab 容器 ├── SplashPage.ets # 启动引导页 ├── AddPenPalPage.ets # 新增笔友表单 ├── PenPalDetailPage.ets # 笔友详情 ├── ComposePage.ets # 写信编辑器 ├── ReadLetterPage.ets # 信件列表阅读 ├── EditProfilePage.ets # 个人资料编辑 └── StatsPage.ets # 数据统计概览这种分层的核心思想是职责单一、依赖单向。下面我们逐一拆解每一层的具体职责。
二、四层架构设计详解
上图展示了 xiexin 的四层架构,依赖方向严格自上而下:入口层 → 页面层 → 模型层 → 数据层。表现层(组件)则独立存在,供页面层调用。
2.1 入口层(EntryAbility)
入口层是应用与系统交互的第一道关卡,承担三个核心职责:
- 声明应用入口:通过
module.json5的mainElement: "EntryAbility"注册为应用启动入口 - 处理生命周期:响应系统的
onCreate/onForeground/onBackground等回调 - 加载首屏内容:通过
windowStage.loadContent()加载初始页面
xiexin 的 EntryAbility 实现非常简洁:
// entry/src/main/ets/entryability/EntryAbility.ets import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit'; import { hilog } from '@kit.PerformanceAnalysisKit'; import { window } from '@kit.ArkUI'; const TAG: string = 'EntryAbility'; const DOMAIN: number = 0xFF00; export default class EntryAbility extends UIAbility { onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { hilog.info(DOMAIN, TAG, '%{public}s', 'Ability onCreate'); } onWindowStageCreate(windowStage: window.WindowStage): void { hilog.info(DOMAIN, TAG, '%{public}s', 'Ability onWindowStageCreate'); windowStage.loadContent('pages/Index', (err) => { if (err.code) { hilog.error(DOMAIN, TAG, 'Failed to load content. Cause: %{public}s', JSON.stringify(err) ?? ''); return; } hilog.info(DOMAIN, TAG, 'Succeeded in loading the content.'); }); } onForeground(): void { hilog.info(DOMAIN, TAG, '%{public}s', 'Ability onForeground'); } onBackground(): void { hilog.info(DOMAIN, TAG, '%{public}s', 'Ability onBackground'); } }可以看到,EntryAbility 只做三件事:日志记录、首屏加载、生命周期回调占位。所有业务逻辑都被推到下游层级,保持了入口层的“轻薄“特性。
提示:
hilog的DOMAIN参数必须是0x0000到0xFFFF范围内的十六进制数,用于标识日志来源。%{public}s表示明文输出,避免敏感信息泄露问题。
2.2 页面层(pages)
页面层是用户交互的直接载体,每个.ets文件对应一个@Entry @Component页面组件。xiexin 共有 8 个页面,按职责可分为四类:
| 页面分类 | 页面文件 | 职责描述 |
|---|---|---|
| 容器页 | Index.ets | 首页 Tab 容器,承载笔友列表与功能入口 |
| 引导页 | SplashPage.ets | 启动引导三屏切换 |
| 表单页 | AddPenPalPage.ets/EditProfilePage.ets | 笔友新增、个人资料编辑 |
| 详情页 | PenPalDetailPage.ets/ComposePage.ets/ReadLetterPage.ets/StatsPage.ets | 笔友详情、写信编辑器、信件阅读、数据统计 |
页面层的设计原则是单一职责、低耦合。每个页面只负责自己领域内的交互逻辑,跨页面的数据共享通过 AppStorage 完成。以 SplashPage 为例:
// entry/src/main/ets/pages/SplashPage.ets @Entry @Component struct SplashPage { @State currentPage: number = 0; @State fadeOpacity: number = 0; private guidePages: GuidePage[] = [ { title: '用心书写', subtitle: '每一封信都值得被认真对待', desc: '在这里,写信是一种仪式。放慢节奏,用文字传递温度与思念。', emoji: '✉️' }, { title: '慢慢等待', subtitle: '等待回信是最美的期待', desc: '两人只能写信联系,不能即时回复。每封信都承载着对方的心意。', emoji: '⏳' }, { title: '深度连接', subtitle: '少而精,深而暖', desc: '设置联系频率,每天一次或每周一次,让每封信都弥足珍贵。', emoji: '💌' } ]; aboutToAppear(): void { setTimeout(() => { this.fadeOpacity = 1; }, 100); } build() { Stack() { Column().width('100%').height('100%').backgroundColor(AppColors.PRIMARY_BG) // ... 引导卡片、指示器、按钮 ... } } }SplashPage 的所有状态都是页面级@State,不依赖 AppStorage。这种设计让引导页可以独立测试,也避免了冷启动时与其他页面争抢 AppStorage 资源。
2.3 模型层(model)
模型层定义应用的核心领域实体。xiexin 的Models.ets集中定义了用户、笔友、信件、统计、时间线等 6 个领域类:
// entry/src/main/ets/model/Models.ets export class UserProfile { id: number = 0; name: string = ''; signature: string = ''; signatureSuffix: string = ''; totalLetters: number = 0; totalWords: number = 0; totalPenPals: number = 0; streakDays: number = 0; longestLetter: number = 0; favoriteWriteTime: string = ''; mostUsedHonorific: string = ''; longestWaitDays: number = 0; totalWaitDays: number = 0; } export class PenPal { id: number = 0; name: string = ''; avatar: string = ''; signature: string = ''; relationStage: RelationStage = RelationStage.NEW; frequency: Frequency = Frequency.WEEKLY; totalLetters: number = 0; daysSinceMet: number = 0; lastLetterAt: number = 0; lastLetterPreview: string = ''; lastLetterStatus: LetterStatus = LetterStatus.CAN_WRITE; isLastLetterSender: boolean = false; metAt: number = 0; } export class Letter { id: number = 0; penPalId: number = 0; penPalName: string = ''; honorific: string = ''; recipientName: string = ''; greeting: string = ''; body: string = ''; closing: string = ''; signature: string = ''; signatureSuffix: string = ''; isSender: boolean = false; status: LetterStatus = LetterStatus.WAIT_REPLY; paperStyle: PaperStyle = PaperStyle.PLAIN; wordCount: number = 0; isRead: boolean = false; createdAt: number = 0; }模型层使用class而非interface定义实体,这是 ArkUI 响应式系统的要求:只有类实例才能被@Observed装饰器追踪属性变化。每个字段都显式声明了默认值,避免undefined引发渲染异常。
提示:ArkUI 的状态管理依赖代理对象(Proxy),对
interface类型无法生效。如果你需要在@Observed类中嵌套其他类,记得给嵌套类也加上@Observed。
2.4 数据层(database)
数据层是整个架构的“地基“,承担状态持久化、数据读写、跨页面共享三大职责。xiexin 的DataStore.ets通过静态方法实现了一个轻量级的“门面模式“:
// entry/src/main/ets/database/DataStore.ets export class DataStore { static initializeData(): void { const penPals: PenPal[] = DataStore.createMockPenPals(); AppStorage.setOrCreate<PenPal[]>('penPals', penPals); const letters: Letter[] = DataStore.createMockLetters(); AppStorage.setOrCreate<Letter[]>('letters', letters); const profile: UserProfile = DataStore.createMockProfile(); AppStorage.setOrCreate<UserProfile>('userProfile', profile); const stats: WriteStats = DataStore.createMockStats(); AppStorage.setOrCreate<WriteStats>('writeStats', stats); AppStorage.setOrCreate<InviteRecord[]>('invites', []); AppStorage.setOrCreate<boolean>('hasSeenSplash', false); AppStorage.setOrCreate<number>('currentTab', 0); } // 添加新信件 static addLetter(letter: Letter): void { const letters: Letter[] = AppStorage.get<Letter[]>('letters') ?? []; letter.id = letters.length > 0 ? Math.max(...letters.map((l: Letter) => l.id)) + 1 : 1; letters.unshift(letter); AppStorage.set<Letter[]>('letters', letters); // 同步更新笔友的最近一封信字段 const penPals: PenPal[] = AppStorage.get<PenPal[]>('penPals') ?? []; const idx = penPals.findIndex((p: PenPal) => p.id === letter.penPalId); if (idx >= 0) { penPals[idx].totalLetters += 1; penPals[idx].lastLetterAt = letter.createdAt; penPals[idx].lastLetterPreview = letter.body.substring(0, 30); penPals[idx].lastLetterStatus = LetterStatus.WAITING_OTHER; penPals[idx].isLastLetterSender = true; AppStorage.set<PenPal[]>('penPals', penPals); } } }数据层的关键设计决策包括:
- 统一入口:所有 AppStorage 读写都通过 DataStore,避免散落各处难维护
- 业务联动:
addLetter同时更新 letters 和 penPals,保证数据一致性 - ID 自增策略:用
Math.max(...ids) + 1生成新 ID,简单有效
三、表现层与组件复用
表现层位于components/CommonComponents.ets,集中放置可复用的 UI 控件。xiexin 选择“单文件多组件“的组织方式,把 Avatar、LetterCard、PenPalItem、EmptyState 等组件都放在一个 146 行的文件里。
这种设计的取舍如下:
- 优势:组件之间的耦合可见,调试时只读一个文件;小型项目避免了文件爆炸
- 劣势:当组件数量超过 10 个时,单文件可读性下降;多人协作易产生合并冲突
如果你打算扩展 xiexin,建议按“组件类型“拆分:
components/ ├── avatars/ │ ├── Avatar.ets │ └── GroupAvatar.ets ├── cards/ │ ├── LetterCard.ets │ └── StatsCard.ets ├── lists/ │ ├── PenPalItem.ets │ └── LetterItem.ets └── states/ └── EmptyState.ets四、配置文件体系
除了 ArkTS 源码,xiexin 的架构还包含一系列 JSON5 配置文件,它们各司其职:
// AppScope/app.json5:应用级全局配置 { "app": { "bundleName": "com.xiexin.letter", "vendor": "xiexin", "versionCode": 1000000, "versionName": "1.0.0", "icon": "$media:app_icon", "label": "$string:app_name" } }// entry/src/main/module.json5:模块清单 { "module": { "name": "entry", "type": "entry", "mainElement": "EntryAbility", "deviceTypes": ["phone", "tablet", "2in1"], "pages": "$profile:main_pages", "abilities": [ { "name": "EntryAbility", "srcEntry": "./ets/entryability/EntryAbility.ets", "startWindowIcon": "$media:startIcon", "startWindowBackground": "$color:start_window_background", "exported": true, "skills": [ { "entities": ["entity.system.home"], "actions": ["action.system.home"] } ] } ], "requestPermissions": [ { "name": "ohos.permission.INTERNET" } ] } }// entry/src/main/resources/base/profile/main_pages.json:路由表 { "src": [ "pages/Index", "pages/SplashPage", "pages/ComposePage", "pages/ReadLetterPage", "pages/PenPalDetailPage", "pages/AddPenPalPage", "pages/StatsPage", "pages/EditProfilePage" ] }这三个配置文件之间存在严格的契约关系:
module.json5的mainElement必须指向abilities数组中某个 ability 的namemodule.json5的pages字段指向路由表main_pages.jsonmain_pages.json的src数组中每个路径必须对应一个真实的.ets页面文件EntryAbility通过loadContent('pages/Index')加载的页面必须存在于路由表
任何一个环节断裂,应用都无法正常启动。
五、依赖关系与数据流向
理解了各层职责后,我们来看看它们之间的依赖关系与数据流向。xiexin 的依赖方向是单向自上而下的:
graph TD EA[EntryAbility 入口层] -->|loadContent| PAGES[Pages 页面层] PAGES -->|import| MODELS[Models 模型层] PAGES -->|call static methods| DS[DataStore 数据层] PAGES -->|use| CC[CommonComponents 表现层] DS -->|CRUD| MODELS CC -->|render with| MODELS DS -->|read/write| AS[AppStorage 全局状态]数据流向遵循“页面 → DataStore → AppStorage → 渲染“的循环:
- 页面写入:用户在
ComposePage写完信点击发送,调用DataStore.addLetter(letter) - 数据层更新:
addLetter内部调用AppStorage.set('letters', newLetters) - 状态广播:AppStorage 自动通知所有
@StorageLink('letters')的订阅者 - 页面刷新:
Index页面订阅了 letters,自动重新渲染笔友列表
这种数据流的核心优势是单向、可追溯、易调试。所有状态变更都经过 DataStore 这个“瓶颈“,我们可以在 DataStore 的每个方法里加埋点,就能完整还原用户操作链路。
六、设计令牌与枚举体系
xiexin 的common/Constants.ets文件是整个项目的“约定中心“,包含三部分内容:
6.1 AppColors 设计令牌
export class AppColors { static readonly PRIMARY_BG: string = '#FAF6F0'; static readonly SECONDARY_BG: string = '#F0EBE3'; static readonly PAPER_BG: string = '#FFFDF7'; static readonly PRIMARY: string = '#8B6914'; static readonly SECONDARY: string = '#C4956A'; static readonly TEXT_PRIMARY: string = '#2D2A26'; static readonly TEXT_SECONDARY: string = '#7A746B'; static readonly ACCENT: string = '#B8860B'; static readonly DIVIDER: string = '#E8E2D8'; static readonly SUCCESS: string = '#4CAF50'; // ... }设计令牌(Design Tokens)是设计系统的核心,统一管理颜色、字号、间距等视觉常量。xiexin 用static readonly字段实现,访问方式为AppColors.PRIMARY。
提示:更规范的做法是把颜色定义到
resources/base/element/color.json,然后在代码里用$r('app.color.primary')引用。这样能自动适配深浅色模式。本文的“四层解耦“暂不展开这一话题,后续文章会专门讲解。
6.2 业务枚举
export enum LetterStatus { WAIT_REPLY = 0, // 等待你回信 WAITING_OTHER = 1, // 等待对方回信 REPLIED = 2, // 已回信 CAN_WRITE = 3 // 可以写信 } export enum Frequency { DAILY = 1, WEEKLY = 7, BIWEEKLY = 14, MONTHLY_TWICE = 15, MONTHLY = 30 } export enum RelationStage { NEW = 0, // 初识 FAMILIAR = 1, // 熟悉 INTIMATE = 2 // 亲密 } export enum PaperStyle { PLAIN = 0, // 素白 LINED = 1, // 信笺线 INK_BORDER = 2, // 水墨边 PETAL = 3 // 花瓣边 }这些枚举定义了笔友通信场景的核心状态空间。把枚举集中放在 Constants.ets 中,避免了散落各处的“魔法数字“,也让 UI 层和数据层的约定更加显式。
6.3 日期工具函数
export function daysBetween(d1: Date, d2: Date): number { const diff = Math.abs(d2.getTime() - d1.getTime()); return Math.floor(diff / (1000 * 60 * 60 * 24)); } export function formatTimeDiff(date: Date): string { const now = new Date(); const days = daysBetween(date, now); if (days === 0) return '今天'; else if (days === 1) return '昨天'; else if (days < 7) return `${days}天前`; else if (days < 30) { const weeks = Math.floor(days / 7); return `${weeks}周前`; } else { return formatShortDate(date); } }这些工具函数被多个页面共享调用,放在 Constants.ets 里能避免循环依赖问题。
七、构建与依赖管理
xiexin 的构建配置分为工程级和模块级两层:
// 工程级 build-profile.json5 { "app": { "signingConfigs": [/* 签名配置 */], "products": [ { "name": "default", "signingConfig": "default", "targetSdkVersion": "6.0.2(22)", "compatibleSdkVersion": "6.0.2(22)", "runtimeOS": "HarmonyOS" } ], "buildModeSet": [ { "name": "debug" }, { "name": "release" } ] }, "modules": [ { "name": "entry", "srcPath": "./entry", "targets": [/* ... */] } ] }// 工程级 oh-package.json5 { "modelVersion": "6.0.2", "description": "Please describe the basic information.", "dependencies": {}, "devDependencies": { "@ohos/hypium": "1.0.25", "@ohos/hamock": "1.0.0" } }可以看到,xiexin 在工程级只引入了两个开发依赖:
- @ohos/hypium:HarmonyOS 官方单元测试框架
- @ohos/hamock:组件 Mock 工具,配合 Hypium 测试 UI 组件
这反映了 xiexin 项目对测试可测性的重视。整个 DataStore 都设计为静态方法,正是为了方便在测试用例里直接调用而无需实例化。
八、四层架构的边界与例外
虽然“四层解耦“听起来很美,但在实际项目中总会遇到边界模糊的灰色地带。xiexin 也不例外,下面列出几个值得注意的取舍:
8.1 DataStore 是否承担了过多职责
目前的DataStore.ets同时负责:
- AppStorage 读写
- Mock 数据生成(
createMockPenPals等) - 业务联动逻辑(
addLetter同步更新 penPals) - 聚合查询(
getTimelineForPenPal)
如果项目继续扩展,建议把 Mock 数据生成独立到mock/MockDataFactory.ets,把聚合查询独立到query/LetterQueryService.ets,让 DataStore 回归纯粹的 CRUD 职责。
8.2 Constants.ets 文件的膨胀风险
Constants.ets已经包含 201 行内容,涵盖设计令牌、枚举、字典、工具函数。随着业务增长,这个文件会进一步膨胀。建议按内容类型拆分:
common/ ├── AppColors.ets # 设计令牌 ├── Enums.ets # 业务枚举 ├── Dicts.ets # 称谓/问候/署名字典 ├── DateUtils.ets # 日期工具 └── Constants.ets # 路由键、其他常量8.3 组件层与页面层的耦合
CommonComponents.ets中的PenPalItem组件直接依赖了PenPal模型类。这种依赖是合理的,但如果未来PenPalItem需要在不同场景下展示不同字段,应该考虑用@Prop解耦。
九、与官方推荐的工程结构对比
HarmonyOS 官方文档应用程序包结构中给出了推荐的工程结构。xiexin 的实现与官方推荐基本一致,但有几处细微差异:
| 维度 | 官方推荐 | xiexin 实现 | 评价 |
|---|---|---|---|
| AppScope 配置 | app.json5 | 完全一致 | ✓ |
| 模块入口 | module.json5 + abilities | 完全一致 | ✓ |
| 源码组织 | ets 目录分层 | 四层分层 | ✓ 创新点 |
| 路由表 | resources/base/profile | 完全一致 | ✓ |
| 资源引用 | r/rawfile | 完全一致 | ✓ |
| 测试框架 | @ohos/hypium | 完全一致 | ✓ |
总体来看,xiexin 是一个非常贴近 HarmonyOS 官方推荐实践的项目,对新手学习 HarmonyOS 工程结构具有很高的参考价值。
十、从 xiexin 看鸿蒙 Stage 模型的精髓
xiexin 项目虽小,却完美体现了 HarmonyOS Stage 模型的设计精髓:
- 配置驱动:从 app.json5 到 module.json5 到 main_pages.json,全部用配置文件描述应用结构,避免硬编码
- Ability 解耦:UIAbility 只负责生命周期与窗口管理,UI 内容由独立的页面组件承载
- 资源化设计:颜色、字符串、图片全部走 resources 目录,通过
$r引用,为多语言、深浅色模式预留扩展空间 - 状态集中管理:AppStorage 作为全局状态枢纽,配合
@StorageLink实现跨页面数据同步
理解了这些设计哲学,你就掌握了 HarmonyOS 应用开发的“骨架“。后续的文章将深入每一层的具体实现,从入口层到数据层,逐一剖析 xiexin 是如何一步步实现“笔友通信“这个核心场景的。
总结
本文以开源鸿蒙笔友通信应用 xiexin 为例,详细剖析了其“入口-页面-模型-数据“四层解耦设计。我们看到:
- 入口层(EntryAbility)保持轻薄,只做生命周期回调与首屏加载
- 页面层(pages)按职责拆分,每个页面单一职责
- 模型层(model)用 class 定义领域实体,配合 @Observed 实现响应式
- 数据层(DataStore)作为门面统一管理 AppStorage,保证数据一致性
四层之间通过单向依赖、配置契约、状态广播三大机制协同,既保证了代码的可维护性,又为后续扩展预留了充足空间。下一篇文章我们将深入入口层,剖析 EntryAbility 的六个生命周期回调的时序与职责。
如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!
相关资源
- 开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
- HarmonyOS Stage 模型概述:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/stage-model-development-overview
- HarmonyOS 应用程序包结构:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/application-package-structure-stage
- HarmonyOS 应用配置文件概述:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/application-configuration-file-overview-stage
- HarmonyOS ArkUI 状态管理概述:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-state-management-overview
- HarmonyOS AppStorage 全局状态:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-appstorage
- HarmonyOS 资源分类与访问:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/resource-categories-and-access
- HarmonyOS hilog 日志使用:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/hilog