HarmonyOS 7 AbilityAccessCtrl + ArkUI:权限弹窗重入治理与提审证据链【鸿蒙心迹】
这次提审前自测,我故意连续点了三次“扫描讲义”。结果权限说明弹层叠了两层,系统授权请求也被触发了两次。功能最终能用,却很难向审核人员解释:应用究竟在什么场景申请相机权限,用户拒绝后会发生什么。

Demo 叫 Permission Gate Lab,只有一个核心场景:用户主动点击“扫描讲义”,应用解释用途,然后请求相机权限。请求编号固定为 perm_20261001_03,用途文案是“扫描纸质讲义并生成课堂笔记”。测试时间为 14:42,权限状态按 IDLE → EXPLAINING → REQUESTING → GRANTED 推进;拒绝时进入 DENIED,返回设置页后再刷新,不自动重弹。
一开始我以为这只是按钮防抖。加上 500 毫秒节流后,慢一点连点确实不再叠层,可应用从后台回来、页面重新构建或两个入口同时调用时,还是会产生并发请求。真正的问题不是点击频率,而是权限申请没有被当成一项有生命周期、有唯一所有者的任务。
一、权限能申请,不代表申请时机合理
上架前检查权限时,容易只看两个地方:module.json5 有没有声明,调用 requestPermissionsFromUser() 有没有返回。实际产品还要回答另外几个问题:权限是否由明确功能触发;说明文案是否对应真实用途;拒绝后是否仍能使用不需要权限的部分;应用是否频繁索取;第三方 SDK 是否在用户操作前提前启动采集。
华为应用市场的发布说明把隐私、兼容性、稳定性和性能列为提交前测试内容。对第三方 SDK,官方上架说明还要求在隐私政策中逐一明示其收集个人信息的目的、方式和范围。也就是说,代码层的授权结果只是证据的一部分,触发链路和披露内容还要能对得上。
Permission Gate Lab 不在 aboutToAppear() 或 onForeground() 中请求相机权限。页面首次打开只显示功能说明,用户点击“扫描讲义”后才进入申请链路。权限声明也把使用场景限制为使用时。
下面的配置解决的是“代码会申请,但清单无法解释申请场景”的问题。reason 应使用项目资源,正式文案要和页面说明、隐私政策保持一致。
{
"module": {
"requestPermissions": [
{
"name": "ohos.permission.CAMERA",
"reason": "$string:camera_permission_reason",
"usedScene": {
"abilities": ["EntryAbility"],
"when": "inuse"
}
}
]
}
}
这里的 inuse 不是“应用一启动就用”,而是说明该权限服务于前台使用场景。业务层仍需选择合理触发点。当前 Demo 的按钮文案、说明弹层和资源文案都使用“扫描纸质讲义并生成课堂笔记”,避免清单写“拍照”,页面写“识别”,隐私政策又写“上传图片”这种三套说法。
如果正式产品还会把照片上传云端,就必须继续说明传输目的和处理范围,不能因为系统相机授权已经通过,就把后续数据处理一并视作用户同意。
二、把一次申请收口成一个 Promise
问题复现时,三个入口都会触发同一个方法:主页扫描按钮、空状态引导按钮、快捷操作卡片。每个入口都先查权限,再各自弹说明、各自请求。按钮节流只能保护一个组件,保护不了跨组件和生命周期重入。
我把请求收进 PermissionGate,同时只允许一个 inFlight Promise 存在。后续入口拿到同一个结果,不再创建第二次系统请求。状态也从布尔值改成枚举,避免 isLoading=true 和 hasPermission=false 组合出含义不明的页面。
这段代码解决的是“多个入口重复发起权限申请”的问题。
import { abilityAccessCtrl, Permissions } from '@kit.AbilityKit'
import type { common } from '@kit.AbilityKit'
export enum GateState {
IDLE = 'IDLE',
EXPLAINING = 'EXPLAINING',
REQUESTING = 'REQUESTING',
GRANTED = 'GRANTED',
DENIED = 'DENIED',
FAILED = 'FAILED'
}
export class PermissionGate {
private readonly permission: Permissions = 'ohos.permission.CAMERA'
private inFlight?: Promise<GateState>
state: GateState = GateState.IDLE
request(context: common.UIAbilityContext): Promise<GateState> {
if (this.inFlight) return this.inFlight
this.inFlight = this.execute(context).finally(() => {
this.inFlight = undefined
})
return this.inFlight
}
private async execute(context: common.UIAbilityContext): Promise<GateState> {
const manager = abilityAccessCtrl.createAtManager()
this.state = GateState.REQUESTING
const result = await manager.requestPermissionsFromUser(context, [this.permission])
const index = result.permissions.indexOf(this.permission)
this.state = index >= 0 && result.authResults[index] === 0
? GateState.GRANTED : GateState.DENIED
return this.state
}
}
代码没有默认取 authResults[0],而是先按权限名找索引。当前列表只有相机权限,看起来多此一举;当正式项目同时请求多个权限时,这个细节能避免结果顺序变化后映射错位。
finally() 只负责释放“本次请求占用”,不会把业务状态重置为 IDLE。如果用户拒绝,页面要稳定停在 DENIED,展示替代路径。异常则进入 FAILED,记录错误码并允许用户再次从明确入口发起,不能用循环重试制造弹窗轰炸。
三、说明层和系统弹窗不能同时抢状态
权限说明层是业务 UI,系统授权弹窗由系统管理。如果页面在说明层仍显示时就调用系统请求,返回后又触发页面刷新,很容易出现遮罩未关闭、按钮仍可点击或焦点落到错误组件的问题。
当前页面把流程拆成两步:第一次点击只进入 EXPLAINING;用户在说明层点击“继续申请”,页面先关闭说明层,再等待下一次 UI 状态稳定后进入 REQUESTING。这并不是为了多做一步,而是让用户知道权限与哪个功能有关。
下面这段页面代码解决的是“业务弹层、系统弹窗和扫描任务同时启动”的次序问题。
@Entry
@Component
struct PermissionGatePage {
@State gateState: GateState = GateState.IDLE
@State showExplanation: boolean = false
private gate: PermissionGate = new PermissionGate()
private openExplanation(): void {
if (this.gateState === GateState.REQUESTING) return
this.gateState = GateState.EXPLAINING
this.showExplanation = true
}
private async continueRequest(): Promise<void> {
this.showExplanation = false
this.gateState = GateState.REQUESTING
const context = getContext(this) as common.UIAbilityContext
this.gateState = await this.gate.request(context)
if (this.gateState === GateState.GRANTED) {
this.startScanner('perm_20261001_03')
}
}
private startScanner(requestId: string): void {
hilog.info(0x0000, 'PermissionGate',
`requestId=${requestId} state=GRANTED scanner=start`)
}
}
这里还有一个容易被忽略的风险:用户关闭页面时,系统请求可能尚未返回。PermissionGate 可以继续完成权限状态更新,但页面对象不应该再启动扫描器。正式项目应让扫描任务由页面可见的控制器持有,并在组件销毁时取消订阅;权限结果本身则在下一次进入页面时重新查询,不能依赖一个已经销毁的回调去更新 UI。

DevEco Studio 画面中,左侧目录把 PermissionGate.ets、PermissionAudit.ets 与页面分开;中间正是 inFlight 去重逻辑;右侧模拟器停在 REQUESTING;底部 HiLog 只出现一条 perm_20261001_03 请求。连续点击三次时,日志中的 deduplicated=2 表示两次入口复用了同一个 Promise,而不是又弹两次系统窗口。
四、拒绝以后,返回前台只刷新,不自动再申请
我在自测脚本里专门加入了“拒绝—切到设置—回到应用”。早期版本在 onForeground() 中再次执行申请,结果用户刚拒绝一次,切回来又看到请求动作。即使系统没有再次展示弹窗,这种实现也会让页面状态反复跳动。
现在 onForeground() 只做一件事:重新检查授权状态。如果用户已经在系统设置中开启相机权限,页面从 DENIED 更新为 GRANTED;如果仍未授权,继续显示说明和“前往设置”指引。绝不在生命周期回调中自动调用 requestPermissionsFromUser()。
下面这段代码解决的是“从设置返回后状态过期”和“前台恢复时再次索权”两个问题。
import { bundleManager, abilityAccessCtrl } from '@kit.AbilityKit'
export async function refreshCameraGrant(): Promise<GateState> {
const info = bundleManager.getBundleInfoForSelfSync(
bundleManager.BundleFlag.GET_BUNDLE_INFO_WITH_APPLICATION)
const tokenId = info.appInfo.accessTokenId
const manager = abilityAccessCtrl.createAtManager()
const result = await manager.checkAccessToken(tokenId, 'ohos.permission.CAMERA')
return result === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED
? GateState.GRANTED : GateState.DENIED
}
// EntryAbility.onForeground 只发出 refresh 事件;
// 页面收到事件后调用 refreshCameraGrant(),不自动申请权限。
checkAccessToken() 适合做状态刷新,不会制造授权弹窗。用户拒绝后的再次申请策略还要结合当前 API 行为和产品交互验证;不能写一个死循环,只要结果不是 0 就再次请求。若系统不再允许同一路径重复拉起,应展示前往设置的明确操作,并保留不需要相机权限的手动录入功能。
五、证据链不记录隐私,只记录流程是否守规矩
提审自检需要证据,但“证据”不等于把用户行为完整埋点。Permission Gate Lab 只记录请求编号、入口、声明用途版本、状态变化、时间与结果,不记录拍摄内容,也不保存设备中的图片路径。
本次自测记录如下:
14:42:06 requestId=perm_20261001_03 source=scan_note state=EXPLAINING
14:42:09 requestId=perm_20261001_03 state=REQUESTING deduplicated=2
14:42:12 requestId=perm_20261001_03 result=DENIED
14:43:01 requestId=perm_20261001_03 source=FOREGROUND_REFRESH result=GRANTED
这组日志能回答审核人员最关心的工程事实:请求由扫描讲义功能触发;用途说明先于系统请求;并发入口被合并;用户拒绝后没有自动重弹;从设置返回后只做状态查询。它不能替代隐私政策、页面录屏或第三方 SDK 清单,却能让代码行为与提交材料相互校验。

手机运行图展示的是设置返回后的最终状态:请求 perm_20261001_03 已授权,页面显示“相机权限已就绪”,扫描入口恢复可用;顶部状态栏保留时间、5G、Wi‑Fi、信号和电量。页面下方还有“拒绝后未自动重弹”和“前台刷新通过”两条验收结果,而不是只放一个绿色对勾。
六、上架前我会按这条链路再走一遍
第一遍测全新安装。打开应用时不应出现相机请求;进入 Permission Gate Lab 也不应自动申请;只有点击“扫描讲义”并确认用途后,系统弹窗才出现。
第二遍测连续操作。快速点击三个入口,页面只出现一层说明、一次系统请求和一个请求编号。旋转窗口、切后台再回来,不新增请求。
第三遍测拒绝。拒绝后扫描功能不可用,但手动录入、查看历史笔记等不需要相机的功能仍可进入。页面解释如何重新授权,不使用误导文案催促用户。
第四遍测设置返回。用户在设置中开启相机权限,回到应用后通过 checkAccessToken() 刷新为 GRANTED,不再拉起申请弹窗。关闭权限后返回也应立刻回到 DENIED,不能继续沿用缓存结果启动相机。
第五遍核对材料。module.json5 的 reason、页面说明、隐私政策、第三方 SDK 清单和实际网络行为要一致。如果 SDK 会采集个人信息,应逐一披露目的、方式和范围;不使用的权限从清单和代码中一起删除。
七、把授权结果和业务可用性分开
这次修改以后,我不再用一个 hasPermission 控制整个页面。授权结果只决定扫描器能否启动,页面其余能力仍按各自条件工作。这样用户拒绝相机权限时,不会被挡在应用首页,也不会陷入“授权才能退出说明页”的死路。
更重要的是,状态机让每个动作都有来源:EXPLAINING 是业务说明,REQUESTING 是系统交互,DENIED 是当前授权事实,GRANTED 只是允许进入扫描准备阶段,并不代表应用可以随意处理后续图片。权限、隐私同意和业务数据处理不是同一个开关。
一个权限弹窗能否正常出现,只能证明 API 被调用了。能够经受上架检查的实现,还要证明它没有提前调用、没有重复调用、拒绝后有替代路径、重新授权后能正确刷新,并且代码行为与提交材料一致。把这些证据留在工程里,比临近提审时人工点一遍更可靠。
参考资料:
更多推荐




所有评论(0)