鸿蒙开发做到后面,工程里的模块一定会多起来。我之前维护的一个项目就是典型:入口模块 entry,几个业务能力模块,一个跨端公共库,还有一个内部调试工具模块。平时开发调试离不开那个工具模块,但发布正式包的时候,它又绝对不能出现在最终产物里。一开始大家都是手动改配置,发布前靠人肉检查,说实话心里没底。后来我把这套逻辑直接写进了 hvigorfile 构建脚本里,让构建过程自己判断要不要把指定模块排除掉,省心很多。这篇文章就把我在 hvigorfile 上做"排除指定模块"这整件事的完整思路、具体实现和踩坑记录分享出来。
1. hvigorfile:被很多人当成摆设的构建入口
1.1 根目录和模块目录的 hvigorfile 各自负责什么
每次新建一个鸿蒙工程,根目录下都会自动生成一个 hvigorfile.ts(旧版本可能是 .js),每个模块目录下也会各有一个 hvigorfile.ts。大部分开发者一辈子都不会去动它,因为 DevEco Studio 的图形化操作已经覆盖了日常需求。但其实这两个东西的分工非常清晰:
- 根目录的 hvigorfile.ts:管整个工程级别的任务编排,比如同步所有模块、全局编译配置、最终打包聚合。
- 模块目录的 hvigorfile.ts:只管当前模块自己的构建任务,比如编译、生成中间产物、产出自有的 HAP/HAR/HSP。
这两个文件本质上就是 hvigor 构建工具在启动时加载的入口脚本。hvigor 自身是一个基于任务流(Task)的构建引擎,它本身是个 Node 运行时环境,所以这两个文件里不仅可以写导出的默认配置,还能写任何 Node.js 的代码——读写文件、解析 JSON、执行命令行脚本,全都允许。这就是我们做"排除指定模块"的底层基础。
1.2 从任务流视角理解打包流程
hvigor 的构建过程是一条很长的任务流水线。我理解它的方式就是类比工厂装配线:Sync(同步)阶段把工程里的所有模块和配置整理清楚,然后进入 Load 阶段加载插件和任务,接着是 Configure 阶段确定要执行哪些任务,最后 Execute 阶段才真正开始编译、打包。
对于我们做"排除指定模块"来说,最关键的一个认知是:hvigor 在 Sync 阶段就会确定"这个工程由哪些模块组成"。如果你在 Sync 阶段让某个模块消失,后面所有编译、打包的过程都不会有它。反之,如果你到了 Execute 阶段才想起要排除某个模块,那就只能靠各种 hook(钩子)在任务之间做干预,难度和风险都会成倍上升。
所以我把实操思路定在了"Sync 之前干预模块列表"这个节点上,这也是后面所有方案的根本方向。
2. 排除指定模块,先分清 HAP、HAR、HSP 再说
2.1 三种产物在打包时的命运完全不同
这是我踩过最大的坑之一:刚开始没搞清楚模块类型就动手排除,结果发现怎么排都排不干净。鸿蒙工程里的模块,打包产物严格分三种:
- HAP:应用安装包,最终要发布到用户手机上的东西。
- HAR:静态共享包。它会被编译进引用方模块的产物里,也就是说一旦 entry 依赖了一个 HAR,打包时这个 HAR 的内容就进 HAP 了,没有"独立存在"这一说。
- HSP:动态共享包。它是独立打包的,入口模块只是引用它,运行时动态加载。最终多包发布时 HSP 和 HAP 是分开的两个文件。
搞清楚这个之后,"排除指定模块"的语义就变得非常具体了:
- 排除一个 HAR 模块,本质上是排除"引用关系"——让 entry 不再依赖它,这样它就不会被打进 HAP。
- 排除一个 HSP 模块,本质上是排除"发布打包时的产物集合"——让最终的发布任务不产出或包含这个 HSP。
- 排除一个 HAP 模块,那就更直接了,整个模块都不参与构建。
2.2 真实需求长什么样
我遇到的需求就是前面说的调试模块。它是一个 HAR 类型的模块,被 entry 在 debug 场景下引用。平时开发调试需要它提供一些内部工具入口,但正式发布包绝对不能带这些能力。这种需求用一句话说就是:Release 构建时,把这个模块从 entry 的依赖链里摘掉。
还有一种很常见的需求是多产品形态。比如同一个工程,既有普通版 A 产品,又有定制版 B 产品。B 产品不需要某个独立业务模块,但 A 产品要。这种场景下每次都手动改模块列表,迟早会出事。
不管是哪种情况,核心都是同一个技术动作:让构建脚本在特定条件下,自动调整工程的模块参与范围。明白了目标和模块类型的关系,方案选型就不会跑偏。
3. 四种排除路径,我最后选了脚本化
3.1 手动改配置的静态方案
最原始的方案是改根目录的 build-profile.json5。这个文件里有一个 modules 数组,列出了工程参与构建的所有模块。手动把它注释掉,再点 Sync 工程,那个模块就消失了。缺点也很明显:容易忘、容易漏,而且每个人都要记住这个操作流程。
还有一种静态方案是改 entry 模块的 oh-package.json5,把 dependencies 里对指定模块的依赖项删掉。这个方法也能达到目的,但同样依赖人肉记忆,而且多人协作时经常出现"有人改了没提交"的混乱。
我不否认静态方案简单直观,但如果你经历过一次"正式包发出去之后发现调试功能还在里面"的事故,就会明白自动化是刚需,不是可选项。
3.2 基于 hvigorfile 的动态方案
既然 hvigorfile 是构建启动器且具备完整的 Node 能力,那就可以在脚本里读取工程配置、分析参数、动态调整模块列表。这才是标题说的"hvigorfile 构建脚本的使用"真正有价值的地方。
我调研过几种公开的思路,最后筛选出两条可行路线:
第一,脚本预处理 build-profile.json5。在 hvigor 正式启动任务流之前,通过脚本修改 modules 数组,把指定模块踢出去。这个方案的优点是干预得非常彻底,效果等同于手动注释再 Sync,但是自动化了。
第二,脚本调整 entry 的依赖配置。针对 HAR 类型的排除需求,在 Release 构建时把依赖项从 entry 的 oh-package.json5 里临时移除。这个方案更精确,只影响依赖链,不会让模块从工程里消失,调试时还能继续引用。
两条路线没有绝对的好坏,取决于你的场景。如果你的目的是"这个模块在正式包里彻底不要出现",我建议路线一;如果你的目的是"保留模块但临时解除与某个入口的依赖关系",路线二更合适。
4. 实操:把排除逻辑写进根目录 hvigorfile
4.1 准备阶段:先想清楚触发条件
我在实际项目里做的第一件事,是确定触发条件。我当时的诉求很简单:Debug 构建保留调试模块,Release 构建排除它。所以我需要读取构建模式。
hvigor 执行时,可以通过命令行参数传入模式等信息。常见命令大致长这样:
hvigorw assembleHap --mode module -p product=default -p buildMode=release --no-daemon这里的 buildMode 参数就是我们要抓的东西。在 hvigorfile 里,可以通过解析 process.argv 拿到它。我把兼容环境变量的方式也做了:定义一个环境变量 EXCLUDE_MODULE_DEBUG_TOOL,如果设为 true,则无论什么构建模式都排除指定模块。这样 CI 环境里也好控制。
4.2 核心编码:动态编辑 build-profile.json5
接下来是核心代码。我用的技术栈是 Node.js 原生能力加一个 JSON5 解析库。build-profile.json5 支持注释,普通 JSON.parse 解析不了,所以必须用 json5 库读取。
先写公共函数部分。我把这些逻辑放在一个独立文件里,方便维护,比如项目根目录下放一个 build-script-utils.js,然后在 hvigorfile.ts 里引用。
// build-script-utils.js const fs = require('fs'); const path = require('path'); const JSON5 = require('json5'); const ROOT_DIR = __dirname; // 这个文件放在工程根目录下 function readJson5(filePath) { const content = fs.readFileSync(filePath, 'utf-8'); return JSON5.parse(content); } function writeJson5(filePath, data) { // 注意:JSON5.stringify 不是所有版本都有,我遇到的情况是 // 这个库只提供 stringify 方法,但输出质量还行 const content = JSON5.stringify(data, null, 2); fs.writeFileSync(filePath, content, 'utf-8'); } function getBuildProfilePath() { return path.join(ROOT_DIR, 'build-profile.json5'); } function removeModuleFromProfile(moduleName) { const profilePath = getBuildProfilePath(); const profile = readJson5(profilePath); const modules = profile.modules || []; const beforeCount = modules.length; profile.modules = modules.filter(item => { // 模块项可能是字符串,也可能是对象,取决于你的工程结构 if (typeof item === 'string') { return item !== moduleName; } return item.name !== moduleName; }); const afterCount = profile.modules.length; writeJson5(profilePath, profile); return beforeCount !== afterCount; }这里有个需要注意的点:build-profile.json5 的 modules 数组在不同版本的 IDE 里生成的结构不完全一样。有的版本是纯字符串数组,有的版本是对象数组(带 name 和 srcPath 字段),所以代码里要做兼容处理。我两种都见过,写成兼容判断才不会翻车。
与此同时,我强烈建议在修改前先备份原文件,构建结束后恢复。因为构建工作区的配置变更不应被提交到版本库。我在项目中是这么处理的:
function backupProfile() { const src = getBuildProfilePath(); const backupPath = src + '.bak'; fs.copyFileSync(src, backupPath); } function restoreProfile() { const src = getBuildProfilePath(); const backupPath = src + '.bak'; if (fs.existsSync(backupPath)) { fs.copyFileSync(backupPath, src); fs.unlinkSync(backupPath); } }4.3 接入 hvigor 任务流程
有了工具函数,接下来就是把它挂到 hvigorfile.ts 里。
hvigor 在导出配置时,允许通过 plugins 字段注入自定义能力。如果只是做"构建开始前改配置"这种操作,可以注册一个非常朴素的插件,利用 Node 脚本的执行时机来实现。我的做法是直接在导入阶段执行逻辑,但通过判断参数避免每次都改动配置:
// 根目录 hvigorfile.ts import { appTasks } from '@ohos/hvigor-ohos-plugin'; const { removeModuleFromProfile, backupProfile, restoreProfile } = require('./build-script-utils'); function getBuildMode() { const argv = process.argv; const idx = argv.indexOf('-p'); while (idx !== -1 && idx < argv.length - 1) { const param = argv[idx + 1]; if (param.startsWith('buildMode=')) { return param.split('=')[1]; } // 继续找下一个 -p } const env = process.env.EXCLUDE_MODULE_DEBUG_TOOL; if (env === 'true' || env === '1') { return 'release'; } return 'debug'; } const buildMode = getBuildMode(); const excludeModuleName = 'feature_debug'; if (excludeModuleName && buildMode === 'release') { backupProfile(); const removed = removeModuleFromProfile(excludeModuleName); console.log(`[hvigorfile] 构建模式=${buildMode}, 排除模块=${excludeModuleName}, 移除结果=${removed}`); } export default { system: appTasks, plugins: [] };这段代码是根目录 hvigorfile.ts,核心逻辑是:解析到 release 模式时,把指定模块从 build-profile.json5 的 modules 数组里移除。因为 hvigor 在加载完 hvigorfile 之后才会开始 Sync,等 Sync 发生时模块已经被排除掉了,整个构建流程自然就不会再有任何与该模块相关的任务。
调试的时候可能还想看看到底移除没有,我在代码里加了日志输出。hvigor 构建时终端能看到这些日志。
4.4 路线二:解除 entry 对 HAR 模块的依赖
如果你不想让模块从工程里消失,只是想让 entry 不引用某个 HAR 模块,可以改一下思路,把目标从 build-profile.json5 换成 entry 模块的 oh-package.json5。
我封装了另一个函数:
function removeDependency(moduleDir, depName) { const pkgPath = path.join(ROOT_DIR, moduleDir, 'oh-package.json5'); const pkg = readJson5(pkgPath); const deps = pkg.dependencies || {}; let removed = false; if (deps[depName]) { delete deps[depName]; removed = true; } writeJson5(pkgPath, pkg); return removed; }然后在 hvigorfile 里调用时,改成移除依赖而不是移除模块。这个方案的好处是:调试模块仍留在工程里,IDE 里还能正常打开、编译,只是 Release 时 entry 不再依赖它。
但要注意,这种方法有个副作用:如果被移除依赖的模块同时还被别的模块引用,那么别的模块仍然会把相关内容打进去。所以用路线二之前,最好理清楚模块依赖关系,确认"被排除模块"只被 entry 依赖。
4.5 构建结束后的恢复
无论走路线一还是路线二,我都建议在构建完成后立刻恢复配置。hvigorfile 里没有现成的"构建结束 hook"可以让我简单挂回调,但我在实际项目中用了一个取巧办法:在 pipeline 的最后一个阶段扫描产物。如果检测到产物生成完毕,就调用 restoreProfile() 恢复配置。
更简单的做法是,利用 Node 的 process.on('exit') 在进程退出前恢复:
process.on('exit', () => { restoreProfile(); });这个做法虽然听起来有点粗暴,但在实际项目中确实能覆盖大部分场景:构建成功、失败、被用户中断,Node 进程退出时都会触发 exit 事件。唯一照顾不到的是 IDE 强制杀进程的情况,概率很低。
如果担心极端情况,也可以做成每 5 秒检查一次"是否有 hvigor 进程存活"的守护逻辑,但工程化讲没必要,exit 事件已经够用了。
5. 验证和排错:确保真的没打进包里
5.1 怎么验证排除成功
写完脚本后,不要急着改代码,先构建一次验证效果。我总结了几种验证手段,按检查成本从低到高排列:
第一种,看构建日志。脚本里打了日志,release 构建时如果提示移除结果为 true,说明配置改成功了。
第二种,看构建产物目录。以 HAP 为例,构建完成后在模块的 build/default/outputs/ 目录下面会生成 HAP 文件。如果是排除 HAR 模块,HAP 里不该看到该模块的任何产物目录。
第三种,解包检查。HAP 本质上是个 ZIP 包,把扩展名改成 .zip 后可以直接解压。正常情况下,HAR 的内容会被合入到对应模块的产物里。我在验证时重点搜索被排除模块的类名、资源目录和 .abc 文件。比如调试模块叫 feature_debug,解压后如果搜不到 feature_debug 相关的任何文件,那就是真排除了。
还有个更狠的验证方式:在 release 包里触发一下调试工具的入口,看是不是提示找不到模块。这个属于运行时验证,我在模拟器里测过,确实点不进去了,说明依赖链切得干净。
5.2 常见问题速查表
我在这个过程中整理了一份问题排查表格,基本覆盖了可能翻车的场景:
| 症状 | 可能原因 | 解决办法 |
|---|---|---|
| 模块还是被打进了 HAP | 模块被其他模块间接依赖 | 用依赖分析工具查一下引用关系,确认只有 entry 依赖它 |
| Release 构建报错:找不到 xxx 模块 | 排除太彻底,其他位置还引用着它 | 改用路线二,只解除 entry 的依赖,保留模块本身 |
| IDE 一重新 Sync,模块又回来了 | hvigorfile 的修改没生效或配置被 IDE 重写 | 检查是否 build 前执行脚本,确认 IDE 的同步流程确实经过 hvigorfile |
| 动态共享包排除后,运行时找不到 So 库 | HSP 被排除,但代码里还有动态引用 | 检查 import 路径,把动态引用统一清理掉 |
| 构建产物异常,配置没恢复 | 进程被杀,exit 事件没触发 | 手动删除 .bak 文件,恢复原配置 |
5.3 我踩过的几个坑
第一个坑是 json5 的 stringify 格式问题。JSON5.stringify 生成的内容会把原本的注释全部丢掉,如果团队里有人在 build-profile.json5 里写了注释来说明模块用途,运行一次脚本注释就没了。我后来规定:公共配置文件的注释要少写,要写就写到单独文档里。
第二个坑是版本差异。我在不同版本的 DevEco Studio 工程里测试,发现 hvigorfile.ts 的默认导出结构有过变化。新版本的 hvigor 更推荐用插件注册的写法,老版本则是直接导出 system 字段。在文章里我给的是兼容性比较强的写法,如果你发现自己的工程结构跟代码对不上,优先去查当前 IDE 版本对应的文档。
第三个坑是命令行参数解析。一开始我只做了 process.argv 的 buildMode 判断,结果在 CI 环境里构建命令格式不一样,参数位置变了,导致判断失效。后来我改成同时支持环境变量,并加了兜底逻辑:只要检测到 release 编译标志就执行排除。这个兜底逻辑不要太激进,否则 debug 构建也可能误伤。
第四个坑是恢复时机。我最初把恢复逻辑写在脚本开头,准备在下一次构建时恢复上一轮的备份,结果发现连续构建时第二轮构建会读取到被修改过的配置,状态混乱。改成 process.on('exit') 之后,这个问题彻底消失,因为每次构建进程结束之前都会恢复。
6. 进阶扩展:把排除范围扩展到多 Target 组合
如果你的工程用到了多 Target 能力,情况会稍微复杂一点,但思路可以无缝扩展。Target 可以理解为一套独立的构建配置组合,每个 Target 可以指定不同的模块集合、产品名和构建参数。我在另一个模拟项目里试过这种用法:根据传入的 target 名称,决定从 modules 列表里排除哪几个模块。
实现方式和前面几乎相同,只需要在 getBuildMode() 之外再加一个 getTargetName():
function getTargetName() { const argv = process.argv; // 类似 buildMode 的解包逻辑,可以放到公共函数里统一做 // 这里我以 product 参数为例 for (let i = 0; i < argv.length - 1; i++) { if (argv[i] === '-p' && argv[i + 1] && argv[i + 1].startsWith('product=')) { return argv[i + 1].split('=')[1]; } } return 'default'; } const targetName = getTargetName(); const excludeTargetMap = { productA: ['feature_debug'], productB: ['feature_share', 'feature_debug'] }; const excludeList = excludeTargetMap[targetName] || []; for (const modName of excludeList) { removeModuleFromProfile(modName); }这样写的好处是,团队里新增一个产品变体时,不需要去改构建脚本的核心逻辑,只需要往 excludeTargetMap 里加一行映射。我把这个映射表放到独立配置里,其他人也能轻松维护。
另外,如果你的模块是 HAR 且要排除时,可以在 entry 的 module.json5 里也确认一下依赖声明。HAR 不像 HSP 有独立产物,它的"存在感"完全体现在依赖关系里,所以依赖关系的清理必须彻底,否则编译期不报错,运行期才可能出问题,那时候排查成本就高了。
关于"排除指定模块"这件事,我最终沉淀下来的经验是:先分清模块类型,再决定动哪个文件。HAR 动依赖,HSP 动发布产物集合,通用场景直接动 build-profile.json5 的模块列表。把 hvigorfile 当成一个可以编程的构建入口,而不是默认导出配置的静态占位文件,很多构建定制需求其实都能在这个文件里解决。这套方案我在项目上用了很长一段时间,再也没有出现过"正式包带着调试工具"的事故,团队里其他同事也能通过环境变量和 target 映射表自行控制,不需要有人专门守着发布流程。如果你也在头疼模块裁剪和打包控制,不妨按这个思路试试。