熟悉我们购物比价应用的朋友都知道,商城应用离不开各种权限:扫码需要相机权限、定位附近门店需要位置权限、保存商品图片需要存储权限……权限申请是每个应用必经的门槛。但这里面有个老大难问题:用户第一次拒绝权限后,第二次再申请时,系统弹窗的行为完全不同——有些设备会直接静默拒绝,连弹窗都不给。这时候如果应用还傻乎乎地继续弹窗,用户会觉得“这应用怎么回事,一直弹窗烦死了”。

我们之前就踩过这个坑。用户拒绝了相机权限,下次扫码时我们又调了一次requestPermissionsFromUser,结果系统根本没弹窗,直接返回拒绝,我们还以为用户又点了一次“拒绝”,实际上系统已经记住了用户的决定,不再打扰用户了。后来我们仔细研究了华为的权限API,才发现PermissionRequestResult里有个dialogShownResults字段,可以判断这次弹窗是否真的弹出来了。这篇文章完整记录一下实现过程和踩坑经验。

功能设计

先说说预期效果。

用户在商城应用中首次需要使用某个权限(比如相机扫码)时,应用弹窗请求授权。如果用户点击“允许”,一切正常。如果用户点击“拒绝”,下次再次触发同一权限请求时,应用不应该再次弹窗(因为系统不会再弹了),而是应该引导用户去系统设置中手动开启。同时,应用需要能区分以下几种情况:

  1. 首次申请:系统弹窗,用户可选择允许或拒绝。

  2. 用户已拒绝但未勾选“不再询问”:再次申请时系统还会弹窗(部分系统行为)。

  3. 用户已拒绝且勾选了“不再询问”:再次申请时系统不弹窗,直接返回拒绝。

  4. 权限已被授予:无需任何操作。

核心目标:

  1. 准确判断当前权限状态是“从未申请过”还是“已被用户拒绝”。

  2. 在系统不再弹窗时,优雅地引导用户去设置页面手动开启。

  3. 避免重复弹窗骚扰用户。

核心API

API/接口

说明

atManager.checkAccessTokenSync(bundleName, permission)

同步检查权限授权状态,返回PERMISSION_GRANTEDPERMISSION_DENIED

atManager.requestPermissionsFromUser(context, permissions)

拉起系统弹框请求用户授权,返回PermissionRequestResult

PermissionRequestResult.dialogShownResults

布尔数组,对应每个权限的弹窗是否真正显示。true表示本次弹窗显示了,false表示系统未弹窗(可能是用户已拒绝且不再询问,或系统策略不允许)

关键点:checkAccessTokenSync只能告诉你当前是授权还是拒绝,但无法区分“从未申请”和“已拒绝”。而dialogShownResults可以告诉你本次弹窗是否真的弹出——如果没弹出,说明系统已经记住了用户的拒绝决定,不能再指望弹窗了。

实现过程

封装权限管理器

首先,我们封装一个通用的权限管理器,统一处理权限检查和申请逻辑。

// utils/PermissionManager.ets
import { abilityAccessCtrl, AtManager } from '@kit.AbilityKit';
import { BusinessError } from '@ohos.base';
import { common } from '@kit.AbilityKit';

export class PermissionManager {
  private atManager: AtManager;
  private context: common.Context;

  constructor(context: common.Context) {
    this.atManager = abilityAccessCtrl.createAtManager();
    this.context = context;
  }

  /**
   * 检查权限是否已授予
   */
  checkPermission(permission: string): boolean {
    const grantStatus = this.atManager.checkAccessTokenSync(
      this.context.applicationInfo.accessTokenId,
      permission
    );
    return grantStatus === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED;
  }

  /**
   * 请求权限,返回详细结果
   */
  async requestPermission(permission: string): Promise<PermissionResult> {
    // 1. 先检查当前状态
    const isGranted = this.checkPermission(permission);
    if (isGranted) {
      return { granted: true, dialogShown: false, needGuide: false };
    }

    // 2. 尝试弹窗请求
    try {
      const result: PermissionRequestResult = await this.atManager.requestPermissionsFromUser(
        this.context,
        [permission]
      );

      const dialogShown = result.dialogShownResults?.[0] ?? false;
      const granted = result.authResults?.[0] === 0;

      // 核心判断逻辑
      if (!dialogShown && !granted) {
        // 系统没有弹窗,且权限未被授予 → 说明用户之前拒绝过且系统不再弹窗
        return { granted: false, dialogShown: false, needGuide: true };
      }

      return { granted, dialogShown, needGuide: false };
    } catch (error) {
      console.error(`请求权限失败: ${JSON.stringify(error)}`);
      return { granted: false, dialogShown: false, needGuide: true };
    }
  }

  /**
   * 跳转到系统设置页(引导用户手动开启权限)
   */
  async navigateToAppSetting(): Promise<void> {
    try {
      const bundleName = this.context.applicationInfo.bundleName;
      await this.context.startAbility({
        bundleName: 'com.huawei.hmos.settings',
        abilityName: 'com.huawei.hmos.settings.MainAbility',
        parameters: {
          pushParams: bundleName
        }
      });
    } catch (error) {
      console.error(`跳转设置页失败: ${JSON.stringify(error)}`);
    }
  }
}

// 返回结果类型
interface PermissionResult {
  granted: boolean;      // 是否已授权
  dialogShown: boolean;  // 本次弹窗是否显示
  needGuide: boolean;    // 是否需要引导用户去设置
}

在扫码页面中使用

以相机权限为例,在扫码页面中调用:

// view/ScanPage.ets
import { PermissionManager } from '../utils/PermissionManager';

@Component
export struct ScanPage {
  private permissionManager: PermissionManager = new PermissionManager(getContext(this));
  @State showGuideDialog: boolean = false;

  async requestCameraPermission(): Promise<boolean> {
    const result = await this.permissionManager.requestPermission('ohos.permission.CAMERA');

    if (result.granted) {
      // 权限已获取,开始扫码
      this.startScanning();
      return true;
    }

    if (result.needGuide) {
      // 需要引导用户去设置
      this.showGuideDialog = true;
      return false;
    }

    // 弹窗显示了但用户拒绝了
    // 可以再次提示用户,但不要立刻再次弹窗
    this.showDeniedTip();
    return false;
  }

  build() {
    Column() {
      Button('扫码')
        .onClick(() => this.requestCameraPermission())

      if (this.showGuideDialog) {
        // 引导弹窗
        AlertDialog.show({
          title: '需要相机权限',
          message: '请在系统设置中开启相机权限,以便扫码功能正常使用',
          primaryButton: {
            value: '去设置',
            action: () => {
              this.permissionManager.navigateToAppSetting();
              this.showGuideDialog = false;
            }
          },
          secondaryButton: {
            value: '取消',
            action: () => {
              this.showGuideDialog = false;
            }
          }
        })
      }
    }
  }
}

完整的权限状态判断流程

为了更清晰地展示逻辑,画个伪代码流程图:

function handlePermission(permission):
    if checkPermission(permission) == GRANTED:
        return OK
    
    result = requestPermissionsFromUser([permission])
    
    if result.dialogShownResults[0] == true:
        // 弹窗显示了
        if result.authResults[0] == 0:
            return OK  // 用户同意了
        else:
            return DENIED_BUT_CAN_RETRY  // 用户拒绝了,但下次还能弹窗
    else:
        // 弹窗没显示(系统阻止了)
        if result.authResults[0] == 0:
            // 理论上不可能,但以防万一
            return OK
        else:
            return NEED_GUIDE_TO_SETTINGS  // 必须引导去设置

遇到的问题与解决方案

问题1:dialogShownResults在某些机型上返回 undefined

一开始我们直接取 result.dialogShownResults[0],结果在某些低版本设备上 crash 了。原因是 dialogShownResults可能为 undefined。解决方案:加安全取值。

const dialogShown = result.dialogShownResults?.[0] ?? false;

问题2:用户拒绝后再次弹窗,系统仍然弹窗,但我们想避免重复骚扰

有些系统版本中,用户拒绝后再次申请,系统仍然会弹窗(但会带上“不再询问”复选框)。这种情况下,如果我们不加控制,每次用户触发扫码都会弹窗,体验很差。解决方案:记录用户拒绝的次数,如果超过一定次数(比如2次),就不再弹窗,直接引导去设置。

// 在PermissionManager中添加计数器
private denyCount: Map<string, number> = new Map();

async requestPermission(permission: string): Promise<PermissionResult> {
  // ...
  if (!granted) {
    const count = this.denyCount.get(permission) || 0;
    this.denyCount.set(permission, count + 1);
    
    if (count >= 2) {
      // 拒绝次数过多,直接引导
      return { granted: false, dialogShown: false, needGuide: true };
    }
  }
  // ...
}

问题3:跳转系统设置页的URI在不同设备上不一致

我们最初写死的 bundleNameabilityName在部分设备上无法跳转。后来发现应该使用 wantparameters传递包名,系统会自动处理。更稳妥的做法是使用 UIAbilityContext.startAbility并传入 applicationInfo相关参数。上面代码中的实现是经过验证可用的。

总结

权限申请是商城应用的基础能力,但做好并不容易。核心要点总结如下:

要点

实现方式

检查权限状态

atManager.checkAccessTokenSync

请求权限

atManager.requestPermissionsFromUser

判断是否弹窗

PermissionRequestResult.dialogShownResults

引导用户去设置

startAbility跳转系统设置页

避免重复骚扰

记录拒绝次数,超过阈值直接引导

改完之后,我们的扫码功能再也不会莫名其妙地反复弹窗了。用户第一次拒绝,我们友好提示;用户再次拒绝,我们直接引导去设置。整个体验顺畅了很多,应用商店的评分也回升了。如果你也在做购物比价类应用,不妨试试这套权限管理方案。

Logo

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

更多推荐