最近在折腾鸿蒙工程,模块一多就碰上一个很现实的问题:release 包越打越大,打开产物一看,调试用的模拟数据模块、内部工具模块全被打进去了。直接删掉吧,日常开发还要用;每次打包前手动改配置吧,又很容易忘记恢复,甚至漏改。后来我把注意力放到 hvigorfile 构建脚本上,折腾出一套“打包时排除指定模块”的做法,既能保住源码完整,又能让产物干净不少。
这篇内容主要面向已经在用 DevEco Studio 做多模块鸿蒙应用开发、但对 hvigor 构建脚本还不太熟的开发者。我会从 hvigorfile 在构建链路里的位置讲起,把排除模块的原理、三种实现方式、实操脚本,以及我踩过的坑都过一遍,最后附上可以直接拿来改的流程。全程不绕弯子,能省时间的都写在前面了。
1. hvigorfile 在整个打包流程里扮演什么角色
1.1 先认清 hvigor、hvigorw、hvigorfile 三个名字
很多人在刚接触鸿蒙工程时会有个困惑:项目里既有hvigorfile.ts,又有hvigorw命令,还有build-profile.json5,它们到底是什么关系?
简单类比一下:如果整个构建是一个工厂流水线,那么:
hvigor是流水线的引擎本体,负责解析任务、调度执行、管理构建产物的增量与缓存。hvigorw是流水线的操作入口,也就是你在命令行里敲的那些命令,它负责启动引擎。hvigorfile.ts是这条流水线的工单,它声明了当前工程到底要执行哪些任务、装哪些插件。build-profile.json5是原料清单,它告诉引擎这个工程有哪些模块、签名信息、编译选项。
这个区分很重要。很多文章把 hvigorfile 描述得很玄乎,其实它的核心职责就一句话:告诉 hvigor 当前的构建目标长什么样。比如你新建一个标准工程,根目录下的 hvigorfile.ts 里通常长这样:
// 工程根目录 hvigorfile.ts import { appTasks } from '@ohos/hvigor-ohos-plugin'; export default { system: appTasks, // 应用级任务集合 plugins: [] };这个appTasks不是某一个任务,而是一组任务的总称。实际执行hvigorw assembleHap时,引擎会根据这一组任务构建出完整的依赖图,然后按顺序执行。
1.2 根 hvigorfile 与模块 hvigorfile 的分工
工程根目录有 hvigorfile.ts,每个模块目录下也有自己的 hvigorfile.ts,两者职责完全不同。
根目录的 hvigorfile.ts 面向整个应用,它导入的是appTasks,管的是“所有模块如何组装成一个 App”。模块目录下的 hvigorfile.ts 则面向单个模块,按模块类型导入不同的任务集合:
| 模块类型 | 产物 | 模块级 hvigorfile 导入的任务 | 说明 |
|---|---|---|---|
| entry 类应用模块 | HAP | hapTasks | 应用入口,最终安装包 |
| 静态共享库模块 | HAR | harTasks | 编译期打包进依赖方 |
| 动态共享库模块 | HSP | hspTasks | 运行时按需加载 |
我用一个真实工程的简写来演示。entry 模块下的 hvigorfile.ts 一般是:
// entry/hvigorfile.ts import { hapTasks } from '@ohos/hvigor-ohos-plugin'; export default { system: hapTasks, plugins: [] };而某个 HAR 模块下的 hvigorfile.ts 则是:
// debug-tool/hvigorfile.ts import { harTasks } from '@ohos/hvigor-ohos-plugin'; export default { system: harTasks, plugins: [] };也就是说,如果你想让某个模块不参与打包,最直接的做法是让它在构建任务图里“消失”。模块的 hvigorfile 本身只负责定义任务,真正决定模块是否进入构建的,是根目录的模块清单和 hvigorfile 的任务编排。
1.3 为什么要在构建脚本里做“打包排除”,而不是动源码
这里我要强调一个工程习惯:排除某个模块,优先级一定是“构建行为 > 源码修改”。
如果你只是为了打一个临时包,直接删目录、注释代码确实更快。但这种操作有两个隐患:
第一,源码被改动后很容易忘记恢复,尤其是团队协作时,别人拉你分支看到一堆“被注释掉的模块”,沟通成本极高。
第二,删掉目录后,工程里的依赖关系、路由跳转、资源引用可能全部报错,你不得不同时改很多地方。
而在构建脚本层面做排除,是把“打不打这个模块”变成一种可切换的策略。源码保持原样,日常开发一切正常,只有执行特定构建流程时,模块才会被临时摘除。这样既不会污染仓库,也可以把同一个逻辑交给 CI 流水线反复使用。
2. 排除模块之前,先判断它能不能被安全排除
2.1 HAP、HAR、HSP 三种模块的差别
不是所有模块都能随便排除。动手之前,先搞清楚模块类型之间的差异,否则后面会遇到“编译过了但运行时崩了”这种诡异问题。
HAP 是应用安装包,通常只有一个入口模块是 HAP,多设备场景下可能有多个 entry 类模块。
HAR 是静态共享包,它在编译期就直接被打进依赖它的模块的产物里。也就是说,如果 entry 模块依赖了某个 HAR,那么最终打包时,这个 HAR 的代码和资源会作为 entry 的一部分存在,而不是作为独立文件存在。
HSP 是动态共享包,它运行时要独立安装或由宿主按需加载。如果你排除了一个被 entry 依赖的 HSP,构建阶段不一定会立刻报错,但运行到加载代码时会崩。
所以判断“能不能排除”,最关键的一点不是模块自己长什么样,而是它和入口模块之间的依赖关系是什么。
2.2 判断一个模块能否被排除的三个条件
我总结了三个条件,全部满足才建议排除:
入口模块或其他保留模块没有直接 import 它的代码。这是最硬的条件。只要代码里有
from 'xxx'指向这个模块,排除后编译就过不去。没有资源被保留模块直接引用。比如某个字符串、图片资源通过
$r('app.media.xxx')引用了待排除模块里的资源,编译期不会报引用错误,但产物里会出现空洞调用。没有在配置里注册会被系统直接拉起的 Ability。某些调试面板模块会注册独立的 UIAbility,如果在配置文件里被声明为可导出,排除后系统可能找不到对应页面。
如果以上三个条件都满足,这个模块大概率可以安全排除。如果条件 1 或 2 不满足,你要做的就不是“排除”,而是先“解除依赖”,后面第 5 节我会详细讲这个坑。
2.3 我遇到的典型可排除模块清单
在真实工程里,这几类模块是最常见的排除对象:
- 调试工具面板模块:只在开发环境用,发布到商店毫无意义。
- 模拟数据模块:提供假接口数据的 HAR,联调完成后就没用了。
- 演示页面模块:给内部评审用的功能演示页,不能出现在正式包。
- 灰度验证模块:还在验证阶段的新功能,希望藏起来不对外发布。
这些模块普遍符合一个特征:业务主流程不依赖它,只有开发者在需要时才显式调用。也就是说,它们挂在主工程旁边的“附加件”,而不是骨架的一部分。这类模块才是包体积优化里最值得优先处理的目标。
3. 三种“打包时排除模块”的实现方式
3.1 方式A:直接在 build-profile.json5 里裁剪 modules
先说一下最简单、也最不容易出错的方式。
打开工程根目录的build-profile.json5,里面通常有一个modules数组,长这样(此处为结构示意,字段以实际工程为准):
{ "app": { "signingConfigs": [], "products": [] }, "modules": [ { "name": "entry", "srcPath": "./entry", "targets": [] }, { "name": "debug-tool", "srcPath": "./debug-tool", "targets": [] }, { "name": "mock-data", "srcPath": "./mock-data", "targets": [] } ] }如果想把debug-tool和mock-data排除,直接删掉这两个对象,或者整段注释掉:
"modules": [ { "name": "entry", "srcPath": "./entry", "targets": [] } // , { "name": "debug-tool", "srcPath": "./debug-tool", "targets": [] } // , { "name": "mock-data", "srcPath": "./mock-data", "targets": [] } ]然后用hvigorw正常执行打包即可。
这种方式的优点是零学习成本,任何人都能看懂;缺点是它是手动操作,打完包记得恢复,一旦忘记恢复,下一次开发调试会因为缺少模块而出现各种红错。
我的建议是:方式A只适合临时应急打个一次性包,不适合作为长期流程。
3.2 方式B:用 Node 脚本自动改写配置
鸿蒙工程的构建脚本是跑在 Node 环境里的,所以你可以用 Node 脚本在构建前自动修改build-profile.json5,构建结束后再恢复。这就解决了“忘记恢复”的问题。
具体做法是维护一份待排除模块清单,比如环境变量EXCLUDE_MODULES=debug-tool,mock-data,脚本读取这个变量后,动态过滤掉 modules 数组里对应的模块。
这种方式已经可以进入日常流程了,操作成本低,也不容易漏。但它的缺点是需要手动记得执行“先跑脚本再跑打包”这两条命令,本质上还是人工干预。
3.3 方式C:把排除逻辑注册成 Hvigor 自定义 Task
方式B虽然自动化了配置修改,但还没有和 hvigor 的构建链打通。更彻底的做法,是把排除逻辑作为一个自定义 Task 注册到根hvigorfile.ts里,让它自动串到打包任务的依赖链前面。
这样你只需要敲一条打包命令,排除逻辑会自动执行,打包完成后再自动恢复配置,全程不需要额外的人工步骤。
这个方案我最终落地用在了正式 CI 流程里。它的核心倒不算复杂:在根 hvigorfile.ts 的plugins中注册一个自定义任务,任务是脚本方式调用 node 函数来读写配置。关键点在于,你要保证它执行的时机一定早于模块编译任务,否则模块已经被引擎扫描进构建图了,改配置就来不及。
3.4 三种方式怎么选
| 方式 | 自动化程度 | 维护成本 | 适用场景 |
|---|---|---|---|
| A. 手动裁剪 modules | 低 | 最低 | 临时应急打包 |
| B. 脚本改写配置 | 中 | 中低 | 日常手动打包 |
| C. 自定义 Task 接入构建链 | 高 | 中等 | CI 流水线、多人协作 |
如果你只是偶尔排除一两个模块,方式A完全够用。如果你每周要打好几次包,建议上 B。如果你要把它固化到发布流程里,C 是最不容易出错的选择。
4. 实操一套可复用的排除构建流程
4.1 我落地的工程结构
下面是我在某个模拟项目 X 里实际采用的方案结构,参考价值比较大:
项目根目录/ ├── hvigorfile.ts // 根构建脚本,挂自定义逻辑 ├── build-profile.json5 // 模块清单 ├── hvigor/ │ └── hvigor-config.json5 └── scripts/ ├── filter-modules.js // 核心脚本:按环境变量过滤模块 └── filter-modules.json // 每次执行后的状态记录其中filter-modules.js负责读写build-profile.json5,hvigorfile.ts负责在构建链早期触发脚本。
4.2 核心脚本:filter-modules.js
核心脚本不依赖任何鸿蒙内部 API,只做两件事:读取配置、过滤模块。这里我用了json5库解析配置,因为build-profile.json5本质是 JSON5 格式,直接用JSON.parse会解析失败。如果你的工程不方便额外引入依赖,也可以用正则替换的方式,但稳定性差一些,我更推荐引入解析库。
// scripts/filter-modules.js const fs = require('fs'); const path = require('path'); const JSON5 = require('json5'); // 从环境变量读取要排除的模块,逗号分隔 const excludeModules = (process.env.EXCLUDE_MODULES || '') .split(',') .map((s) => s.trim()) .filter(Boolean); const rootDir = path.join(__dirname, '..'); const profilePath = path.join(rootDir, 'build-profile.json5'); const backupPath = path.join(rootDir, 'build-profile.json5.applied'); function main() { if (excludeModules.length === 0) { console.log('[filter-modules] 未设置 EXCLUDE_MODULES,跳过模块过滤'); return; } const profile = JSON5.parse(fs.readFileSync(profilePath, 'utf-8')); const original = JSON.stringify(profile); // 备份原始配置,供构建后恢复 fs.writeFileSync(backupPath, original); // 过滤掉需要排除的模块 profile.modules = profile.modules.filter((m) => { for (const name of excludeModules) { if (m.name === name) { console.log(`[filter-modules] 排除模块: ${name}`); return false; } } return true; }); // 写回目标配置 fs.writeFileSync(profilePath, JSON.stringify(profile, null, 2)); } function restore() { if (!fs.existsSync(backupPath)) return; fs.copyFileSync(backupPath, profilePath); fs.unlinkSync(backupPath); console.log('[filter-modules] 已恢复原始构建配置'); } // 当作为独立命令执行时,第一个参数是 apply 或 restore const action = process.argv[2]; if (action === 'apply') main(); else if (action === 'restore') restore(); else if (action === 'applyAndRestore') { main(); process.on('exit', restore); }这段脚本里我刻意把“应用”和“恢复”拆成两个动作,方便你在不同构建阶段调用。applyAndRestore适合在hvigorfile.ts里注册 Task 时直接使用。
4.3 在根 hvigorfile.ts 里接入
根目录下的hvigorfile.ts这样接入(示意代码,API 名称以你当前 SDK 版本为准):
// 工程根目录 hvigorfile.ts import { appTasks } from '@ohos/hvigor-ohos-plugin'; import { execSync } from 'child_process'; // 在构建流程开始前,先根据环境变量过滤模块 // 这里的命令等价于: node scripts/filter-modules.js applyAndRestore execSync('node scripts/filter-modules.js applyAndRestore', { stdio: 'inherit', cwd: __dirname }); export default { system: appTasks, plugins: [] };这段代码的关键点是execSync执行时机必须足够早。放在文件顶层,意味着模块还没被注册成任务,过滤已经完成了。如果你把它放在某个回调函数里,要确认该回调是在构建图生成之前执行的,否则就晚了。
4.4 从命令行触发与结果验证
实际打包时,命令大致是:
EXCLUDE_MODULES=debug-tool,mock-data hvigorw assembleHap --mode module -p product=default执行后,你会看到脚本日志:
[filter-modules] 排除模块: debug-tool [filter-modules] 排除模块: mock-data [filter-modules] 已恢复原始构建配置然后去产物目录确认:
项目根目录/entry/build/default/outputs/产物包里不再包含debug-tool和mock-data模块对应的资源或代码。为确认这一点,可以解压 HAP 检查资源目录,也可以直接看构建日志里模块编译任务列表,确认没有出现这两个模块的任务记录。
5. 排除模块后踩过的坑:依赖链与构建缓存
5.1 坑一:模块明明排除了,包产物里还有它的资源
我第一次用方式A排除mock-data模块时,出现了很诡异的现象:build-profile.json5里已经没有这个模块了,但最终 HAP 里依然能找到它的字符串资源。
一开始我以为是缓存问题,后来一步步排查才找到真正的根因:entry模块的oh-package.json5里直接声明了对mock-data的依赖。
遇到Cannot find module之类报错时,正常的编译流程会直接失败。但资源引用不一样,某个字符串资源如果被保留模块间接引用,编译期内不会报警,只有运行到对应页面才暴露问题。
我的排查链路是这样的:
- 排除前,先确认
entry是否依赖了待排除模块的 HAR。 - 打开
entry/oh-package.json5,在dependencies里找到对应的依赖声明。 - 把该依赖从依赖列表里移除或改成可选依赖。
也就是说,“从构建清单里排除模块”和“从依赖关系里摘除模块”是两件事,必须同时做。
5.2 坑二:排除模块后入口模块报 Cannot find module
这个坑和上一个相反。有一次我在没解除依赖的情况下直接排除了debug-tool,编译时报了一连串错误,集中在入口模块的 import 语句上。
原因很简单:entry里有大量代码直接写了:
import DebugTool from 'debug-tool';模块从构建清单中消失后,hvigor 就找不到这个模块提供的代码了,自然到处报红。
这其实反映了一个原则:构建层面的排除,解决的是“打包结果”问题,不解决“源码依赖”问题。如果你的工程已经强耦合了这个模块,直接排除是不可行的。你只能要么先做依赖解耦,要么把该模块从“参与打包”降级为“仅参与编译”,而后面这种需求通常需要调整依赖范围,比如把编译期依赖改成仅构建时需要的类型。这一点和 Android 工程里implementation与runtimeOnly的取舍很像。
5.3 坑三:不改构建缓存导致的“半排除”现象
还有一次,我确认了配置已经过滤掉模块,依赖也解除了,但产物依然奇怪:某些老资源还在,新代码又没打进去。最后定位到是 hvigor 的自定义缓存目录在作怪。
hvigor 默认会按模块、产物、配置哈希来缓存中间产物。如果你改动了build-profile.json5但某些模块的缓存没有被判定失效,引擎就可能复用旧的构建结果,表现就是“半排除”。
遇到这种情况,最直接的办法是执行一次干净构建:
hvigorw clean或者直接删掉工程根目录的build目录、.hvigor缓存目录,再重新打包。这个操作虽然笨,但在调整模块清单之后是最可靠的验证方式。我现在的习惯是:凡是改了模块参与范围,一律先 clean 再打正式包,不要在这上面赌增量构建。
6. 这套流程跑顺之后的几点经验
6.1 配合 CI 流水线的推荐姿势
如果你准备把排除逻辑放到 CI 流水线里,我建议不要直接用方式A,也不要在 hvigorfile.ts 里写死模块名,而是通过流水线变量传入。
具体做法是:在流水线构建任务里设置:
EXCLUDE_MODULES=debug-tool,mock-data然后脚本读取这个变量执行过滤。这样,同一套工程代码既能打出完整包,也能打出裁剪包,只需要切换流水线的构建参数,不需要改代码。我还建议在流水线日志里显式打印本次排除的模块列表,这样发布后追查产物内容时,不用猜当时用了什么配置。
另外,filter-modules.json这个状态文件要放进.gitignore,避免每次构建在本地仓库产生无意义的改动记录。
6.2 DevEco Studio 升级后脚本可能受影响
说实话,Hvigor 和 DevEco Studio 的版本迭代速度不慢。我最早的脚本是用自定义插件 API 实现的,升级过一次 IDE 后 API 有了变化,脚本直接失效。后来我把脚本改成纯 Node 读写文件的方式,只把 hvigorfile.ts 当作一个触发器,兼容性问题就少了很多。
这里给一个非常实际的建议:尽量把核心逻辑放在 scripts 目录的普通 JS 文件里,hvigorfile.ts 只保留一行 execSync 调用。这样即使 hvigor 内部 API 变化,风险也只集中在那一行调用上,不至于整个流程瘫痪。
6.3 最后一点实用提醒
每次用完排除方案,记得确认两件事:第一,build-profile.json5是否已经恢复原状;第二,有没有把依赖关系改乱。
我的经验是,排除模块并不是包体积优化的终局方案。真正彻底的优化,是把那些调试模块从架构层面做成可插拔组件,甚至独立成单独的应用入口,这样连构建期裁剪都不需要了。但在架构改造完成之前,hvigorfile + Node 脚本这套方案,足够让多模块工程的打包过程省下大量手工操作。