ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Android 13 权限适配:通知、媒体与 Photo Picker

2026/9/30 13:32:55 拓冰建站 浏览量
Android 13 权限适配:通知、媒体与 Photo Picker 把 targetSdkVersion 从 32 提到 33第一波问题往往不在代码里而在用户的系统设置里。通知推出去静悄悄没人收到相册选图回来是空列表后台读传感器一跑就抛 SecurityException——这三个现象的共性在于它们在 Android 12 的机器上全都正常只有跑到 Android 13 上才露馅。Android 13 的运行时权限变更不是一次简单地加几个权限声明而是把通知、媒体文件、附近设备这几块的授权模型整体重做了一遍。这篇就把 Android 13 里跟运行时权限相关的变更逐条拆开从 POST_NOTIFICATIONS 到 READ_MEDIA_* 的拆分再到 NEARBY_WIFI_DEVICES、BODY_SENSORS_BACKGROUND 这些冷门但会直接崩的权限最后落到 ActivityResult API 的写法、Photo Picker 这条零权限路线以及权限自动重置的自查策略。做 Android 客户端开发、正在做 targetSdk 33 适配、或者被应用市场审核卡过权限声明的人可以对照着看。1. POST_NOTIFICATIONSAndroid 13 第一次把要不要被打扰的选择权交回用户1.1 通知权限从默认开变成必须问Android 13 之前应用只要在代码里注册了通知渠道系统层面对通知就是一路绿灯用户想静音某个应用得自己钻进系统设置里一点点关。Android 13 新增了POST_NOTIFICATIONSprotectionLevel 是dangerous意味着它必须走运行时请求的那一套流程和相机、定位是同一级别。这个变更背后的动机不难理解通知是应用触达用户最直接的手段也是骚扰的重灾区。把它变成显式授权等于给了用户一次先看看你要不要打扰我的机会。但对开发者来说代价是所有依赖通知的功能都要重新评估——推送、前台服务、下载进度、闹钟提醒全都在这个权限的管辖范围内。有一点容易被忽略前台服务的通知同样受 POST_NOTIFICATIONS 影响。你在 Android 13 上跑一个前台服务通知权限被拒绝的话通知栏里什么都没有服务本身还在跑。做音乐播放、导航、录音、运动记录的团队第一次遇到这个会非常懵——服务活着用户却看不到任何提示体验上等于应用偷偷在后台运行。Android 13 为此引入了快捷设置面板里的活跃应用入口用户能在那里看到有哪些前台服务在跑算是给了一个补偿性的可观测手段。1.2 targetSdk 32 和 33 在通知权限上的行为差异这一块是适配时最容易搞混的地方因为同一个 APK 装在不同系统上弹窗行为完全不一样。我按 targetSdk 分两种情况说。targetSdk装在 Android 13 上的通知权限行为33 及以上默认不授予必须由应用主动发起请求32 及以下应用创建第一个通知渠道时系统自动弹一次对话框targetSdk 32 及以下的应用系统会在应用第一次创建通知渠道的时机弹窗。这个弹窗是系统级别的应用无法定制文案、无法控制位置。用户如果选了不允许后续应用再创建渠道系统也不会重复弹——除非用户卸载重装或清除应用数据。也就是说对于没做适配的老应用系统其实已经替你问过一遍了只是问的时机由系统决定你没法干预。targetSdk 33 的应用则完全靠自己。你在合适的位置发起POST_NOTIFICATIONS请求系统才弹。用户拒绝之后再次请求也不会弹只能等用户自己去设置里打开。这里有个临界细节如果用户在短时间内连续拒绝两次系统会把它当作永久拒绝处理之后的请求调用直接回调 denied对话框连闪都不闪。所以第一次请求的机会非常珍贵别浪费在一个用户还没搞清来意的时机上。1.3 请求时机放在哪里决定了通过率的生死我见过最多的错误写法是在Application.onCreate()里或者主 Activity 的onCreate里直接请求。用户的感受是刚装好应用还没搞清这个 App 是干嘛的上来就是一个是否允许发送通知。这种场景下的拒绝率通常高得离谱而且一旦被拒绝后续基本没有挽回余地。我更推荐的做法是在用户第一次触发与通知强相关的业务动作时再请求。举几个具体的落点用户点开消息提醒这个开关、完成一次下单后引导开启物流通知、进入消息中心时提示开启提醒不错过回复。这些场景下用户对通知的价值有预期同意率会明显更高。实测过同一个应用把请求时机从启动页挪到勾选提醒的动作之后同意率能从三成左右提升到六成以上差别非常明显。还有一类是要谨慎处理的如果应用的核心功能依赖通知比如待办提醒、闹钟那请求的时机应该前置到引导页或首次创建提醒的那一刻并且要在拒绝后给一个清楚的说明——告知用户关闭通知会导致提醒失效并提供一个跳转设置页的入口。光弹一个系统对话框、用户拒绝后什么也不说是最省事也最伤用户的做法。通知权限本质上是用户对这个应用值不值得信任的一次投票前面铺垫得越充分后面的授权越顺。2. 存储权限一拆三READ_MEDIA_IMAGES / VIDEO / AUDIO 拆分背后的逻辑2.1 拆分的动机粒度换隐私Android 13 之前应用想读一张相册里的图片需要申请READ_EXTERNAL_STORAGE。这个权限名义上是读外部存储实际上授权范围覆盖了图片、视频、音频甚至部分文档。用户点一下允许等于把整个外部存储的读权限交出去。对于只想换个头像的应用来说这个授权范围明显过大也一直是隐私争议的焦点。Android 13 把媒体读权限按类型拆成了三个独立的 dangerous 权限READ_MEDIA_IMAGES读取图片READ_MEDIA_VIDEO读取视频READ_MEDIA_AUDIO读取音频拆开之后一个换头像的应用只需要申请READ_MEDIA_IMAGES用户看得明白授权意愿也更高。这是 Android 13 权限模型里我认为最合理的一次改动因为它把我要用哪些文件这个问题从系统层面细化到了业务层面应用没法再偷懒用一个大权限搞定所有事。2.2 IMAGES 和 VIDEO 会合并弹窗AUDIO 单独一个这点第一次做适配的时候很容易踩懵。系统把READ_MEDIA_IMAGES和READ_MEDIA_VIDEO归到了同一个权限组READ_MEDIA_VISUAL所以应用同时申请这两个权限时用户看到的是一个合并的对话框而READ_MEDIA_AUDIO是独立的权限组单独弹一个。权限所属权限组对话框表现READ_MEDIA_IMAGESREAD_MEDIA_VISUAL与 VIDEO 合并为一个对话框READ_MEDIA_VIDEOREAD_MEDIA_VISUAL与 IMAGES 合并为一个对话框READ_MEDIA_AUDIOREAD_MEDIA_AUDIO独立对话框为什么音频要单独拎出来因为音频文件里可能包含用户的私人录音、语音备忘隐私敏感度比图片视频更高。系统不希望用户为了看个相册就把录音权限一并交出去。所以做音乐播放器或者语音笔记类应用时要预期到用户对READ_MEDIA_AUDIO的授权会明显更谨慎产品层面最好准备一个不授权也能用的降级路径比如只允许播放应用自身目录下的音频。2.3 迁移路径声明方式与兼容写法manifest 里的声明要区分新旧用maxSdkVersion卡住旧权限的生效范围!-- Android 13 起生效的媒体权限 -- uses-permission android:nameandroid.permission.READ_MEDIA_IMAGES / uses-permission android:nameandroid.permission.READ_MEDIA_VIDEO / uses-permission android:nameandroid.permission.READ_MEDIA_AUDIO / !-- 兼容 Android 12 及以下用 maxSdkVersion 限制生效范围 -- uses-permission android:nameandroid.permission.READ_EXTERNAL_STORAGE android:maxSdkVersion32 /这里有个细节值得说如果不加maxSdkVersion32在 Android 13 且 targetSdk 33 的设备上READ_EXTERNAL_STORAGE会被系统直接忽略你请求它只会拿到 denied而且不弹任何对话框。很多人调试时看到请求了但没反应实际上就是系统认为这个权限在你的 targetSdk 下已经失效。代码层面的版本分支基本是固定套路fun requiredMediaPermissions(): ArrayString { return if (Build.VERSION.SDK_INT Build.VERSION_CODES.TIRAMISU) { arrayOf( Manifest.permission.READ_MEDIA_IMAGES, Manifest.permission.READ_MEDIA_VIDEO ) } else { arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE) } }另外要记住一点应用自己创建的文件不需要任何权限。在 scoped storage 体系下应用往自己的沙盒目录写文件、读文件全程不涉及运行时权限。所以如果你只是要读自己拍的图片、存自己的缓存压根不用申请这些权限。真正需要的场景是读取其他应用创建的、通过 MediaStore 暴露出来的媒体文件。2.4 requestLegacyExternalStorage 在 33 上已经彻底退场requestLegacyExternalStorage这个标志在 Android 10 引入 scoped storage 时是个过渡方案让开发者能暂时沿用旧的存储访问方式。但从 Android 11targetSdk 30开始它对 targetSdk 30 及以上的应用就完全失效了。到了 Android 13、targetSdk 33legacy storage 这个概念基本不存在了再在 manifest 里写这个标志除了自欺欺人没有任何作用。如果你手上还有老代码依赖Environment.getExternalStorageDirectory()直接拼路径读文件在 Android 13 上要么会被拒要么会读到空。正确的替代是走MediaStore查询或者干脆用应用专属目录getExternalFilesDir()。这个迁移本身不是 Android 13 新引入的但升级 targetSdk 时往往和权限变更一起集中爆发所以放在这里提一句别把它当成新问题去查。3. 冷门但会直接抛异常的权限变更3.1 NEARBY_WIFI_DEVICESWi-Fi 扫描与定位权限正式解绑Android 13 之前应用只要做 Wi-Fi 相关的操作——扫描列表、连接指定热点、P2P——几乎都必须申请ACCESS_FINE_LOCATION。这个设计一直被人诟病我连个 Wi-Fi只是想扫描附近设备为什么要暴露我的精确定位Android 13 用NEARBY_WIFI_DEVICES把这件事解开了。从 targetSdk 33 开始调用WifiManager、WifiP2pManager、WifiAwareManager、WifiRttManager这些 API需要的是NEARBY_WIFI_DEVICES在 Android 13 及以上的设备上而不再强制要求精确定位权限。uses-permission android:nameandroid.permission.NEARBY_WIFI_DEVICES android:usesPermissionFlagsneverForLocation tools:targetApitiramisu /这里的关键是android:usesPermissionFlagsneverForLocation这个标志。加上它等于向系统声明我拿这些信息不是为了推断用户位置。代价是 Wi-Fi 扫描结果会被系统过滤返回的ScanResult里不会包含可用于定位的 SSID、BSSID 等字段只剩下信号强度之类的信息。如果你的业务需要拿到完整的周边 Wi-Fi 列表并展示 SSID那就不能加这个标志得老老实实把定位相关的流程走一遍。我踩过一次坑做设备配网的时候需要拿到当前连接的 Wi-Fi 名称getConnectionInfo().SSID一开始加了neverForLocation结果 SSID 一直返回unknown ssid排查半天才反应过来是这个标志过滤掉了。后来改成了申请ACCESS_FINE_LOCATION问题立刻解决。所以这个标志不是加了更好而是要看你的实际需求决定涉及 SSID、BSSID 的功能就别加。3.2 BODY_SENSORS_BACKGROUND后台读心率必须单独申请Android 13 之前应用拿到BODY_SENSORS权限后不管在前台还是后台都能读心率、步数这类身体传感器数据。Android 13 引入了BODY_SENSORS_BACKGROUND把后台访问单独抽了出来。要点有几条BODY_SENSORS_BACKGROUND是 dangerous 级别但它不能单独申请前提是应用已经持有BODY_SENSORS。它的使用场景是应用退到后台后继续读传感器比如运动记录、健康监测类应用。这个权限没有独立的系统对话框用户授权通常要通过系统设置页面或者已有的前台服务流程引导。所以做健康类应用时如果你的功能会在后台持续读心率就要规划好BODY_SENSORSBODY_SENSORS_BACKGROUND的授权链路并且最好配合一个前台服务来让用户明确知道应用正在后台读取传感器。用户体验和合规两方面都要顾及否则很容易在应用市场审核或者隐私合规检查时被挑出来。3.3 蓝牙三件套与精确闹钟在 13 上的实际表现蓝牙权限的拆分其实是从 Android 12API 31开始的BLUETOOTH_SCAN、BLUETOOTH_ADVERTISE、BLUETOOTH_CONNECT替换掉了旧的BLUETOOTH和BLUETOOTH_ADMIN。Android 13 沿用这套设计没有大的变化但在 33 上做适配时仍然经常和 Wi-Fi 变更一起集中处理。!-- 新蓝牙权限 -- uses-permission android:nameandroid.permission.BLUETOOTH_SCAN android:usesPermissionFlagsneverForLocation tools:targetApis / uses-permission android:nameandroid.permission.BLUETOOTH_CONNECT / uses-permission android:nameandroid.permission.BLUETOOTH_ADVERTISE /BLUETOOTH_SCAN同样支持neverForLocation如果你的扫描结果不用于推导位置加上它能避免申请定位权限。但如果你的扫描要展示设备名称或用于位置关联那就得保留定位权限。这里和 Wi-Fi 是一个道理标志位和实际需求要匹配别为了少申请一个权限而让功能跑不起来。精确闹钟这块Android 12 引入了SCHEDULE_EXACT_ALARMAndroid 13 又在它之外新增了USE_EXACT_ALARM。区别在于权限授予方式适用应用SCHEDULE_EXACT_ALARM用户可在设置里关闭默认开需要精确闹钟的通用应用USE_EXACT_ALARM安装即授予用户无法关闭闹钟、日历等核心功能依赖精确闹钟的应用USE_EXACT_ALARM看起来更省事但应用商店对它的使用有严格限制只有真正的闹钟类和日历类应用才允许声明。普通应用想用精确闹钟还是得走SCHEDULE_EXACT_ALARM并做好用户关闭这个权限后的降级处理——比如改用setWindow()或者WorkManager的近似调度。我在项目里的做法是在每次设置精确闹钟前先调alarmManager.canScheduleExactAlarms()判断返回 false 就走近似调度避免直接抛异常。4. 落地写法ActivityResult API 下的多权限请求与拒绝链路4.1 为什么 requestPermissions 已经不该再用Activity.requestPermissions()这套 API 从很早就被标记为过时了官方推荐用ActivityResultContracts.RequestPermission和RequestMultiplePermissions。原因很实际旧 API 的权限请求结果要靠onRequestPermissionsResult回调接收这个回调绑定在 Activity 上稍不注意就会因为配置变更、Fragment 生命周期错位而丢失结果新 API 把请求和回调绑定在一起注册一次、自动处理生命周期返回的 result 更可靠。private val requestMultiplePermissions registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions() ) { result: MapString, Boolean - result.forEach { (permission, granted) - Log.d(PermissionDebug, $permission granted $granted) } val allGranted result.values.all { it } if (allGranted) { // 全部授权走正常流程 } else { // 部分或全部被拒走降级或说明逻辑 } }调用的时候按系统版本分支把权限列表传进去val permissions mutableListOfString() if (Build.VERSION.SDK_INT Build.VERSION_CODES.TIRAMISU) { permissions Manifest.permission.READ_MEDIA_IMAGES } else { permissions Manifest.permission.READ_EXTERNAL_STORAGE } requestMultiplePermissions.launch(permissions.toTypedArray())这里我建议把通知权限和媒体权限分开请求不要一个对话框里塞一堆。原因很简单用户一次性面对四五个权限弹窗第一反应通常是这个应用想干嘛然后就是连点拒绝。按业务场景分批请求同意率会好很多。请求的粒度越贴近用户当前的操作意图越容易拿到授权。4.2 shouldShowRequestPermissionRationale 的三种状态判断是否要展示为什么需要这个权限的说明靠的是shouldShowRequestPermissionRationale()。这个方法的返回值有三种情况很多人只知道其中一两种。返回情况含义应对策略从未请求过该权限false可以直接发起请求请求过被拒绝但未永久拒绝true先展示说明再发起请求请求过被永久拒绝false引导用户去设置页手动开启第三种情况最容易被忽略它和从未请求过同样返回 false但含义完全不同。这时候再调用请求 API系统对话框根本不会弹回调直接返回 denied。判断的方法通常是配合一个是否曾经请求过该权限的标记位或者在本地的 SharedPreferences 里记录首次请求的时间戳。没有可靠的纯 API 手段能百分百区分这两种情况所以实践里常用的做法是在应用本地记录第一次请求的时机结合返回值来判断是否该引导去设置页。4.3 永久拒绝后的设置页兜底被永久拒绝之后唯一能让用户重新授权的路径是系统设置。跳转的代码很固定private fun openAppSettings() { val intent Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { data Uri.fromParts(package, packageName, null) } startActivity(intent) }但光跳转不够关键在于跳转前要向用户解释清楚为什么需要这个权限、不开会有什么影响。我见过很多应用一检测到权限被拒就直接跳设置页用户一脸茫然退出后干脆把应用卸载了。更好的做法是先弹一个应用内的说明卡片写清楚开启相册权限后你可以在发布时选择本地图片不开启的话只能使用默认图然后提供一个明确的去设置按钮。用户点与不点的决定权在他手里但至少他知道自己在关掉什么。5. Photo Picker一条不用申请权限的替代路线5.1 什么时候应该考虑放弃申请权限Android 13 引入的 Photo Picker照片选择器其实是这一波变更里最实用、但最常被开发团队忽视的东西。如果你的应用只是想让用户选一张图片或一段视频根本不需要申请任何媒体权限——Photo Picker 是系统提供的 UI 组件用户选中的文件以 Uri 的形式返回应用拿到的是用户明确指定的那一份而不是整个相册的访问权。这就带来一个很实际的取舍能用 Photo Picker 解决的场景就别去申请 READ_MEDIA_IMAGES。授权流程、拒绝后的兜底、隐私说明全都可以省掉用户的疑虑也少。判断标准很简单——如果你只需要用户主动挑选的文件而不是遍历整个相册自己找那 Photo Picker 就是更好的方案。头像上传、发布配图、表单附件这类功能九成都属于这个范围。而且这个组件在低版本上有兜底Google 通过 Google Play 服务把 Photo Picker 向后兼容到了 Android 4.4所以在低版本设备上也能用行为基本一致。这一点对需要覆盖大量老机型的应用来说很关键不用再为低版本单独维护一套相册读取逻辑。5.2 PickVisualMedia 的接入写法接入本身很简单一行注册、一行启动private val pickMedia registerForActivityResult( ActivityResultContracts.PickVisualMedia() ) { uri: Uri? - if (uri ! null) { // 拿到用户选中的文件 Uri可以上传、预览、压缩 previewImage(uri) } else { // 用户取消了选择 } } // 启动指定类型为图片或视频 pickMedia.launch( PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo) )如果你需要多选用PickMultipleVisualMedia指定最大选择数量private val pickMultipleMedia registerForActivityResult( ActivityResultContracts.PickMultipleVisualMedia(5) ) { uris: ListUri - uris.forEach { uri - /* 处理每个 Uri */ } }有一个细节要注意PickMultipleVisualMedia传入的最大数量不能小于 2也不能大于系统支持的上限通常 150。如果传了 1 或者超过上限会直接抛 IllegalArgumentException这个错误在编译期发现不了只能在运行时报出来。还有一个容易踩的坑Photo Picker 返回的 Uri 是临时授权的通常只在当前进程有效。如果你需要把它持久化比如缓存路径、上传后保存记录得用contentResolver.takePersistableUriPermission()拿持久权限否则下次启动应用再去读这个 Uri 就会拿到 SecurityException。这个坑我在做上次选择的图片记忆功能时踩过一次重启应用直接崩查了半天才发现是 Uri 授权过期了。5.3 和直接申请权限的对比方案权限需求用户可访问范围适用场景申请 READ_MEDIA_IMAGES需要运行时授权整个相册的图片需要遍历、扫描、批量处理Photo Picker无需权限仅用户选中的文件头像、发布配图、单次选取大部分选一张图的场景其实都落在右边这一栏。这也是我建议团队在升级 targetSdk 33 时顺便做的一次架构梳理把那些其实只需要用户选的功能从权限流程里摘出来改成 Photo Picker权限弹窗数量能降一大半用户投诉也会少很多。真正需要申请权限的只剩下批量扫描相册、做本地图片管理这类确实要遍历的场景。6. 权限自动重置用户很久没打开授权就悄悄没了6.1 自动重置的触发条件Android 11 开始系统会对长期未使用的应用自动重置运行时权限。Android 13 继续沿用这套机制触发条件大致是应用在过去几个月内没有被使用具体时长由系统决定不是固定的系统就会把该应用之前获得的运行时权限全部重置下次启动时应用会回到未授权状态。这个机制影响的场景非常典型一个用户半年前授权过相册权限这次重新打开应用直接去读相册结果拿到 SecurityException 或者空列表。很多应用在这一步直接崩溃因为开发时默认授权过一次就永久有效没做重新检查。要应对它核心原则就是每次需要用到权限的时候都重新检查一遍状态不要依赖内存里缓存的授权标记。系统设置里的授权状态随时可能被用户改掉也可能被系统重置。把权限检查放在真正使用之前的那一刻而不是应用启动时缓存一个布尔值是最稳妥的做法。6.2 应用侧的自查与调试方法自查靠的是ContextCompat.checkSelfPermission()fun hasPermission(context: Context, permission: String): Boolean { return ContextCompat.checkSelfPermission( context, permission ) PackageManager.PERMISSION_GRANTED }调试的时候可以用 adb 命令模拟权限被重置的场景# 重置指定应用的所有运行时权限 adb shell pm reset-permissions package_name # 清除某个权限的用户设置标记模拟未请求过状态 adb shell pm clear-permission-flags package_name permission_name user-set user-fixed还有一个技巧是查看某个应用当前的实际权限状态adb shell dumpsys package package_name | grep -A 20 runtime permissions调试时我一般会做两件事一是手动pm reset-permissions然后重启应用看看启动路径有没有正确处理权限突然没了的情况二是把手机设置里的权限手动关掉再打开验证应用在前台时能否正确响应权限状态的变化比如onResume里重新检查。另外一个提醒是Android 13 里用户可以在设置中手动关闭某个应用的自动重置但绝大多数用户不会去操作所以默认按随时可能被重置来处理是稳妥的。真正依赖权限的功能宁可多检查一次也不要想当然地认为授权还在。最后再分享一个我在实际开发里用得比较顺手的小习惯把权限状态检查和请求都包一层统一在ActivityResultCallback里落日志把权限名、当前状态、请求结果、返回的 rationale 值一起打出来。权限问题的排查成本主要在不知道是哪一步卡住日志清晰了定位问题的时间能从半天缩短到几分钟。