本文基于 HarmonyOS 多 Product / 多 Target 能力,整理一套"同一代码仓生成多个 App 产物"的落地方案。示例中的工程名、包名、应用名、client_id、scheme、URL 均为脱敏示例,不对应真实项目。

1. 先说结论
多产物构建不应该靠复制工程解决。更稳的做法是把差异拆到四层:
| 层级 | 负责什么 | 典型配置 |
|---|---|---|
| Product | 最终 App 身份 | 包名、签名、App 名、图标、AppScope 资源 |
| Target | 模块产物形态 | entry / HSP / HAR 的资源、源码、native lib 过滤 |
| 构建期注入 | 入口 profile 动态字段 | metadata、DeepLink scheme、querySchemes |
| 运行时配置 | 业务差异分发 | SDK 参数、协议 URL、分享参数、包名传递 |
一句话概括:Product 管 App 是谁,Target 管模块怎么进包,hvigorfile.ts 管入口动态字段,业务代码只读取 BuildProfile 和产品配置层。
2. 为什么不要复制一套工程
多产物需求通常看起来只是"换个包名、图标和应用名",但真正落地会牵出更多差异:
| 差异类型 | 例子 | 推荐落点 |
|---|---|---|
| App 身份 | 包名、签名 profile、App 名、App 图标 | 根 build-profile 的 products |
| 入口配置 | EntryAbility、启动图、DeepLink、metadata | entry target + hvigorfile.ts |
| 资源素材 | Logo、隐私弹窗素材、广告加载图 | 模块 target 的 resource.directories |
| 三方 SDK | 统计、Crash、推送、分享、TTS、风控 | ProductSdkConfig |
| 协议合规 | 隐私协议、用户协议、备案号、许可证 URL | ProductUrlConfig |
| CI 产物 | debug/release、签名、归档路径、文件名 | 构建脚本 product 参数化 |

复制工程的问题是后续所有基础能力都要双份维护:公共 bug 修复、新模块接入、依赖升级、合规文案调整都会变成长期分叉。多 Product / 多 Target 的价值,是把差异放进构建体系,让主业务代码最大程度复用。
3. Product 和 Target 的职责边界
HarmonyOS 工程里可以这样理解:
- Product 是 App 级身份:适合管理 bundleName、signingConfig、App 图标、App 名称、AppScope 资源目录。
- Target 是模块级形态:适合管理 entry、公共资源模块、商业化模块等模块维度差异。
- applyToProducts 负责组合关系:根工程决定某个 product 要使用哪些模块 target。
- 构建时显式选择 product + buildMode :例如
-p product=lite_app -p buildMode=release。

建议把"差异属于哪一层"先判断清楚,再动代码:
| 需求 | 应该改哪里 | 不建议怎么做 |
|---|---|---|
| 改包名、签名、App 名、App icon | 根 products + AppScope productResources | 复制 AppScope 或脚本散改 |
| 改 EntryAbility 名称、启动图、权限 reason | entry 新 target + 资源覆盖 | 复制整份 entry |
| 改 DeepLink scheme、client_id、querySchemes | 根 hvigorfile.ts 注入 moduleJsonOpt | 维护多份 module.json5 |
| 改公共 Logo 或模块资源 | 对应模块新 target + resource.directories | 业务代码按 product 拼图片名 |
| 改 SDK 参数 | ProductSdkProtocol + 产品实现 | 每个 SDK 初始化点写 switch |
| 改协议 URL / 备案 / 许可证 | ProductUrlProtocol + 产品实现 | 页面里硬编码 URL |
| CI 出不同 product 包 | PRODUCT 参数 + product/buildMode 归档命名 | 固定 default 后手动改文件 |
4. Product:定义最终 App 身份
根 build-profile.json5 里先定义 product。建议保留 default,再增加明确命名的业务 product,比如旗舰版和极速版:
json5
{
"app": {
"signingConfigs": [
{ "name": "default", "material": { "profile": "debug-profile" } },
{ "name": "flagship_app", "material": { "profile": "flagship-release-profile" } },
{ "name": "lite_app", "material": { "profile": "lite-release-profile" } }
],
"products": [
{
"name": "default",
"signingConfig": "default",
"bundleName": "com.example.app.flagship",
"icon": "$media:app_icon_layer",
"label": "$string:app_name"
},
{
"name": "lite_app",
"signingConfig": "lite_app",
"bundleName": "com.example.app.lite",
"icon": "$media:app_icon_layer",
"label": "$string:app_name",
"resource": {
"directories": [
"./AppScope/productResources/lite_app",
"./AppScope/resources"
]
}
}
]
}
}
这里有两个关键点:
bundleName必须和签名 profile 匹配。icon和label建议仍引用同名资源,例如$media:app_icon_layer、$string:app_name,由 product 资源目录覆盖具体内容。
AppScope 资源目录可以这样组织:
text
AppScope/
resources/
base/element/string.json
base/media/app_icon_layer.json
base/media/logo_bg.png
base/media/logo_fg.png
productResources/
lite_app/
base/element/string.json
base/media/app_icon_layer.json
base/media/logo_bg.png
base/media/logo_fg.png
这样页面和 profile 仍然引用同一个资源 key,减少跨模块改引用的风险。
5. Target:控制模块形态和入包范围
Target 解决的是模块维度问题:这个模块在某个 product 下用哪种资源、源码、native lib,或者是否参与该 product 构建。
根 build-profile.json5 通过 applyToProducts 绑定模块 target:
json5
{
"modules": [
{
"name": "entry",
"targets": [
{ "name": "default", "applyToProducts": ["default", "flagship_app"] },
{ "name": "lite_app", "applyToProducts": ["lite_app"] }
]
},
{
"name": "core_hsp",
"targets": [
{ "name": "default", "applyToProducts": ["default", "flagship_app", "lite_app"] }
]
},
{
"name": "lite_only_hsp",
"targets": [
{ "name": "lite_app", "applyToProducts": ["lite_app"] }
]
}
]
}
模块自己的 target 再配置资源覆盖:
json5
{
"targets": [
{ "name": "default" },
{
"name": "lite_app",
"resource": {
"directories": [
"./src/lite_app/resources",
"./src/main/resources"
]
}
}
]
}
注意:差异目录要放前面,公共目录放后面。新增 target 后要确认 module.json5 中引用到的 $string、$media、$profile 在目标 target 下都能解析,权限 reason、EntryAbility label、启动图和隐私文案尤其容易漏。
6. hvigorfile.ts:构建期注入入口动态字段
入口模块的 module.json5 不建议为每个 product 复制一份。Ability、权限、DeepLink、extension、启动窗口这些声明应该集中维护。
但有些字段不能只靠资源覆盖解决:
| 字段 | 为什么动态处理 | 推荐方式 |
|---|---|---|
| metadata.client_id | 不同 product 接不同平台配置 | 根 hvigorfile.ts 修改 metadata |
| skills.uris\[\].scheme | DeepLink scheme 随 product 变化 | 按 host 映射替换 scheme |
| querySchemes | 可查询外部 scheme 白名单可能不同 | 按 product 覆盖数组 |
最小骨架如下:
ts
const MODULE_PROFILE_CONFIGS = {
default: {
metadataValues: { client_id: 'flagship-client-id' },
uriSchemesByHost: { 'app.example.com': 'flagshipapp' }
},
lite_app: {
metadataValues: { client_id: 'lite-client-id' },
uriSchemesByHost: { 'app.example.com': 'liteapp' },
querySchemes: ['liteshare']
}
};
hvigor.nodesEvaluated(() => {
const productName = getCurrentProductName();
const config = MODULE_PROFILE_CONFIGS[productName] ?? MODULE_PROFILE_CONFIGS.default;
forEachHapNode((hapNode) => {
if (hapNode.moduleName !== 'entry') {
return;
}
const moduleJson = hapNode.getModuleJsonOpt();
patchMetadata(moduleJson, config.metadataValues);
patchUriScheme(moduleJson, config.uriSchemesByHost);
patchQuerySchemes(moduleJson, config.querySchemes);
hapNode.setModuleJsonOpt(moduleJson);
});
});
边界要守住:hvigorfile.ts 只处理入口 profile 差异,不承载业务逻辑。建议构建日志打印 product 和注入字段摘要,方便 CI 排查。
7. 运行时:BuildProfile 收口到产品配置层
构建系统会生成 BuildProfile。业务代码不要直接散落判断包名、App 名、渠道名,而是先收口到统一配置。

推荐封装:
ts
export class AppProductConfig {
static readonly productName = BuildProfile.PRODUCT_NAME as string;
static readonly bundleName = BuildProfile.BUNDLE_NAME as string;
static readonly isFlagship = productName === 'default' || productName === 'flagship_app';
static readonly isLite = productName === 'lite_app';
static appendProductParamsForWebpageUrl(url?: string): string | undefined {
return isLite ? upsertQueryParam(url, 'product', 'lite_app') : url;
}
}
SDK 和 URL 配置再往下一层收口:
ts
class ProductSdkConfigCenter {
static readonly config = getConfigByProduct(AppProductConfig.productName);
}
export class ProductSdkConfig {
static get APP_CHANNEL() {
return ProductSdkConfigCenter.config.APP_CHANNEL;
}
}
class ProductUrlConfig {
static readonly url = getUrlByProduct(AppProductConfig.productName);
}
这样统计、Crash、分享、TTS、风控、隐私协议、用户协议、备案号等差异都从统一入口读取。新增 product 时只扩展配置实现,不把 switch 写进每个初始化点。
8. CI:显式传 product 和 buildMode
构建脚本不要固定 product=default。建议统一支持环境变量或第一个参数:
bash
PRODUCT="${PRODUCT:-${1:-default}}"
PRODUCT="$(printf '%s' "${PRODUCT}" | tr '[:upper:]' '[:lower:]')"
hvigorw --sync --mode project \
-p product=${PRODUCT} \
-p buildMode=${BuildMode} \
--no-daemon
hvigorw assembleApp --mode project \
-p product=${PRODUCT} \
-p buildMode=${BuildMode} \
--no-daemon

发布态还需要让签名和产物归档跟 product 走:
| 环节 | 要点 |
|---|---|
| 入参 | 支持 PRODUCT 和 BuildMode |
| 校验 | 不支持的 product 直接退出 |
| 签名 | 按 product 选择 release 证书目录和 profile |
| sync | hvigorw --sync 携带 product/buildMode |
| assemble | assembleApp 携带 product/buildMode |
| 拷贝 | 从 build/ PRODUCT/outputs和entry/build/{PRODUCT}/outputs 查找产物 |
| 归档 | 文件名包含 product、buildMode、branch、time |
命令示例:
bash
PRODUCT=flagship_app BuildMode=debug ./script/test_build.sh
PRODUCT=lite_app BuildMode=debug ./script/test_build.sh
PRODUCT=lite_app BuildMode=release ./script/devops_build.sh
9. 新增一个 Product 的最小步骤
新增 product 时,按这个顺序推进更稳:
- 在根
build-profile.json5增加 signingConfig 和 product,确认 bundleName、profile、icon、label、resource.directories。 - 给 entry 增加对应 target,补齐
entry/src/<product>/resources下的字符串、图标、启动素材、profile 和多语言资源。 - 审查
modules[].targets[].applyToProducts,决定哪些业务模块、广告模块、诊断模块、公共资源模块进入该 product。 - 扩展
hvigorfile.ts的 product 配置,保证 metadata、DeepLink scheme、querySchemes 等入口字段由构建期统一注入。 - 扩展
AppProductConfig、ProductSdkConfig、ProductUrlConfig,让分享、统计、Crash、推送、TTS、协议 URL 等运行时差异都从同一层读取。 - 扩展 CI 脚本中的 product 白名单、release 证书映射、输出路径和归档命名。
- 分别跑 debug 和 release 构建,并安装验证桌面图标、系统设置页、DeepLink、协议页、分享参数、SDK 后台归属。

10. 排查和验收清单
最后给一张落地检查表:
| 检查项 | 检查方式 |
|---|---|
| 根配置是否包含目标 product | 看 products[]、signingConfig、bundleName、resource.directories |
| 模块是否绑定目标 product | 看 modules[].targets[].applyToProducts |
| 资源覆盖是否完整 | 检查 base、dark、zh_CN、en_US、profile 是否齐全 |
| entry profile 是否被正确注入 | 看构建日志中的 product、metadata、scheme 摘要 |
| 代码是否仍有硬编码 | 搜索固定包名、固定 App 名、固定 SDK Key、固定协议 URL |
| SDK 和 URL 是否收口 | 看是否统一走 ProductSdkConfig / ProductUrlConfig |
| CI 是否参数化 | 看 sync、assemble、签名、拷贝、归档是否都带 product/buildMode |
| App 名和图标 | 安装后看桌面图标、任务列表、系统设置页 |
| DeepLink scheme | 用测试链接拉起对应 product |
| SDK 参数 | 看初始化日志和后台数据归属 |
| 包名传递 | 检查支付、分享、Flutter、文件预览等依赖包名的场景 |
落地判断标准也很简单:新增 product 后,如果大部分改动集中在 build-profile、资源目录、hvigor 注入、产品配置层和 CI 映射,而不是散落在页面、路由、SDK 初始化和业务判断里,说明多产物架构边界是健康的。
参考资料
- 华为开发者文档:多目标产物构建实践
developer.huawei.com/consumer/cn... - HarmonyOS Stage 模型应用包结构
developer.huawei.com/consumer/cn... - HarmonyOS 资源分类与访问
developer.huawei.com/consumer/cn... - HarmonyOS Hvigor 构建参考
developer.huawei.com/consumer/cn...