应用包能构建成功,不代表发布素材已经准备好。商店截图常见的问题并不复杂:分辨率错一档、某个语言少一张、横竖图混放、文件超过限制。麻烦在于这些问题分散在不同目录里,人工检查很容易漏。

这篇文章用 StoreAssetGate 演示怎样把 AppGallery Connect 的素材规格转成一段可执行的门禁。文章中的审核页和日志是演示配图,不冒充真实 AGC 后台,也不虚构已经通过正式审核。规则以官方“Asset Specifications”页面 2026 年 1 月 20 日更新内容为依据,提交前仍应复核当前页面。

一、发布前最后半小时,最容易把素材目录当成普通文件夹

代码进入发布分支后,团队通常会把注意力放在 HAP、签名和版本号。截图由设计或运营补齐,开发者只确认“目录里有图”。等到上传时才发现,中文竖图是 1080×1920,英文第三张却从设计源稿直接导出了 1170×2532;另一张中文横图尺寸正确,但 PNG 达到 5.7 MB。

这类问题看上去只是素材返工,实际上会打断整个发布节奏。更隐蔽的是本地化错位:zh-CN 有三张竖图,en-US 只有两张;文件名都叫 01.png,画面里却保留了另一种语言。尺寸检查通过,也不能证明语言矩阵完整。

官方素材规格页面对 HarmonyOS 手机和平板截图给出了明确约束:横图为 16:9、1920×1080,数量 3~5;竖图为 9:16、1080×1920,数量 3~5;PNG、JPG 或 JPEG 单张最大 5 MB。这里把这几项转成机器可读规则,检查范围限定为手机/平板商店截图,不擅自扩展到其他终端的素材要求。

演示批次编号是 ASSET-0055,包含 zh-CN 与 en-US 两种语言,每种语言各三张竖图和三张横图,总计 12 张。第一次扫描发现 2 个错误:en-US/phone/portrait/03.png 为 1170×2532,zh-CN/phone/landscape/02.png 为 5.7 MB。替换后第二次扫描为 12 / 12 PASS。

二、规则文件先表达“我们准备发布什么”

脚本不应该从目录结构猜业务意图。一个项目只准备竖图,和一个项目忘记横图,在文件系统里可能完全相同。门禁需要一份清单,明确本批次的语言、终端、方向、数量、尺寸、格式和体积上限。

这段代码解决什么问题:把官方页面中的手机/平板截图要求落成项目级规则,避免扫描器自行猜测。

export type Orientation = 'portrait' | 'landscape'

export interface ScreenshotRule {
  device: 'phone'
  orientation: Orientation
  width: number
  height: number
  minCount: number
  maxCount: number
  maxBytes: number
  formats: Array<'png' | 'jpeg'>
}

export const releasePlan = {
  batchId: 'ASSET-0055',
  locales: ['zh-CN', 'en-US'],
  rules: [
    {
      device: 'phone', orientation: 'portrait',
      width: 1080, height: 1920, minCount: 3, maxCount: 5,
      maxBytes: 5 * 1024 * 1024, formats: ['png', 'jpeg']
    },
    {
      device: 'phone', orientation: 'landscape',
      width: 1920, height: 1080, minCount: 3, maxCount: 5,
      maxBytes: 5 * 1024 * 1024, formats: ['png', 'jpeg']
    }
  ] as ScreenshotRule[]
}

为什么把规则写在代码里,而不是散落在命令参数中?因为它需要跟发布分支一起评审。以后官方规格变化,修改记录能说明从哪一版开始调整,也能让历史版本继续使用当时的规则。

这里把 .jpg 和 .jpeg 都归一为 jpeg,并没有把 WebP 纳入演示清单。官方页面还列出 WebP 的单独体积要求,如果项目需要使用,应增加独立格式规则,不能沿用 PNG/JPEG 的 5 MB 上限。

实际项目容易犯的错误,是把 5 MB 写成 5,000,000 字节。平台页面通常以 MB 表达,脚本采用 5 * 1024 * 1024 时,应在团队内确认口径,并保留接近边界的安全余量。演示把 5 MB 视为硬门槛,但建议设计导出目标控制在 4.5 MB 以下,减少重新编码差异带来的边缘问题。

三、Sharp 只负责读证据,门禁逻辑留在业务层

Sharp 的 metadata() 可以返回格式、宽高、页数等信息;文件体积则从文件系统读取。它不需要修改原图。发布门禁的第一原则是“检查和修复分开”:脚本发现 1170×2532 后应阻止提交,而不是静默拉伸成 1080×1920。

静默修图看起来省事,实际会把新的风险带进来。截图中的文字可能被缩放发虚,安全区可能被裁掉,横竖构图也可能改变。工具可以生成修复建议,但最终素材应回到设计源文件重新导出。

这段代码解决什么问题:读取每张图片的真实格式、尺寸和体积,输出稳定的错误码。

import sharp, { Metadata } from 'sharp'
import { stat } from 'node:fs/promises'

export interface AssetEvidence {
  file: string
  format: string
  width: number
  height: number
  bytes: number
  errors: string[]
}

export async function inspectAsset(file: string,
  rule: ScreenshotRule): Promise<AssetEvidence> {
  const metadata: Metadata = await sharp(file, {
    animated: false,
    limitInputPixels: 40_000_000
  }).metadata()
  const info = await stat(file)
  const format = metadata.format === 'jpg' ? 'jpeg' : (metadata.format ?? '')
  const errors: string[] = []

  if (!rule.formats.includes(format as 'png' | 'jpeg')) {
    errors.push(`FORMAT:${format || 'UNKNOWN'}`)
  }
  if (metadata.width !== rule.width || metadata.height !== rule.height) {
    errors.push(`SIZE:${metadata.width}x${metadata.height}`)
  }
  if (info.size > rule.maxBytes) {
    errors.push(`BYTES:${info.size}`)
  }
  if ((metadata.pages ?? 1) > 1) {
    errors.push(`ANIMATED:${metadata.pages}`)
  }
  return {
    file, format,
    width: metadata.width ?? 0,
    height: metadata.height ?? 0,
    bytes: info.size,
    errors
  }
}

limitInputPixels 是工具自身的防御边界,防止异常大图消耗过多内存,不是 AppGallery Connect 的素材规格。animated: false 只读取静态页面,但我们仍检查 pages,避免动画资源误入截图目录。

状态变化很简单:文件从 PENDING 进入 READING,读取成功后根据 errors.length 进入 PASS 或 FAIL。读取异常要单独记为 UNREADABLE,不能等价为尺寸错误。批量任务结束后再汇总,否则一个损坏文件抛异常会让后续 11 张图都没有报告。

易错点是方向判断。脚本不根据 width > height 猜 landscape,而是由目录和规则共同决定。如果一张 1920×1080 的图被放进 portrait,它应该报告与目标规则不匹配,而不是被自动移动。

DevEco 风格演示图中,左侧是 scripts/store-asset-gate 目录,中间显示 inspectAsset(),右侧模拟器展示 AssetAuditPage,底部日志对应两条失败记录。图用于解释工程结构,不是实际 IDE 截屏。

四、真正容易漏的是“矩阵缺口”,不是单张图片

单张图片全部通过后,还要检查目录是否完整。zh-CN/phone/portrait 有 3 张,en-US/phone/portrait 也必须达到计划数量;不能因为总目录凑够了 12 张,就把某个语言的缺口掩盖掉。

目录约定为 store-assets/{locale}/phone/{orientation}/。例如中文竖图放在 zh-CN/phone/portrait/01.png ... 03.png,英文横图放在 en-US/phone/landscape/01.png ... 03.png,其余两个组合遵循相同规则。

文件名使用两位数字,是为了让运营、脚本和上传顺序看到同一套排序。门禁还应检查重复序号、非连续序号和隐藏临时文件。03-final-v2.png 对人类很熟悉,对自动上传流程却容易造成顺序不稳定。

这段代码解决什么问题:逐个检查语言 × 方向组合的数量和编号,防止总数正确但局部缺失。

import { readdir } from 'node:fs/promises'
import { join } from 'node:path'

interface MatrixResult {
  key: string
  files: string[]
  errors: string[]
}

export async function inspectMatrix(root: string,
  locale: string, rule: ScreenshotRule): Promise<MatrixResult> {
  const dir = join(root, locale, rule.device, rule.orientation)
  const names = (await readdir(dir))
    .filter((name: string) => /^(0[1-9]|[1-9][0-9])\.(png|jpe?g)$/i.test(name))
    .sort()
  const errors: string[] = []

  if (names.length < rule.minCount || names.length > rule.maxCount) {
    errors.push(`COUNT:${names.length},EXPECTED:${rule.minCount}-${rule.maxCount}`)
  }
  names.forEach((name: string, index: number) => {
    const expected = `${String(index + 1).padStart(2, '0')}.`
    if (!name.startsWith(expected)) {
      errors.push(`ORDER:${name},EXPECTED_PREFIX:${expected}`)
    }
  })
  return {
    key: `${locale}/${rule.device}/${rule.orientation}`,
    files: names.map((name: string) => join(dir, name)),
    errors
  }
}

这段实现故意没有吞掉 readdir 异常。目录不存在不是“数量为 0”的普通情况,而是发布计划没有落地,报告中应标成 MISSING_DIRECTORY。完整实现可在调用层捕获并归类,但不能静默创建空目录后继续通过。

实际项目还应核对语言内容。纯脚本难以可靠判断画面中文字属于哪种语言,可以在素材旁放置 manifest.json,记录页面名、语言、来源设计稿版本和导出时间,再对文件摘要做绑定。OCR 只能作为辅助提示,不能替代设计与运营复核。

五、把失败报告做成“能马上返工”的页面

门禁页不需要展示几十个图表。AssetAuditPage 顶部只放批次、规则日期和总状态,中间按语言与方向列出 4 个矩阵,底部给出具体文件与修复建议。

第一次扫描结果为:

  • zh-CN / portrait:3/3,通过;
  • zh-CN / landscape:3/3,其中 02.png 为 5.7 MB,失败;
  • en-US / portrait:3/3,其中 03.png 为 1170×2532,失败;
  • en-US / landscape:3/3,通过。

手机运行图时间为 03:15,批次 ASSET-0055,总计 10 / 12 PASS,状态 BLOCKED。红色标注分别指向错误尺寸与超限体积。它让读者看到门禁结果怎样映射到文件,而不是只展示一个漂亮的“审核失败”页面。

设计重新导出素材后,第二次扫描显示 12 / 12 PASS,但脚本仍不会声称“审核通过”。它只能证明当前目录满足已编码的素材规则。应用内容合规、隐私声明、截图真实性和其他审核项仍由正式发布流程判断。

六、退出码要服务发布流水线,也要保留人工确认

门禁最终需要给构建流程一个明确结果。演示约定:全部通过返回 0;规则或素材错误返回 2;脚本自身异常返回 3。这样 CI 可以区分“素材不合格”和“检查器坏了”。

这段代码解决什么问题:把扫描结果收口为报告文件与稳定退出码,并确保异常不会误报通过。

import { writeFile } from 'node:fs/promises'

async function main(): Promise<void> {
  const report = await runAudit('store-assets', releasePlan)
  await writeFile('build/reports/store-assets-ASSET-0055.json',
    JSON.stringify(report, null, 2), 'utf8')

  if (report.internalErrors.length > 0) {
    process.exitCode = 3
    return
  }
  if (report.failedAssets > 0 || report.failedMatrices > 0) {
    process.exitCode = 2
    return
  }
  process.exitCode = 0
}

main().catch((error: Error) => {
  console.error(`[StoreAssetGate] INTERNAL ${error.message}`)
  process.exitCode = 3
})

为什么不直接在第一处错误 process.exit(2)?因为发布前最怕一轮只修一个问题。完整扫描一次给出两条证据,设计可以同时返工,减少来回次数。状态上,批次从 SCANNING 进入 BLOCKED 或 READY_FOR_MANUAL_REVIEW;即使退出码为 0,仍然保留人工确认步骤。

脚本需要在 package.json 或流水线中固定 Sharp 和 Node.js 的运行环境,避免不同机器对图片元数据处理不一致。若要接入 Hvigor,可在打包前任务中调用脚本,但应避免把商店素材强行放进 HAP 资源目录。它们属于发布资产,不应增加应用包体。

七、诊断页比“全部通过”更值得保留

第二次扫描后,诊断页记录两项变化:1170×2532 → 1080×1920,5.7 MB → 4.3 MB。矩阵仍是 12 张,错误从 2 变为 0,状态从 BLOCKED 变为 READY_FOR_MANUAL_REVIEW。

这张图与运行页明显不同:它展示修复前后、规则快照、报告路径和退出码,而不是重复列缩略图。红圈标注用于说明为什么状态改变。报告保留 rulesUpdatedAt=2026-01-20,提醒发布者下一次提审前重新核对官方页面。

如果官方规格发生变化,旧报告不能自动代表新版本仍然有效。规则文件应带版本或更新时间,并让 CI 在规则过期时给出提示,而不是自行抓取网页后静默改动门槛。自动更新规则虽然省事,却会让同一提交在不同时间得到不同结果。

八、把工具停在正确的边界上

StoreAssetGate 的价值,是把可机械判断的内容提前:数量、尺寸、方向、格式、体积、命名和语言目录。它不会判断截图是否真实反映应用,也不会判断文案是否合规,更不会替代 AppGallery Connect 的正式审核。

这条边界很重要。工具脚本最容易从“减少低级错误”滑向“替团队做发布决定”。当报告为绿色时,最合适的状态名不是 APPROVED,而是 READY_FOR_MANUAL_REVIEW。它说明机器检查已完成,接下来仍要核对画面、语言、功能和隐私信息。

如果继续扩展,我会优先增加三项:对 manifest.json 与图片摘要做绑定;为不同终端建立独立规则组;在拉取请求中输出差异报告。不会优先加入自动裁切,因为自动改变商店画面带来的风险,通常高于节省的那几分钟。

参考资料:

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐