HarmonyOS 多 Product 构建实践:一套代码生成多个产物

本文基于 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"
          ]
        }
      }
    ]
  }
}

这里有两个关键点:

  1. bundleName 必须和签名 profile 匹配。
  2. iconlabel 建议仍引用同名资源,例如 $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 和 entry/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 时,按这个顺序推进更稳:

  1. 在根 build-profile.json5 增加 signingConfig 和 product,确认 bundleName、profile、icon、label、resource.directories。
  2. 给 entry 增加对应 target,补齐 entry/src/<product>/resources 下的字符串、图标、启动素材、profile 和多语言资源。
  3. 审查 modules[].targets[].applyToProducts,决定哪些业务模块、广告模块、诊断模块、公共资源模块进入该 product。
  4. 扩展 hvigorfile.ts 的 product 配置,保证 metadata、DeepLink scheme、querySchemes 等入口字段由构建期统一注入。
  5. 扩展 AppProductConfigProductSdkConfigProductUrlConfig,让分享、统计、Crash、推送、TTS、协议 URL 等运行时差异都从同一层读取。
  6. 扩展 CI 脚本中的 product 白名单、release 证书映射、输出路径和归档命名。
  7. 分别跑 debug 和 release 构建,并安装验证桌面图标、系统设置页、DeepLink、协议页、分享参数、SDK 后台归属。

10. 排查和验收清单

最后给一张落地检查表:

检查项 检查方式
根配置是否包含目标 product products[]signingConfigbundleNameresource.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 初始化和业务判断里,说明多产物架构边界是健康的。


参考资料

相关推荐
TT_Close4 小时前
别劝退了!5秒搞定 Flutter 鸿蒙 FVM 起跑线
flutter·harmonyos·visual studio code
TrisighT5 小时前
ArkTS 列表滚动时为什么会闪现旧数据?我扒了 LazyForEach 的复用逻辑
harmonyos·arkts·arkui
MonkeyKing5 小时前
鸿蒙ArkTS深度剖析:ArkTS与TS/JS核心差异、静态强类型实战优势
typescript·harmonyos
TrisighT5 小时前
Electron鸿蒙PC上写日志文件,我被权限和路径坑了两次
electron·harmonyos
TrisighT1 天前
一个下午搞定 ArkTS 折叠面板?结果我从两点写到晚上九点
harmonyos·arkts·arkui
花椒技术4 天前
HJPusher / HJPlayer SDK 实践:我们为什么把直播推播链路拆成一套可复用能力
设计模式·harmonyos·直播
一维Ace4 天前
HarmonyOS ArkTS 按钮组件全解:Button、Toggle 状态交互实战
harmonyos
anyup5 天前
来简单聊聊鸿蒙开发,万元奖金的事~
前端·华为·harmonyos
Georgewu5 天前
【无测试机别害怕】华为云鸿蒙云手机南:从零到联调全流程详解
harmonyos