ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Android 13 运行时权限变更与 targetSdk 33 适配实战

Android 13 运行时权限变更与 targetSdk 33 适配实战 上周把一个维护了三年的老项目从 targetSdk 30 一口气拉到 33本以为改个版本号、跑一遍回归就收工结果在 Android 13 的模拟器上第一次冷启动就翻车埋点日志里通知一条没发出去用户中心点头像选图直接卡在权限回调里不返回WiFi 扫描列表永远是空的。从那一刻起我才意识到Android 13 的运行时权限变更不是一个可以顺手带上的小改动它是一次会穿透到业务代码、UI 交互甚至是线上监控指标的深层调整。这篇内容就把 Android 13API 33运行时权限这一轮变更从头到尾捋一遍哪些权限是新加的哪些是被拆开的哪些是老权限在新系统上名存实亡以及一个真实项目要落地适配时应该按什么顺序改、改完怎么测。不管你是刚接触 Android 权限机制的新手还是已经带过几轮适配的老手这里面的清单、代码和踩坑记录都能直接抄走。下面所有的判断和参数都基于我实际改过的几个项目以及官方文档的交叉验证涉及厂商 ROM 差异的地方我会单独标出来。1. 这一轮权限变更到底动了哪些地方1.1 先搞清楚 targetSdk 和运行时权限的关系讲权限变更之前必须先把这个基础概念钉死因为后面所有为什么我的代码在 12 上正常、在 13 上失效的问题答案八成都在这里。targetSdkVersion现在统一写在build.gradle的targetSdk里不是一个单纯的数字它是你向系统声明我按哪个版本的规则来玩的契约。Android 的兼容策略是系统版本决定了有什么新能力而应用的 targetSdk 决定了系统对你施加哪一套行为约束。同一台 Android 13 手机装一个 targetSdk 30 的应用和一个 targetSdk 33 的应用两者在权限上的表现可以完全不同甚至同一个权限的弹窗次数、返回值都不一样。运行在 Android 13 上、但 targetSdk 停在 32 或更低的应用系统会尽量沿用旧行为避免老应用直接崩掉。而一旦你把 targetSdk 升到 33就等于主动接受了这一轮全部的新约束新权限要自己申请被拆分的旧权限不再生效后台权限必须走设置页。所以适配 Android 13 权限第一步永远是确认自己的 targetSdk 是几第二步是确认自己打算升到几。我个人建议不要一次跨太多版本从 30 直接跳 33 的那种改法出问题的时候很难定位到底是哪一版的变更导致的拆成 30 到 31、31 到 32、32 到 33 分三次升每次跑一遍完整回归排查成本会低很多。还有一点容易被忽略运行时权限Runtime Permission指的是那些必须在代码里动态申请、由用户点弹窗授权的权限危险权限基本都属于这一类。而普通权限只要在 Manifest 里声明就自动生效。这一轮变更影响的全是运行时权限这一层所以本文提到的每一条都需要你在代码里真的写申请逻辑光在 Manifest 里加一行是没用的。1.2 Android 13 权限变更的核心清单为了让你有个全局印象我先把这一轮的主要变化整理成一张表。这张表是我自己在做适配清单时用的版本直接照着它逐项核对就行。变更项涉及权限影响范围是否必须处理通知权限升级为运行时权限POST_NOTIFICATIONStargetSdk 33 的应用发通知必须媒体读取权限按类型拆分READ_MEDIA_IMAGES/READ_MEDIA_VIDEO/READ_MEDIA_AUDIO相册、音乐、视频类功能必须旧存储读取权限在 33 上失效READ_EXTERNAL_STORAGE沿用旧写法的所有项目必须新增邻近 WiFi 设备权限NEARBY_WIFI_DEVICESWiFi 扫描、直连、感知类功能按需新增后台身体传感器权限BODY_SENSORS_BACKGROUND健康、运动类后台采集按需权限自动重置范围扩大全部敏感运行时权限长期未使用的应用建议处理精确定位需同时声明大致定位ACCESS_FINE_LOCATION/ACCESS_COARSE_LOCATION地图、打卡、附近的人按需这张表里必须处理和按需的区别在于你的业务有没有用到对应能力。但有一点要注意POST_NOTIFICATIONS和媒体权限这两项几乎所有带推送、带头像上传、带图片展示的应用都会踩到所以我把它们放在最前面讲。另外READ_EXTERNAL_STORAGE这一行看着像历史遗留但它恰恰是最容易在测试阶段被漏掉的——因为很多老项目里这个权限被封装在某个工具类里你以为改了一处实际上还有三处在偷偷用它。1.3 变更背后的设计逻辑最小权限与按需授权理解了具体条目之后我更想聊聊这轮变更背后的思路因为它能帮你预判后面几个版本还会往哪走。核心方向有两个一是权限粒度最小化二是授权时机用户可控。权限粒度最小化最典型的例子就是媒体权限的拆分。以前一个READ_EXTERNAL_STORAGE拿到手用户的照片、视频、音乐、下载目录里的文档全都能读这对用户来说是极度过度的授权。拆分之后你只需要图片就申请READ_MEDIA_IMAGES用户看到弹窗上写着允许访问照片和视频心理负担小得多通过率反而会提升。这背后的逻辑很简单用户不是不愿意授权而是不愿意把不该给的东西一起给出去。授权时机用户可控体现最明显的是通知权限。以前通知是默认开启的应用想推就推用户只能在被骚扰之后去设置里手动关。现在变成运行时权限之后主动权交回用户手上应用必须证明自己值得被授权。这个变化对产品同学来说其实是个新课题通知权限的申请文案、申请时机会直接影响推送到达率而推送到达率又直接影响留存。我在做通知权限适配的时候专门跟产品一起设计了一个软引导弹窗把开启通知能收到什么讲清楚再走系统弹窗整体通过率比直接弹系统框高了将近两成。这个数字因产品而异但先解释再申请这个思路是通用的。2. 通知权限 POST_NOTIFICATIONS 的适配细节2.1 为什么通知会被升级成运行时权限POST_NOTIFICATIONS是 Android 13 新增的运行时权限它管的是应用能不能向通知栏投递通知这件事。在 Android 12 及以前通知是不需要申请的应用创建了通知渠道就能推。到了 13只要你的 targetSdk 升到 33就必须显式申请这个权限用户拒绝之后你调用NotificationManagerCompat.notify()不会有任何报错但通知就是不会出现——这也是我第一次踩坑时最迷惑的地方日志一切正常通知栏空空如也。这里有个非常重要的兼容细节很多资料一笔带过但它直接决定了你要不要写申请代码如果你的应用 targetSdk 仍然是 32 或更低但运行在 Android 13 上系统会在应用创建第一个通知渠道的时候自动帮你弹一次权限申请对话框不需要你自己写代码。也就是说低 targetSdk 的应用在 13 上并不是完全没通知而是走了系统代弹的路径。但这条路径的前提是创建通知渠道这个动作真的被执行到了如果你的通知渠道是在某个冷门分支里创建的可能永远都触发不了。所以我个人的建议是不管你的 targetSdk 现在是多少只要计划升到 33就老老实实按新规则写申请逻辑别指望系统代弹。2.2 声明、时机与软引导的三段式设计先看 Manifest 声明这一步最简单但漏了就直接回调永远是 denieduses-permission android:nameandroid.permission.POST_NOTIFICATIONS /接下来是时机问题。我不建议在 Application 的 onCreate 里或者首页一打开就申请理由很直接用户这个时候对你还没建立信任弹窗被拒绝的概率很高而 Android 的权限弹窗有拒绝两次之后不再弹出的机制一旦被永久拒绝你就只能引导用户去设置页手动开转化率会断崖式下跌。我一般会把申请时机放在用户第一次主动触发与通知相关的行为之后比如用户点了关注商品降价提醒、点了开启订单状态推送这类按钮这时候用户的心理预期和通知能力是匹配的通过率最高。配合这个时机我推荐一套三段式设计第一段是应用内的软引导弹窗用你自己的 UI 说明开启通知后你能收到什么按钮是去开启和以后再说第二段是用户点了去开启之后才调用系统权限申请第三段是用户拒绝之后不再反复骚扰而是在设置页留一个入口标注当前通知状态。private val notificationPermissionLauncher registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted - if (granted) { // 授权成功可以正常推送 notifyFeatureReady() } else { // 用户拒绝降级到应用内消息中心不要反复弹 fallbackToInAppMessageCenter() } } fun ensureNotificationPermission() { // Android 13 以下不需要申请直接放行 if (Build.VERSION.SDK_INT Build.VERSION_CODES.TIRAMISU) { notifyFeatureReady() return } val granted ContextCompat.checkSelfPermission( this, Manifest.permission.POST_NOTIFICATIONS ) PackageManager.PERMISSION_GRANTED if (granted) { notifyFeatureReady() } else { // 这里先展示自己的软引导弹窗用户点去开启后再 launch showSoftGuideDialog { notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) } } }2.3 判断是否还能弹窗的正确姿势有一段逻辑我强烈建议加上就是判断用户是不是已经永久拒绝了。如果用户已经勾选了拒绝并且不再弹出你再调launch()也不会有任何反应用户会觉得点了按钮没反应体验很差。判断方法是用shouldShowRequestPermissionRationale()fun canRequestNotification(): Boolean { if (Build.VERSION.SDK_INT Build.VERSION_CODES.TIRAMISU) return false val activity this as? Activity ?: return false val granted ContextCompat.checkSelfPermission( this, Manifest.permission.POST_NOTIFICATIONS ) PackageManager.PERMISSION_GRANTED if (granted) return false // 返回 true 说明还能弹返回 false 且未授权通常是不再询问 return ActivityCompat.shouldShowRequestPermissionRationale( activity, Manifest.permission.POST_NOTIFICATIONS ) }这个方法的语义有点绕我用自己的话总结一遍授权状态为未授权且shouldShowRequestPermissionRationale返回 false且你之前已经申请过一次这三个条件同时成立基本可以判定用户选择了不再询问。注意最后那个申请过一次的条件很关键因为首次进入应用时这个方法本来就返回 false不能一上来就以为用户永久拒绝了。我在项目里会用一个本地标记记录是否申请过配合这个方法一起判断准确率才够。注意部分厂商 ROM 在权限弹窗的计数逻辑上和原生有差异尤其是拒绝几次后不再弹的阈值。线上最好加上埋点把弹窗展示、用户选择、最终授权状态都上报用真实数据来判断自己用户的拒绝习惯别只靠模拟器上的表现下结论。2.4 实操心得通知渠道与权限是两件事有个坑我踩过一次值得单独说通知权限和通知渠道是两个独立的东西权限被拒绝不代表渠道创建失败渠道被用户关掉也不代表权限被拒绝。如果你的应用在设置页展示通知已开启/已关闭只读权限状态是不够的还要读对应渠道的重要性等级val channel notificationManager.getNotificationChannel(order_channel) val channelEnabled channel null || channel.importance ! NotificationManager.IMPORTANCE_NONE val permissionGranted NotificationManagerCompat.from(this).areNotificationsEnabled()areNotificationsEnabled()这个方法在 13 上会同时考虑权限和渠道状态适合用来做统一的能不能发通知的判断入口。我在做订单推送的时候就是用它做前置检查不满足就直接走短信兜底避免用户投诉说好的推送呢。3. 媒体权限拆分与旧存储权限的失效3.1 READ_MEDIA_IMAGES、READ_MEDIA_VIDEO、READ_MEDIA_AUDIO 的拆分规则Android 13 把原来那个大而全的READ_EXTERNAL_STORAGE拆成了三个细粒度权限READ_MEDIA_IMAGES读取其他应用创建的图片文件READ_MEDIA_VIDEO读取其他应用创建的视频文件READ_MEDIA_AUDIO读取其他应用创建的音频文件这里有个前提条件必须说清楚你自己应用创建的文件无论读还是写都不需要任何权限。这一条从 Android 10 的分区存储开始就已经成立13 只是延续。所以如果你的应用是拍照存到自己目录再展示那你一个媒体权限都不用申请这也解释了为什么有些项目升级之后反而发现少了权限申请。真正需要申请的场景是你要读取用户相册里由其他应用比如系统相机、微信保存的图创建的图片。至于READ_EXTERNAL_STORAGE在 targetSdk 33 的应用上它在 Android 13 设备上已经不再具备实际授权能力。我实测的表现是请求它会直接回调 denied弹窗根本不出现。所以如果你的代码里还在申请这个权限升级到 33 之后表现一定是权限申请失败导致功能不可用而不是权限申请被用户拒绝。3.2 兼容矩阵与按需申请的实现不同系统版本要用不同的权限这个分支逻辑建议封装成一个方法避免散落在各处系统版本图片权限视频权限音频权限Android 13 (API 33) 及以上READ_MEDIA_IMAGESREAD_MEDIA_VIDEOREAD_MEDIA_AUDIOAndroid 12 及以下READ_EXTERNAL_STORAGEREAD_EXTERNAL_STORAGEREAD_EXTERNAL_STORAGEenum class MediaKind { IMAGE, VIDEO, AUDIO } fun requiredMediaPermission(kind: MediaKind): String if (Build.VERSION.SDK_INT Build.VERSION_CODES.TIRAMISU) { when (kind) { MediaKind.IMAGE - Manifest.permission.READ_MEDIA_IMAGES MediaKind.VIDEO - Manifest.permission.READ_MEDIA_VIDEO MediaKind.AUDIO - Manifest.permission.READ_MEDIA_AUDIO } } else { Manifest.permission.READ_EXTERNAL_STORAGE }写这段代码的时候最大的价值不是能跑而是按需二字。以前一个项目里做选择图片上传直接申请一整个存储权限就完事了。现在我会先问产品这个场景是不是只选图片只选图片就只申请READ_MEDIA_IMAGES。用户在弹窗上看到的文案会从允许访问照片、视频、音乐和其他文件变成允许访问照片和视频这个差异对通过率的影响是实打实的。3.3 更优解照片选择器让权限申请直接归零如果你的场景只是让用户选几张图/几个视频我强烈推荐用系统照片选择器这是 Android 13 带来的另一个大礼包。它的核心优势是用户通过选择器主动挑出来的文件应用可以直接读不需要申请任何存储相关权限。等于把权限问题从申请变成了不申请。private val pickMedia registerForActivityResult( ActivityResultContracts.PickMultipleVisualMedia(9) ) { uris: ListUri - // 直接拿到可读的 Uri无需 READ_MEDIA_* 权限 uris.forEach { uri - loadPreview(uri) } } fun launchPicker() { pickMedia.launch( PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo) ) }这套 API 通过 Jetpack 的 Activity Result 封装在 Android 13 上是原生实现在 Android 11 及以上带 Google Play 服务的设备上也能通过系统组件提供覆盖范围比想象中大。对我们项目来说把从相册选图这个高频场景切到选择器之后存储权限的申请次数直接下降了七八成用户投诉里为什么一个换头像要读我全部文件这类问题也没了。注意照片选择器返回的 Uri 授权是有时效的如果你需要在页面销毁后继续使用比如后台上传要么在拿到 Uri 之后立刻复制到应用私有目录要么显式申请持久化读权限。我在做后台上传的时候就遇到过 Uri 失效导致上传失败的问题排查了半天才发现是授权过期。3.4 迁移时的排查清单我在做这一块适配时总结了一个排查顺序分享出来可以省时间。第一步全局搜索READ_EXTERNAL_STORAGE把每一处出现的地方记下来区分申请处和判断处第二步对每一处判断它是图片、视频、音频还是混合场景混合场景要么拆成多次按需申请要么评估能不能改用照片选择器第三步检查有没有代码依赖checkSelfPermission(READ_EXTERNAL_STORAGE)的返回值来做功能开关这类代码升级之后会永远返回未授权必须改第四步检查测试用例和自动化脚本里有没有硬编码的权限名CI 上跑失败往往就是这里。4. 邻近 WiFi 设备权限 NEARBY_WIFI_DEVICES4.1 从扫描 WiFi 要定位权限说起以前做 WiFi 相关功能最别扭的一件事是我只是想扫描一下附近有哪些 WiFi却必须申请ACCESS_FINE_LOCATION。用户看到弹窗上写允许获取精确位置的时候十个里有八个会犹豫因为你扫描 WiFi 为什么要我的位置。系统的本意是 WiFi 扫描结果可以反推用户位置所以强制绑定定位权限但这在产品体验上确实很别扭。Android 13 引入的NEARBY_WIFI_DEVICES就是为了解开这个绑定。只要你不打算从 WiFi 结果里推导用户位置就可以用这个新权限替代定位权限声明方式如下uses-permission android:nameandroid.permission.NEARBY_WIFI_DEVICES android:usesPermissionFlagsneverForLocation /其中usesPermissionFlagsneverForLocation这个标记是重点它相当于你向系统承诺我不会拿 WiFi 信息去定位用户。加上这个标记之后用户在弹窗上看到的就是设备附近的提示而不是位置提示。4.2 版本分支的正确写法NEARBY_WIFI_DEVICES是 API 33 才有的权限在低版本上申请会直接失败所以必须做版本分支fun wifiScanPermissions(): ArrayString if (Build.VERSION.SDK_INT Build.VERSION_CODES.TIRAMISU) { arrayOf(Manifest.permission.NEARBY_WIFI_DEVICES) } else { arrayOf(Manifest.permission.ACCESS_FINE_LOCATION) }这里有一个容易忽略的点如果你的应用同时还有真实的定位需求比如地图打点那ACCESS_FINE_LOCATION还是得申请只不过它和 WiFi 扫描用的是两条独立的权限路径。我见过有的项目图省事在 33 上两个权限一起申请结果用户在弹窗上连续看到两个授权请求体验很割裂。正确的做法是按功能触发时机分开申请用户点扫描附近设备时申请邻近设备权限用户点定位我的位置时申请定位权限。4.3 用了 neverForLocation 之后信息会变少这一点我一定要提醒因为它属于文档里写了但很容易忽略的那类细节一旦你加了neverForLocation标记系统会对 WiFi 相关的部分标识信息做过滤在部分场景下 SSID、BSSID 这类字段可能拿不到完整值返回的是占位数据。如果你的业务强依赖这些字段比如要做设备指纹、要做特定路由器的识别那你就得认真权衡要么保留定位权限那条路径要么调整业务方案不依赖这些字段。我个人的判断标准是只做附近有几台设备信号强度多少这类展示需求用邻近设备权限就够了一旦业务上需要唯一标识某一台设备就得回到定位权限路线。这个判断在项目早期定下来能省掉后面反复改权限的麻烦。注意邻近设备权限在部分厂商 ROM 上的弹窗文案和分组逻辑与原生有差异有的 ROM 会把它归到附近的设备分组里和蓝牙权限共用一次授权。测试阶段一定要在真机上覆盖至少两三个主流品牌的 Android 13 设备模拟器上的表现不能代表全部。5. 后台身体传感器权限 BODY_SENSORS_BACKGROUND5.1 前景与后台为什么必须分成两步授权Android 13 新增了BODY_SENSORS_BACKGROUND它管的是应用在后台读取身体传感器数据比如心率的能力。关键的机制是这是一个后台权限不能通过requestPermissions()直接拿到。你必须先拿到前台的BODY_SENSORS然后引导用户去系统设置页手动把权限从仅前台改成始终允许。这个设计逻辑和后台定位权限是一脉相承的系统认为后台采集用户生理数据是高敏感行为不能让用户在一个弹窗里稀里糊涂就同意必须让用户主动去设置页完成这一步。对开发者来说这意味着你的权限流程里必须有一个跳转到应用设置页的环节而不是单纯的弹窗。5.2 声明与请求的两段式流程uses-permission android:nameandroid.permission.BODY_SENSORS / uses-permission android:nameandroid.permission.BODY_SENSORS_BACKGROUND /// 第一步申请前台身体传感器权限 private val bodySensorLauncher registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted - if (granted) { // 第二步检查后台权限是否已授予未授予则引导去设置 if (!hasBackgroundBodySensor()) { showJumpToSettingsDialog() } } } fun hasBackgroundBodySensor(): Boolean Build.VERSION.SDK_INT Build.VERSION_CODES.TIRAMISU ContextCompat.checkSelfPermission( this, Manifest.permission.BODY_SENSORS_BACKGROUND ) PackageManager.PERMISSION_GRANTED引导跳转的代码很通用我这里写一版可以直接抄的fun jumpToAppSettings() { val intent Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { data Uri.fromParts(package, packageName, null) } startActivity(intent) }5.3 健康类场景的实战建议后台身体传感器权限最典型的应用场景是运动健康类的持续监测。我在做这类需求时会坚持几个原则。第一不要在用户第一次打开应用时就引导去后台权限设置页。用户还没体验到任何价值就让他去设置里改权限转化率极低。我一般会在用户完成一次前台监测、看到数据之后再提示想要夜间也持续记录需要开启后台权限。第二必须做权限降级方案。后台权限拿不到是常态产品功能不能因此不可用。我的做法是没有后台权限时把监测能力限制在前台页面可见期间同时在数据页明确告知用户当前仅前台记录而不是默默少记录、让用户以为数据丢了。第三权限状态要实时刷新。用户从设置页返回应用时onResume里必须重新检查一次权限状态否则界面还停留在旧状态用户会以为设置没生效。这个细节很小但影响感知非常明显。6. 权限自动重置被系统悄悄回收的授权6.1 触发条件与豁免范围权限自动重置这个机制从 Android 11 就有但 Android 13 把它推到了更大的覆盖范围所以升级到 33 之后你更有可能遇到用户明明授权过某天权限又没了的情况。它的触发条件是应用在一段较长时间内通常是几个月没有被用户使用系统就会自动把该应用已授予的敏感运行时权限重置为未授予状态相当于把权限打回原形。这个机制对用户是好事避免了长期不用的应用一直持有敏感权限。对开发者来说它带来了一个新问题你的应用可能在毫无预兆的情况下失去权限而用户对此一无所知。如果应用的某个功能因为没有权限而静默失败用户会觉得是应用坏了。需要说明的是系统对某些应用是有豁免的比如设备管理员类应用、持久化的系统应用、以及用户在设置里明确关掉了该应用自动重置开关的情况。不同厂商 ROM 的豁免清单可能有差异所以我的建议不是在代码里假设自己会被豁免而是老老实实把权限可能随时丢失当成前提来设计。6.2 检测与恢复的三步做法我处理这个问题的方法是三步。第一步在应用启动的关键路径上重新校验权限而不是依赖本地缓存的我已经授权过标记第二步发现权限丢失且功能需要它时给出明确的解释和一次重新申请的入口第三步用系统提供的入口把用户直接引导到相关设置页。// Android 11 及以上可用用于把用户引导到权限自动重置相关的设置入口 fun openAutoRevokeSettings() { if (Build.VERSION.SDK_INT Build.VERSION_CODES.R) { val intent Intent(Intent.ACTION_AUTO_REVOKE_PERMISSIONS).apply { data Uri.fromParts(package, packageName, null) } runCatching { startActivity(intent) } .onFailure { jumpToAppSettings() } } else { jumpToAppSettings() } }这里用runCatching包一层是必要的因为不是所有设备都能响应该 Intent失败时回退到通用的应用详情页保证用户总能到达一个能改权限的地方。6.3 用 adb 模拟这个状态自动重置在真机上很难自然触发因为要等几个月。开发阶段可以用 adb 直接把应用标记为未使用来模拟# 把应用标记为非活跃状态模拟长期未使用 adb shell am set-inactive com.example.app true # 查询当前状态 adb shell am get-inactive com.example.app # 恢复为活跃状态 adb shell am set-inactive com.example.app false我一般在做权限相关的回归测试时会专门跑一遍这个流程授予权限、标记为非活跃、重启应用、检查功能表现。这个测试能一下子暴露出那些假设权限永远在的代码比在线上等用户报障强得多。7. 一次完整的权限适配改造实操7.1 改造顺序与盘点方法说了这么多变更点落到一个真实项目上最怕的是东改一处西改一处最后自己都不知道改全了没有。我现在的做法是固定一套顺序跑完一遍心里就踏实了。第一步是全量盘点。用 Android Studio 的全局搜索依次搜requestPermissions、checkSelfPermission、shouldShowRequestPermissionRationale、READ_EXTERNAL_STORAGE、ACCESS_FINE_LOCATION、BLUETOOTH_SCAN这些关键词把所有涉及权限的代码位置列成一张表标注它属于哪个功能模块、当前申请的是哪个权限、在 33 上是否还成立。这张表就是你的适配路线图。第二步是按业务模块分批改不要按权限类型改。因为同一段业务代码往往涉及多个权限按模块改能保证每次改完的代码是自洽的、可测试的。我通常把通知、媒体、定位这三块放在最前面因为它们覆盖率最高。第三步是统一封装。散落各处的权限判断迟早会不同步我一般会抽一个轻量的PermissionHelper把这个权限在当前版本应该用哪个名字是否已授权能否再申请这三件事收敛到一个地方。它不需要多复杂两三百行就够了但收益非常明显。object PermissionHelper { fun notificationPermission(): String? if (Build.VERSION.SDK_INT Build.VERSION_CODES.TIRAMISU) Manifest.permission.POST_NOTIFICATIONS else null fun isGranted(context: Context, permission: String?): Boolean { if (permission null) return true return ContextCompat.checkSelfPermission(context, permission) PackageManager.PERMISSION_GRANTED } fun canAskAgain(activity: Activity, permission: String?): Boolean { if (permission null) return false return ActivityCompat.shouldShowRequestPermissionRationale(activity, permission) } }7.2 测试矩阵与 adb 速查权限这块的测试我坚持按矩阵来跑而不是随手点点。矩阵的两个维度是系统版本和 targetSdk至少要覆盖Android 10API 29、Android 12API 31、Android 13API 33三个系统版本配合当前线上 targetSdk 和升级后的 targetSdk 两种构建。实际跑下来最关键的组合是Android 13 设备 新 targetSdk这是新行为集中爆发的地方。下面这些 adb 命令是我调试权限时的常备工具建议存一份# 授予 / 撤销某个运行时权限 adb shell pm grant com.example.app android.permission.POST_NOTIFICATIONS adb shell pm revoke com.example.app android.permission.POST_NOTIFICATIONS # 查看应用的完整权限状态 adb shell dumpsys package com.example.app | grep permission # 清空应用数据回到首次安装状态 adb shell pm clear com.example.app # 查看和修改 appops 层面的权限 adb shell cmd appops get com.example.app adb shell cmd appops set com.example.app POST_NOTIFICATION allowWindows 环境下把grep换成findstr就行。另外pm clear这个命令要慎用它会连同登录态一起清掉测权限的时候我一般先做完其他测试再跑它。7.3 灰度发布与线上监控代码改完、测试跑通并不代表适配就结束了。权限这类变更最大的风险是在线上真实用户身上的表现和老版本不一样所以灰度阶段一定要盯几个指标。我最关注的是三个权限弹窗展示率、权限授权通过率、权限相关功能的降级触发率。第一个指标如果在新版本上突然下降大概率是申请入口没触发或者前置条件判断错了第二个指标下降通常是申请时机变了用户觉得打扰第三个指标上升说明有更多用户因为没权限而走了降级路径需要评估降级方案是否够好。同时权限相关的异常一定要单独上报。我见过最隐蔽的一个线上问题是某段代码在申请权限后立刻读取数据没等回调就执行了导致在授权成功的用户里也有小概率读到空数据。这类问题在测试阶段很难复现只能靠线上监控兜底。我在权限回调里都会加一条日志记录申请了什么权限、用户选了什么、结果如何出问题时能快速定位。8. 常见问题速查与避坑清单8.1 典型问题排查表下面这张表是我这几年做权限适配时积累的问题库覆盖了升级到 Android 13 之后最常遇到的几种现象。遇到问题先在这里对一遍能省不少时间。现象可能原因排查方式通知完全发不出去无异常未申请POST_NOTIFICATIONS或用户拒绝检查权限状态与目标渠道重要性请求存储权限立刻返回 denied33 上请求READ_EXTERNAL_STORAGE已失效改用READ_MEDIA_*或照片选择器选图后读取 Uri 报错照片选择器返回的 Uri 授权已过期拿到 Uri 后立即复制到私有目录WiFi 扫描列表为空未申请NEARBY_WIFI_DEVICES或未加neverForLocation检查 Manifest 声明与版本分支后台权限申请没有弹窗后台权限必须走设置页不能直接请求引导用户跳转应用详情页老用户反馈权限突然消失触发了权限自动重置启动时重新校验并给重新申请入口部分机型权限弹窗文案不同厂商 ROM 定制真机覆盖测试不要只看模拟器8.2 我踩过的几个坑第一个坑是权限回调里做了耗时操作。我在一次改造中把数据刷新直接写在了权限回调里结果发现回调在 Activity 重建的场景下会丢失导致用户授权成功但界面没刷新。后来改成权限只更新状态刷新交给 ViewModel 里的状态流统一处理问题就消失了。这个坑的教训是权限回调只做状态变更别在里面耦合业务逻辑。第二个坑是想当然地认为 Manifest 声明了就万事大吉。有一次为了省事把NEARBY_WIFI_DEVICES声明里漏掉了neverForLocation结果在 33 上申请时行为不符合预期排查了半天才发现是这个标记的问题。这个标记不是可选的美化项它直接决定了权限在系统里的归类。第三个坑是用本地缓存判断权限状态。早期我为了减少checkSelfPermission调用把权限状态缓存在了 SharedPreferences 里。权限自动重置一上线这个缓存就彻底不可靠了用户明明看到应用以为自己有权限实际调用时全部失败。现在的做法是缓存可以留但只作为 UI 的首屏占位真实判断永远走系统 API。第四个坑是忽略了低版本系统的回退路径。新权限只在 33 上存在但你的应用还有大量运行在 10、11、12 上的用户。我有一次只在 33 上测了新逻辑发版之后低版本用户全部拿不到权限。后来我在PermissionHelper里加了强制断言只要出现该版本不应该走到这个分支就直接抛异常在测试阶段就能暴露出来。提示如果你维护的是一套多模块的大型工程权限相关的代码尽量只保留一个入口。我在一个项目里曾发现三个模块各自实现了通知权限申请升级时只改了两个第三个在灰度阶段才暴露出来。收敛入口这件事在任何涉及系统能力的改造上都值得做。最后分享一个我在实际项目里坚持了很久的小习惯每次做权限相关改造我都会在本地建一个permission-notes.md把这个版本遇到的每个坑、每条 adb 命令、每个厂商差异记进去。刚开始觉得多余但当下一次 Android 版本再带来权限变更的时候这份笔记就是最省时间的参考资料。权限这块没有一劳永逸的方案只有不断累积的实战经验把每次踩的坑沉淀下来下一次适配的起点就会高很多。
返回列表