打开一个鸿蒙应用工程,根目录这几个文件多少会让你有点懵:hvigorfile.ts、hvigorw、hvigorw.bat、build-profile.json5。我第一次接触鸿蒙的构建体系时,习惯性地把hvigorfile.ts当成“另一个世界的build.gradle”,结果发现它的写法、任务模型、插件机制跟Gradle差得挺远。等到真正需要“打包时排除指定模块”这个需求时,才逼着自己把hvigorfile的结构和构建任务链翻了个底朝天。
这篇文章就把我在实际项目里用hvigorfile做模块排除的思路、代码、踩坑记录一次说清楚。内容包括:hvigorfile在整个鸿蒙构建流程里到底是什么角色、排除模块前先想清楚哪几种方案、三种能落地的做法(含可直接抄的配置)、以及排查产物异常的常见问题清单。无论你是刚接触鸿蒙构建的小白,还是已经写过自定义构建任务的开发者,应该都能从里面找到有用的东西。
1. hvigorfile到底在构建流程里扮演什么角色
1.1 一个“构建任务调度台”,而不是普通的配置文件
鸿蒙的构建工具叫hvigor,它跟Gradle一样有任务、有依赖、有插件机制,但它的默认入口脚本不是XML或Groovy,而是TypeScript文件——也就是hvigorfile.ts。新建一个工程后,根目录的hvigorfile.ts长这样:
import { appTasks } from '@ohos/hvigor-ohos-plugin'; export default { system: appTasks, plugins: [] }如果你打开某个模块目录(比如entry),会看到类似这样的hvigorfile.ts:
import { hapTasks } from '@ohos/hvigor-ohos-plugin'; export default { system: hapTasks, plugins: [] }区别就在system这个字段:根目录的appTasks表示“整个应用”的构建任务集合,模块目录里的hapTasks表示“HAP包”的构建任务集合。除了hapTasks,还有harTasks(静态共享包)、hspTasks(动态共享包)等,分别对应不同类型的模块。
所以hvigorfile.ts并不是用来声明“我要打哪些模块”的,它更像一个任务调度台:告诉构建工具,当前这个项目/模块使用哪一套预置任务,以及要挂哪些自定义插件。真正决定“哪些模块参与打包”的,是build-profile.json5这类工程配置文件。
1.2 包产物是怎么把多个模块“卷”进来的
一个标准的鸿蒙应用工程,往往不只有entry一个模块。比如你有entry(入口HAP)、feature_home(业务HSP)、common_utils(工具HAR),最终打包成APP时,hvigor会从根任务出发,沿着任务依赖树一个一个执行:
- 先编译每个模块;
- 再把HAP、HSP、HAR按依赖关系组装;
- 最后生成
app包或hap包。
这个过程很像飞机起飞前把所有托运行李都装上货舱:只要行李牌上写着这趟航班,不管你想不想带,它都会被搬上飞机。想要“某个模块不上飞机”,就得从行李清单里把它的“牌”摘掉。
回到构建场景,“摘牌”有几种做法:一是修改模块注册表,让hvigor根本不知道有这个模块;二是只指定某个模块单独构建,其他模块自然不参与;三是在hvigorfile.ts里对任务链做过滤,动态控制哪些模块的构建任务被保留。下面第二部分先对比一下这些思路,再进入实操。
2. 动手前先搞懂:排除模块的几种可行思路
2.1 方案对比总览
我先用一张表把常见方案列出来,后面再逐个说适用场景。这张表也是我给项目组同学做培训时的笔记,省得大家一上来就改hvigorfile。
| 方案 | 修改入口 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 从build-profile模块表移除模块 | build-profile.json5 | 操作简单、直观,IDE和命令行都能生效 | 每次都要改配置,容易忘加回来 | 临时排除、发布前裁剪 |
| 只构建指定模块 | 命令行参数 | 不改任何脚本,一条命令搞定 | 只能“选一个”,不适合批量组合 | 单模块调试、CI流水线里按需构建 |
| 在hvigorfile里过滤任务依赖 | hvigorfile.ts | 可以根据环境变量、构建参数动态控制,自动化友好 | 对任务API理解要求高,SDK版本升级可能失效 | 复杂工程、多产品形态定制 |
| 从依赖配置里去掉模块引用 | oh-package.json5 | 从根上断掉依赖关系,产物最干净 | 可能导致编译报错,只能处理“可被引用”的模块 | 模块本身是可选依赖、按需加载场景 |
2.2 为什么我一般不首选“硬删hvigorfile里的任务”
这里有个经验:很多人看到“排除模块”第一反应是去hvigorfile.ts里把所有跟某模块相关的任务删掉。我不太推荐一上来就这么干,原因有三个。
第一,hvigorfile.ts里的任务是预置的、成体系的。appTasks下面挂着一长串任务,比如assembleApp、assembleHap、compileJS、packageResource等等。手动删任务,容易把任务依赖链弄断,出现“这个模块确实没打,但别的模块也跟着崩了”的诡异问题。
第二,删除任务和“模块不参与构建”不是等价关系。hvigor的任务节点名称往往跟模块名不是一一对应的,而且模块之间还有依赖关系。你把A模块的任务删了,如果B模块依赖A,那B的构建也会失败。
第三,脚本可维护性差。hvigorfile是构建入口,团队里每个人都可能改,一旦加了大量自定义逻辑,后续升级SDK版本时很容易冒出莫名其妙的兼容性问题。
所以我通常的建议是:能用配置文件解决,就不动脚本;一定要动脚本,也尽量只做“读参数、决定执行路径”这种轻量逻辑,别在hvigorfile里写重型业务代码。
3. 实操记录:三种“打包时不带某个模块”的做法
3.1 做法一:从build-profile模块表里临时移除
这是最直接、最不容易出错的方法,适合临时发版、确认产物大小等场景。我先把关键概念说清楚:build-profile.json5里的modules数组就是工程模块的“登记册”。凡是参与构建的模块,都会在这个数组里出现。把某个模块从这个数组里注释掉,等于是告诉hvigor:这趟构建不需要它。
下面是一个简化版的build-profile.json5结构(不同DevEco版本字段略有差异,但核心意思一致):
{ "app": { "products": [ { "name": "default", "signingConfig": "default", "compileSdkVersion": "4.0.0(10)", "compatibleSdkVersion": "4.0.0(10)" } ] }, "modules": [ { "name": "entry", "srcPath": "./entry", "targets": [ { "name": "default", "applyToProducts": ["default"] } ] }, { "name": "feature_home", "srcPath": "./feature_home", "targets": [ { "name": "default", "applyToProducts": ["default"] } ] } ] }如果现在要排除feature_home,最省事的做法就是把feature_home这一段从modules数组里注释掉:
"modules": [ { "name": "entry", "srcPath": "./entry", "targets": [ { "name": "default", "applyToProducts": ["default"] } ] } // { // "name": "feature_home", // "srcPath": "./feature_home", // "targets": [ // { // "name": "default", // "applyToProducts": ["default"] // } // ] // } ]改完以后,点击IDE里的Sync,或者直接运行hvigorw,hvigor在解析工程结构时就不会再为feature_home创建构建任务。
这里有几个坑特别提醒:
- 不要只注释
srcPath或只改name,最好整段注释掉,否则hvigor容易“认识这个模块但找不到路径”,报错信息又长又绕。 - 签名、打包配置里如果引用了这个模块,比如证书的
profile里绑定了某个module,那移除后可能在签名阶段报错。这种情况建议先确认签名配置。 - 使用DevEco Studio的图形界面操作更稳妥:项目右键 ->
Build->Select Build Mode,或者直接编辑modules后让IDE重新同步。图形界面能顺手刷新很多缓存。
这个方法适合“这个版本就不想带这个模块”的场景。但如果你经常会切换“带/不带”,每次手改配置就太烦了,看做法二和做法三。
3.2 做法二:命令行只构建指定模块
很多时候我们不是真的“永久排除”,只是这次打包想轻装上阵,比如CI里只要打entry这一个HAP,或者只想验证某个模块的编译结果。这时候用命令行参数指定模块,不改任何脚本,最干净。
hvigor提供了命令行模式,大致命令格式如下(不同SDK版本参数名会有差异,以你本地的hvigorw -h输出为准):
hvigorw assembleHap --mode module -p module=entry@default拆开解释下:
assembleHap:要执行的任务名,意思是“打HAP包”。--mode module:指定按模块模式执行,而不是整个应用模式。-p module=entry@default:指定要构建的模块和target。entry是模块名,default是build-profile里配置的target名称。
如果只想构建某个HAR模块,大致就是把任务换成assembleHar,模块名换成对应HAR模块名。具体命令拿hvigorw --help查一下最可靠。
用命令行指定模块有个好处:它不会改动任何配置文件,不污染工程状态,特别适合写进CI脚本。比如我在某模拟项目X里,流水线默认是“全量打APP包”,但有个“仅打基础壳”的分支,就是靠这一段shell实现的:
#!/bin/bash if [ "$BUILD_SCENE" = "shell_only" ]; then hvigorw assembleHap --mode module -p module=entry@default else hvigorw assembleApp fi这种方式的一个限制是:它本质上是“只选一个模块”,不是“从全量里排除某几个模块”。如果你要打一个APP包,但里面不想含某个HSP,那还是得靠配置层裁剪,或者用第三种方法。
3.3 做法三:在hvigorfile.ts里做任务级过滤
终于到了跟标题直接相关的部分。先说明一点:hvigorfile里可以做任务过滤,但不同版本的hvigor API有过变化,下面这份代码是我在某个工程里实际用过的思路,不一定能原封不动跑在你本机,但逻辑是通用的。
先说需求背景。我当时负责的那个多模块工程,有一个内部调试模块debug_panel,只在开发阶段用,提审和正式发布时必须从包里剔除。我既不想每次手动改build-profile,也不希望依赖CI参数去指定模块,因为工程师在本地直接点IDE的“Build”时,一样会自动带上它。
最终方案是:在根目录hvigorfile.ts里增加一个自定义插件,读取环境变量作为开关,在构建任务注册阶段过滤掉指定模块的任务。
下面是简化后的示意代码:
import { appTasks } from '@ohos/hvigor-ohos-plugin'; // 环境变量为 true 时,不参与构建的模块 const EXCLUDED_MODULES = ['debug_panel']; const shouldExcludeModule = (taskName: string): boolean => { if (process.env.BUILD_EXCLUDE_DEBUG_PANEL !== 'true') { return false; } return EXCLUDED_MODULES.some((moduleName) => taskName.includes(moduleName)); }; export default { system: appTasks, plugins: [ { apply(manager: any) { // 这里的 API 名称以本机 SDK 为准 const assembleTask = manager.getTask('assembleApp'); if (assembleTask && Array.isArray(assembleTask.dependsOn)) { assembleTask.dependsOn = assembleTask.dependsOn.filter( (task: any) => !shouldExcludeModule(task.name || '') ); } } } ] };这段代码干了三件事:
- 通过
process.env.BUILD_EXCLUDE_DEBUG_PANEL控制是否启用排除逻辑; - 定义了一份“不参与构建的模块名单”
EXCLUDED_MODULES; - 在插件
apply阶段拿到assembleApp任务,把依赖列表中匹配到的模块任务过滤掉。
为什么用taskName.includes(moduleName)而不是直接判断相等?因为hvigor里任务节点名往往带后缀,比如assembleAppDebug_panel、packageDebug_panel之类,精确匹配很容易漏。用includes做模糊匹配,对“排除某模块相关所有任务”更有效,代价是模块名不能太长太普通,否则会误伤别人。
再强调一次:这个代码的核心是“思路参考”,不同版本的任务对象结构、属性名、依赖存储方式都可能不同。你拿到工程后,先打印一下assembleTask的结构,看看dependsOn到底叫什么、任务节点用什么字段暴露名字,再动手改。盲抄网上代码是很容易翻车的。
这种做法的最大优势是“动态”。比如你在build-profile.json5里临时注释模块,需要手动改文件;而这里只要构建时带上环境变量,就能自动决定是否排除,适合做持续集成、多产品定制。
4. 排除模块过程中的踩坑与排查清单
4.1 “模块明明移除了,产物里却还有”的排查思路
这是被问得最多的一个问题。明明build-profile.json5里已经注释掉了,生成的HAP/APP包里却还能找到对应模块的代码或资源。我自己排查过几次,总结下来原因基本集中在下面几个地方:
- 缓存没有刷新。IDE和构建工具都会缓存工程结构。改完
build-profile.json5后别直接打包,先执行Sync,或者重启一下IDE。 - 签名配置里硬编码了模块。有些自动生成签名profile会绑定所有模块。模块从工程里移掉后,签名profiler里还留着,产物组装时又从签名信息里拉回来了。
- 模块之间存在隐式依赖。比如
entry的oh-package.json5里写死了依赖debug_panel,那么就算debug_panel没有出现在modules数组里,hvigor在解析依赖时也会把它编译进来。 - 你打包用的target不对。只改了
modules,但products下的targets数组里还引用着这个模块。这俩是两套配置,都要检查。
排查时先别急着翻日志,先把产物解压出来直接看包内结构,比什么日志都直观。entry的产物里会有一个module.json,列出当前包实际包含的模块能力;同时看一下libs目录、assets目录,有没有排除模块的特征文件。
4.2 hvigorfile过滤失效的原因
用第三种方法时,最尴尬的就是代码写了、环境变量也配了,但打包产物纹丝不动。我归纳了几个容易出问题的点:
- 插件执行顺序不对。
plugins数组是按顺序执行的,如果你的过滤插件在appTasks任务还没有完全构建好依赖关系之前就跑,assembleTask.dependsOn可能是空的,自然过滤了个寂寞。你需要在正确生命周期阶段处理,比如任务注册完成之后再操作。 - 任务名匹配太严或太松。我前文提到用
includes做模糊匹配,但如果你要排除的模块名长得跟别的模块很像,比如home和home_admin,就可能误伤。建议把过滤名单写得全一点、特异一点,比如直接用模块路径或者包名前缀。 - 环境变量没传到构建子进程。在IDE里启动构建时,环境变量未必和你的终端一致。用
process.env做开关,先确保变量真的打进了环境里。最简单的方法是在hvigorfile里临时加一行日志输出,确认能否读到。 - 任务对象属性名变了。hvigor升级后,内部任务结构改动是常有的事。
dependsOn可能改叫depends,或者变成了方法而不是数组。遇到这种情况,别硬刚,去SDK目录下翻hvigor的实际类型定义,或者打印整个任务对象,看真实结构。
4.3 本地调试和发布构建互不干扰的小技巧
做“排除模块”最怕的是:为发布做的临时裁剪,把本地开发环境也搞坏了。我自己踩过一次,发布前注释了debug_panel,发布完忘记加回来,然后第二天debug时发现功能没了,排查了半天。
后来我把逻辑收敛成了“只有明确标记发布构建时才启用排除”,比如在hvigorfile里读取构建模式:
const isReleaseBuild = process.env.BUILD_MODE === 'release'; export default { system: appTasks, plugins: [ { apply(manager: any) { if (!isReleaseBuild) { return; } // 只有 release 构建时才做任务过滤 const assembleTask = manager.getTask('assembleApp'); if (assembleTask && Array.isArray(assembleTask.dependsOn)) { assembleTask.dependsOn = assembleTask.dependsOn.filter( (task: any) => !EXCLUDED_MODULES.some((moduleName) => (task.name || '').includes(moduleName)) ); } } } ] };这样本地跑debug构建时,构建模式不是release,过滤逻辑直接跳过,所有模块正常参与;只有发布构建时,才会把debug_panel排除掉。这个思路也可以换成“根据构建产物类型判断”,本质一样:把开关和场景绑定,别让全局状态污染所有构建。
另外还有一个细节:如果你在build-profile.json5里做了临时注释,别只注释不备份。我习惯在注释块旁边写一行// 发布后记得恢复,或者直接用Git分支管理,发布分支专门维护一份裁剪后的配置。这些都是日常习惯,但能省下不少麻烦。
5. 围绕hvigorfile的几条经验总结
写到这里,分享几个我个人在实际操作中的体会,不打算做那种“本文通过什么方法实现什么”的总结,就想给后来人一点参考。
第一个体会是:hvigorfile是构建脚本,不是业务代码,越简单越好。我刚接触时总想在脚本里加各种判断、分支、循环,觉得这样很“灵活”。后来发现,构建脚本一旦复杂到需要调试,它在整个工程里就是最不受欢迎的部分。能用build-profile和依赖配置表达的规则,就不要写进hvigorfile;hvigorfile只负责“这个工程/模块用哪套任务体系,挂哪些插件”。
第二个体会是:做模块排除前,先分清“临时需求”和“长期需求”。如果只是这个版本不要某个模块,直接改build-profile.json5,别动任务链;如果每个月都要做一次,那再考虑命令行参数或者hvigorfile的过滤逻辑。长期逻辑如果写进了任务过滤,一定要配上日志输出,不然团队成员根本不知道这次构建为什么少了模块。
第三个体会是:hvigor版本升级时,构建脚本是最容易出问题的地方。每次升级SDK后,我都习惯先在命令行跑一次hvigorw --help,再试着构建一次全量包,确认自定义插件没有因为内部API变化而失效。尤其是任务过滤这种直接操作任务依赖的写法,升级后一定要回归测试。
最后一个实用小技巧:如果想快速验证一个模块到底有没有进包,不用解包,直接在构建输出目录里搜模块名。oh_modules、build目录下会产生对应的中间产物,搜不到基本就说明这个模块没参与编译。这个方法在各种“看似排除成功但心里没底”的场景下都很管用。