把 targetSdkVersion 从 32 改成 33,编译一次通过,装到机器上测试同学马上来敲门:通知不弹了、相册选图一片空白、WiFi 扫描列表一条都出不来。这三个现象看着八竿子打不着,根因却是同一个——Android 13(API 33)把运行时权限又切了一刀。我前前后后给三个线上项目做过 33 的权限适配,踩的坑基本都集中在这几处:全新的通知权限 POST_NOTIFICATIONS、被拆成三份的媒体权限 READ_MEDIA_IMAGES / READ_MEDIA_VIDEO / READ_MEDIA_AUDIO,以及 NEARBY_WIFI_DEVICES、BODY_SENSORS_BACKGROUND 这两个新面孔。下面把这几个变更逐个拆开,代码怎么写、清单怎么配、拒绝之后怎么判断、adb 怎么快速复现,都按我实际改过的顺序讲一遍,能直接照着抄。
1. Android 13 运行时权限变更总览:先搞清楚影响面
每次遇到系统大版本升级,我的习惯是先不写代码,而是把"哪些权限被新增、哪些被废弃、哪些只是规则变了"列成一张表,再对着自己项目的清单文件一条条勾。Android 13 这次看起来条目不多,但每一条都卡在真实业务路径上,漏一条就是一个线上事故。先把全貌铺开,后面的细节才好对号入座。
1.1 一份可以直接对照的变更清单
下面这张表是我自己适配时用的对照表,左边是变化的权限,右边是它在 Android 13 上的真实状态。建议你把项目里的 AndroidManifest.xml 打开,逐行核对一遍,凡是命中的都标记出来。
| 权限 | Android 13 上的状态 | 影响范围 |
|---|---|---|
| POST_NOTIFICATIONS | 全新运行时权限,API 33 起生效 | 所有发通知的功能 |
| READ_EXTERNAL_STORAGE | 对 target 33 应用已废弃,请求直接返回拒绝 | 相册、文件选择、图片扫描 |
| WRITE_EXTERNAL_STORAGE | 早已是空壳,Android 13 上彻底不再授予 | 保存图片到公共目录 |
| READ_MEDIA_IMAGES | 新增,替代读图片能力 | 图片读取 |
| READ_MEDIA_VIDEO | 新增,替代读视频能力 | 视频读取 |
| READ_MEDIA_AUDIO | 新增,独立成组 | 音频读取 |
| NEARBY_WIFI_DEVICES | 新增,属于附近设备类 | WiFi 扫描、直连、配网 |
| BODY_SENSORS_BACKGROUND | 新增,硬限制权限 | 后台读取身体传感器 |
这张表里最容易被忽略的是 READ_EXTERNAL_STORAGE 那一行。它不是"请求了但用户可能拒绝",而是请求这个动作本身就失效了,系统会立刻回调拒绝,压根不弹框。很多同学看到"权限被拒"就去查用户设置,其实用户那边什么都没做过,是代码在走一条已经不存在的路。
另外要提醒一句:Android 13 新增的这几个权限,都遵循同一个基本原则——只有 targetSdkVersion 提升到 33 的应用才会看到新规则。target 还停在 32 的应用,系统会按旧规则处理,但会给你一些兼容性的"过渡提示"。这个差异后面单独讲,因为它直接决定了你要不要立刻改代码。
1.2 targetSdkVersion 33 与 32 的差异到底在哪
这是适配时问得最多的一个问题:我不升 33 行不行?短期行,长期不行,而且中间态最容易出错。原因在于系统对不同 target 的应用走的是两套分支逻辑,尤其是通知权限和媒体权限。
以通知权限为例。targetSdk 33 的应用必须显式声明并主动请求 POST_NOTIFICATIONS,不请求就发不出通知。而 targetSdk ≤ 32 的应用,系统会代你弹一次对话框:时机是应用第一次创建 NotificationChannel 的时候;如果渠道在旧版本里早就建好了,那就在应用第一次启动 Activity 时弹。这就解释了一个常见困惑——"我明明没写请求代码,怎么测试机上弹了个通知权限框"。那不是你写的,是系统补的。
媒体权限同理。Android 13 设备上跑一个 target 32 的应用,请求 READ_EXTERNAL_STORAGE 依然会被授予,读取方式也还是老样子;但一旦你把 target 升到 33,同一条请求立刻返回拒绝,代码路径整个断掉。所以我一般建议:权限适配和 target 升级同步做,不要拆成两个迭代,否则中间会有一段时间你既吃不到新规则的好处,又要维护两套判断。
注意:不要为了省事把 targetSdk 卡在 32。应用商店的 target 要求是逐年抬高的,而且低 target 在 Android 13 上虽然能跑,但系统会自动替你弹一些对话框,实际用户体验反而更不可控——你连弹窗时机都掌握不了,就没法在弹之前做引导说明。
还有一个小细节值得记一下:从 Android 12 升级到 Android 13 的设备上,已经安装且通知处于开启状态的应用会被系统预先授予POST_NOTIFICATIONS。也就是说老用户升级系统后通知照常收到,不会突然静默;只有全新安装的应用才需要走请求流程。这个设计挺人性化,但也意味着你在开发机上反复卸载重装测试时,看到的永远是"需要请求"的路径,很容易忽略升级用户的场景。
2. 通知权限 POST_NOTIFICATIONS:新装应用最容易被投诉的那一个
通知权限是 Android 13 里存在感最强的一项变更,因为它影响的不只是推送,还包括前台服务的常驻通知、下载进度条、播放控制条——只要走 NotificationManager 发出去的东西,全都在这个权限的管辖范围内。而且它失效时的表现非常安静,不会抛异常,只会"什么都没发生"。
2.1 为什么升级用户没事,新装用户却收不到通知
前端时间我们一个项目做灰度,反馈很奇怪:老用户里零星有人说收不到推送,新装用户里则是一大片。查下来就是这个权限的两套逻辑在作怪。新装用户走的是 target 33 的显式请求路径,代码里没写请求就永远拿不到;老用户则是升级系统时被预先授权了,属于"运气好"。
这里有一个隐蔽的坑:没有通知权限时调用 notify(),系统通常不会抛 SecurityException,通知就是静默消失。这跟相机、定位那种"没权限直接崩"的权限完全不同。所以如果你的埋点只统计"有没有调用发送方法",而不是"通知有没有真正展示",这个 bug 可以在线上躺很久。
我的做法是在发送通知之前加一道检查,同时兼顾 target 33 和低版本设备:
fun canPostNotification(context: Context): Boolean = NotificationManagerCompat.from(context).areNotificationsEnabled()这个判断在 Android 13 上等价于"用户是否授予了 POST_NOTIFICATIONS",在低版本上等价于"用户是否在设置里关掉了通知"。用同一个方法覆盖所有版本,比按 Build.VERSION 分支要干净得多。
另外提醒一句关于通知渠道的事。Android 8.0 引入 NotificationChannel 之后,一旦某个渠道被用户手动关闭,应用就无法再打开它,只能引导用户去设置页面。Android 13 把通知权限做成了应用级的总开关,再叠加渠道级的开关,两层都可能掐断你的通知。排查"收不到通知"时,这两层都要看。
2.2 清单声明与请求代码的完整落地
先看清单,一行就够,但位置很关键,建议放在文件靠前的位置和定位权限排在一起:
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />请求时机我一般选在"用户即将收到第一条通知之前",而不是应用冷启动。理由很简单:冷启动就弹通知权限,用户根本不知道你要通知他什么,拒绝率会明显偏高。比如一个订单类应用,在用户首次下单成功、要推送订单状态时再请求,通过率会好很多。
代码用 ActivityResultContracts 这套新 API 写,不要再用 onRequestPermissionsResult 了:
private val notificationLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> if (granted) { // 授权成功,正常发送 sendOrderNotification() } else { handleDenied() } } private fun requestNotificationPermission() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { val granted = ContextCompat.checkSelfPermission( this, Manifest.permission.POST_NOTIFICATIONS ) == PackageManager.PERMISSION_GRANTED if (granted) { sendOrderNotification() } else { notificationLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) } } else { // Android 13 以下没有这个运行时权限,直接走通知开关判断 if (NotificationManagerCompat.from(this).areNotificationsEnabled()) { sendOrderNotification() } else { handleDenied() } } }这段代码有两个容易被忽略的地方。第一,Build.VERSION_CODES.TIRAMISU是常量 33,但不要用设备版本判断代替代码路径判断——低版本设备上调用 requestPermissions 传一个不认识的权限名,某些 ROM 会有奇怪表现,加一层版本判断最稳。第二,checkSelfPermission那一步不能省,用户可能已经在设置里手动开了通知,这时候再弹一次请求框会显得很蠢。
实操心得:如果你是在已有通知渠道的项目里加这个权限,务必确认请求发生在创建渠道之后。低 target 应用由系统代弹的对话框依赖渠道存在,高 target 应用虽然不依赖,但把"请求权限"和"创建渠道"放在同一个初始化流程里,代码逻辑更清晰,也方便后面排查。
2.3 拒绝之后怎么判断:临时拒绝、永久拒绝与跳设置
Android 的权限拒绝分两种状态,这是所有运行时权限的通用规则,但通知权限上表现得特别典型。用户第一次点"不允许",属于临时拒绝,下次你再请求,系统还会弹框;当用户明确拒绝到一定次数(各厂商 ROM 略有差异,通常是第二次明确拒绝之后),系统就不再弹框了,requestPermissions 会直接回调 denied。
判断方法还是那个老三样组合:granted == false且shouldShowRequestPermissionRationale返回 false,基本可以认定是永久拒绝。
private fun handleDenied() { val canAskAgain = ActivityCompat.shouldShowRequestPermissionRationale( this, Manifest.permission.POST_NOTIFICATIONS ) if (canAskAgain) { // 临时拒绝,先解释为什么需要,用户同意后再请求一次 showRationaleDialog { notificationLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) } } else { // 永久拒绝,只能引导到设置页 showGoToSettingsDialog() } }跳设置的时候有个细节值得注意:通知权限不要跳应用详情页,而是直接跳通知设置页,路径更短,用户更容易找到那个开关。
val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS) .putExtra(Settings.EXTRA_APP_PACKAGE, packageName) startActivity(intent)这个 Intent 从 Android 8.0 开始支持,Android 13 上会直接打开"应用通知"页面,比让用户从应用详情里翻要友好得多。我实测下来,跳这一页之后的开启率比跳详情页高出不少,因为详情页里权限和通知是分开的两块,用户容易找不到。
最后一个坑:用户从设置里手动关掉通知后,应用侧的 shouldShowRequestPermissionRationale 会返回 false,和永久拒绝的表现一样。所以不要把这个判断写死成"永久拒绝",直接统一引导到设置页就行,反正用户最终都得去那儿操作。
3. 媒体权限大拆分:READ_MEDIA_IMAGES / VIDEO / AUDIO
媒体权限的拆分是 Android 13 改动最大的一块。以前一个 READ_EXTERNAL_STORAGE 走天下,现在要按媒体类型分别申请。这个改动背后其实是 Android 10 分区存储思路的延续:既然系统已经知道每个文件属于哪个应用,那读取权限就不该是"全有或全无",而应该按类型、按范围精细控制。
3.1 旧权限为什么一夜之间变成空壳
先说结论:在 targetSdk 33 的应用上,READ_EXTERNAL_STORAGE和WRITE_EXTERNAL_STORAGE已经被标记为废弃,请求它们会立刻返回拒绝,而且不会弹任何对话框。这个"静默失败"是最坑的地方,因为你的权限回调里只会看到 granted = false,然后代码里通常会提示用户"请授予存储权限",用户一脸懵——根本没有那个框可点。
具体行为可以拆成三种情况:
在 Android 13 设备 + target 33 应用上,请求 READ_EXTERNAL_STORAGE 返回拒绝,必须改用 READ_MEDIA_*;在 Android 13 设备 + target 32 应用上,旧权限仍然会被授予,读取方式也还是旧的,能读到全部媒体文件;在 Android 12 及以下设备上,无论 target 多少,都还是老规矩,READ_EXTERNAL_STORAGE 照常工作。
这三个分支决定了你的代码必须按"设备版本 + 媒体类型"两维判断,而不是简单的一句版本号比较。
还有一个容易混淆的点:应用读取自己创建的媒体文件,本来就不需要任何权限。这是分区存储带来的特性,MediaStore 会把文件归属到创建它的应用,后续读写都是自己的东西,不用申请。所以如果你的功能只是"拍照后立刻在应用内展示",理论上不申请权限也能跑通。真正需要权限的是读取其他应用创建的媒体文件,比如相册扫描、图片选择器的全量列表。
3.2 清单与代码的多版本兼容写法
清单文件里最稳妥的写法是"新旧都留,但旧的限版本"。这样低版本设备能正常走旧逻辑,高版本自动落到新权限上:
<!-- Android 12 及以下使用 --> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="29" /> <!-- Android 13 起使用 --> <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" /> <uses-permission android:name="android.permission.READ_MEDIA_VIDEO" /> <uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />WRITE_EXTERNAL_STORAGE 的 maxSdkVersion 我习惯写 29,因为从 Android 11(API 30)开始它对第三方应用就已经没有实际作用了,留着只是为了让 Android 10 及以下的老设备能正常保存文件。
代码侧的请求分支建议封装成一个方法,各个业务模块统一调用,避免每个页面各写一套:
private fun mediaPermissionsFor(vararg types: MediaType): Array<String> { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { return types.map { when (it) { MediaType.IMAGE -> Manifest.permission.READ_MEDIA_IMAGES MediaType.VIDEO -> Manifest.permission.READ_MEDIA_VIDEO MediaType.AUDIO -> Manifest.permission.READ_MEDIA_AUDIO } }.toTypedArray() } return arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE) }这里有个实操细节:READ_MEDIA_IMAGES 和 READ_MEDIA_VIDEO 在系统对话框里会合并成一条"照片和视频",用户点一次允许,两个权限同时到手;而 READ_MEDIA_AUDIO 会单独弹一条"音乐和音频"。所以如果你的应用只用图片和视频,一次请求就够;如果还涉及音频,用户要面对两次对话框。我一般会把音频请求往后放,等用户真正要播放本地音频时再弹,而不是在首页一次性弹两个框,那样拒绝率会翻倍。
注意事项:三个权限虽然拆开了,但它们的授权状态是独立的。用户可能只允许了"照片和视频",拒绝"音乐和音频"。所以代码里不能用"任意一个授予就算通过"这种偷懒判断,每个类型都要单独 checkSelfPermission。我见过一个项目在权限检查里写了个 OR 条件,结果用户只授权图片后点开音乐列表直接空白。
3.3 改别人的媒体文件:从 WRITE_EXTERNAL_STORAGE 到 createWriteRequest
读取说完了,写入更绕一点。Android 11 之后,第三方应用已经无法用 WRITE_EXTERNAL_STORAGE 往公共目录随便写文件了。想修改或删除其他应用创建的媒体文件,正规路径是走 MediaStore 的请求 API:
val uris: List<Uri> = listOf(targetUri) val intentSender = MediaStore.createWriteRequest(contentResolver, uris).intentSender // 用 ActivityResultLauncher 启动,等用户确认这个调用会弹一个系统确认框,问用户"是否允许修改这张照片"。删除则是MediaStore.createDeleteRequest。这套机制在 Android 10 上就有了,Android 13 继续沿用,属于"没有写入权限也能完成的合法操作"。
我在适配时总结出一个判断原则,挺好用:凡是自己应用创建的文件,永远不需要权限;凡是别的应用创建的文件,读取走 READ_MEDIA_*,修改走 createWriteRequest,删除走 createDeleteRequest。按这个原则去梳理业务代码,基本不会漏。
另外补一句前瞻性的提醒:Android 14 在图片和视频上进一步引入了"仅选择部分照片"的授权模式,对应的权限是 READ_MEDIA_VISUAL_USER_SELECTED。如果你的应用现在正准备做媒体权限重构,建议把"部分授权"这条路径也考虑进去,把媒体读取封装成带回调的统一入口,后面接新权限时改一处就行,不用再翻全项目。
4. 两个新面孔:NEARBY_WIFI_DEVICES 与 BODY_SENSORS_BACKGROUND
通知和媒体是"人人都会碰到"的变更,这两个则是"碰上了就很头疼"的类型。它们的共同特点是权限组新、文档少、行为依赖 target 版本,而且都不是简单的加一行声明就能解决。
4.1 附近 WiFi 设备权限:neverForLocation 该不该加
Android 13 把"发现和连接附近 WiFi 设备"的能力从定位权限里剥离出来,新增了 NEARBY_WIFI_DEVICES,归在附近设备这一大类里。这个改动的意图很明确:过去很多应用只是想扫描 WiFi 列表或者给智能设备配网,却被迫申请精确定位权限,用户看着心里也不舒服。
清单有两种写法,区别在于你要不要用 WiFi 结果去推断物理位置:
<!-- 不用于推断位置,只需要扫描和连接 --> <uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" android:usesPermissionFlags="neverForLocation" /> <!-- 需要用 WiFi 结果推断位置 --> <uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" />加了neverForLocation之后,系统就认为你不是拿 WiFi 当定位用,不用额外申请 ACCESS_FINE_LOCATION。对配网类、设备发现类应用来说,这个标记非常划算,能省掉一个用户最敏感的定位权限。
但要注意两点。第一,neverForLocation只对 target 33 及以上生效;target 还停在 32 的应用,系统仍然按旧规则要求 ACCESS_FINE_LOCATION,而且扫描结果还会被限流(比如扫描频率受限、返回的列表被裁剪)。第二,如果你确实用 WiFi 信息做室内定位或者地理围栏,老老实实申请精确定位,别为了省事加这个标记,否则被系统判定为违规使用会有下架风险。
踩坑记录:我们做过一个智能硬件的配网页面,代码里同时保留了旧版定位逻辑和新版权限逻辑。升级 target 33 后发现扫描列表为空,排查半天才想起来,新设备上根本没申请 NEARBY_WIFI_DEVICES,只申请了定位。系统在 target 33 下不再把定位权限当作 WiFi 扫描的替代品,这条路径彻底断了。后来统一改成"33 以上申请 NEARBY_WIFI_DEVICES,33 以下申请 ACCESS_FINE_LOCATION",问题消失。
4.2 后台身体传感器:一个普通应用申请不到的硬限制权限
BODY_SENSORS_BACKGROUND 是 Android 13 新增的另一个权限,它的特殊之处在于属于硬限制权限(hard restricted permission)。这类权限的特点是:普通第三方应用在清单里声明了、代码里申请了,系统也不会弹框,直接返回拒绝。只有满足特定条件(比如属于系统预置、或者被判定为合格的特定类型应用)才能被授予。
它的设计目的是把"前台读身体传感器"和"后台持续读取"这两件事分开。Android 13 之前,一个 BODY_SENSORS 权限拿到手,理论上后台也能持续读心率、步数这类数据,系统拦不住。拆分之后,target 33 的应用如果要在后台读,就需要额外的 BODY_SENSORS_BACKGROUND。
所有涉及前台服务读取传感器的场景,都要重新审视一遍。清单里加上这一行:
<uses-permission android:name="android.permission.BODY_SENSORS_BACKGROUND" />但更重要的是产品层面的调整:如果拿不到这个权限,后台采集就没法做,那就要考虑改成"用户主动打开应用时前台采集",或者用前台服务 + 常驻通知的方式保持前台状态。这不是技术能绕过去的,得从功能设计上解决。
调试阶段如果需要验证后台路径的逻辑有没有写对,可以用 adb 手动授予:
adb shell pm grant com.example.app android.permission.BODY_SENSORS_BACKGROUND这类硬限制权限在真机上是走不到授权流程的,用命令行模拟授权是验证代码分支的有效手段。记得验证完执行adb shell pm revoke还原,不然会污染后面的测试结果。
5. 顺带被改掉的相关行为:广播注册、包查询与权限自动重置
Android 13 还有几处变更,严格说不是运行时权限,但和权限模型紧密相关,经常在适配时一起冒出来。我把它们归到这一节,因为很多团队是在排查权限问题时才发现这些改动也生效了。
5.1 registerReceiver 的导出标志
从 target 33 开始,注册非系统广播接收器时必须显式声明这个接收器是否对外导出,不写会直接抛异常。这个改动的出发点是防止内部广播被其他应用监听或伪造,属于安全加固。
ContextCompat.registerReceiver( context, receiver, IntentFilter(ACTION_INTERNAL_UPDATE), ContextCompat.RECEIVER_NOT_EXPORTED )只用应用内部通信的,写RECEIVER_NOT_EXPORTED;需要接收外部应用发来的广播,写RECEIVER_EXPORTED,但同时要考虑加签名级权限做保护,不能裸奔。
这里有个冷知识:当你用RECEIVER_NOT_EXPORTED注册时,系统在安装阶段会往你的应用清单里自动注入一个名为DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION的签名级权限,用来确保发出的广播只有自己这个 UID 能收到。你在打包后的清单里能看到它,别以为是自己手抖加进去的。
实操心得:如果你用的是老版本 androidx.core(低于 1.9.0),
ContextCompat.registerReceiver这个方法还不存在,得手动按版本分支调用registerReceiver并传 flags。升级 androidx.core 是更省事的做法,顺手还能兼容 Android 14 的进一步收紧。
5.2 休眠应用与权限自动重置对适配的影响
权限自动重置从 Android 11 就有了:应用几个月没被使用,系统就会撤销它已经拿到的运行时权限。Android 13 在这个基础上又加了一层"休眠"机制,长期未使用的应用会被置入休眠状态,除了撤销权限,还会停止通知、清理临时缓存文件。
这对开发者的实际影响是:你不能假设权限一旦拿到就永远有效。每次用到敏感权限之前,都要重新 checkSelfPermission,不能用一次请求后存个标记位就完事。我之前接手过一个项目,代码里在首次启动时申请权限,通过后写进 SharedPreferences,后面所有地方都读这个标记位。结果用户三个月没打开应用,再进来时权限早被系统撤销了,功能直接瘫痪。
如果某个应用的权限确实不能自动重置(比如企业设备管理类),可以在清单里禁用:
<application android:autoRevokePermissions="disallow">但这个属性要慎用,用得不对可能引起用户投诉。另一个思路是用PackageManager.isAutoRevokeWhitelisted()在运行时判断当前状态,只做提示,不强行干预。
顺带说一句:用户手动清理应用数据(设置里点"清除数据")也会重置所有权限状态,这跟休眠是两回事,但排查问题时经常一起出现。测试同学报"权限又没了"的时候,先问一句是不是清过数据,能省不少时间。
6. 调试与排查:adb 命令、速查表与测试矩阵
权限相关的问题,光看代码很难定位,因为你不知道系统内记录的状态到底是什么。我这边有几个常用命令,基本上遇到权限问题先跑一遍,比读代码快得多。
6.1 我常用的几条 adb 命令
# 撤销某个运行时权限,模拟未授权状态 adb shell pm revoke com.example.app android.permission.POST_NOTIFICATIONS # 手动授予,用来验证拿到权限后的代码路径 adb shell pm grant com.example.app android.permission.POST_NOTIFICATIONS # 查看应用当前的权限授予情况 adb shell dumpsys package com.example.app | grep -A 20 "runtime permissions" # 通过 appops 直接开关通知权限(比 revoke 更底层) adb shell cmd appops set com.example.app POST_NOTIFICATION ignore adb shell cmd appops set com.example.app POST_NOTIFICATION allow # 列出所有危险权限及其所属权限组 adb shell pm list permissions -d -g其中dumpsys package那条最有用,它能告诉你每个权限是 granted 还是 denied、有没有被标记为"不再询问"(flags 里的 user-fixed 字段)。比跑一遍应用再猜快得多。
pm list permissions -d -g适合刚开始适配时用,它能一次性列出 Android 13 上所有危险权限和权限组的对应关系,对着看就能确认自己用的权限名有没有写错。权限名字拼错这种事听起来很蠢,但在改了几十行清单之后真的会发生,而且拼错的表现也是"静默拒绝",很难查。
6.2 常见问题速查表
下面这张表是我在实际项目里遇到过的典型问题整理出来的,按现象查会快一些。
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 新装应用收不到通知,不报错 | 未请求 POST_NOTIFICATIONS | 加权限声明和请求逻辑 |
| 低 target 应用突然弹通知框 | 系统代弹,渠道创建或启动时触发 | 把引导文案提前,避免用户困惑 |
| 请求存储权限立刻被拒,无弹框 | target 33 下旧权限已废弃 | 换成 READ_MEDIA_* |
| 只授权图片后音频列表空白 | 三个媒体权限状态独立 | 每个类型单独判断 |
| WiFi 扫描列表为空 | 未申请 NEARBY_WIFI_DEVICES | 按版本分支申请 |
| 后台传感器读不到数据 | BODY_SENSORS_BACKGROUND 未被授予 | 改前台采集或走特殊授权流程 |
| 注册广播时崩溃 | 未指定导出标志 | 用 RECEIVER_NOT_EXPORTED |
| 几个月后权限失效 | 系统自动重置或应用休眠 | 每次使用前重新检查权限 |
用这张表的时候有个建议:先确认设备版本和 targetSdk 的组合,再去对现象。同一段代码在 Android 12 和 13 上表现可能完全相反,不确认这两个前提,很容易查错方向。
6.3 上线前必跑的测试矩阵
权限问题最容易在"版本组合"上翻车,所以我一般会在发版前跑一个固定矩阵。不需要覆盖所有机型,覆盖这四个组合就能把绝大多数分支打出来:
| 系统版本 | targetSdk | 重点验证项 |
|---|---|---|
| Android 13 | 33 | 全部新权限的请求、拒绝、永久拒绝路径 |
| Android 13 | 32 | 系统代弹通知框的时机、旧媒体权限是否仍可用 |
| Android 12 | 33 | 低版本设备上的降级分支是否正确 |
| Android 10 | 33 | 老设备上的存储写入路径 |
跑矩阵的时候有个细节:每换一个组合,都要先卸载重装。因为权限状态是跟着安装实例走的,直接覆盖安装会保留上一次的授权结果,测试结论不可信。我是吃过这个亏的——在 Android 13 上测通知权限拒绝路径,怎么测都是已授权,后来发现是上一轮的 pm grant 还留着。
另外建议在通知权限的测试里加两条用例:一是"升级路径",模拟从 Android 12 升到 13 的老用户,验证通知会不会突然中断;二是"长期未使用",用 adb 手动触发权限重置,验证代码有没有在每次使用前重新检查权限。这两条用例在线下跑不难,但能挡住最典型的线上事故。
我在实际做 Android 13 权限适配的过程中,最大的体会是:这次的变更没有一条是"加个权限声明就完事"的,每一条都牵涉到请求时机、拒绝后的引导、以及不同系统版本上的分支判断。真正省时间的做法不是赶紧把编译错误消掉,而是先把权限清单和业务路径对齐一遍,把每个权限的"首次请求、临时拒绝、永久拒绝、系统撤销"四种状态都过一遍脑子,再动手写代码。这样改完之后,后面几个版本基本不用再回头返工。