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

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 的配置{ 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但可以有多个 moduleentry、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 的实现import{AbilityConstant,ConfigurationConstant,UIAbility,Want}fromkit.AbilityKit;import{hilog}fromkit.PerformanceAnalysisKit;import{window}fromkit.ArkUI;exportdefaultclassEntryAbilityextendsUIAbility{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但它的设计代表了整个应用的注册范式exportinterfaceToolItem{id:string;name:string;description:string;icon:string;color:string;category:string;routerPath:string;}exportconstCATEGORIES:string[][全部,格式化,编解码,计算,文本];exportconstTOOL_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为例import{cryptoFramework}fromkit.CryptoArchitectureKit;import{buffer}fromkit.ArkTS;exportclassHashUtils{staticasynchash(input:string,algorithm:MD5|SHA1|SHA256):Promisestring{try{letmdcryptoFramework.createMd(algorithm);letuint8ArraynewUint8Array(buffer.from(input,utf-8).buffer);awaitmd.update({data:uint8Array});letresultawaitmd.digest();returnbuffer.from(result.data).toString(hex);}catch(e){return计算失败: (easError).message;}}}注意try-catch包围了整个异步操作链确保方法永远不会抛出未捕获的异常。components/可复用 UI 组件两个组件ToolCard.ets和CopyButton.ets。它们的特点是纯Prop驱动无内部State无页面级路由逻辑不调用pushUrl或back一个文件一个组件不做一个文件导出多个小组件以CopyButton为例import{pasteboard}fromkit.BasicServicesKit;Componentexportstruct CopyButton{Proptext:string;PropbtnText:string复制;build(){Button(this.btnText).fontSize(12).height(32).padding({left:12,right:12}).backgroundColor(#f0f0f0).onClick((){if(this.text.length0){letdatapasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN,this.text);letsysPasteboardpasteboard.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.etspages/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存放字符串资源{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存放颜色资源{color:[{name:start_window_background,value:#F5F5F5}]}media/媒体资源HarmonyKit 的 media 目录包含四张图片其中最特别的是layered_image.json{layered-image:{background:$media:background,foreground:$media:foreground}}这是鸿蒙 API 12 引入的分层图标机制——前景和背景分开绘制系统可以对它们分别施加动效如点击时的缩放、长按时的颜色变化。profile/配置文件main_pages.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 目录中的资源。$r(app.media.startIcon)// media 资源$r(app.string.app_name)// string 资源$r(sys.media.ohos_ic_public_arrow_left)// 系统资源sys 前缀$rawfile() 引用引用 rawfile 目录中的原始文件。$rawfile(config.json)直接引用在非 UI 上下文中使用资源 ID。this.context.resourceManager.getString($r(app.string.app_name))module.json5模块的技能清单entry/src/main/module.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/ 下的重构。项目仓库https://atomgit.com/VON-/harmony-kit