HarmonyOS开发实战:笔友-xiexin 项目架构与四层解耦设计


前言
在 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 的依赖方向是单向自上而下的:
数据流向遵循"页面 → 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 / r/ 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
更多推荐




所有评论(0)