HarmonyKit | 鸿蒙开发:项目目录结构与多模块架构最佳实践

HarmonyKit | 鸿蒙开发:项目目录结构与多模块架构最佳实践

引言:目录结构是架构的物理投射

一个好的项目目录结构解决三个问题:新成员打开项目后知道文件在哪里、修改一个功能时知道改动范围的上限、添加一个功能时知道代码应该放在哪里。

HarmonyKit 经历了三次目录重构才稳定为现在的结构。第一次重构是发现页面和逻辑混在一起难以测试,第二次是工具数量从 5 个增长到 10 个后页面层变得臃肿,第三次是将 DevEco Studio 的模板目录按实际开发习惯重新组织。

这篇文章基于 HarmonyKit 的实战经验,逐层拆解鸿蒙项目(Stage 模型)的目录结构,解释每一层的职责边界、命名规范和组织原则。

项目仓库:https://atomgit.com/VON-/harmony-kit

项目顶层结构:两棵树

打开 HarmonyKit 的根目录,你看到的是两层平行的文件树:

复制代码
harmonykit/
├── AppScope/                    # 应用全局配置
│   └── app.json5
├── entry/                       # entry 模块(主模块)
│   ├── src/
│   ├── build-profile.json5
│   └── oh-package.json5
├── hvigor/                      # 构建系统配置
│   └── hvigor-config.json5
├── hvigorfile.ts                # 构建入口
├── build-profile.json5          # 项目级构建配置
├── oh-package.json5             # 项目级依赖声明
├── .gitignore
└── .hvigor/                     # 构建缓存(不入库)

顶层结构遵循一个基本原则:项目的全局配置放在根目录下,模块的局部配置放在模块目录下build-profile.json5(项目级)和 entry/build-profile.json5(模块级)的命名相似但作用域不同------项目级的 product 和 signingConfig 作用于所有模块,模块级的 buildOptionSet 只作用于当前模块。

AppScope:应用的"身份证"

AppScope/app.json5 是整个应用的元数据集合。HarmonyKit 的配置:

json5 复制代码
{
  "app": {
    "bundleName": "com.claude.harmonykit",
    "vendor": "example",
    "versionCode": 1000000,
    "versionName": "1.0.0",
    "buildVersion": "1",
    "icon": "$media:layered_image",
    "label": "$string:app_name"
  }
}

每个字段的含义和重要性:

  • bundleName :应用的唯一标识符,采用反向域名格式(如 com.claude.harmonykit)。bundleName 一旦确定就不要更改------更改后系统会将其视为全新应用,导致已安装用户的数据丢失。AppGallery 上线后不可再改。
  • vendor:开发者标识,用于区分不同开发者。上架 AppGallery 时需与开发者账号一致。
  • versionCode :整数版本号,用于系统判断版本新旧(数值越大版本越新)。每次提交 AppGallery 审核时必须递增。HarmonyKit 使用 1000000 表示 1.0.0(主版本×1000000 + 次版本×10000 + 修订版本×100)。
  • versionName:面向用户的版本名称,SemVer 格式。这是用户在 AppGallery 页面上看到的版本号。
  • buildVersion:构建版本号,通常用于 CI/CD 流水线中标识具体的构建编号。
  • icon :应用图标资源引用,$media:layered_image 指向 resources/base/media/layered_image.json------鸿蒙从 API 12 开始支持的分层图标(foreground + background)。
  • label :应用名称资源引用,指向 resources/base/element/string.jsonnameapp_name 的字符串值。

AppScope vs entry 的职责分离:AppScope 描述"这个应用是谁",entry 描述"这个模块有什么能力"。形象地说,AppScope 是护照(标识身份),entry 是技能清单(列出你能做什么)。一个应用只有一个 AppScope,但可以有多个 module(entry、feature、HSP、HAR)。

entry 模块结构:三层分离

entry 模块是 HarmonyKit 唯一的模块(Stage 模型的 entry 类型模块)。其内部结构遵循严格的三层分离:

复制代码
entry/
├── src/
│   ├── main/
│   │   ├── ets/                # ArkTS 源码
│   │   │   ├── entryability/   # Ability 入口
│   │   │   ├── entrybackupability/  # 备份扩展
│   │   │   ├── model/          # 数据模型层
│   │   │   ├── utils/          # 工具函数层
│   │   │   ├── components/     # 可复用 UI 组件层
│   │   │   └── pages/          # 页面层
│   │   │       ├── Index.ets
│   │   │       └── tools/      # 各工具页面
│   │   ├── resources/          # 资源文件
│   │   │   ├── base/           # 默认资源
│   │   │   │   ├── element/    # 字符串/颜色/浮点数
│   │   │   │   ├── media/      # 图片/音频
│   │   │   │   └── profile/    # 配置文件(如 main_pages.json)
│   │   │   ├── dark/           # 深色模式覆盖资源
│   │   │   └── rawfile/        # 原始文件(按原样打包)
│   │   └── module.json5        # 模块描述文件
│   ├── ohosTest/               # 自动化测试
│   └── test/                   # 本地单元测试
├── build-profile.json5         # 模块级构建配置
└── oh-package.json5            # 模块级依赖声明

entryability/:应用的唯一入口

EntryAbility.ets 是应用的启动点,继承自 UIAbility。HarmonyKit 的实现:

typescript 复制代码
import { AbilityConstant, ConfigurationConstant, UIAbility, Want } from '@kit.AbilityKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { window } from '@kit.ArkUI';

export default class EntryAbility extends UIAbility {
  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    try {
      this.context.getApplicationContext().setColorMode(
        ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET
      );
    } catch (err) {
      hilog.error(DOMAIN, 'testTag', 'Failed to set colorMode. Cause: %{public}s',
        JSON.stringify(err));
    }
  }

  onWindowStageCreate(windowStage: window.WindowStage): void {
    windowStage.loadContent('pages/Index', (err) => {
      if (err.code) {
        hilog.error(DOMAIN, 'testTag', 'Failed to load the content.');
        return;
      }
    });
  }
  // ... onDestroy, onForeground, onBackground
}

关键点:

  • ColorMode.COLOR_MODE_NOT_SET:不强制设置颜色模式,让系统根据设备设置自动选择深色/浅色模式。
  • windowStage.loadContent('pages/Index'):指定首页路径为 pages/Index。这个路径与 main_pages.json 中的路由配置对应。
  • 每个生命周期方法中都有 hilog 日志------这是调试时的重要线索。

entrybackupability/:数据备份扩展

EntryBackupAbility.ets 是 Stage 模型引入的备份扩展能力。HarmonyKit 使用默认配置------备份能力由系统管理,开发者只需声明存在此扩展。对于不存储用户数据的工具类应用,这个扩展的实际意义不大,但它是 Stage 模型的模板代码的一部分。

model/:数据定义与注册中心

HarmonyKit 的 model 层只有一个文件 ToolItem.ets,但它的设计代表了整个应用的"注册"范式:

typescript 复制代码
export interface ToolItem {
  id: string;
  name: string;
  description: string;
  icon: string;
  color: string;
  category: string;
  routerPath: string;
}

export const CATEGORIES: string[] = ['全部', '格式化', '编解码', '计算', '文本'];

export const TOOL_LIST: ToolItem[] = [
  {
    id: 'json',
    name: 'JSON 格式化',
    description: '格式化、校验与压缩 JSON 字符串',
    icon: '{}',
    color: '#007aff',
    category: '格式化',
    routerPath: 'pages/tools/JsonFormatter'
  },
  // ... 更多工具
];

interfaces 定义在 .ets 文件中而不是 .d.ts 中------这是 ArkTS 的实践习惯。ArkTS 不支持 .d.ts 声明文件(它是 TypeScript 独有的概念),所以类型定义直接写在 .ets 文件中通过 export 关键字导出。

utils/:纯函数工具层

六个 util 文件,每个文件一个类,每个类只包含静态方法:

复制代码
utils/
├── Base64Utils.ets       # Base64 编解码
├── ColorUtils.ets        # RGB/HEX/HSL 互转
├── HashUtils.ets         # MD5/SHA1/SHA256 哈希
├── JsonUtils.ets         # JSON 格式化/压缩/校验
├── TimestampUtils.ets    # 时间戳/日期互转
└── UrlUtils.ets          # URL 编解码

util 文件的设计准则:

  1. 零 import 从页面层:util 文件不应 import 任何 pages 或 components 中的模块------它们是单向依赖的末端。
  2. 无状态:所有方法都是纯函数(给定的输入产生确定的输出),不操作全局变量。
  3. 错误处理内聚:每个方法自己处理异常,不要让调用方猜测会抛出什么异常。

HashUtils 为例:

typescript 复制代码
import { cryptoFramework } from '@kit.CryptoArchitectureKit';
import { buffer } from '@kit.ArkTS';

export class HashUtils {
  static async hash(input: string, algorithm: 'MD5' | 'SHA1' | 'SHA256'): Promise<string> {
    try {
      let md = cryptoFramework.createMd(algorithm);
      let uint8Array = new Uint8Array(buffer.from(input, 'utf-8').buffer);
      await md.update({ data: uint8Array });
      let result = await md.digest();
      return buffer.from(result.data).toString('hex');
    } catch (e) {
      return '计算失败: ' + (e as Error).message;
    }
  }
}

注意 try-catch 包围了整个异步操作链,确保方法永远不会抛出未捕获的异常。

components/:可复用 UI 组件

两个组件:ToolCard.etsCopyButton.ets。它们的特点是:

  • @Prop 驱动,无内部 @State
  • 无页面级路由逻辑(不调用 pushUrlback
  • 一个文件一个组件,不做"一个文件导出多个小组件"

CopyButton 为例:

typescript 复制代码
import { pasteboard } from '@kit.BasicServicesKit';

@Component
export struct CopyButton {
  @Prop text: string;
  @Prop btnText: string = '复制';

  build() {
    Button(this.btnText)
      .fontSize(12).height(32)
      .padding({ left: 12, right: 12 })
      .backgroundColor('#f0f0f0')
      .onClick(() => {
        if (this.text.length > 0) {
          let data = pasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN, this.text);
          let sysPasteboard = pasteboard.getSystemPasteboard();
          sysPasteboard.setData(data, (err) => {
            if (err) {
              this.getUIContext().getPromptAction().showToast({
                message: '复制失败', duration: 1500
              });
            } else {
              this.getUIContext().getPromptAction().showToast({
                message: '已复制到剪贴板', duration: 1500
              });
            }
          });
        }
      })
  }
}

这个组件的设计考量:

  • @Prop text:被复制的文本内容。由父组件传入,不为空时才允许复制。
  • @Prop btnText:按钮文字,默认值 '复制'。设计了默认值,父组件不传时也能正常工作。
  • 使用了 @kit.BasicServicesKitpasteboard API------这是鸿蒙的剪贴板能力,不依赖 Android 的 ClipboardManager

pages/:页面层

页面层是 HarmonyKit 中唯一同时引用 model、utils 和 components 的层级。它承担"组装"职责。

复制代码
pages/
├── Index.ets              # 首页:工具分类导航
└── tools/
    ├── JsonFormatter.ets
    ├── Base64Tool.ets
    ├── TimestampConverter.ets
    ├── UrlEncoder.ets
    ├── HashCalculator.ets
    ├── RegexTester.ets
    ├── UuidGenerator.ets
    ├── ColorConverter.ets
    ├── RadixConverter.ets
    └── TextCounter.ets

pages/tools/ 子目录的必要性 :当页面数量超过 5 个时,平铺在 pages/ 下会导致文件列表难以浏览。HarmonyKit 将工具页收进 tools/ 子目录,首页文件保留在 pages/ 根目录。

页面的四要素模式:每个工具页面由四个要素组成:

  1. @Entry @Component struct 装饰器声明
  2. 一组 @State 变量保存用户输入和结果
  3. build() 方法中的 UI 声明(标题栏 + Scroll + 输入区 + 操作区 + 结果区)
  4. 一组操作方法(formatJson()doMatch() 等)处理用户交互

为什么页面文件不建议进一步分子目录? 有人可能想按分类组织------tools/encoding/tools/calc/。但页面的主要导航是通过路由路径(不是文件系统)完成的,分类信息已经体现在 ToolItem.category 字段中。增加文件系统层级只会增加 import 路径的心智负担。

resources/:资源的四种形态

element/:结构化资源

string.json 存放字符串资源:

json 复制代码
{
  "string": [
    { "name": "module_desc", "value": "开发者工具箱" },
    { "name": "EntryAbility_desc", "value": "开发者工具箱" },
    { "name": "EntryAbility_label", "value": "HarmonyKit" }
  ]
}

通过 $string:EntryAbility_label 引用。使用结构化资源的好处是支持国际化------在不同语言目录下提供同名资源,系统自动根据设备语言选择。

HarmonyKit 目前只做了中文,不涉及国际化。如果要做英文版,需要在 resources/en/element/string.json 中提供对应的翻译。

color.json 存放颜色资源:

json 复制代码
{
  "color": [
    { "name": "start_window_background", "value": "#F5F5F5" }
  ]
}
media/:媒体资源

HarmonyKit 的 media 目录包含四张图片,其中最特别的是 layered_image.json

json 复制代码
{
  "layered-image": {
    "background": "$media:background",
    "foreground": "$media:foreground"
  }
}

这是鸿蒙 API 12 引入的分层图标机制------前景和背景分开绘制,系统可以对它们分别施加动效(如点击时的缩放、长按时的颜色变化)。

profile/:配置文件

main_pages.json 是页面路由的配置文件:

json 复制代码
{
  "src": [
    "pages/Index",
    "pages/tools/JsonFormatter",
    "pages/tools/Base64Tool",
    "pages/tools/TimestampConverter",
    "pages/tools/UrlEncoder",
    "pages/tools/HashCalculator",
    "pages/tools/RegexTester",
    "pages/tools/UuidGenerator",
    "pages/tools/ColorConverter",
    "pages/tools/RadixConverter",
    "pages/tools/TextCounter"
  ]
}

这个文件的职责是告诉系统"哪些页面是可路由的"。只有列在这里的页面才能通过 router.pushUrl() 导航到。添加新页面时,必须在 main_pages.json 中添加对应的路径------这是最容易忘记的步骤。

dark/:深色模式资源

dark/element/color.json 提供了深色模式下的颜色覆盖值。系统会根据当前的颜色模式自动选择 base/dark/ 中的资源值。

资源引用的三种方式

  1. $r() 引用:引用 resources 目录中的资源。

    typescript 复制代码
    $r('app.media.startIcon')        // media 资源
    $r('app.string.app_name')        // string 资源
    $r('sys.media.ohos_ic_public_arrow_left')  // 系统资源(sys 前缀)
  2. $rawfile() 引用:引用 rawfile 目录中的原始文件。

    typescript 复制代码
    $rawfile('config.json')
  3. 直接引用:在非 UI 上下文中使用资源 ID。

    typescript 复制代码
    this.context.resourceManager.getString($r('app.string.app_name'))

module.json5:模块的"技能清单"

entry/src/main/module.json5 描述了模块的能力:

json5 复制代码
{
  "module": {
    "name": "entry",
    "type": "entry",           // entry | feature | shared
    "mainElement": "EntryAbility",
    "deviceTypes": ["phone"],
    "deliveryWithInstall": true,
    "installationFree": false,
    "pages": "$profile:main_pages",
    "abilities": [
      {
        "name": "EntryAbility",
        "srcEntry": "./ets/entryability/EntryAbility.ets",
        "description": "$string:EntryAbility_desc",
        "icon": "$media:layered_image",
        "label": "$string:EntryAbility_label",
        "startWindowIcon": "$media:startIcon",
        "startWindowBackground": "$color:start_window_background",
        "exported": true,
        "skills": [
          {
            "entities": ["entity.system.home"],
            "actions": ["ohos.want.action.home"]
          }
        ]
      }
    ]
  }
}

关键字段:

  • type: "entry":主模块,应用的入口。一个应用可以有多个 entry 模块(如 phone entry + tablet entry)。
  • pages: "$profile:main_pages":通过 profile 引用页面路由配置。
  • abilities.skills :声明 Ability 可以响应的 Want。ohos.want.action.home 表示该 Ability 可以响应"返回桌面"操作。
  • startWindowIconstartWindowBackground:启动窗口(白屏/闪屏)的图标和背景色。快速启动时系统先显示这个窗口,等 Ability 加载完毕后再切换。

目录结构的设计原则

总结 HarmonyKit 三次重构积累的原则:

1. 层级内高内聚,层级间单向依赖

复制代码
pages → components + utils + model
components → model
utils → (无依赖)
model → (无依赖)

pages 是最上层的"消费者",utils 和 model 是最底层的"供给者"。永远不要让 utils 引用 pages------那意味着工具函数依赖了页面状态,破坏了纯函数的独立性。

2. 文件粒度:一个文件做一件事

不要出现 CommonComponents.ets 这种"万能文件"。每个组件一个文件,每个工具类一个文件,每个页面一个文件。文件多了没关系------IDE 的导航和搜索能处理------但文件职责不清会让维护成本指数级上升。

3. 子目录的创建时机:当且仅当同级文件超过 5 个

5 个页面的 pages/ 不需要子目录。10 个页面的 pages/ 值得引入 tools/ 子目录。这个阈值不是绝对的,但"不到 5 个不分目录"是一个好的经验法则。

4. 资源与代码分离

不要把颜色值写死在代码里(如 '#007aff'),但 HarmonyKit 也没有把所有颜色都抽象为资源引用。原因是:HarmonyKit 使用了一套固定的设计系统颜色(蓝色主色调 #007aff,灰色背景 #f5f5f5),这些颜色不需要随主题切换而变化。只有当某个值确实需要在不同上下文中替换时,才应该使用资源引用。

从模板到实战:DevEco Studio 模板的不足

DevEco Studio 创建项目时给出的模板结构是:

复制代码
entry/src/main/ets/
├── entryability/
└── pages/
    └── Index.ets

这个模板缺少了 model、utils、components 三层。这暗示了一个重要的认知:模板是启动点,不是最佳实践。模板的目的让你快速看到 "Hello World",不是教你组织生产级代码。

HarmonyKit 从一开始就按生产标准组织目录结构。虽然初期只有 2 个页面和 3 个工具函数,但目录结构已经是完整的四层分离。这样做的成本很低------只是多建几个文件夹------但避免了一次"所有文件挤在 pages/ 下"的重构。

项目仓库:https://atomgit.com/VON-/harmony-kit

相关推荐
没落英雄2 小时前
6. 从零搭建一个 AI Agent —— 构建 Web 前端,让用户能和 agent 实时交互
前端·人工智能·架构
Csvn2 小时前
单元测试"假绿"实录:测试全过但线上崩了,这 3 个坑你踩过吗?
单元测试
tyqtyq222 小时前
营养餐单规划:AI 科学饮食规划系统的鸿蒙实现
人工智能·学习·华为·职场和发展·生活·harmonyos
不才难以繁此生3 小时前
HarmonyOS RDB 索引实战:中式美食搜索和购物清单查询怎么避免扫全表
harmonyos·arkts·arkui·rdb·中式美食·relationalstore
星释3 小时前
鸿蒙智能体开发实战:26.Skill 与插件协同开发
microsoft·华为·ai·harmonyos·鸿蒙·智能体
星释3 小时前
鸿蒙智能体开发实战:32.鸿蒙壁纸大师 - API认证与会话管理
华为·ai·harmonyos·智能体
花开彼岸天~3 小时前
鸿蒙实战:导航栏与顶部栏设计规范 HmTitleBar
华为·harmonyos·鸿蒙系统·设计规范
珠海西格电力4 小时前
数据采集与治理:零碳园区管理系统的 “生命线”
大数据·人工智能·算法·架构·能源
星释4 小时前
鸿蒙智能体开发实战:33.鸿蒙壁纸大师 - 多轮交互工作流设计
华为·交互·harmonyos·鸿蒙