ARTICLE DETAIL

资讯详情

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

Android 10/11 分区存储 EACCES 权限错误排查与适配指南

Android 10/11 分区存储 EACCES 权限错误排查与适配指南 如果你最近把应用的 targetSdkVersion 升到 29 或 30然后突然收到一堆用户反馈说某些功能“打不开文件”“下载失败”捞日志一看全是open failed: EACCES (Permission denied)这事我太熟了。过去半年我前前后后帮团队和几个外包项目处理过不下二十次同类问题每次排查的起点都是同一个——把读写存储的代码全部拉出来重新过一遍。这篇文章不聊大道理直接把这几年踩过的坑、翻过的车、最后沉淀下来的排查套路全部写出来。如果你正被 Android 10 和 Android 11 的存储权限折腾得头疼按这篇文章的顺序过一遍大概率能省下好几个通宵。1. 先搞明白 EACCES 是谁在拒绝Android 权限体系和分区存储的分水岭很多人在遇到EACCES (Permission denied)时第一反应是“我明明加了权限啊怎么还报错”。这个想法正好踩中了 Android 权限体系里最容易混淆的误区——你加的权限和系统实际检查的权限可能根本不是同一个东西。1.1 运行时权限与文件系统权限是两码事从 Android 6.0 开始系统引入了运行时权限机制。像READ_EXTERNAL_STORAGE、WRITE_EXTERNAL_STORAGE这类“危险权限”不能只在AndroidManifest.xml里声明一遍还必须在代码里调用requestPermissions()让用户弹窗确认否则权限等于没有。我在实际项目里见过很多次这样的情况targetSdkVersion一升到 23 以上老代码全部失灵因为以前安装时自动授予的权限现在变成了运行时动态申请。如果你的应用在 Android 10/11 上出现EACCES先检查这一层就排除了三成问题。但这里有个更阴间的细节就算你运行时权限全部申请通过文件系统层照样可能拒绝你。因为 Android 10 开始访问外部存储的规则彻底变了以前那一套“拿到权限就能到处读写”的日子一去不复返。1.2 Android 10 是分界点从“全部可见”到“分区存储”Android 10API 29引入了 Scoped Storage也就是分区存储机制。它的核心目的是规范应用对外部存储的访问让每个应用默认只能看到两类内容自己专属的目录以及公共媒体目录图片、音频、视频里自己创建的文件。这意味着即使你持有WRITE_EXTERNAL_STORAGE权限也不能再像 Android 9 及以前那样直接在/sdcard/任意目录下新建文件、遍历其他应用的目录。一旦尝试内核直接返回EACCES跟你权限有没有授权没关系。我在适配初期犯过一个很蠢的错在 Android 10 真机上直接File.mkdirs()创建/sdcard/MyApp/Download目录明明运行时权限都弹窗通过了但mkdirs()返回 falseopen()直接抛异常。后来才反应过来分区存储模式下非媒体目录的写入必须通过 MediaStore 或 SAF不能直接拿 File 路径硬写。1.3 targetSdkVersion 才是真正的开关不是手机版本这里要特别注意一个绝大多数人都会搞混的概念行为变化由 targetSdkVersion 决定不由手机系统版本决定。如果手机是 Android 10但你的应用 targetSdkVersion 是 28 及以下系统仍然按旧规则运行可以访问整个外部存储。如果手机是 Android 9但你的应用 targetSdkVersion 是 29系统在部分能力上也会按 Android 10 的兼容策略处理尽管主要行为还是跟随系统版本。所以遇到 EACCES 时第一步不是看用户手机是什么系统而是去build.gradle里确认targetSdkVersion到底是几。大部分同事把应用升到 29 或 30 后出现大量存储问题就是因为行为和以前不同了。到 Android 11API 30分区存储进一步收紧不仅强制启用而且requestLegacyExternalStorage这个临时逃生舱也不再生效。这个细节太容易坑人了我单独拉出来讲。2. 最容易踩的四个坑为什么你的 open failed: EACCES 会出现复盘这半年处理过的案例绝大多数 EACCES 都可以归结到下面四个典型场景里。每一个我都用真实出过问题的代码来还原现场你对照自己的工程排查就行。2.1 老代码直接访问/storage/emulated/0/下的路径这是最经典、最普遍的一种。很多早期项目的数据备份、文件下载、日志导出功能都习惯硬编码外部存储根路径比如File dir new File(Environment.getExternalStorageDirectory(), MyApp/backup); if (!dir.exists()) { dir.mkdirs(); } File outFile new File(dir, data.bin); FileOutputStream fos new FileOutputStream(outFile); // 这里崩在 Android 10 分区存储模式下如果你的 targetSdkVersion 29这里mkdirs()很可能返回 falseFileOutputStream大概率直接抛EACCES。根因在于你试图在根目录下的MyApp/backup创建文件但分区存储不允许应用直接访问自己专属目录之外的任意路径。公共媒体目录只能通过 MediaStore 写入非媒体目录只能借助 SAF系统文件选择器。我在排查一个项目时发现那位同事把下载文件写到/storage/emulated/0/Download/应用名/Android 11 上偶现 EACCESAndroid 10 上运行正常。原因是系统版本差异导致策略执行有细微差别但本质上都违反了分区存储规则只是触发时机和方式不太一样。2.2 对/Android/data/和/Android/obb/的访问被系统拦死这个坑在 Android 11 上尤为致命。/Android/data/和/Android/obb/这两个目录在 Android 11 上被系统严格管控无论是读还是写都会直接返回 EACCES而且在多数设备上连列表都看不了。我遇到过一个微信文件转发场景的线上问题应用通过系统的ACTION_OPEN_DOCUMENT让用户选择一个存放在/storage/emulated/0/Android/data/com.tencent.mm/下的文件然后拿到的 URI 在自己进程里无法读取始终报 EACCES。排查到最后发现这根本不是授权码问题而是 Android 11 对 Android/data 目录的硬限制应用没法绕过。你自己试一下就知道了在 Android 11 上用adb shell ls /storage/emulated/0/Android/data/看到的往往是一堆Permission denied。系统在这里做了一个非常严格的文件系统级管控。如果你的业务非要读/Android/data/下其他应用的文件常规手段基本无解。这种情况只能考虑弹窗引导用户用系统文件管理器手动操作或者把目标文件先复制到公共目录再分享。2.3 FileProvider 分享出去的文件对方打开时 EACCES很多应用对外分享文件时喜欢直接传一个file://协议的 Uri 给微信、QQ。在 Android 7.0 以上这种写法大概率会直接抛FileUriExposedException而不是 EACCES。但如果你改成 FileProvider 生成content://Uri 之后忘了授权那目标应用读取该文件时就会收到 EACCES。典型错误代码Intent shareIntent new Intent(Intent.ACTION_SEND); Uri uri FileProvider.getUriForFile(context, com.example.fileprovider, file); shareIntent.putExtra(Intent.EXTRA_STREAM, uri); shareIntent.setType(*/*); startActivity(Intent.createChooser(shareIntent, 分享));这段代码生成的content://Uri 只对你自己的应用有效如果没有给接收方添加临时访问权限微信或系统相册去读取时底层就是 EACCES 或 SecurityException。我在适配 Android 10/11 的紧急版本时就因为这个漏掉了FLAG_GRANT_READ_URI_PERMISSION导致线上分享图片全部失败。2.4 想要“所有文件访问权限”结果只申请了普通权限有些应用类型比如文件管理器、备份工具、数据迁移助手确实需要访问整个存储空间的能力。Android 11 为此提供了MANAGE_EXTERNAL_STORAGE这个特殊权限。这个权限不像普通危险权限那样弹个窗就能拿到需要跳转到系统设置页让用户手动开启“所有文件访问权限”。如果你在 Android 11 的 targetSdk 30 应用里直接尝试遍历/sdcard/DCIM/下所有目录但不申请这个权限系统会在你触碰到非权限范围内文件时返回 EACCES。注意Google Play 对这项权限的审核非常严格必须是文件管理相关核心功能才能使用用途不符会被下架。国内应用市场虽然没有 Google 那么硬性要求但各厂商应用商店也有自己的审核规则能不用尽量不用。3. 实操解法从兼容底层到替换 API 的完整改造路径排查问题只是第一步最终还是要落地修代码。下面这套改造方案是我在几个生产项目里反复验证过、能真正跑通的路径。强烈建议按顺序执行。3.1 99% 的媒体文件场景用 MediaStore 替代 File如果你的业务主要是图片、视频、音频这类媒体文件最简单的方案就是把 File API 换成 MediaStore API。以保存一张图片到Pictures/MyApp为例现在推荐的写法fun saveImageToGallery(context: Context, displayName: String, mimeType: String, data: ByteArray) { val values ContentValues().apply { put(MediaStore.Images.Media.DISPLAY_NAME, displayName) put(MediaStore.Images.Media.MIME_TYPE, mimeType) put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES /MyApp) } val uri context.contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) ?: throw IOException(insert failed) context.contentResolver.openOutputStream(uri)?.use { stream - stream.write(data) } ?: throw IOException(openOutputStream failed) }关键在于RELATIVE_PATHAndroid 10 里用它指定相对路径系统会自动在公共媒体目录下创建对应的子目录不需要你自己 mkdir。Android 11 也支持这种方式。读取公共目录文件时也建议用 MediaStore 查询或者 SAF 选择而不是直接拼接路径。很多面试里会问“分区存储怎么适配”其实说的就是这一点把路径访问改成内容访问。我测试下来MediaStore 方式在 Android 10、11 的真机和模拟器上都是稳定的。唯一要注意的是通过 MediaStore 写入的文件默认不会立刻出现在 MIUI、ColorOS 等定制系统的相册里必要时得发一个ACTION_MEDIA_SCANNER_SCAN_FILE广播或者调用 MediaScannerConnection。3.2 任意文件读写场景用 SAF 让用户亲自授权对于非媒体文件比如 PDF、压缩包、数据库备份推荐使用 SAFStorage Access Framework。你不需要自己去拼权限用户通过系统文件选择器选中目录后系统会返回一个带访问权限的 Uri你能对它进行读写同时又不触碰其他无关目录。保存文件时用ACTION_CREATE_DOCUMENTval intent Intent(Intent.ACTION_CREATE_DOCUMENT).apply { addCategory(Intent.CATEGORY_OPENABLE) type application/octet-stream putExtra(Intent.EXTRA_TITLE, backup.db) } startActivityForResult(intent, REQUEST_CREATE_FILE)在onActivityResult里拿到 Uri 后通过contentResolver.openOutputStream(uri)写入即可。整个过程中你不需要申请任何存储权限也不会再碰到 EACCES。选择目录时用ACTION_OPEN_DOCUMENT_TREE拿到目录 Uri 后如果想要长期使用加上val takeFlags Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION contentResolver.takePersistableUriPermission(uri, takeFlags)这个持久化权限很关键否则重启后 Uri 就失效了。3.3 分享给其他 AppFileProvider 配置的正确姿势回到 2.3 的分享场景正确做法分两步走。第一步在AndroidManifest.xml的application标签下配置 FileProviderprovider android:nameandroidx.core.content.FileProvider android:authorities${applicationId}.fileprovider android:exportedfalse android:grantUriPermissionstrue meta-data android:nameandroid.support.FILE_PROVIDER_PATHS android:resourcexml/file_paths / /provider第二步在res/xml/file_paths.xml里声明要暴露的路径paths xmlns:androidhttp://schemas.android.com/apk/res/android external-path nameexternal_files path. / external-files-path nameapp_external_files path. / cache-path nameapp_cache path. / /paths分享时记得加 FLAGUri uri FileProvider.getUriForFile(context, context.getPackageName() .fileprovider, file); Intent shareIntent new Intent(Intent.ACTION_SEND); shareIntent.putExtra(Intent.EXTRA_STREAM, uri); shareIntent.setType(*/*); shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);这样微信或其他应用拿到content://Uri 后在临时权限有效期内是可以正常读取的。如果对方读到一半报 EACCES多半是目标应用有自己额外的缓存或下载逻辑它会先复制再处理这时候需确认临时授权是否能覆盖它的后续行为。3.4 应急参数requestLegacyExternalStorage 到底该怎么用android:requestLegacyExternalStoragetrue是写给 targetSdkVersion 29 应用用的一个兼容开关。在 Android 10 上如果把它设为 true系统会尽量保持旧版文件访问行为降低适配压力。application android:requestLegacyExternalStoragetrue ...但用之前必须记住两个事实这个开关只对 Android 10 有效对 Android 11 完全无效。如果后续目标版本升到 30Google Play 要求的 targetSdk 是 30 之后这个属性也会被系统忽略。所以我的建议是仅作为临时应急使用绝不能长期依赖。碰到线上紧急闪退、一时间改不完存储逻辑时先把开关打开稳住用户但排期里还是要把存储逻辑改造提上日程否则 Android 11 设备上问题照旧。4. 问题排查与定位遇到 EACCES 后怎么一步步找原因很多时候报错来的不是一套完整代码而是一个单点异常日志。下面这套排查流程是我自己总结的能帮你快速确定是不是权限问题、以及属于哪一种权限问题。4.1 先用 adb 确认当前进程有没有拿到运行时权限遇到 EACCES先不要急着改代码用 adb 快速验证应用在前台时到底持有哪些权限adb shell dumpsys package com.your.package | grep -A 5 runtime permissions正常应该能看到类似android.permission.READ_EXTERNAL_STORAGE: grantedtrue的输出。如果显示grantedfalse说明运行时权限没拿到那和分区存储没有任何关系先回去检查requestPermissions的调用时机。这里有个经验如果targetSdkVersion已经升到 30 但权限弹窗流程没适配很多国产 ROM 上会出现权限申请失败但不报错的情况用户点了允许系统实际上拒绝了。这个可以在onRequestPermissionsResult里加日志仔细核实。4.2 判断是否命中分区存储限制如果运行时权限全部拿到了还是在某些路径上 EACCES那基本就是分区存储的锅。在工程里快速判断当前环境是否启用分区存储adb shell dumpsys package com.your.package | grep -E targetSdk|forceAllAppsResizability或者在代码里Suppress(DEPRECATION) fun isScopedStorageEnabled(context: Context): Boolean { return if (Build.VERSION.SDK_INT Build.VERSION_CODES.R) { Environment.isExternalStorageManager() || true // Android 11 上基本一直处于分区存储模式 } else { val appOps context.getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager // 实际操作中等价于看 targetSdkVersion 29 context.applicationInfo.targetSdkVersion Build.VERSION_CODES.Q } }不过说实话查到这一步单单“是不是分区存储”还不够你还得确认是哪条路径触发的。我常用的方法是在 FileNotFoundException 堆栈里找到具体FileDescriptor路径然后手工 adb shell ls 看一下adb shell ls -la /storage/emulated/0/MyApp/Backup如果系统返回Permission denied那说明当前进程真的没权限碰这条路径接下来就要配置 SAF 或 MediaStore 替代。4.3 常见 FileProvider 异常怎么区分FileProvider的报错形式和普通文件访问不一样一种是自己的进程读取时提示 EACCES另一种是其他应用读取时提示 EACCES。自己的进程读取报 EACCES通常是路径没在file_paths.xml里声明换成系统生成 Uri 后找不到对应的内容描述。排查时可以打印 FileProvider 生成的 Uri 看一眼FileProvider.getUriForFile(...)到底把路径映射成了什么名字。其他应用读取报 EACCES优先检查grantUriPermissions是否开启、分享 Intent 有没有加FLAG_GRANT_READ_URI_PERMISSION、接收方是不是在启动后长时间没有读取导致临时授权过期。遇到微信、QQ 的分享链接点开报 EACCES还有一个非常隐蔽的原因很多定制 ROM 会在后台把目标应用杀进程导致其重新启动后无法继承临时 URI 授权。这个没有稳定规避方法只能引导用户关闭相关省电策略。4.4 日志与监控建议不要只打Log.e看异常建议在 File 操作的外层统一捕获IOException并把失败类型、路径、系统版本、targetSdkVersion 全部记录下来埋点到自己的统计平台。我习惯在异常信息里额外拼三个字段String msg path path |sdk Build.VERSION.SDK_INT |target context.getApplicationInfo().targetSdkVersion |error e.getMessage();这样报错日志一进来就能快速判断是哪个适配层面出了问题而不用再去找用户要真机日志。实测这套字段帮我们解决了不少“看起来一模一样”但实际是不同原因导致的 EACCES。5. 踩坑经验速查表与最终自检清单下面的速查表是我把常见问题直接汇总成的你可以贴在团队文档里当作排查手册。场景与报错常规根因推荐解法open failed: EACCES路径为/sdcard/自定义目录分区存储限制不能直接写任意目录改用 MediaStore媒体或 SAF任意文件open failed: EACCES路径为/Android/data/包名/Android 11 对 Android/data 严格管控避免直接访问该路径用 FileProvider 或引导用户手动选择文件FileProvider 分享后对方打开闪退/报 EACCES未给接收方临时 URI 权限分享 Intent 添加FLAG_GRANT_READ_URI_PERMISSIONtargetSdk 升到 30 后部分文件访问全挂Android 11 已强制分区存储需完整改造存储逻辑不能依赖 requestLegacyExternalStorage申请了所有权限仍无法遍历目录缺少MANAGE_EXTERNAL_STORAGE跳转特殊权限设置页手动开启并核实应用类型是否符合审核要求文件在私有目录/data/data/包名/外无法读取应用沙箱隔离私有文件优先放在getExternalFilesDir()下再通过 FileProvider 对外分享5.1 新旧系统与 targetSdk 组合速查手机系统targetSdkVersion分区存储是否生效旧代码还能不能跑Android 1028 及以下不生效基本可以Android 1029默认生效可通过 requestLegacyExternalStorage 关闭部分需要适配Android 1129默认强制开关失效需要适配Android 1130强制生效必须适配这张表我每次做技术方案评审都会放出来目的就是让团队明白问题不是“手机版本太高”而是“targetSdk 升太高了”。很多人只看到用户手机是 Android 11却忽略了自己 targetSdkVersion 才是决定性变量。5.2 我自己总结的自检清单每次改完存储相关代码我都会过一遍下面这些问题防止改完一个坑又掉进另一个坑所有外部存储的写操作是不是都走 MediaStore 或 SAF 了分享文件的 Intent 有没有加 URI 授权 FLAGFileProvider 的file_paths.xml是否覆盖了待分享路径有没有在代码里偷偷拼接/sdcard/***路径测试时是否覆盖了 Android 10 和 Android 11 两套真机有没有在 Android 11 上验证requestLegacyExternalStorage的失效行为这套清单救了我好几次。有一次我到一个新项目做技术支持代码里 80% 的文件操作都改成了 MediaStore唯独一个导出功能没有改用户数据量一大就报 EACCES。后来就是靠这个清单一条条对在“有没有偷偷拼接 /sdcard 路径”这条上发现的。6. 最后记录一点实际操作的感受我在处理这些兼容问题的过程中最深的一个体会是Android 权限报错尤其是 EACCES很少有“玄学”原因。表面上看起来是同一个错误码实际底层原因可能是运行时权限、分区存储、FileProvider 授权、SELinux 或其他文件系统策略的排列组合。所以我的建议是拿到 EACCES 之后不要急着搜代码加权限先花两分钟确认三件事当前 targetSdkVersion 是多少、运行环境在哪个系统版本、代码里有没有绕开 MediaStore 和 SAF 去写裸路径。这三个问题一确认80% 的 EACCES 原因基本就定位了。另外想说即便你在测试机上适配得再完美也别忘了在模拟器、国产 ROM、旧版本 Android 上各过一遍。单就 EACCES 这个错误小米和华为的反馈逻辑都不太一样你可能会因为设备的定制策略不同而看到完全不同的堆栈。把这些差异提前记录下来后面做兼容测试会省心很多。这套方法论陪我熬过了好几个版本迭代也让我在走过一次完整的适配流程后对 Android 的权限模型有了更通透的理解。如果这篇文章能帮你少走点弯路哪怕只是省掉一个晚上排查日志的时间我就觉得值了。
返回列表