
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.json中name为app_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 文件的设计准则:
- 零 import 从页面层:util 文件不应 import 任何 pages 或 components 中的模块------它们是单向依赖的末端。
- 无状态:所有方法都是纯函数(给定的输入产生确定的输出),不操作全局变量。
- 错误处理内聚:每个方法自己处理异常,不要让调用方猜测会抛出什么异常。
以 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.ets 和 CopyButton.ets。它们的特点是:
- 纯
@Prop驱动,无内部@State - 无页面级路由逻辑(不调用
pushUrl或back) - 一个文件一个组件,不做"一个文件导出多个小组件"
以 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.BasicServicesKit的pasteboardAPI------这是鸿蒙的剪贴板能力,不依赖 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/ 根目录。
页面的四要素模式:每个工具页面由四个要素组成:
@Entry @Component struct装饰器声明- 一组
@State变量保存用户输入和结果 build()方法中的 UI 声明(标题栏 + Scroll + 输入区 + 操作区 + 结果区)- 一组操作方法(
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/ 中的资源值。
资源引用的三种方式
-
$r() 引用:引用 resources 目录中的资源。
typescript$r('app.media.startIcon') // media 资源 $r('app.string.app_name') // string 资源 $r('sys.media.ohos_ic_public_arrow_left') // 系统资源(sys 前缀) -
$rawfile() 引用:引用 rawfile 目录中的原始文件。
typescript$rawfile('config.json') -
直接引用:在非 UI 上下文中使用资源 ID。
typescriptthis.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 可以响应"返回桌面"操作。 - startWindowIcon 和 startWindowBackground:启动窗口(白屏/闪屏)的图标和背景色。快速启动时系统先显示这个窗口,等 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/ 下"的重构。