麦克风、相机、位置、日历、通讯录——应用要用这些能力,先得过用户这一关。权限体系是应用与用户之间的一份契约:应用声明"我需要什么",系统问用户"给不给",用户随时可以反悔。对开发者来说,这份契约的每一步都有对应的代码动作:module.json5 里声明、运行时弹窗请求、按结果分支、被拒绝了怎么降级、被"不再询问"了怎么引导。五步走完,权限才算真正接好。

这五步里,前两步教程遍地都是,后三步才是真实产品里的分水岭——用户点"拒绝"的概率远比开发者想象的高(第一次见到弹窗就条件反射点拒绝是普遍行为),拒绝之后的体验决定了功能留存:优雅降级的应用把拒绝当普通分支处理,没准备的应用要么反复骚扰弹窗(被用户标记骚扰),要么默默瘫痪(用户以为功能坏了)。

这篇文章用一个"权限实验室" demo 把五步全部走通:四个 user_grant 权限(麦克风、相机、粗略位置、日历读)各一张状态卡,请求、授权、拒绝、“不再询问"四种状态实时流转,永久拒绝后按钮自动变成"去设置开启”。五种状态全部实测复现过,包括最难触发的那条路径——用户勾选"禁止(不再询问)"之后,应用侧靠什么信号识别出来。
1.png

一、两种权限:先分清 system_grant 和 user_grant

权限的第一课不是怎么请求,是分清哪些权限需要请求。这一课的背景值得先交代:HarmonyOS 的权限体系是应用安全模型的骨架,它解决的问题是"应用的能力边界谁来定"——开发者说了不算(会要得太多),系统全说了算(应用没法差异化),最终形态是三方分权:系统定义权限的档位和风险等级,开发者按需申请,用户逐项放行。每一方都有越界的诱惑:开发者想"先申请了备用",系统想"一刀切省事",用户想"全都拒绝保平安"——权限体系的设计就是在这三种张力里找平衡,而开发者的正确姿势(最小化申请、尊重拒绝、优雅降级)本质上是"不去做放大张力的那一方"。HarmonyOS 的权限按授权方式分两族:

system_grant(系统授权):安装即授权,用户无感知。联网(INTERNET)、震动(VIBRATE)、保持后台运行(KEEP_BACKGROUND_RUNNING)都是这一族——它们风险低,系统替用户做了决定。代码动作只有一个:module.json5 里声明,装上就生效。

user_grant(用户授权):必须运行时弹窗,用户逐个决定。麦克风、相机、位置、日历、通讯录、媒体库读写——凡是触碰"用户的数据"或"用户的传感器"的权限都在这一族。代码动作是完整的五步:声明、请求、分支、降级、引导。

判断一个权限属于哪族,最可靠的方式是查权限列表文档(每个权限的授权方式是系统定义的,不能自选)。有个快速的心智模型:权限触碰的东西如果是"换一台设备就不一样"的(你的照片、你的位置、你的麦克风收到的声音),就需要用户授权;如果只跟"这台设备能不能工作"有关(联网、震动),系统就代劳了。两族对照着记更快:

对比项system_grantuser_grant
代表权限INTERNET、VIBRATE麦克风、相机、位置、日历
授权时机安装即授权运行时弹窗
声明要求只写 namename + reason + usedScene
用户感知无逐个决定、随时可改
典型错误以为要弹窗(多余担心)漏 reason(运行时炸)

demo 里特意混了两种:四个演示权限全是 user_grant,而应用本身声明的 INTERNET、VIBRATE 是 system_grant——对照着看两族在声明写法上的差异,比文字描述直观。

还有一个容易想偏的问题:权限声明会影响上架审核吗?会,而且影响很直接。审核侧对权限的审查集中在两点:最小化(申请的权限必须与应用功能对得上——一个笔记应用申请位置权限,要能说出哪个功能用;说不出的权限在自检阶段就该删掉)和 reason 文案质量("用于获取信息"这种敷衍文案会被打回,要写清具体功能场景)。权限清单越短,审核越顺——这不是技巧是原则:每多一个权限,用户的信任成本和应用的风险面同时增加,砍权限永远不亏。demo 为了演示申请了四个"演示用途"的权限,真实应用不要学这个做法——演示代码的权限可以宽,产品代码的权限要吝啬。

module.json5 里 user_grant 权限的声明长这样:

{
  "name": "ohos.permission.CAMERA",
  "reason": "$string:camera_reason",
  "usedScene": {
    "abilities": ["EntryAbility"],
    "when": "inuse"
  }
}

三个字段三个作用。name 是权限全名;reason 是弹窗里展示给用户的用途说明(必须用字符串资源,弹窗里"用于权限演示:展示运行时权限的请求与授权流程"那行字就来自它——写不清楚用途的权限,用户拒绝得最快);usedScene 声明使用的 ability 和时机(inuse 使用中 / always 始终)。漏了 reason 的 user_grant 声明,运行时请求会直接失败——这是权限开发的第一个编译期拦不住、运行期才爆的坑。

reason 文案的写法多说两句,因为它直接挂在弹窗上、直通用户的决策瞬间。三条实测观感排序:最好的文案说具体功能(“用于扫描二维码时使用相机”),用户一读就知道权限跟哪个功能挂钩;中等的说数据去向(“读取日历以同步您的日程安排”);最差的说抽象目的(“用于提供更好的服务”——什么都没说,等于让用户盲猜)。demo 的 reason 写的是"用于权限演示"——这是演示场景的诚实写法,产品场景照着"功能 + 动作"的公式写即可。另外 reason 是要过审核的:文案与实际用途不符(申请位置写"提升体验")属于高危项,别给自己埋雷。

usedScene 的 when 字段顺带说清:inuse 表示"使用期间",应用在前台时权限生效;always 表示后台也要用(比如导航类应用的后台定位)。能 inuse 就别 always——后台权限的风险等级和用户的警惕心都高一档,审核也更严。

二、请求与弹窗:requestPermissionsFromUser 的形态

运行时请求的入口是 abilityAccessCtrl 的 AtManager:

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

const atm: abilityAccessCtrl.AtManager = abilityAccessCtrl.createAtManager()
const perms: Array<Permissions> = ['ohos.permission.CAMERA'] as Array<Permissions>
const res = await atm.requestPermissionsFromUser(ctx, perms)

三个细节值得点名。Permissions 是个类型而不是命名空间成员——它从 @kit.AbilityKit 直接导入(写成 abilityAccessCtrl.Permissions 会报"不存在该导出");它本质是权限名的字符串字面量联合类型,所以动态拼出来的权限名要 as 转换。请求可以批量(数组一次传多个),弹窗会按权限逐个展示,结果也按序返回——但产品上更推荐一次一个:一次弹三个权限的"连环弹窗"体验极差,用户大概率连着点拒绝。批量的合理用法是强绑定的一组——"拍摄"功能同时要相机和麦克风(录像场景),一次请求两个、弹窗连着出现,用户心智上是一件事;跨功能的权限拼一批(位置加通讯录)就是灾难。返回结果是个结构体,三个数组按序对应:

res.permissions        // 本次请求的权限名列表
res.authResults        // 授权结果:0 = 已授权,-1 = 拒绝
res.dialogShownResults // 每个权限的弹窗是否真的展示了这个数组是第三节的主角,先按下

弹窗本身是系统渲染的,应用不能定制外观、不能在弹窗前插"预热页"去软磨硬泡(系统检测到请求前有诱导行为属于审核红线)。实测中弹窗的形态值得记录:位置权限的弹窗是三个竖排选项——“本次使用允许”、“仅使用期间允许”、“不允许”,外加一个"禁止(不再询问)“的勾选入口;相机权限是"允许 / 禁止(不再询问)+ 拒绝"的形态。系统按权限类型选择弹窗模板,应用侧无感——你只需要知道用户可能给出三种回答:允许(可能带时效)、拒绝、勾了禁止再拒绝。带时效的允许(本次/期间)是较新的模板形态,应用侧拿到的一律是"已授权”,时效由系统管理到期自动收回,下次请求会再次弹窗——对应用透明的过期机制,省了续期逻辑。

还有个容易被忽略的时机问题:请求要在用户操作的上下文里发起,不要在页面加载时自动弹。用户刚点开"扫一扫"就弹相机权限,弹窗与用户意图强关联,通过率高;进应用第一秒就弹三个权限,用户还不知道应用是干嘛的,拒绝率飙升。权限请求的最佳实践是延迟到功能触点——demo 把请求挂在每张卡的按钮上就是模拟这个节奏。

请求前还有一道可选工序:预热说明。在弹窗出现之前,先用应用自己的界面解释"为什么要这个权限、拒绝会怎样",用户点"知道了"再发起真请求。这不是系统要求的(系统禁止的是诱导欺骗,正常的说明页不在此列),而是转化率的胜负手——系统弹窗只有一个 reason 字段的空间,说不清楚复杂场景(比如"通讯录权限用于找到也在用本应用的朋友"),预热页补足语境后再弹窗,通过率普遍高出一截。预热页同样要控制"只在第一次出现"(每次请求都先看一遍说明页,用户会烦)。
2.gif

3.gif
"

三、四态流转:拒绝好办,"不再询问"靠弹窗标记识别

请求返回后,应用要面对四种状态。前三种好理解:

  • 已授权:authResult 为 0,功能直接放行;
  • 已拒绝(可再请求):authResult 为 -1 且弹窗展示过——用户这次不想给,下次请求还会弹窗;
  • 未请求:还没问过(或冷启动时状态未知)。

第四种是关键分歧点:不再询问(永久拒绝)——用户在拒绝时勾选了"禁止(不再询问)",此后 requestPermissionsFromUser 调用不再弹窗、直接返回拒绝。这个状态下反复调用请求 API 是走不通的(弹窗永远不会出现),唯一出路是引导用户去系统设置里手动开启。

四态对应用侧的意义可以换个角度记:前两态是"用户的临时态度"(这次给不给),第三态是"还没发生过",第四态是"用户走完了弹窗这条路"——四态本质上描述的是"弹窗这条路还通不通":通(未请求/已拒绝,下次还弹)、不通(已授权是终点意义上的通了、不再询问是终点意义上的不通)。应用的一切策略围绕这个"通不通"展开:路通就用请求,路不通就用引导设置,已授权就用功能。

识别第四态的信号只有一个:dialogShownResults。请求返回时,某个权限的 authResult 是 -1、且 dialogShown 为 false——“用户拒绝了,但弹窗根本没弹”——这两个条件合起来就是永久拒绝的铁证。这个字段的语义值得咀嚼一下:它不是"用户的态度",而是"系统做了什么"——弹窗展示了,说明用户还有被问的机会;弹窗没展示,说明系统认为再问没有意义。应用侧的一切后续策略(还弹不弹、要不要引导设置)都建立在对这个信号的正确解读上:把 dialogShown=false 误当普通拒绝处理,应用就会在一个永远不会弹窗的循环里空转——每次用户点功能都调一次请求 API,每次都秒回拒绝,功能永远瘫着,用户和开发者都找不到原因。判定逻辑收成纯函数:

export function interpretRequest(authResult: number, dialogShown: boolean): number {
  if (authResult === 0) {
    return PS_GRANTED
  }
  if (dialogShown) {
    return PS_DENIED
  }
  return PS_BLOCKED
}

demo 实测的完整流转:相机权限第一次请求弹窗、用户勾选"禁止(不再询问)“并拒绝 → 状态卡变为"不再询问(需去设置)”,按钮自动换成"去设置开启";此后再点按钮,不再弹窗(dialogShown 为 false,状态不变),直接走引导。而只拒绝过没勾禁止的权限(demo 里的位置),再次请求弹窗照常出现,用户这次点了"本次使用允许",状态流转为已授权——"拒绝不是终局,用户随时可以改主意"这个产品假设,靠的就是这条可再请求的路径。

把四态的实测序列排成时间线,流转的骨架更清楚:

时刻用户动作系统行为应用侧状态
冷启动打开应用checkAccessToken 返回两态麦克风=已授权(历史),其余=未授权→显示"已拒绝(可再请求)"档
第一次请求位置点请求授权弹窗(三竖排选项)用户点"不允许" → 已拒绝
第二次请求位置再点请求授权弹窗再次出现用户点"本次使用允许" → 已授权
第一次请求相机点请求授权弹窗(含禁止入口)用户勾"禁止"并拒绝 → 已拒绝
第二次请求相机再点请求不弹窗,直接返回拒绝dialogShown=false → 不再询问,按钮换"去设置开启"

这张表也是权限功能的测试用例清单:每行一个断言点(弹窗出没出、状态徽章变没变、按钮形态换没换),四个权限按不同路径各走一遍,权限模块的回归就覆盖全了。

一个容易踩的误判要单独说:checkAccessToken 查不出第四态。冷启动时用 checkAccessToken(tokenID, permission) 查当前状态,返回值只有两态(授权/未授权)——已拒绝和不再询问在它眼里都是"未授权"。所以 demo 的状态机是查询与请求双轨:冷启动用 checkAccessToken 刷一遍两态初值(已授权/未授权),精确的第四态只在请求发生时由 dialogShown 判定更新。想主动区分拒绝和永久拒绝?没有直接 API——只能请求一次看结果(弹窗没弹 = 永久拒绝),这也是"引导去设置"的按钮通常放在请求流程里而不是独立查询里的原因。

四、tokenId:查询授权态的钥匙从哪来

checkAccessToken 的第一个参数是 accessTokenId——应用的访问令牌标识,它不在 UIAbilityContext 上直接挂着,取的路径绕了一小段:

import { bundleManager } from '@kit.AbilityKit'

// 默认 flag 不含 appInfo(实测 null)——必须用 WITH_APPLICATION 档
const bi: bundleManager.BundleInfo = bundleManager.getBundleInfoSync(
  ctx.abilityInfo.bundleName, bundleManager.BundleFlag.GET_BUNDLE_INFO_WITH_APPLICATION)
const tokenId: number = bi.appInfo.accessTokenId

const atm: abilityAccessCtrl.AtManager = abilityAccessCtrl.createAtManager()
const status: number = await atm.checkAccessToken(tokenId, 'ohos.permission.MICROPHONE' as Permissions)
// status: 0 = 已授权,-1 = 未授权

这条链上有一个实测踩过的坑值得放大:getBundleInfoSync 用默认 flag(GET_BUNDLE_INFO_DEFAULT)时,返回的 BundleInfo.appInfo 是 null,直接取 accessTokenId 抛"Cannot read property accessTokenId of null"。必须显式用 GET_BUNDLE_INFO_WITH_APPLICATION 档,appInfo 才会被填充。"默认值不含关键信息"是 bundle 查询 API 的通用脾气(查 hapModule、ability 同理,各有各的 flag),记忆方法:flag 名字里带"你要什么",默认档什么都不带。

bundleName 的来源用 ctx.abilityInfo.bundleName(UIAbilityContext.abilityInfo 上挂着 ability 元信息)——比硬编码包名体面,比从 ApplicationInfo 反查简洁。checkAccessToken 返回的 GrantStatus 就是 0/-1 两值,和 authResults 的语义对齐。

flag 家族再多看一眼,因为"默认档为空"的坑会在别处复发。BundleFlag 里除了 WITH_APPLICATION,还有 WITH_HAP_MODULE(带模块信息)、WITH_ABILITY(带 ability 信息)等档位,各自解锁 BundleInfo 的不同字段;查得越全,序列化和耗时越大——flag 的本意是"按需取用"。拿到一个字段为 null 的查询结果,第一反应查 flag,第二反应才是查代码——这个顺序能省掉半小时的误诊。同理心也适用于其它查询型 API:系统能力查询、应用信息查询,凡返回结构体的,都先确认"我选的档位含不含这个字段"。

五、引导设置:永久拒绝后的最后一公里

永久拒绝之后,代码侧能做的只剩一件事:把用户送到能改主意的地方。跳系统设置的代码很短:

const want: Want = {
  bundleName: 'com.huawei.hmos.settings',
  abilityName: 'com.huawei.hmos.settings.MainAbility'
}
ctx.startAbility(want)

实测这条 Want 能稳定拉起系统设置主页。能不能直达"本应用的权限管理页"?系统设置支持带参数路由(uri 里的 application_info_entry 等),但参数格式随版本波动,直接落应用详情页的路径不如主页稳定——demo 选择跳主页 + 文案指路(“应用与服务 → 权限管理里手动开启”),这在工程上是笔划算的交易:直达省两步点击,但跳转失败时用户被晾在一个黑屏 startAbility 错误里;主页一定成功,指路文案把剩下两步说清楚。

这个取舍背后还有一层更朴素的原因:引导设置的成败不在跳转在文案。直达详情页的方案,用户省了找路但依然面对一排开关(哪个开关对应刚才那个弹窗?);主页+指路的方案,路径长一步但每一步都有文字引导。两种方案的完成率差异,远小于"有没有把开关名说清楚"的差异——把精力花在文案上比花在路由参数上回报高。demo 的日志行用了简化文案,产品化时这段话要出现在用户眼前的弹层里而不是日志里。

引导的文案设计比跳转本身更值得投入。"去设置开启"点击后,用户要经历:切到设置 → 找到应用 → 找到权限 → 打开开关 → 切回应用,五步。每一步都在流失。两个实测有效的文案技巧:说清开关叫什么("在设置中找到’相机’开关并打开"比"去设置开启权限"完成率高——用户在权限列表里对着一排开关找"权限"两个字是找不到的);说清回来之后会怎样(“开启后回到本页即可使用扫描功能”——给用户一个完成闭环的预期)。demo 的提示文案是简化版,产品化时这两句值得展开成半屏的引导弹层。

还有个细节:用户从设置切回来后,状态要主动刷新。用户开着应用去设置开了权限、再切回来,页面上还挂着"去设置开启"就尴尬了——demo 用显式的"刷新当前状态"按钮解决(也方便演示),产品化时换成页面回到前台时自动刷一遍,用户回来看到的就是最新状态。实现挂在页面级的前后台回调上:

// UIAbility 侧
onForeground(): void {
  // 通知当前页面重查权限态(事件或 AppStorageV2 共享一个刷新号)
}

// 页面侧:收到刷新通知后
private async refreshAll(): Promise<void> {
  // 与冷启动同款:getBundleInfoSync 拿 tokenId → checkAccessToken 逐项查 → 更新徽章
}

刷新的粒度只刷两态(checkAccessToken 的能力边界),但足够覆盖这个场景——用户刚在设置里开了权限,两态查询恰好能反映"从未授权变为已授权"这个变化;至于"从已拒绝变成不再询问"这种反向变化,只有设置里才会发生且不影响界面(两种未授权态的按钮形态在本场景下等价),不必强求。用能力边界内的查询解决边界内的问题,是这类"够用就好"设计的要领。

引导设置的文案与刷新配合起来,完整的用户体验闭环是:点"去设置开启" → 切到设置开权限 → 切回应用 → 状态自动变绿 → 功能占位图消失、真功能就位。最后一步的"功能就位"别忘了做——状态刷新了但占位图还在,用户要杀应用重来,前面的五步就白走了。

六、拒绝之后的降级:功能还剩多少

权限被拒绝后应用怎么活,是五步里最见产品功力的一步。demo 的"拒绝影响"一栏(“语音功能不可用”、“拍摄功能不可用”)是降级的文字形态,真实产品里降级分三个层次。

功能内降级:被拒的权限只锁定依赖它的功能,其余照常。相机被拒不等于扫一扫页面打不开——页面照常渲染,只是取景框位置放一张"需要相机权限"的占位图加一个"去开启"按钮。页面级的瘫痪是最粗暴也最不必要的降级——用户被拒绝一次权限,代价不该是整个页面变墓碑。

替代路径:位置被拒,手动选城市;相机被拒,从相册选图;麦克风被拒,键盘输入。每一条替代路径都是"给权限降级的用户留的门"——demo 的位置卡写的"附近推荐、城市天气"就是典型的可降级场景(城市天气手动选城市完全成立)。设计功能时就想好"没有这个权限时功能缩水成什么样",比被拒后现场想体面得多。

三个具体场景把三个层次落地看。扫码页(相机被拒):页面结构不变(标题、历史记录、相册导入都在),取景区换成一张占位插画加两行字(“扫码需要相机权限”/“您可以在系统中开启后回来”)和一个"去开启"按钮;相册导入二维码图片的入口本来就是扫码页的标配,相机被拒时它从次要入口升级为主入口——功能没有断,只是换了条腿走路。天气页(位置被拒):默认城市列表直接展示(北上广深加用户手动添加),定位只是"自动选城市"的快捷方式;用户手动选了城市,体验完整度九成以上。语音输入(麦克风被拒):输入框回退到键盘,语音按钮保留但点击后展示引导;用户哪天想通了去开权限,按钮无缝变回可用。三个场景的共同点:被拒的功能缩水,但页面和入口全部活着——降级设计做对的标准就是用户感知不到"被惩罚",只感知到"换了个方式"。

再请求的分寸:已拒绝(非永久)状态下,什么时候可以再弹?分寸是用户新发起一次强相关操作时——用户第二次点"扫一扫",再弹一次相机权限是合理的(用户可能上次是误点拒绝);用户每次点都弹,就是骚扰。业界常见的节流策略是"同一功能的再请求间隔一个会话"或"最多主动再问一次,之后只留入口"——具体策略看功能权重,但"无限再弹"一定错。永久拒绝态则一次都不要再弹(弹也没用),把引导设置的入口常驻在功能占位区即可。

“最多主动再问一次"展开成可执行的策略:给每个权限记一个本地计数(首选项里存"已主动请求数”),首次请求不计,用户拒绝后的第二次请求算"再问",这次再拒绝就把计数封顶——此后功能入口点击直接进降级态(占位图加常驻的"去开启"入口),不再调请求 API。这个计数只约束应用主动发起的请求,用户从降级占位图主动点"重新授权"的不受限(用户主动要问,当然问)。计数还有个副作用要注意:卸载重装会清零,用户的"拒绝记忆"也一并清了——这是系统设计留给应用的合理重置点,不要试图用云同步把拒绝计数也同步过去(用户换了设备,拒绝意愿不该被继承,那是骚扰)。

降级三层的预算分配也值得说:多数团队把精力全花在"请求转化"上(预热页、文案、时机),降级层是上线后用户投诉才补的。合理的分配是倒过来的——请求转化优化的是"愿意给权限的用户",降级设计兜底的是"没给的用户";前者的上限是授权率,后者的下限是差评率。一个授权率 60% 的功能,剩下 40% 的用户体验完全由降级层决定——这 40% 里藏着大部分的负面反馈。demo 把"拒绝影响"做进每张卡,就是把这个常被忽略的维度显性化:每个权限被拒的那一刻,影响是什么,界面上一行字说清楚,别让用户猜。

七、自动化验证:权限流程怎么测

权限功能的测试有个天然难题:弹窗是系统渲染的,不受应用测试代码控制——单元测试覆盖不到弹窗这一环,手动测试又繁琐(每次要卸载重装才能重置授权态)。demo 开发过程中摸索出一套可复用的自动化验证方法,记录如下。

弹窗其实抓得到。系统权限弹窗虽然独立于应用页面,但它的节点树挂在应用窗口上——用界面树导出工具(uitest dumpLayout)在弹窗展示时抓一次,三个选项按钮的坐标就在树里(“本次使用允许”、“仅使用期间允许”、"不允许"各占一行)。拿到坐标就能用触摸注入点选,弹窗后的授权/拒绝分支可以全自动跑。一个要注意的时窗:注入点击要在弹窗稳定之后(实测 2.5 秒的间隔够用),太早点击会落到弹窗底下的页面上。

状态徽章就是断言点。demo 每张卡的状态文字(“已授权”/“已拒绝(可再请求)”/“不再询问(需去设置)”)是页面树里的普通文本节点——注入操作后重新导出树,检查目标文字是否出现,就是一条现成的断言。四态流转的全部用例(第三节那张时间线表)都能这样跑:操作一步、导树一次、断言一个徽章。徽章断言还有个隐性好处:它断言的是状态机的输出而不是中间过程——哪怕内部实现重构(比如判定逻辑从页面挪到数据层),断言一字不改,测试跟着状态机走而不是跟着代码走,稳定性天然高一档。

重置授权态的正道是系统设置。要反复测"第一次弹窗"路径,得把权限退回未授权态:应用详情页的权限管理里把开关关掉,或干脆卸载重装(授权态随应用数据清空)。自动化脚本里做前者(跳设置关开关再回来),手动测试做后者更省事。不要试图用代码重置——权限态存在系统侧,应用没有任何 API 能改它,这是安全模型的底线。

这套方法的价值在于把权限回归从"手点十几遍"变成"脚本跑两分钟"——权限逻辑改动频繁的应用(调整请求时机、加降级路径),这套基建的回报率很高。测试脚本本身的维护成本也低:弹窗按钮坐标、徽章文字两组常量,加一张用例表,半小时搭完。

八、完整源码

纯逻辑层 PermLabData.ets 全文(权限清单、四态模型、判定函数):

// 权限实验室纯逻辑层:权限条目模型、状态判定、状态徽章文案。

export interface PermItem {
  name: string          // 完整权限名(ohos.permission.XXX)
  label: string         // 展示名
  desc: string          // 一句话用途
  scene: string         // 拒绝后的影响描述
}

export function permItems(): PermItem[] {
  return [
    { name: 'ohos.permission.MICROPHONE', label: '麦克风', desc: '语音识别、语音输入', scene: '语音功能不可用' },
    { name: 'ohos.permission.CAMERA', label: '相机', desc: '拍照、扫码、视频通话', scene: '拍摄功能不可用' },
    { name: 'ohos.permission.APPROXIMATELY_LOCATION', label: '粗略位置', desc: '附近推荐、城市天气', scene: '定位类功能不可用' },
    { name: 'ohos.permission.READ_CALENDAR', label: '日历(读)', desc: '读取日程安排', scene: '日程同步不可用' }
  ]
}

/** 权限的四种状态 */
export const PS_UNSET: number = 0      // 尚未请求过
export const PS_GRANTED: number = 1    // 已授权
export const PS_DENIED: number = 2     // 已拒绝(还会弹窗)
export const PS_BLOCKED: number = 3    // 不再询问(只能去设置开)

export function stateLabel(state: number): string {
  if (state === PS_GRANTED) {
    return '已授权'
  }
  if (state === PS_DENIED) {
    return '已拒绝(可再请求)'
  }
  if (state === PS_BLOCKED) {
    return '不再询问(需去设置)'
  }
  return '未请求'
}

/**
 * 由请求结果推导状态:授权码 + 弹窗展示标记的组合判定。
 * authResult = 0(授权)/-1(拒绝);dialogShown = false 说明系统没弹窗
 * ——用户此前勾了"不再询问",这是识别永久拒绝的唯一信号。
 */
export function interpretRequest(authResult: number, dialogShown: boolean): number {
  if (authResult === 0) {
    return PS_GRANTED
  }
  if (dialogShown) {
    return PS_DENIED
  }
  return PS_BLOCKED
}

/** 由查询结果推导状态(checkAccessToken 两态,区分不了拒绝和不再询问) */
export function interpretQuery(grantStatus: number): number {
  if (grantStatus === 0) {
    return PS_GRANTED
  }
  return PS_DENIED
}

/** 徽章配色(状态到色的映射收在数据层) */
export function stateColor(state: number): string {
  if (state === PS_GRANTED) {
    return '#2F7D4F'
  }
  if (state === PS_BLOCKED) {
    return '#B0553F'
  }
  if (state === PS_DENIED) {
    return '#B08A3F'
  }
  return '#8A919C'
}

界面层的请求与查询两个核心方法:

/** 请求一个权限:弹窗 → 按结果与弹窗标记推导四态 */
private async requestPerm(index: number): Promise<void> {
  const perm: PermItem = this.items[index]
  const atm: abilityAccessCtrl.AtManager = abilityAccessCtrl.createAtManager()
  const perms: Array<Permissions> = [perm.name] as Array<Permissions>
  const res = await atm.requestPermissionsFromUser(this.ctx, perms)
  const authResult: number = res.authResults.length > 0 ? res.authResults[0] : -1
  const dialogShown: boolean = res.dialogShownResults !== undefined && res.dialogShownResults.length > 0
    ? res.dialogShownResults[0] : true
  const nextState: number = interpretRequest(authResult, dialogShown)
  // 更新对应下标的状态徽章与按钮形态(永久拒绝时按钮换"去设置开启")
}

/** 冷启动读当前状态(两态) */
private async refreshAll(): Promise<void> {
  const bi: bundleManager.BundleInfo = bundleManager.getBundleInfoSync(
    this.ctx.abilityInfo.bundleName, bundleManager.BundleFlag.GET_BUNDLE_INFO_WITH_APPLICATION)
  const tokenId: number = bi.appInfo.accessTokenId
  const atm: abilityAccessCtrl.AtManager = abilityAccessCtrl.createAtManager()
  const next: number[] = []
  for (let i = 0; i < this.items.length; i++) {
    const st: number = await atm.checkAccessToken(tokenId, this.items[i].name as Permissions)
    next.push(interpretQuery(st))
  }
  this.states = next
}

(完整界面代码约二百行,骨架值得过一遍——它演示了"状态驱动的权限卡"这个模式:)

ForEach(this.items, (item: PermItem, idx: number) => {
  Column({ space: 8 }) {
    Row({ space: 8 }) {
      Text(item.label)                                        // 权限名
      Text(stateLabel(this.states[idx]))                      // 状态徽章(直读状态数组)
        .backgroundColor(stateColor(this.states[idx]))        // 徽章配色(数据层映射)
      Text(item.desc)                                         // 用途一句话
    }
    Row({ space: 10 }) {
      Button(this.states[idx] === PS_BLOCKED ? '去设置开启' : '请求授权')   // 按钮随状态换形态
        .onClick(() => {
          if (this.states[idx] === PS_BLOCKED) {
            this.openSettings()          // 永久拒绝:唯一的出路是设置
          } else {
            this.requestPerm(idx)        // 其余状态:走请求(含已授权态的幂等重问)
          }
        })
      Text(`拒绝影响:${item.scene}`)      // 降级预告常驻卡上
    }
    Text(item.name)                                           // 权限全名(调试可见性)
  }
  .padding(12).borderRadius(12).backgroundColor('#FFFFFF')
}, (item: PermItem) => `perm-${item.name}`)

骨架的要点有三个。状态数组是唯一事实源——states 按下标与权限清单对齐,徽章文字、徽章颜色、按钮文案全部从它派生,任何状态变化只改数组(V2 状态体系自动刷新派生物)。按钮的形态切换收敛在一处三元表达式——"请求授权"和"去设置开启"是同一个按钮的两种形态,由状态决定;拆成两个按钮会让"什么时候显示哪个"的逻辑散到两处。权限全名故意露出来(小字灰色)——调试权限问题时,全名是日志和系统设置里的唯一标识,界面上能看到它,对照日志不用翻 module.json5。

总结:权限五步的检查清单

  1. 分族:system_grant 只声明(INTERNET/VIBRATE),user_grant 走全流程;判断看权限触碰的是"用户的数据"还是"设备的能力"。
  2. 声明:module.json5 里 name + reason(字符串资源,写人话)+ usedScene 三件套;漏 reason 运行时必炸,编译期不拦。
  3. 请求:AtManager.requestPermissionsFromUser,挂用户操作触点而非页面加载;Permissions 类型从 @kit.AbilityKit 导入;一次一个权限,别连环弹。
  4. 判定:authResults 定授权/拒绝,dialogShownResults 定"不再询问"——第四态只有这一个信号,checkAccessToken 查不出来。
  5. 降级:功能内降级(页面不瘫痪)+ 替代路径(手动选城市类)+ 再请求分寸(强相关操作时再弹、最多一两次)。
  6. 引导:永久拒绝后跳设置主页 + 文案指路(说清开关名、说清回来后怎样);回到前台自动刷新状态。
  7. 查询钥匙:tokenId 走 getBundleInfoSync(必须 WITH_APPLICATION 档,默认档 appInfo 为 null)→ appInfo.accessTokenId。

这七条里,第 4 条是多数权限实现的盲区(只判断了授权/拒绝两态,永久拒绝态下反复调请求 API 空转),第 6 条是多数产品的体验洼地(把用户送去设置就不管了)。权限流程的代码量不到一百行,但从"能跑"到"体面",差的从来不是代码,是对用户每一次"拒绝"的尊重程度——用户说不的时候,应用的世界不应该塌掉一角,而是换一扇门继续开;用户改主意的时候,应用要在一秒内跟上他的新决定。把这两句话写进权限模块的设计文档,比任何检查清单都管用。

Logo

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

更多推荐