同一个 HarmonyOS 应用开始区分普通版和专业版后,发布工作里最容易被忽略的变化不是新增了几个页面,而是同一工程会产生不同产品配置。开发时看到的桌面图标,不一定能代表另一个 product 的最终图标;默认入口跑得通,也不证明专业版所引用的资源文件仍然存在。

考虑一个演示工程 IconMatrix:default 和 readerPro 两个构建产品都声明了自己的图标和名称。某次资源整理后,readerPro 仍引用 $media:pro_icon,但 AppScope/resources/base/media/pro_icon.png 没有进入本地源文件集合。页面运行逻辑可能完全正常,可交付包的外观配置却已经留下风险。

本文不把这个情形写成平台“审核拒绝”的亲历故事。我们设计的是一条构建前资源引用审计,用固定样本演示 REL-ICON-061 检查任务:两个产品、六项规则、五项通过、一项失败,最终状态为 BLOCKED。这只是业务门禁判断,不是 HarmonyOS 应用市场的正式审核结论。

一、先界定验证对象,不让脚本越权

华为的多目标构建文档明确介绍了工程级 build-profile.json5 中 app.products 可按产品配置 icon、label,未配置时存在默认值继承关系。文档也说明,真正的产物仍取决于目标 product、模块 target 和构建工具处理的优先级。因此脚本应区分两个层次:源配置引用是否可解析与最终发布产物是否符合预期。

前者可以在构建前静态检查;后者必须通过正式构建、安装和渠道验证。本文只实现前者,不尝试解析签名包内部资源表,也不声称扫描结果等于实际桌面效果。这样主动缩小目标,反而更容易让团队采用,因为门禁结论清楚、失败也可复现。

1. 一项图标引用,实际跨了三处文件

本例将核查范围锁定为工程级 build-profile.json5 的两个 product,以及 AppScope/resources/base/media/ 中的图标源文件和 AppScope/resources/base/element/string.json 中的名称项。它不自动解析所有变体资源目录、不合并 app.json5 / module.json5 / product 的复杂优先级,也不覆盖多 HAP 的 launcher entry 图标。

这个前提必须公开,否则开发者会误以为静态脚本已经完整模拟 Hvigor。对于图标使用 SVG、分层图标或资源变体的项目,应扩展资源解析器,而不是让简单检查器悄悄放行未知类型。

二、把产品配置变成可检查的数据

1. 示例配置先固定两条 product 记录

要解决的第一个问题是:普通版和专业版不能共享一个模糊的“当前图标”变量。下面是 build-profile.json5 中与本案例有关的局部片段,省略了签名、模块 target 和其他工程字段;正式项目不能把它当作完整构建文件直接覆盖。

{
  "app": {
    "products": [
      {
        "name": "default",
        "icon": "$media:default_icon",
        "label": "$string:default_name"
      },
      {
        "name": "readerPro",
        "icon": "$media:pro_icon",
        "label": "$string:pro_name"
      }
    ]
  }
}

示例还约定 string.json 包含 default_name 和 pro_name 两条资源;default_icon.png 文件存在,pro_icon.png 则作为故意遗漏的测试样本。把这些前提写在检查之前,才能说明为什么结果必然是 5/6,而不是故作神秘地贴一张“检测失败”截图。

2. 三项规则分别解释三种失败

对每个 product 都执行三步:第一,图标引用必须符合本轮支持的 $media:<name> 格式;第二,指定源目录里至少存在一份 <name>.png;第三,label 引用的 $string:<name> 必须能在字符串资源表里找到。两个产品各三项,总共六项。这里的 png 限制是本检查器的输入合同,不是 HarmonyOS SDK 只支持 PNG 图标的声明。

检查器需要返回结构化记录,而不是一遇到缺文件就抛异常退出。只有这样,CI 和展示页才能从同一份报告派生 PASS、FAIL 和人类可读原因,避免开发者只拿到一个无上下文的失败退出码。

三、用 Node.js 实现只读审计,不修改工程

第二个问题是怎样读取 JSON5。Node.js 内置 JSON.parse 不能可靠处理 JSON5 的注释和尾随逗号,所以示例明确依赖标准 json5 包:在工程开发依赖中安装它,再用 node scripts/icon-audit.mjs 运行。不要通过 eval 执行配置文件,也不要随手删注释后假装完成了通用 JSON5 解析。

下面的模块从工程根目录读入配置,扫描两个指定产品的六项规则,返回数组。它不改写配置、不删除文件、也不做网络请求,因此失败后可以反复检查同一源树。

// scripts/icon-rules.mjs;安装开发依赖:npm install --save-dev json5
import { readFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import JSON5 from 'json5';

function row(product, rule, ok, detail) {
  return { product, rule, ok, detail };
}

export function inspectIcons(root) {
  const profile = JSON5.parse(readFileSync(join(root, 'build-profile.json5'), 'utf8'));
  const strings = JSON.parse(readFileSync(
    join(root, 'AppScope/resources/base/element/string.json'), 'utf8'
  ));
  const keys = new Set(strings.string.map((item) => item.name));
  const products = (profile.app.products ?? []).filter(
    (p) => p.name === 'default' || p.name === 'readerPro'
  );
  const result = [];
  for (const p of products) {
    const iconValid = /^\$media:[A-Za-z_][\w]*$/.test(p.icon ?? '');
    const iconName = iconValid ? p.icon.slice('$media:'.length) : '';
    const iconPath = join(root, 'AppScope/resources/base/media', `${iconName}.png`);
    const labelValid = /^\$string:[A-Za-z_][\w]*$/.test(p.label ?? '');
    const labelName = labelValid ? p.label.slice('$string:'.length) : '';
    result.push(row(p.name, 'ICON_SYNTAX', iconValid, p.icon ?? 'missing'));
    result.push(row(p.name, 'ICON_FILE', iconValid && existsSync(iconPath), iconPath));
    result.push(row(p.name, 'LABEL_KEY', labelValid && keys.has(labelName), p.label ?? 'missing'));
  }
  return { products: products.length, rows: result };
}

这段代码的风险边界也应当讲清楚。其一,资源目录是本 Demo 的约定路径,不是通用的资源优先级解析引擎;其二,existsSync 只证明文件存在,不证明图片尺寸、编码、透明通道或最终产物质量;其三,若 products 配置本身丢失,这段函数会返回空记录。生产门禁还应强制检查产品清单与预期数量吻合,不能接受零项检查却显示“全部通过”。

四、CI 应当只接受完整的六项报告

接下来要解决的是“检查没跑完却被当成通过”的情况。把预期产品名称、规则总数和失败数量一起写进退出条件,任何异常都转成明确的非零退出码。这里使用的是 Node.js 标准输出和退出码,不依赖虚构的 Hvigor 官方门禁 API;项目可以把它作为构建前独立步骤调用。

// scripts/icon-audit.mjs
import { inspectIcons } from './icon-rules.mjs';

const TASK_ID = 'REL-ICON-061';
try {
  const report = inspectIcons(process.cwd());
  const checks = report.rows.length;
  const failed = report.rows.filter((r) => !r.ok);
  const passed = checks - failed.length;
  const blocked = report.products !== 2 || checks !== 6 || failed.length !== 0;
  const status = blocked ? 'BLOCKED' : 'PASS';
  console.log(`IconMatrix task=${TASK_ID} products=${report.products} checks=${checks}`);
  console.log(`IconMatrix pass=${passed} fail=${failed.length} status=${status}`);
  for (const item of failed) console.error(`${item.product}/${item.rule}: ${item.detail}`);
  process.exitCode = blocked ? 1 : 0;
} catch (error) {
  console.error(`IconMatrix task=${TASK_ID} status=BLOCKED`, error);
  process.exitCode = 2;
}

在本文固定的资源样本下,逻辑上应生成 products=2 checks=6、pass=5 fail=1 status=BLOCKED,失败规则是 readerPro/ICON_FILE,对应丢失的 pro_icon.png。这是一组演示用预期输出,没有声称实际执行了目标工程的发布构建。团队接入 CI 时,建议把扫描报告保存为独立工件,并记录源提交号、产品组合和脚本版本,使失败具有可追溯性。

五、让开发者能在界面上解释失败

只保留控制台错误,往往让非开发角色误以为所有产品都坏了。本例另设 IconAuditPage.ets,展示一张由模型数据构造的只读报告:任务 REL-ICON-061、产品 default/readerPro、检查 5/6、状态 BLOCKED,并在异常详情中单独指出 readerPro 缺少 pro_icon.png。

下面是可以嵌入现有 ArkUI 页面的一段核心视图。注意它只是报告的展示层静态快照,尚未接入 Node.js 扫描结果的文件导入或 IPC。若未来需要同步数据,先设计带版本号的 JSON 报告,再做严格解析,不能让演示中的常量冒充实时构建事实。

@Entry
@Component
struct IconAuditPage {
  @State taskId: string = 'REL-ICON-061';
  @State passed: number = 5;
  @State total: number = 6;
  @State blocked: boolean = true;

  build() {
    Column({ space: 18 }) {
      Text('IconMatrix · 图标引用链').fontSize(24).fontWeight(FontWeight.Bold)
      Text(`任务 ${this.taskId}`)
      Text(`产品 default / readerPro`)
      Text(`检查 ${this.passed}/${this.total}`)
      Text(this.blocked ? 'BLOCKED · 需要修复' : 'PASS')
        .fontColor(this.blocked ? Color.Red : Color.Green)
      Text('readerPro / ICON_FILE')
      Text('缺失 pro_icon.png')
      Text('演示报告 · 非真实上架审核结果').fontSize(12)
    }.padding(24).width('100%')
  }
}

这里的状态生命周期与后台构建完全解耦:脚本失败会通过 CI 的非零退出码阻断流程;ArkUI 展示页只帮助理解失败,不负责决定是否签名或提交。页面销毁无需维持文件监听器,等真正接入报告推送后再成对注册、取消订阅,防止重新进入页面重复消费同一个失败通知。

六、调试顺序与发布前收口

演示阶段应优先固定文件树:default_icon.png 存在、pro_icon.png 不存在、default_name 和 pro_name 都存在。然后以相同提交内容连续运行校验,确认总数始终为六,而不是因为过滤掉一个 product 变成三项却显示成功。补回缺失文件后再执行第二次校验,才有资格预期状态转成 PASS。

对真正的发布工程,还要增加文件类型检查、图片可解析性、产物中有效资源与实际桌面显示核验。特别是多 product 的继承与覆盖问题,不能只依赖 AppScope 路径推导;需要参照目标构建产品的配置优先级、安装测试及工具链输出。图中的图标卡片仅是为了说明哪个产品失败,不是实际安装截图。

完成这一轮设计,我们最终得到的不是“系统保证会拒绝错误图标”的结论,而是一条更朴素、可以逐步扩展的工程规则:声明要有源文件,产品数量要完整,检查总数要固定,失败应能定位到具体 product。这四件事建立起来,发布前的资源风险就能从碰运气转成可解释的日常检查。

官方依据包括华为 多目标与多产品构建指南、工程级 build-profile.json5 说明 及 Stage 模型图标与标签配置。其中产品字段、资源引用属于官方机制;ICON_SYNTAX、ICON_FILE、LABEL_KEY 和六项门禁则是本篇自定义策略,不应误写成 HarmonyOS 审核政策。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐