一个很典型的下午。相机功能写完了,module.json5 里 ohos.permission.CAMERA 声明了,reason 和 usedScene 也填了,requestPermissionsFromUser 在代码里调得明明白白。跑起来,点“拍照”,什么都没发生。没有弹窗,没有报错,没有异常,相机预览就是黑的。你翻权限列表,确认是 user_grant,确认包名没写错,确认签名没问题。然后你开始怀疑是不是模拟器的问题。

问题不在权限本身,而在你调它的那个时刻。权限声明是静态的,写在配置文件里,编译期就能校验;但弹窗能不能出来,取决于运行时窗口有没有就绪、上下文是不是前台、以及这个权限到底走不走弹窗这条路。这几件事,文档不会主动拦你,编译器也不会。

onWindowStageCreate 里调申请,弹窗没有可以依附的窗口

在 onWindowStageCreate 里顺手申请权限,是很多“启动即要权限”思路的自然延续。但 onWindowStageCreate 触发的时候,窗口内容还没有真正加载完成。loadContent 是异步的,页面还没起来,弹窗找不到可以依附的 UI 上下文,请求会被静默忽略——不抛异常,不回调失败,就像什么都没发生过。

官方文档对这一点有明确的时序要求:在 onWindowStageCreate 中申请授权,需要等待 loadContent() 或 setUIContent() 执行结束之后,或者在它们的回调里调用 requestPermissionsFromUser。更稳妥的做法是把权限申请放到首个页面的 onPageShow 或 aboutToAppear 里,用 getUIContext().getHostContext() 拿 UIAbilityContext,窗口已经真实存在,弹窗才有落脚点。

typescript

// 页面侧申请,窗口已就绪
import { abilityAccessCtrl } from '@kit.AbilityKit';
import { common } from '@kit.AbilityKit';

async function requestCamera(ctx: common.UIAbilityContext): Promise<boolean> {
  const atManager = abilityAccessCtrl.createAtManager();
  try {
    const result = await atManager.requestPermissionsFromUser(
      ctx, ['ohos.permission.CAMERA']
    );
    // 先看 authResults,再看 dialogShownResults
    return result.authResults[0] === 0;
  } catch (err) {
    // 上下文不对、权限名写错、权限未声明,都会走到这里
    return false;
  }
}

getUIContext().getHostContext() 拿到的是当前页面所属的 UIAbilityContext,不是 ApplicationContext。用错上下文,弹窗同样出不来。这是第二个静默失败点,通常和调用时机问题叠在一起,排查时容易被混为一谈。

dialogShownResults 为 false,不代表“永久拒绝”,它只说明这次没弹

弹窗终于出来了,用户点了拒绝。你下一次再调 requestPermissionsFromUser,弹窗不再出现,authResults 返回 -1。你的代码写的是“-1 就再次申请”,于是陷入一个死循环:用户点按钮,没反应,再点,还是没反应。

authResults 为 -1 只表示未授权,它不区分“这次弹了但用户拒了”和“系统压根没弹”。真正告诉你“弹没弹”的是同一返回结果里的 dialogShownResults。从 API 12 起,两者的组合才有完整语义:authResults[i] === -1 且 dialogShownResults[i] === true,表示这次弹窗出现了,用户拒绝;authResults[i] === -1 且 dialogShownResults[i] === false,表示这次没有弹窗,原因可能是系统已经记住了拒绝状态,也可能是权限类型根本不该走弹窗申请。

这意味着一个反直觉但关键的结论:dialogShownResults === false 不能单独等同于“永久拒绝” 。官方问答里专门纠正过这个误读,false 只说明这次没弹,它可能来自“用户之前拒绝过”,也可能来自“你申请的权限是 manual_settings,本来就不弹窗”,还可能来自其它系统策略。把 false 直接映射成“去设置页”是工程上合理的降级动作,但把它当成“永久拒绝”的唯一证据,会在权限类型搞错的时候把用户引导到一个本来就无法通过设置页解决的问题上。

正确的判断链是按顺序走:先 checkAccessToken 看是否已授权;未授权再调 requestPermissionsFromUser;根据 authResults 和 dialogShownResults 的组合决定下一步。authResults 为 0 是授权,-1 加 true 是本次拒绝,-1 加 false 是“没弹”,2 是请求本身无效,先回头检查权限声明和名称。

requestPermissionOnSetting 是二次引导的入口,但它有适用边界

用户拒绝过之后,再次调 requestPermissionsFromUser 弹不出窗,正确的下一步不是反复重试,而是拉起权限设置弹窗。API 12 起提供了 requestPermissionOnSetting(),它直接弹出一个权限设置面板,让用户在当前应用内完成二次授权,不需要跳转到系统设置页。

typescript

async function guideToSetting(ctx: common.UIAbilityContext): Promise<boolean> {
  const atManager = abilityAccessCtrl.createAtManager();
  try {
    const result = await atManager.requestPermissionOnSetting(
      ctx, ['ohos.permission.CAMERA']
    );
    return result[0] === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED;
  } catch (err) {
    // 权限不属于同一权限组、非 user_grant 类型,会走到这里
    return false;
  }
}

但 requestPermissionOnSetting 有两个硬边界。第一,传入的权限必须已经在 module.json5 中声明,且属于同一权限组。跨组传入会失败。第二,它只适用于普通 user_grant 权限。如果权限的授权方式是 manual_settings(比如某些悬浮窗或后台弹出界面类权限),requestPermissionsFromUser 从头到尾不会弹任何窗,requestPermissionOnSetting 也用错了对象,正确的入口是 API 22+ 的 openPermissionOnSetting()。把 manual_settings 的权限当 user_grant 来调,表现就是“申请了、没弹窗、日志也没有”,和“被永久拒绝”长得一模一样,但根因完全不同。

判断权限到底走哪条路,不要靠记忆,直接查。getSelfPermissionStatus 或 checkAccessToken 告诉你当前状态,权限列表里的 grantMode 告诉你它属于 system_grant、user_grant 还是 manual_settings。system_grant 的权限(比如 INTERNET)安装时自动授予,运行时调申请接口不会有任何弹窗,也不应该调。

拒绝不是异常,降级才是常态

权限申请里最容易被写坏的不是申请本身,而是拒绝之后的分支。用户点了拒绝,你的代码继续初始化相机、继续打开定位、继续走那条本来需要权限的路径,然后在某个底层接口上抛一个和权限毫无关系的错误,排查方向被彻底带偏。

拒绝是一条正常路径,不是异常路径。authResults 不是 0 的那一刻,当前操作就应该停下来,展示可理解的提示,保留不依赖该权限的功能照常可用。需要二次引导时,把“去授权”做成一个用户主动点击的按钮,点击之后走 requestPermissionOnSetting,不要在一个循环里自动重试。

从设置页回来之后,权限状态可能已经变了,也可能没变(用户只是看了一眼就返回)。在 onForeground 或页面的 onPageShow 里重新查一次状态,而不是假设“去了就一定会授权”。执行受限 API 之前再复查一次,权限可能在检查和调用之间被用户从系统设置里撤销。

权限声明不是一次性配置,HAR 和 HSP 的权限要在使用方收口

最后一个容易被忽略的层面:如果权限相关的调用来自 HAR 或 HSP,权限声明必须在实际使用权限的那个模块的 module.json5 里完成,或者由使用方在自身配置中显式声明。HAR 的声明不会自动传递给依赖它的 HAP。一个三方库在 HAR 里声明了相机权限,使用方 HAP 没写,运行时申请会返回“权限未声明”类的无效结果,而你的代码如果只看“弹没弹”,就会把它误判成用户拒绝。

权限的最小化原则在这里同样适用:不要为了“保险”在每个模块里重复声明同一组权限。重复声明不会让权限更强,反而容易在多模块工程里制造 reason 文案不一致、usedScene 归属错乱的问题。权限集中在真正使用的模块,声明一次,申请一次。

综上所述,权限弹窗没出来,排查顺序应该从“时机”开始,而不是从“声明”开始。onWindowStageCreate 里窗口未就绪,弹窗静默忽略;dialogShownResults 为 false 只说明这次没弹,不能直接当成永久拒绝;requestPermissionOnSetting 管 user_grant 的二次引导,manual_settings 得换入口;拒绝之后先降级、再引导,不要在循环里反复调首次申请接口。下一次“点了拍照什么都没发生”的时候,先看一眼申请是在哪个生命周期回调里调的,再打印 authResults 和 dialogShownResults 两个数组——弹窗没出来这件事,答案通常在调用发生的那一刻就已经决定了。

Logo

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

更多推荐