文章示意图

页面预览

前言

在 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)

入口层是应用与系统交互的第一道关卡,承担三个核心职责:

  1. 声明应用入口:通过 module.json5mainElement: "EntryAbility" 注册为应用启动入口
  2. 处理生命周期:响应系统的 onCreate/onForeground/onBackground 等回调
  3. 加载首屏内容:通过 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 只做三件事:日志记录首屏加载生命周期回调占位。所有业务逻辑都被推到下游层级,保持了入口层的"轻薄"特性。

提示:hilogDOMAIN 参数必须是 0x00000xFFFF 范围内的十六进制数,用于标识日志来源。%{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);
    }
  }
}

数据层的关键设计决策包括:

  1. 统一入口:所有 AppStorage 读写都通过 DataStore,避免散落各处难维护
  2. 业务联动addLetter 同时更新 letters 和 penPals,保证数据一致性
  3. 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.json5mainElement 必须指向 abilities 数组中某个 ability 的 name
  • module.json5pages 字段指向路由表 main_pages.json
  • main_pages.jsonsrc 数组中每个路径必须对应一个真实的 .ets 页面文件
  • EntryAbility 通过 loadContent('pages/Index') 加载的页面必须存在于路由表

任何一个环节断裂,应用都无法正常启动。

五、依赖关系与数据流向

理解了各层职责后,我们来看看它们之间的依赖关系与数据流向。xiexin 的依赖方向是单向自上而下的:

loadContent

import

call static methods

use

CRUD

render with

read/write

EntryAbility 入口层

Pages 页面层

Models 模型层

DataStore 数据层

CommonComponents 表现层

AppStorage 全局状态

数据流向遵循"页面 → DataStore → AppStorage → 渲染"的循环:

  1. 页面写入:用户在 ComposePage 写完信点击发送,调用 DataStore.addLetter(letter)
  2. 数据层更新addLetter 内部调用 AppStorage.set('letters', newLetters)
  3. 状态广播:AppStorage 自动通知所有 @StorageLink('letters') 的订阅者
  4. 页面刷新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 在工程级只引入了两个开发依赖:

  1. @ohos/hypium:HarmonyOS 官方单元测试框架
  2. @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 模型的设计精髓:

  1. 配置驱动:从 app.json5 到 module.json5 到 main_pages.json,全部用配置文件描述应用结构,避免硬编码
  2. Ability 解耦:UIAbility 只负责生命周期与窗口管理,UI 内容由独立的页面组件承载
  3. 资源化设计:颜色、字符串、图片全部走 resources 目录,通过 $r 引用,为多语言、深浅色模式预留扩展空间
  4. 状态集中管理:AppStorage 作为全局状态枢纽,配合 @StorageLink 实现跨页面数据同步

理解了这些设计哲学,你就掌握了 HarmonyOS 应用开发的"骨架"。后续的文章将深入每一层的具体实现,从入口层到数据层,逐一剖析 xiexin 是如何一步步实现"笔友通信"这个核心场景的。

总结

本文以开源鸿蒙笔友通信应用 xiexin 为例,详细剖析了其"入口-页面-模型-数据"四层解耦设计。我们看到:

  • 入口层(EntryAbility)保持轻薄,只做生命周期回调与首屏加载
  • 页面层(pages)按职责拆分,每个页面单一职责
  • 模型层(model)用 class 定义领域实体,配合 @Observed 实现响应式
  • 数据层(DataStore)作为门面统一管理 AppStorage,保证数据一致性

四层之间通过单向依赖、配置契约、状态广播三大机制协同,既保证了代码的可维护性,又为后续扩展预留了充足空间。下一篇文章我们将深入入口层,剖析 EntryAbility 的六个生命周期回调的时序与职责。

如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!


相关资源

Logo

更多推荐