☰
鸿蒙HAP瘦身:用hvigorfile实现打包时排除指定模块
2026/10/20 5:35:13 网站建设 项目流程

最近在折腾鸿蒙工程,模块一多就碰上一个很现实的问题: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 类应用模块HAPhapTasks应用入口,最终安装包
静态共享库模块HARharTasks编译期打包进依赖方
动态共享库模块HSPhspTasks运行时按需加载

我用一个真实工程的简写来演示。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 判断一个模块能否被排除的三个条件

我总结了三个条件,全部满足才建议排除:

  1. 入口模块或其他保留模块没有直接 import 它的代码。这是最硬的条件。只要代码里有from 'xxx'指向这个模块,排除后编译就过不去。

  2. 没有资源被保留模块直接引用。比如某个字符串、图片资源通过$r('app.media.xxx')引用了待排除模块里的资源,编译期不会报引用错误,但产物里会出现空洞调用。

  3. 没有在配置里注册会被系统直接拉起的 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之类报错时,正常的编译流程会直接失败。但资源引用不一样,某个字符串资源如果被保留模块间接引用,编译期内不会报警,只有运行到对应页面才暴露问题。

我的排查链路是这样的:

  1. 排除前,先确认entry是否依赖了待排除模块的 HAR。
  2. 打开entry/oh-package.json5,在dependencies里找到对应的依赖声明。
  3. 把该依赖从依赖列表里移除或改成可选依赖。

也就是说,“从构建清单里排除模块”和“从依赖关系里摘除模块”是两件事,必须同时做。

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 脚本这套方案,足够让多模块工程的打包过程省下大量手工操作。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询