这篇从一次“代码没问题,为什么上架前还是不放心”的整理开始。Demo 项目叫 ReleaseGuard,目标不是模拟审核平台,而是在提交版本前把三份最容易漂移的信息重新对齐:包里声明了什么权限、代码实际在什么时候申请、AppGallery Connect 和隐私政策里又写了什么。

很多上架问题并不复杂,麻烦的是信息分散。module.json5 在工程里,运行时权限申请散在页面和业务服务里,第三方 SDK 的数据处理说明又可能在另一份文档中。开发到后期一改需求,最常见的情况不是功能坏了,而是“代码已经不用了,声明还在”“声明加了,使用原因没同步”“SDK 换了版本,隐私政策还是旧描述”。

华为当前的应用发布指引明确要求,上架前要检查应用信息与包是否完整、运行是否稳定、隐私合规相关内容是否满足要求;对于检测到敏感隐私权限或受限权限的应用,AppGallery Connect 的发布页面还会要求配置对应隐私说明。部分服务的官方上架说明也明确提醒:集成第三方 SDK 时,需要在隐私政策中逐一说明 SDK 收集个人信息的目的、方式和范围。

我做 ReleaseGuard 的原因,就是不想等到提交之后才把这些信息重新拼起来。

一、我先把“审核前自检”缩成 7 个可回答的问题

ReleaseGuard 首页没有做很多花哨功能,只有一个 PRECHECK 7/7 PASS。这 7 项不是平台官方固定清单,而是我在项目内部定义的工程门槛:

  1. 当前版本号和待提交包一致;
  2. 权限声明都能找到业务用途;
  3. 实际需要用户授权的权限有明确触发入口;
  4. 不再使用的权限已经从包声明中移除;
  5. 第三方 SDK 清单与隐私披露一致;
  6. AppGallery Connect 需要填写的隐私说明已经核对;
  7. Release Profile、包名和版本信息完成最终对账。

它最重要的价值不是“自动替代审核”,而是让研发团队在提交前有一份能落到代码和包的证据。

图 03 就是这份自检结果。这里最显眼的不是 CAMERA,而是 LOCATION:页面明确显示 NOT DECLARED。因为当前业务已经不需要位置能力,所以正确状态不是“授权关闭”,而是压根不要把不需要的权限继续留在声明里。

这点特别容易混淆。权限治理不是把所有权限都申请一遍然后让用户拒绝,而是业务需要什么就声明什么,真正要访问用户隐私信息或系统敏感能力时,再在合适的业务时机请求授权。

二、声明、授权、实际功能,是三件不同的事

我把权限问题拆成三层以后,很多排查会变得很直接。

第一层是包声明。比如相机权限是否出现在 module.json5 的 requestPermissions 中,使用原因是否写清楚。

第二层是运行时状态。即使声明了 CAMERA,也不代表用户已经授权。应用仍要查询当前状态,并在真正需要拍摄时申请。

第三层是业务触发。ReleaseGuard 的相机权限只在用户点击“扫描证件”后申请,而不是一打开首页就弹窗。

这段代码解决什么问题:让包内权限声明本身就带上明确使用原因,避免后期只看到权限名,不知道是谁加的。

{
  "module": {
    "name": "entry",
    "type": "entry",
    "requestPermissions": [
      {
        "name": "ohos.permission.CAMERA",
        "reason": "$string:permission_camera_reason",
        "usedScene": {
          "abilities": ["EntryAbility"],
          "when": "inuse"
        }
      }
    ]
  }
}

在这份 Demo 里,原先测试阶段加过 LOCATION,后来“扫描证件”流程不再依赖位置。我的处理不是在代码里永远不调用它,而是把声明一起删掉,再让自检工具把“未使用权限数量”统计为 0。

项目里最好给每个权限维护一个 owner。不是为了流程复杂化,而是需求下线时能快速知道这条声明应该由谁确认。如果所有权限都没人负责,版本越迭代,历史残留越多。

三、运行时授权不要写成启动仪式,应该贴着用户动作发生

HarmonyOS 的权限接口已经提供了查询自身权限状态和向用户请求授权的能力。工程里真正要决定的是“什么时候调用”。

ReleaseGuard 的逻辑是:用户进入首页不会出现授权弹框;点击“扫描证件”后,先查询 CAMERA 当前状态,如果尚未获得授权,再调用 requestPermissionsFromUser()。

这段代码解决什么问题:把相机授权和“扫描证件”这个明确动作绑定,避免页面启动时无上下文弹权限。

import {
  abilityAccessCtrl,
  common,
  Permissions,
  PermissionRequestResult
} from '@kit.AbilityKit';

const CAMERA: Permissions = 'ohos.permission.CAMERA';

async function requestCameraFromScanButton(
  context: common.UIAbilityContext
): Promise<boolean> {
  const atManager = abilityAccessCtrl.createAtManager();
  const before = atManager.getSelfPermissionStatus(CAMERA);
  console.info(`[AUTH] before=${before}`);

  const result: PermissionRequestResult =
    await atManager.requestPermissionsFromUser(context, [CAMERA]);

  const granted = result.authResults.length > 0 && result.authResults[0] === 0;
  console.info(`[AUTH] request source=scan_button granted=${granted}`);
  return granted;
}

实际开发时,GrantStatus 的判断最好使用对应枚举,而不是像演示代码一样依赖裸数字。这里把判断简化,是为了突出调用链:用户动作 → 查询状态 → 必要时请求 → 根据结果继续业务。

还要注意,用户拒绝并不是异常崩溃。用户不授权 CAMERA 时,ReleaseGuard 会停在“需要相机权限才能扫描”的说明页,同时提供手工录入入口。只有把“拒绝”当成一条正常产品路径,权限申请才不会变成强迫用户通过的闸门。

图 02 对应的就是这条调用链:中间代码标了 requestPermissionsFromUser(),右侧模拟器显示相机已经声明,而 LOCATION 没有声明,底部日志能看到 request source=scan_button 和授权结果。

四、我写了一个轻量扫描器,专门找“声明了但项目里没人用”的权限

只靠人工搜代码很难长期稳定。我后来加了一个构建前脚本,思路很简单:读取 module.json5 的 requestPermissions,再对 src/main/ets 中的权限常量和授权调用做扫描,得到一份“声明集合”和“引用集合”的差集。

它不会百分之百理解业务,也不负责判断某个权限是否法律合规,但特别适合抓两类低级问题:

  • 权限已经不用,声明还留着;
  • 新代码开始申请某个权限,但包声明没同步。

这段代码解决什么问题:在提交包之前,用脚本提前发现权限声明和代码引用的明显漂移。

import fs from 'node:fs';
import path from 'node:path';
import JSON5 from 'json5';

const modulePath = path.resolve('entry/src/main/module.json5');
const moduleConfig = JSON5.parse(fs.readFileSync(modulePath, 'utf-8'));

const declared = new Set<string>(
  (moduleConfig.module.requestPermissions ?? []).map(
    (item: { name: string }) => item.name
  )
);

const etsRoot = path.resolve('entry/src/main/ets');
const sourceText = collectEtsText(etsRoot);
const referenced = new Set<string>();

for (const permission of declared) {
  if (sourceText.includes(permission)) {
    referenced.add(permission);
  }
}

const unused = [...declared].filter(item => !referenced.has(item));
console.log(`[PREFLIGHT] declared=${declared.size}`);
console.log(`[PREFLIGHT] unusedPermission=${unused.length}`);
console.log(`[PREFLIGHT] unused=${unused.join(',') || 'NONE'}`);

这里的 collectEtsText() 可以自己递归目录实现,也可以放到现有工程脚本里。更重要的是理解它的边界:如果权限名由三方库内部使用、动态拼接、Native 层引用,简单字符串搜索可能误判。所以这个工具输出的是“需要人工复核”,不是“自动删权限”。

我的做法是:脚本发现差异就让 CI 给出 warning;准备 Release 包时,如果仍存在未解释差异,再升级为阻断。这样平时开发不会太重,上架前又有一道明确门槛。

五、第三方 SDK 最容易漏的不是代码,而是说明文档版本

很多项目权限已经收得很干净,最后还是会在第三方 SDK 这里出现信息不一致。

原因很现实:SDK 升级通常发生在依赖文件里,隐私政策却可能由产品、运营或法务维护。研发把 SDK 从 1.x 升到 2.x 后,如果数据处理范围发生变化,隐私政策不一定有人同步改。

所以 ReleaseGuard 的 SDK 对账不扫描“有没有三方库”这么简单,而是维护一份发布清单:

SDK 名称 / 当前版本 / 使用目的 / 涉及数据 / 隐私声明链接 / 本版本是否变化

华为部分服务的上架说明明确提示:集成第三方 SDK 的应用,需要在隐私政策中逐一明示 SDK 收集个人信息的目的、方式和范围。我的工程做法是把这条要求落成一个可检查字段:2 项 SDK,2/2 已披露。

这也解释了为什么图 03 中第三方 SDK 卡片不是“检测到 2 项”就结束,而是继续显示“隐私披露 2/2 完成”。只发现依赖,不确认说明,依然没有完成对账。

六、AppGallery Connect 的隐私说明,不能用“代码里有 reason”替代

这是我最想强调的一点。

module.json5 里的 reason 解决的是包内权限使用原因;AppGallery Connect 发布页面里的隐私说明,是上架流程的一部分;应用自己的隐私政策又是面向用户的公开说明。三者有关联,却不是同一个字段的三个副本。

当前 AppGallery Connect 文档说明,如果应用包被检测到获取敏感隐私权限或使用受限权限,需要在发布流程里配置对应隐私说明;其中受限权限还可能需要额外的使用场景材料。具体哪些字段需要填写,应以当前上传包扫描结果和发布页面提示为准。

所以 ReleaseGuard 不会自作主张地说“有 CAMERA 就一定审核失败”,而是做两件事:

  1. 告诉你当前包有哪些需要重点关注的权限;
  2. 记录本版本是否已经完成 AppGallery Connect 对应说明的人工确认。

这种设计虽然不“全自动”,却更可靠。因为上架规则、页面字段和权限分类可能变化,工程工具不应该把平台规则永久硬编码成一个不会更新的 if/else。

七、版本号、包名和 Release Profile,要和权限一起看

有一次我排查权限问题查了很久,最后发现上传的根本不是刚才测试的那个包。开发环境、测试包、Release 包同时存在时,这种低级错误一点也不少见。

因此 ReleaseGuard 最后的对账页会把下面几项固定显示出来:

  • 应用版本 1.6.0 (106);
  • 包名 com.example.releaseguard;
  • 当前构建类型 Release;
  • Release Profile 校验 MATCH;
  • 敏感隐私权限 1 项(CAMERA);
  • 受限权限 0 项;
  • 未使用权限 0 项;
  • 第三方 SDK 信息披露 2/2 MATCH;
  • 隐私说明 CONFIGURED。

图 04 就是这张“提交前最后看一眼”的页面。红圈标出来的不是为了好看,而是两个最容易出现历史残留的位置:未使用权限和隐私说明状态。

这里的 7/7 PASS 只表示项目内部预检通过,并不代表华为应用市场已经审核通过。文章和工具都应该把这个边界说清楚,否则内部自检很容易被误解成平台审核结论。

八、权限问题最有效的排查方式,是沿着一条证据链往回找

当测试同学说“系统设置里看不到这个权限”,或者审核前发现权限说明对不上,我现在不会先翻 UI 页面,而是按固定顺序检查:

第一步:看包声明。 module.json5 有没有这个权限?reason 和 usedScene 是否仍符合当前业务?

第二步:看运行时调用。 有没有真正调用 requestPermissionsFromUser()?调用入口是什么?用户拒绝后怎么处理?

第三步:看日志。 过滤权限请求日志,确认请求发生在预期操作之后,而不是应用启动就发生。

第四步:看发布配置。 上传的是不是当前 Release 包?版本号、包名、签名配置是否一致?AppGallery Connect 是否扫描到需要填写的隐私说明?

第五步:看隐私政策与 SDK 清单。 实际集成内容和公开说明是否仍是同一个版本。

这条链路最大的好处是不用猜。每一层都有明确证据,问题停在哪一层,就解决哪一层。

九、我更愿意把上架合规当成持续构建问题,而不是发布当天的文档问题

上架审核最难处理的情况,是所有人都等到发布当天才集中检查。那时任何一个字段不一致,都会变成“谁最后改过”的追责题。

ReleaseGuard 最终没有做成一个庞大的审核模拟器,而是变成三块很小的能力:

  • 开发阶段:权限和调用差异给 warning;
  • Release 构建阶段:版本、权限、SDK 清单生成报告;
  • 提交前:人工确认 AppGallery Connect 隐私说明和公开隐私政策。

真正能自动化的就自动化,必须依赖平台当前页面和人工判断的就保留人工确认。这样比假装所有规则都能被脚本准确判断更稳。

如果后续项目继续变大,我还会把相机、定位、通讯录等权限按业务模块归属,把三方 SDK 版本变更加入 Pull Request 模板,再把 ReleaseGuard 的报告作为发布附件保存。这样半年后回头看某个版本,也能知道当时为什么申请某个权限、由哪个功能触发、隐私说明是否同步。

十、我专门保留了一组“拒绝授权”测试,不让 PASS 只建立在顺利路径上

权限功能最容易出现一种假稳定:开发者自己的设备已经长期授权,于是每次测试都从 GRANTED 开始。代码看起来非常顺,直到新用户第一次安装,或者用户在系统设置里撤回权限,真正的问题才出现。

ReleaseGuard 因此固定跑三种状态:未决定、已授权、已拒绝。未决定状态下点击“扫描证件”,应该出现系统授权流程;已授权状态下再次点击,不应重复制造无意义请求;已拒绝状态下,页面应该解释为什么功能受限,并给出可继续使用的替代路径,而不是无限重复弹窗。

我把这三种情况写进测试记录:

NOT_DETERMINED -> 用户点击扫描 -> 请求 CAMERA -> 依据用户选择继续
GRANTED        -> 用户点击扫描 -> 直接进入拍摄流程
DENIED         -> 用户点击扫描 -> 展示说明 / 可用替代方式 / 必要时引导设置

这里尤其要避免“用户拒绝一次就马上再弹一次”。从产品体验看,这会让授权变成强制拦截;从排查角度看,也会让日志里充满重复请求,反而难以判断真正的触发来源。

测试时我还会主动从系统设置里撤回 CAMERA,再回到应用继续操作。这个动作能验证两个细节:应用是否在每次关键操作前重新确认状态,以及 UI 是否会把之前的“已授权”缓存成永久状态。权限属于可变状态,不能只在应用首次启动时读一次。

1. “设置页里能看到权限”也不是最终验收目标

官方权限 FAQ 提到,不同 API 阶段系统设置页面对权限展示行为存在差异,排查时应该回到 module.json5 声明和 requestPermissionsFromUser() 的真实调用。对项目来说,更可靠的验收不是“设置页出现某个开关”,而是“功能触发、系统授权状态、业务降级逻辑三者一致”。

因此 ReleaseGuard 的报告不会简单把“系统设置有开关”记成 PASS,而是保留请求来源 scan_button,让测试人员知道这次授权是在哪个动作里产生的。

十一、CI 报告最好能被人读懂,而不是只返回一个 0 或 1

做成脚本以后,我一开始只让命令返回成功或失败。很快就发现这对排查不够友好。CI 红了以后,开发者还得重新下载日志,自己猜是哪一项没过。

后来输出改成结构化摘要:

[PACKAGE] version=1.6.0(106) bundle=com.example.releaseguard
[PERMISSION] declared=1 referenced=1 unused=0
[SDK] detected=2 disclosed=2
[PRIVACY] appgallery=confirmed policy=confirmed
[PROFILE] releaseProfile=MATCH
[PREFLIGHT] PASS 7/7

如果失败,则必须写具体原因,例如:

[PREFLIGHT] FAIL
- unused permission: ohos.permission.LOCATION
- sdk disclosure missing: analytics-sdk 2.4.1

这种输出还有一个额外价值:它很适合作为 Release 构建产物的一部分归档。将来某个版本出现争议,不用依赖谁的记忆,直接看当时提交前生成的报告就知道包里是什么状态。

当然,这份报告仍然不能替代平台审核。它只是把团队能控制的工程信息做成可重复检查,让“提交前我应该看什么”从个人经验变成项目流程。

十二、隐私政策不是静态附件,最容易在版本迭代里落后半拍

我以前把隐私政策理解成“上线前准备一次”,后来发现这几乎注定会过期。真正的应用会不断加登录方式、统计 SDK、地图、支付、相机、文件选择等能力,每一次依赖或业务变化都可能让原来的说明失去准确性。

所以我把隐私政策当成版本配置的一部分。每次 SDK 版本、权限声明或数据处理流程变化时,都要求 PR 描述里回答一个问题:这次变化是否需要同步更新隐私说明?

如果答案是“否”,也要说明为什么。这样做会多几十秒,却能避免几个月后没人知道某条隐私描述对应的是哪个历史实现。

对于系统 Picker 这类能够减少额外权限申请的能力,也应该在设计阶段优先考虑。能通过系统提供的受控选择流程完成目标,就没有必要为了“代码方便”扩大长期权限范围。权限越少,业务边界越清晰,后续测试和上架材料也更容易维护。

十三、最后一次人工复核,我只看四种“不一致”

自动化脚本跑完后,我仍然会留一个人工确认步骤,但不会让大家重新通读所有文件,而是专门找四种不一致:

  • 代码与声明不一致:代码要用,包没声明;或者包声明了,代码已经不用;
  • 声明与触发不一致:权限用途写的是扫描证件,实际却在首页启动就申请;
  • 依赖与隐私政策不一致:SDK 已升级或新增,公开说明仍是旧版本;
  • 测试包与提交包不一致:开发机验证的是 Debug 包,真正上传的是另一个 Release 构建。

这四类问题一旦排除,剩下的审核工作就更接近平台规则本身,而不是项目内部信息漂移造成的返工。

十四、这套对账机制解决的不是“怎么过审”,而是“怎么让提交材料和真实应用保持一致”

应用审核不是一个可以靠技巧绕过的流程。真正能降低返工的,是让提交信息准确反映当前版本的真实行为。

这篇里我反复强调“对账”,原因就在这里:

声明要和代码对上,代码要和用户动作对上,用户动作要和隐私说明对上,隐私说明又要和当前上传包对上。

ReleaseGuard 的价值不是替开发者判断平台会不会通过,而是在提交前把明显的不一致暴露出来。对一个长期迭代的 HarmonyOS 项目来说,这比临发布时翻几十个文件、逐项回忆“这个权限还有没有用”靠谱得多。

参考资料

Logo

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

更多推荐