1. 这不是升级,是重新校准开发坐标系
Android 11 发布时,很多团队第一反应是“又来了”,点开官方文档扫两眼,看到“targetSdkVersion=30”就直接改了 build.gradle,然后打包、测试、上线——结果在灰度阶段突然发现:后台定位权限失效、文件访问报错、通知渠道崩溃、甚至某些老设备上应用直接闪退。这不是 Bug,是 Android 系统底层对应用行为边界的重新划界。它不再容忍“历史遗留式兼容”,而是强制所有以 Android 11 为目标平台的应用,必须按新规则重写与系统交互的逻辑链路。
我参与过三个不同体量项目的 Android 11 适配,最小的是一个纯工具类 App(日活约 8 万),最大的是一个跨平台 SDK 集成项目(覆盖 20+ 子应用)。它们无一例外,在首次提测时都暴露出至少 5 类典型异常,其中 3 类无法通过简单 try-catch 规避,必须重构调用路径。核心原因在于:Android 11 不再是“功能增强”,而是“契约重签”。它把过去十年里开发者靠“绕过”“降级”“静默兼容”维持的灰色地带,全部收归为明确的、不可协商的 API 行为契约。
关键词里虽然没填,但实际适配中绕不开的硬核节点就这几个:分区存储(Scoped Storage)强制启用、后台位置权限收紧、一次性权限(One-time Permission)机制落地、通知渠道分组与默认设置变更、Activity 启动限制(特别是隐式 Intent)、以及 Package Visibility 机制引入。这些不是可选优化项,而是 targetSdkVersion=30 的编译期硬性门槛。你改不改,系统都会按新规则执行;你没改,系统就替你“拒绝执行”。
适合谁来读?如果你正在维护一个已上线超过 18 个月的 Android 应用,且尚未将 targetSdkVersion 升至 30;或者你正准备启动一个新项目,想避开从第一天就埋下的兼容雷区;又或者你是 SDK 提供方,需要确保你的库在 Android 11+ 设备上不触发系统拦截——那这篇就是你接下来两周每天要翻三遍的实操手册。它不讲原理图解,不堆概念定义,只告诉你:每一处变更背后,系统到底在拦什么、为什么拦、你该砍哪段代码、换哪条调用路径、以及实测下来最稳的兜底方案是什么。
2. 分区存储:从“文件系统自由”到“沙盒主权移交”
2.1 为什么这次必须动真格?
Android 10 引入 Scoped Storage 是试探,Android 11 是最终裁决。此前,应用只要声明WRITE_EXTERNAL_STORAGE权限,就能在公共目录(如 DCIM、Download、Pictures)里随意读写。这导致两个严重问题:一是恶意应用批量窃取用户照片、文档;二是大量 App 把缓存、数据库、临时文件全塞进 /sdcard/Android/data/ 下,造成空间碎片化,用户手动清理时根本分不清哪些能删、哪些删了就崩。Google 的解决思路很直接:把外部存储的“主权”还给系统,应用只拥有自己的沙盒(app-specific directory)和明确授权的媒体集合。
关键转折点在于:Android 11 起,requestLegacyExternalStorage=true 彻底失效。你在 AndroidManifest.xml 里加这行,对 targetSdkVersion=30 的应用完全不起作用。系统会无视它,强制启用分区存储。这不是 bug,是设计使然——它堵死了所有“假装没升级”的后门。
2.2 公共目录访问:MediaStore 是唯一合法通道
过去你可能这样保存一张截图:
// ❌ Android 11 下必然失败(targetSdk=30) File dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES); File file = new File(dir, "screenshot_" + System.currentTimeMillis() + ".png"); FileOutputStream fos = new FileOutputStream(file); bitmap.compress(Bitmap.CompressFormat.PNG, 100, fos);这段代码在 Android 11 上会抛出SecurityException,因为getExternalStoragePublicDirectory()返回的路径已被系统标记为“受限访问”。正确路径只有一条:通过 MediaStore 插入媒体项。
实操步骤如下:
构造 ContentValues,指定媒体类型与相对路径
ContentValues values = new ContentValues(); values.put(MediaStore.Images.Media.DISPLAY_NAME, "screenshot_" + System.currentTimeMillis() + ".png"); values.put(MediaStore.Images.Media.MIME_TYPE, "image/png"); // 关键:指定相对路径,让系统决定最终存储位置 values.put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES + "/MyAppScreenshots/");通过 ContentResolver 插入,获取 Uri
Uri uri = getContentResolver().insert( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values );用 OutputStream 写入数据(注意:不是 FileOutputStream)
if (uri != null) { try (OutputStream os = getContentResolver().openOutputStream(uri)) { bitmap.compress(Bitmap.CompressFormat.PNG, 100, os); } }
提示:
RELATIVE_PATH的值必须是系统预定义的常量(如DIRECTORY_PICTURES、DIRECTORY_DCIM、DIRECTORY_MOVIES),不能自定义任意路径。否则插入会失败。实测发现,若传入"/MyApp/Cache/"这类非法路径,insert()返回 null,且无明确异常提示,极易被忽略。
2.3 应用专属目录:安全但需主动迁移
/sdcard/Android/data/<package_name>/这个路径在 Android 11 下依然可读写,且无需任何权限。但它有个致命缺陷:当用户卸载应用时,此目录下所有内容被自动清除。这意味着,如果你过去把用户导出的 Excel 报表、离线地图包、加密密钥文件存在这里,升级后用户一卸载就永久丢失。
解决方案是:将重要用户数据迁移到 MediaStore 管理的公共目录,而仅把真正属于应用运行时的缓存(如图片缩略图、网络响应临时文件)留在专属目录。迁移过程必须带用户感知:
- 对于导出类文件(报表、PDF),在用户点击“导出”时,直接走 MediaStore 流程,保存到
DIRECTORY_DOCUMENTS; - 对于大体积离线资源(如地图瓦片),改用
MediaStore.Downloads.EXTERNAL_CONTENT_URI,并设置IS_PENDING=1标记下载中状态,完成后设为0; - 对于纯缓存(如 Glide 的磁盘缓存),保留专属目录,但需在
onTrimMemory()中主动清理过期项,避免占满空间。
我负责的一个工具类 App 在迁移时踩过坑:初期把所有文件都往DIRECTORY_DOWNLOADS塞,结果用户反馈“找不到导出的文件”。查因发现,部分文件管理器默认隐藏Downloads目录下的非.apk文件。最终方案是:对用户可见的导出文件,统一存到DIRECTORY_DOCUMENTS,并在通知栏发送一条“文件已保存至文档目录”的提示,附上 ACTION_VIEW Intent 直达该文件。
2.4 Legacy 模式彻底终结:没有“兼容开关”这回事
有些团队寄希望于“先切到 Android 11,但保持旧行为”,这是最大误区。Android 11 的构建工具链(AGP 4.1+)在编译时会校验targetSdkVersion,若为 30,则自动移除requestLegacyExternalStorage的 manifest 属性,即使你手写上去也无效。我们曾用反编译验证:APK 的 AndroidManifest.xml 中该属性确实消失。
更隐蔽的陷阱是android:preserveLegacyExternalStorage。这个属性在 Android 11 中仅对已安装应用的升级包有效,且只在升级瞬间起作用——它允许旧版应用在升级后的一小段时间内继续使用旧存储模型,但一旦用户杀掉进程重启,系统立即强制切换。它不是长期开关,而是“过渡缓冲期”。新安装的应用(包括首次安装或清除数据后重装)完全不受影响,直接受限。
结论很清晰:不存在平滑过渡,只有硬切换。你的适配工作必须在发版前完成,而不是指望用户升级后给你留出“适应时间”。
3. 后台位置权限:从“默认放行”到“显式授权链”
3.1 权限模型的本质重构
Android 10 允许应用在前台获取位置后,后台继续监听(需声明ACCESS_BACKGROUND_LOCATION)。Android 11 将其升级为“双授权链”:用户必须先授予前台位置权限,再单独授予后台位置权限,且两次授权必须由用户主动触发,系统不会合并弹窗。更关键的是:如果用户只给了前台权限,应用在后台尝试调用requestLocationUpdates()会直接抛出SecurityException,而非静默失败。
这改变了整个定位架构的设计逻辑。过去常见的“保活定位服务”模式(如后台 Service 持续上报位置)在 Android 11 下彻底失效。你不能再假设“只要用户点过一次同意,我就能永远后台跑”。
3.2 后台定位的合规路径:仅限四类场景
Google 明确规定,只有以下四类应用可申请后台位置权限,且需在 Play Console 提交详细说明并通过人工审核:
- 导航类应用:用户正在使用导航功能(Activity 在前台);
- 出行服务类:如打车、共享单车,用户已发起行程且等待接单;
- 健康与健身类:用户主动开启运动记录(如跑步、骑行);
- 家庭看护类:如老人防走失设备,需持续上报位置。
其他所有场景(如“附近商家推送”“天气自动定位”“社交动态更新”)均被禁止后台获取位置。我们曾为一个本地生活 App 申请后台定位权限,理由是“为用户提供实时周边优惠”,被 Play Console 直接拒审,回复是:“该功能可在前台触发,无需后台持续获取”。
3.3 实操中的权限链路设计
合规的后台定位流程必须包含三个强制环节:
前台权限前置校验
在申请后台权限前,必须确保前台权限已授予:if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) { // 先申请前台权限 ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.ACCESS_FINE_LOCATION}, REQUEST_CODE_FOREGROUND_LOCATION); return; }后台权限独立申请
前台权限授予后,再单独申请后台权限:// 注意:必须用 ACCESS_BACKGROUND_LOCATION,不能混用 ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.ACCESS_BACKGROUND_LOCATION}, REQUEST_CODE_BACKGROUND_LOCATION);后台服务启动时机控制
即使权限已获,后台定位服务也只能在用户明确处于相关场景时启动。例如:- 导航类:仅当导航 Activity 处于前台且用户点击“开始导航”按钮后;
- 运动类:仅当运动记录 Fragment 可见且用户点击“开始计时”后;
- 所有后台服务必须绑定
FOREGROUND_SERVICE类型,并显示持续通知(Notification Channel ID 必须为IMPORTANCE_LOW或更高)。
注意:
startForegroundService()启动的服务,若在 5 秒内未调用startForeground(),系统会抛出ForegroundServiceDidNotStartInTimeException并杀死服务。这是 Android 11 新增的硬性超时机制,必须严格遵守。
3.4 替代方案:前台定位 + 智能唤醒
对于无法通过审核的场景(如天气 App 的自动定位),可行的替代路径是:放弃后台持续定位,改为前台高频定位 + 智能唤醒。
具体做法:
- 应用在前台时,每 15 分钟请求一次高精度位置(
PRIORITY_HIGH_ACCURACY); - 将位置结果缓存到本地数据库;
- 当用户下次打开 App 时,优先读取最近一次缓存位置,若超过 30 分钟则触发一次新定位;
- 结合 JobIntentService,在系统空闲时(
JobInfo.NETWORK_TYPE_UNMETERED)同步位置数据,避免蜂窝网络消耗。
实测数据显示,这种方案在用户日均打开 App 3 次的前提下,位置更新延迟平均为 22 分钟,完全满足天气、新闻等场景需求,且零后台耗电。
4. 一次性权限与 Package Visibility:隐私墙的双重加固
4.1 一次性权限:从“永久授权”到“本次有效”
Android 11 引入PermissionController的核心变化是:所有危险权限(位置、麦克风、摄像头、身体传感器)默认以“仅本次”方式授予。用户在权限弹窗中选择“允许”,实际获得的是单次会话权限;下次应用进入前台,需重新申请。
这对依赖长期权限的逻辑产生连锁反应。例如,一个视频会议 App 过去在启动时申请摄像头权限,用户点“允许”后,整个会话周期内都可用。现在,用户点“允许”后,仅当前 Activity 可用;若用户切换到其他 App 再切回,摄像头会黑屏,必须再次申请。
解决方案是:将权限申请时机下沉到具体功能触发点,而非应用启动时。
- 摄像头:在用户点击“开启视频”按钮时才申请;
- 麦克风:在用户点击“开始语音输入”时才申请;
- 位置:在用户点击“查找附近”时才申请。
同时,必须处理onRequestPermissionsResult()中的PackageManager.PERMISSION_DENIED状态。过去开发者习惯忽略拒绝情况,现在必须提供明确引导:
if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_DENIED) { // 检查是否勾选了“不再询问” if (!ActivityCompat.shouldShowRequestPermissionRationale(this, Manifest.permission.CAMERA)) { // 弹出自定义 Dialog,解释为什么需要该权限,并提供跳转设置页入口 showPermissionRationaleDialog(); } }提示:
shouldShowRequestPermissionRationale()在 Android 11 下行为更严格。若用户在系统设置中手动关闭权限(而非在弹窗点拒绝),此方法返回 false,此时必须引导用户去设置页手动开启,不能仅靠弹窗说服。
4.2 Package Visibility:看不见的“应用通讯黑名单”
这是 Android 11 最易被忽视却影响最广的变更。过去,应用可通过PackageManager.getPackageInfo()或queryIntentActivities()获取设备上所有已安装应用信息。Android 11 要求:若要查询其他应用,必须在 AndroidManifest.xml 中显式声明<queries>元素。
例如,你的 App 需要调用微信分享,就必须声明:
<queries> <package android:name="com.tencent.mm" /> </queries>若需查询所有浏览器应用以提供“在浏览器中打开”选项,则需:
<queries> <intent> <action android:name="android.intent.action.VIEW" /> <data android:scheme="http" /> </intent> </queries>未声明的查询将返回空列表。我们遇到的真实案例:一个工具类 App 内置“清理加速”功能,需扫描所有运行中进程。升级后,ActivityManager.getRunningAppProcesses()返回的进程列表为空,导致清理功能失效。根因是未在<queries>中声明目标包名。修复方案是:列出所有可能被清理的系统应用包名(如com.android.systemui,com.android.phone),并添加通用 intent 查询。
更复杂的情况是 SDK 集成。某支付 SDK 内部会检测设备是否安装支付宝,其代码调用getPackageInfo("com.eg.android.AlipayGphone")。若主 App 未在<queries>中声明该包名,调用返回 null,SDK 误判为“未安装支付宝”,直接禁用支付宝支付入口。解决方案是:在集成 SDK 的文档中,强制要求客户在<queries>中添加 SDK 所需的所有包名,并在 SDK 初始化时做运行时校验,若检测失败则抛出明确异常。
4.3 隐私信息访问的“最小必要”原则落地
Android 11 将“最小必要”从理念变为强制技术约束。例如:
- 剪贴板访问:应用在后台时,无法读取剪贴板内容(
ClipboardManager.getPrimaryClip()返回 null)。仅当前前台应用可读; - 传感器访问:后台应用无法访问身体传感器(如心率、步数),
SensorManager.getDefaultSensor(Sensor.TYPE_HEART_RATE)返回 null; - Wi-Fi 信息:
WifiManager.getConnectionInfo()在 Android 11+ 返回的 SSID 和 BSSID 均为<unknown>,除非应用持有ACCESS_FINE_LOCATION权限且用户处于前台。
这些限制意味着:所有依赖后台获取敏感信息的功能必须重构。例如,一个健康 App 原计划在后台持续读取心率传感器数据,现在必须改为:用户开启运动模式后,前台 Activity 持有传感器引用,运动结束时停止监听。后台仅负责数据上传与分析,不参与采集。
5. 通知渠道与 Activity 启动:从“自由调用”到“显式契约”
5.1 通知渠道:分组、默认与用户控制权上收
Android 8.0 引入通知渠道,Android 11 进一步强化其管控力。核心变化有三点:
- 渠道分组强制化:所有渠道必须归属某个组(
createNotificationChannelGroup()),且组 ID 必须在创建渠道前注册。未分组的渠道在 Android 11 下创建失败。 - 默认渠道行为变更:
NotificationManager.createNotificationChannel()若传入null作为渠道 ID,系统不再创建默认渠道,而是抛出IllegalArgumentException。 - 用户控制粒度细化:用户可在设置中对每个渠道组单独开关“允许通知”、“允许声音”、“允许震动”,应用无法绕过。
实操要点:
- 分组设计需符合用户认知:例如,消息类 App 可分“聊天消息”、“系统通知”、“营销推广”三组;每组内再建具体渠道(如“好友消息”、“群聊消息”)。
- 渠道创建必须幂等:在 Application.onCreate() 中检查渠道是否存在,不存在则创建。避免每次启动都重复创建导致崩溃。
- 默认渠道必须显式命名:不要依赖
null,而是定义一个CHANNEL_ID_DEFAULT = "default",并为其设置合理名称与重要性。
// 创建分组 NotificationChannelGroup group = new NotificationChannelGroup( "message_group", "聊天消息"); notificationManager.createNotificationChannelGroup(group); // 创建渠道并指定分组 NotificationChannel channel = new NotificationChannel( "friend_message", "好友消息", IMPORTANCE_HIGH); channel.setGroup("message_group"); // 关键:绑定分组 notificationManager.createNotificationChannel(channel);5.2 Activity 启动限制:隐式 Intent 的“死刑判决”
Android 11 对隐式 Intent(即不指定 ComponentName,仅靠 Action/Category/DataType 匹配)施加严格限制。以下场景将直接失败:
- 启动
android.intent.action.VIEW且 Scheme 为http/https的 Activity,若目标应用未在<queries>中声明; - 启动
android.intent.action.SEND分享文本,若目标应用未声明<queries>; - 通过
PackageManager.resolveActivity()查询匹配 Activity,若未声明<queries>则返回 null。
最典型的崩溃点是:分享功能。过去常用Intent.createChooser()构造分享 Intent,现在必须确保<queries>中包含所有可能的分享目标(如微信、QQ、钉钉的包名),否则resolveActivity()返回 null,startActivity()抛出ActivityNotFoundException。
解决方案是:对分享 Intent 做双重兜底。
- 先查询
<queries>中声明的目标; - 若无匹配,降级为“复制链接到剪贴板”;
- 同时提供“手动选择应用”入口,跳转至系统分享面板(
Intent.createChooser()本身仍可用,但需确保 Intent 有足够信息)。
Intent shareIntent = new Intent(Intent.ACTION_SEND); shareIntent.setType("text/plain"); shareIntent.putExtra(Intent.EXTRA_TEXT, "分享内容"); // 检查是否有可处理的应用 if (shareIntent.resolveActivity(getPackageManager()) != null) { startActivity(Intent.createChooser(shareIntent, "分享到")); } else { // 降级:复制到剪贴板 ClipboardManager cm = (ClipboardManager) getSystemService(CLIPBOARD_SERVICE); ClipData clip = ClipData.newPlainText("分享链接", "https://example.com"); cm.setPrimaryClip(clip); Toast.makeText(this, "链接已复制", Toast.LENGTH_SHORT).show(); }5.3 Pending Intent 的显式化:安全边界的最后一道闸
Android 11 要求:所有 PendingIntent 必须指定 FLAG_IMMUTABLE 或 FLAG_MUTABLE。未指定者,系统抛出IllegalArgumentException。
FLAG_IMMUTABLE:PendingIntent 内容不可被接收方修改,适用于大多数场景(如通知点击跳转);FLAG_MUTABLE:允许接收方修改 Intent 内容,仅用于必须动态注入数据的场景(如 Widget 更新)。
错误示范:
// ❌ Android 11 编译失败 PendingIntent pendingIntent = PendingIntent.getActivity( this, 0, intent, 0); // 第四个参数为 0,非法正确写法:
// ✅ 推荐:绝大多数场景用 IMMUTABLE PendingIntent pendingIntent = PendingIntent.getActivity( this, 0, intent, PendingIntent.FLAG_IMMUTABLE); // ✅ 仅 Widget 等需修改 Intent 的场景用 MUTABLE PendingIntent widgetPendingIntent = PendingIntent.getBroadcast( this, 0, intent, PendingIntent.FLAG_MUTABLE);注意:
FLAG_IMMUTABLE是 Android 11 的默认推荐,FLAG_MUTABLE因安全风险较高,Google 强烈建议避免使用,除非绝对必要。我们曾在一个桌面 Widget 项目中因误用FLAG_MUTABLE导致审核被拒,整改后改用FLAG_IMMUTABLE+ 预置多套 Intent 模板,通过PendingIntent.getActivities()组合实现相同效果。
6. 适配 checklist 与灰度发布实战经验
6.1 不可跳过的 12 项硬性检查
在正式提测前,必须逐项确认以下清单。任何一项未通过,都可能导致线上崩溃或功能失效:
| 检查项 | 检查方法 | 未通过表现 | 修复方案 |
|---|---|---|---|
1.targetSdkVersion=30 | 查 build.gradle | 编译失败或行为不一致 | 修改build.gradle中compileSdkVersion和targetSdkVersion |
2.requestLegacyExternalStorage移除 | 反编译 APK,查 AndroidManifest.xml | 存储异常仍存在 | 删除 manifest 中该属性,重构存储逻辑 |
| 3. 公共目录写入走 MediaStore | 搜索代码中getExternalStoragePublicDirectory | SecurityException | 替换为MediaStore.Images.Media.EXTERNAL_CONTENT_URI插入 |
| 4. 后台定位权限独立申请 | 检查权限申请代码 | 后台定位无响应 | 拆分为前台+后台两次申请,且后台申请前校验前台权限 |
5.<queries>声明完整 | 检查 AndroidManifest.xml | resolveActivity()返回 null | 根据所有 Intent 调用,补全<queries>声明 |
| 6. PendingIntent 标志位 | 搜索PendingIntent.getActivity等调用 | IllegalArgumentException | 添加FLAG_IMMUTABLE或FLAG_MUTABLE |
| 7. 通知渠道分组 | 检查createNotificationChannelGroup()调用 | 通知不显示 | 创建渠道前先创建分组,渠道绑定分组 ID |
| 8. 一次性权限处理 | 检查onRequestPermissionsResult() | 功能间歇性失效 | 在拒绝时引导用户去设置页,而非静默忽略 |
| 9. 剪贴板后台读取移除 | 搜索ClipboardManager.getPrimaryClip() | 后台粘贴功能失效 | 仅在前台 Activity 中读取剪贴板 |
| 10. Wi-Fi 信息访问降级 | 搜索WifiManager.getConnectionInfo() | SSID 显示<unknown> | 改用ConnectivityManager.getNetworkCapabilities()判断网络类型 |
| 11. Activity 启动 Intent 显式化 | 搜索startActivity()无 ComponentName | ActivityNotFoundException | 对关键跳转补充setComponentName(),或确保<queries>完整 |
| 12. 后台服务 Foreground 绑定 | 检查startForegroundService()调用 | 服务启动超时崩溃 | 在 5 秒内调用startForeground(),并显示通知 |
6.2 灰度发布的三阶段策略
我们验证有效的灰度路径是分三阶段推进,而非一次性全量:
阶段一:内部真机白名单(3天)
- 选取 5 款主流机型(覆盖 Samsung、Xiaomi、OPPO、vivo、华为),系统版本严格为 Android 11;
- 安装包仅对内部测试人员开放,不走任何分发渠道;
- 重点监控:Crashlytics 中
SecurityException、ActivityNotFoundException、NullPointerException(因 MediaStore 返回 null)三类异常; - 此阶段目标:暴露所有编译期/运行时硬性崩溃,修复率需达 100%。
阶段二:小流量外网灰度(7天)
- 通过 Firebase Remote Config 控制,向 0.5% 的 Android 11 用户推送新包;
- 开启全量日志上报(脱敏后),重点采集:
MediaStore.insert()返回 null 的频率与机型分布;- 后台定位
requestLocationUpdates()调用失败率; <queries>未声明导致的resolveActivity()失败率;
- 此阶段目标:验证真实用户环境下的稳定性,异常率需低于 0.1%。
阶段三:分批全量(14天)
- 按机型维度分批:先推送给 Pixel 系列(原生系统,兼容性最好),再推送给 Samsung(One UI),最后推送给国内定制 ROM(MIUI、ColorOS);
- 每批间隔 48 小时,期间监控 ANR 率、卡顿率、后台耗电增幅;
- 若某批次 ANR 率突增 20%,立即暂停,回滚至前一版本,排查
Activity启动阻塞或MediaStore插入耗时问题。
6.3 我踩过的三个“教科书级”坑
“文件已存在”导致的 MediaStore 插入失败
我们在保存用户头像时,为避免重名,生成了avatar_123456789.png。但 Android 11 的 MediaStore 对DISPLAY_NAME去重极严:若同名文件已存在,insert()直接返回 null,且无任何异常。修复方案是:插入前先用ContentResolver.query()查询同名文件是否存在,存在则追加时间戳后缀。后台服务通知未设置
setOngoing(true)
一个音乐播放器 App 的后台播放服务,在 Android 11 下频繁被系统杀死。查因发现,其通知未调用setOngoing(true),导致系统判定为“可清理通知”,连带杀死关联服务。Android 11 对前台服务的通知要求更严:必须设置setOngoing(true)且setAutoCancel(false),否则服务无法持久化。<queries>中包名大小写错误
某 SDK 文档写的是com.tencent.mobileqq,我们复制时误写为com.tencent.MobileQQ(首字母大写)。在 Android 11 下,queryIntentActivities()返回空,导致 QQ 登录入口消失。系统不报错,只静默失败。教训:所有<queries>中的包名,必须从目标应用的AndroidManifest.xml中精确复制,区分大小写。
适配 Android 11 不是一次性任务,而是一次开发范式的校准。它逼着我们放弃“先跑起来再说”的惯性,回归到“用户隐私优先、系统契约至上”的设计原点。那些曾经靠权限堆砌、隐式调用、全局监听实现的功能,现在必须拆解为更细粒度、更透明、更可控的模块。这个过程痛苦,但结果是:你的应用更健壮、更省电、更受用户信任。当用户在设置里看到“我的 App 从未在后台偷偷获取位置”,那种踏实感,是任何技术指标都无法替代的。