ARTICLE DETAIL

资讯详情

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

EACCES 权限拒绝排查:Android 10/11 分区存储适配完全指南

EACCES 权限拒绝排查:Android 10/11 分区存储适配完全指南 深夜十一点测试群里飞出来一张截图日志里躺着一行再熟悉不过的异常java.io.IOException: open failed: EACCES (Permission denied)我的第一反应是“运行时权限没申请吧”可翻了代码Manifest 里明明写着READ_EXTERNAL_STORAGE动态申请的逻辑也在。当时整个人是懵的——权限给了、清单也对为什么还是 Permission denied后来在 Android 10 和 Android 11 上陆续踩了各种版本的 EACCES才慢慢摸清这类问题的真正逻辑链。这篇文章写给我自己也写给被这个报错卡住的同行。我会从 Linux 文件权限的底层判断讲起再到 Android 分区存储Scoped Storage在 10、11 上的具体行为差异最后按业务场景分别给出可以“直接抄”的修复代码。适合正在做 targetSdk 29/30 适配、或者被线上用户反馈“保存图片失败 / 文件下载失败”折腾得焦头烂额的 Android 开发同学。1. 先搞明白 EACCES 到底是谁抛出来的1.1 Linux 层 open() 的权限判定远不止“文件读没读权限”很多人一看到 Permission denied 就只想到 Android 的运行时权限但EACCES这个错误码本质上是 Linux 内核从open()系统调用返回的。内核在放行一次文件打开之前会检查好几样东西文件路径上每一层目录是否有x执行/搜索权限目标文件本身是否允许当前用户执行对应的r或w操作进程的 uid/gid 是否落在文件属主或所属组的匹配范围内挂载点是否是只读模式SELinux 或 AppArmor 这类强制访问控制策略是否允许该域访问该文件。这里最容易忽略的是目录权限的x位。Linux 里目录的读权限只决定你能不能列出目录内容而真正决定你能不能“走进这个目录去定位目标文件”的是x权限。如果中间某一层目录缺了x即使文件本身的权限完全放开最终返回的照样是 EACCES。不过这些是纯 Linux 层面的判断。到了 Android 上情况会变得更复杂因为 Android 在用户态又加了几道检查很多 EACCES 根本走不到内核那一步。1.2 Android 在 open 之前加的两道“安检”第一道是 Android 的运行时权限Runtime Permission体系。App 在/storage/emulated/0这类共享存储里操作文件时系统会先检查你是否持有对应的READ_EXTERNAL_STORAGE、WRITE_EXTERNAL_STORAGE或 Android 13 之后的READ_MEDIA_*权限。没权限的时候你在 Java/Kotlin 层拿到的往往就是FileNotFoundException其 message 里带着EACCES (Permission denied)。很多人误以为这是“文件不存在”其实全程被 Permission denied 拦截了。第二道是分区存储策略由 StorageManagerService 配合 ExternalStorageProvider、FUSE daemon 一起实现。从 Android 10 开始系统在共享存储之上做了一个“应用可见范围”的抽象层不是系统不让你读写而是这个 FUSE 文件系统针对每个进程单独做了目录可见性和权限重映射。你在应用里看到的/storage/emulated/0/Pictures/xxx.jpg在 FUSE 层可能并不是直接映射到物理路径而是被 layer 了一层访问控制。分区存储开启后直接拿File去操作公共目录经常会在 FUSE 这一层就给你返回 EACCES。1.3 为什么 Android 10 / 11 成了重灾区Android 10API 29首次默认开启分区存储Android 11API 30又进一步收紧了 Legacy External Storage 的退路。之前几年大家习惯了Environment.getExternalStorageDirectory()拼出一个绝对路径然后直接FileOutputStream写文件这套玩法在 Android 9 及以前畅通无阻。可一旦 targetSdk 升到 29/30再这么写大概率就会在公共目录撞上 EACCES。还有一个让很多团队困惑的现象同一个 APK 在 Android 9 上正常在 Android 10/11 上报错。原因就在分区存储是“按 targetSdkVersion 决定启不启用”的。你把 targetSdk 升上去之后系统不再容忍你在公共目录里横冲直撞EACCES 只是它用一种比较隐晦的方式告诉你你的代码还在用旧时代的方式访问新时代的文件系统。2. 分区存储规则先对照这张表再动手2.1 Android 10 到 Android 13 的访问权限对照表这几年 API 变化太频繁我每次适配都要翻半天文档。后来干脆整理了自己常用的一张表项目里谁遇到存储问题直接对表查效率高很多。路径 / 目录Android 10API 29Android 11API 30Android 13API 33应用内部私有目录/data/data/pkg无需权限直接访问无需权限直接访问无需权限直接访问应用外部私有目录/storage/emulated/0/Android/data/pkg无需权限直接访问无需权限直接访问但其他应用无法访问无需权限直接访问但其他应用无法访问公共媒体目录 DCIM / Pictures / Movies分区存储开启时建议走 MediaStoretargetSdk 29 可通过requestLegacyExternalStorage暂时走 FiletargetSdk 30 强制走 MediaStore需READ_MEDIA_IMAGES/READ_MEDIA_VIDEO等新权限Download 等公共非媒体目录MediaStore.DownloadsAPI 29 新增MediaStore.DownloadsMediaStore.Downloads根目录或其他任意目录大多数情况需要 MANAGE_EXTERNAL_STORAGE 或 SAF仅 MANAGE_EXTERNAL_STORAGE 或 SAF仅 MANAGE_EXTERNAL_STORAGE 或 SAF其他应用的 Android/data 目录可通过 SA F/File 访问有限制常规手段无法访问常规手段无法访问2.2 requestLegacyExternalStorage 这个过渡开关的真相Android 10 刚推出分区存储的时候Google 留了一个后门在 manifest 里给application设置android:requestLegacyExternalStoragetrue应用可以暂时保留旧的文件访问模式。这也是很多老项目最初采用的“低成本的适配方案”。但它的限制很多我实际踩过两个坑这个开关只对 Android 10 有效。在 Android 11 上如果你的 targetSdk 是 30 或更高系统直接忽略该标记强制分区存储。即使 targetSdk 还是 29Android 11 上requestLegacyExternalStorage是否生效要看具体 ROM 实现。我碰到过部分国产 ROM 在系统层做了额外收紧manifest 里写了也没用。所以我的建议非常明确如果是新项目别碰这个开关如果是老项目准备升 targetSdk也不要把它当长期方案。它是一个“给你缓冲时间”的开关不是“让你永远不升级”的保险箱。2.3 WRITE_EXTERNAL_STORAGE 为什么越来越“没用”在 Android 11 分区存储完全生效后一个让很多人跌破眼镜的事实是WRITE_EXTERNAL_STORAGE这个权限在绝大多数场景下已经失去意义了。原因很简单——分区存储下应用对共享存储的写入是由系统托管的你并不需要对整个共享存储的“写权限”。写公共媒体目录用 MediaStore 插入一条记录系统会给你一个 content Uri你只需要往这个 Uri 里写数据写自己的专属目录不需要任何存储权限。那这个权限还能管什么基本只剩MANAGE_EXTERNAL_STORAGE相关逻辑里才会用到它。所以如果你还在用“动态申请 WRITE_EXTERNAL_STORAGE → 拼 File 路径 → 直接写文件”这套组合在 Android 11 上请立刻停下来。这条路已经被系统封死了。3. 不同业务场景下的正确写法从 MediaStore 到 SAF3.1 保存图片到相册不要再用 FileOutputStream 拼路径这是我遇到最多的问题场景。老代码长这样String path Environment.getExternalStorageDirectory() /Pictures/test_ System.currentTimeMillis() .jpg; File file new File(path); FileOutputStream fos new FileOutputStream(file);这套代码在 Android 10/11 分区存储开启后会非常稳定地在new FileOutputStream(file)处抛 EACCES。正确做法是用 MediaStore 的 Insert API让系统帮你分配 Uri再往 Uri 里写。fun saveImageToGallery(context: Context, bytes: ByteArray, displayName: String): Uri? { val values ContentValues().apply { put(MediaStore.Images.Media.DISPLAY_NAME, displayName) put(MediaStore.Images.Media.MIME_TYPE, image/jpeg) if (Build.VERSION.SDK_INT Build.VERSION_CODES.Q) { put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES) put(MediaStore.Images.Media.IS_PENDING, 1) } } val collection MediaStore.Images.Media.EXTERNAL_CONTENT_URI val insertUri context.contentResolver.insert(collection, values) ?: return null return try { context.contentResolver.openOutputStream(insertUri)?.use { out - out.write(bytes) out.flush() } if (Build.VERSION.SDK_INT Build.VERSION_CODES.Q) { values.clear() values.put(MediaStore.Images.Media.IS_PENDING, 0) context.contentResolver.update(insertUri, values, null, null) } insertUri } catch (e: Exception) { context.contentResolver.delete(insertUri, null, null) null } }这里有几个关键点RELATIVE_PATH是 Android 10 才有的字段可以指定相对路径比如Pictures、Movies、Download。IS_PENDING标记表示当前文件还没写完。系统会把这条记录藏起来等你把数据写完后通过 update 把标记置 0文件才会对其他应用可见。这个字段不是必须的但它能避免相册里出现“写到一半的坏文件”。写入用的openOutputStream拿到的是系统给你分配的管道整个过程都不需要申请WRITE_EXTERNAL_STORAGE。读取这张图片时也同样要走 content Uri比如contentResolver.openInputStream(uri)不要尝试把它转成 File 再 open否则一样会在分区存储的策略层吃闭门羹。3.2 App 自己的专属目录不用动态权限但别整错路径/data/data/pkg/files和/storage/emulated/0/Android/data/pkg/files这两个目录属于应用私有目录系统不会对你做任何额外权限拦截也不需要动态申请存储权限。问题在于很多项目历史代码喜欢用硬编码路径val dir File(/sdcard/Android/data/${packageName}/files/cache)这个写法在品牌 ROM 上非常容易翻车因为/sdcard这个软链接在部分 ROM 上指向的挂载点可能不同。正确做法是始终通过 Context 获取内部私有目录context.filesDir、context.cacheDir外部私有目录context.getExternalFilesDir(null)、context.getExternalCacheDir()拿到目录之后再拼子路径。Android 11 上外部私有目录依然可用但要注意它属于Android/data/pkgAndroid 11 之后别的应用用 SAF 也没法浏览这个目录而你自己访问不受影响。如果你的 App 有“老版本把文件存在公共目录新版本想迁移”的需求我的建议是在升级后启动时判断Android/data/pkg/files是否为空为空再尝试从公共目录读取并 copy 过来。注意读取旧公共目录时要用 MediaStore 查到的 content Uri而不是直接 File.listFiles()。3.3 “所有文件访问权限” MANAGE_EXTERNAL_STORAGE 别乱用有些业务确实需要访问公共根目录下散落的文件例如文件管理器、日志分析类应用。Android 11 上可以申请MANAGE_EXTERNAL_STORAGE权限也就是系统设置里的“所有文件访问权限”。声明方式是在 Manifest 里加上uses-permission android:nameandroid.permission.MANAGE_EXTERNAL_STORAGE tools:ignoreScopedStorage /然后引导用户到设置页打开开关if (Build.VERSION.SDK_INT Build.VERSION_CODES.R) { if (!Environment.isExternalStorageManager()) { val intent Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION) intent.data Uri.parse(package:$packageName) startActivity(intent) } }但这个权限不是“万能钥匙”。首先它不是运行时权限不能通过requestPermissions弹窗申请必须跳系统设置页这一步用户拒绝率非常高。其次Google Play 对这个权限的审核非常严格普通应用如果只是保存几张图片拿这个权限去申请基本会被拒。更要命的是就算你拿到了这个权限Android 11 上访问其他应用的Android/data子目录依然会被拦截这在系统设计上就不是给你看别人家后院的地方。所以我的经验是能不用就不用。优先把文件放在自己的filesDir或通过 MediaStore 管理文件是用户主动选择的就走下一节说的 SAF。3.4 让用户自己选目录或文件SAF 才是合规且不易踩坑的方案Storage Access FrameworkSAF是一种让用户通过系统文件选择器授权你访问特定目录或文件的机制。好处是不需要申请任何存储权限用户授权多少你就能用多少而且授权可以持久化。最常用的场景是“导出文件到用户选择的目录”val openTree registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri - uri ?: returnregisterForActivityResult contentResolver.takePersistableUriPermission( uri, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION ) }拿到目录 Uri 之后在当前授权周期内直接用DocumentFile.fromTreeUri(context, uri)创建子文件然后通过contentResolver.openOutputStream(child.uri)写入。takePersistableUriPermission这行很关键。如果不调用它用户杀进程之后授权就丢了下次再访问同一个 Uri 时你会发现明明之前还能写现在却变成 EACCES。如果只是让用户选择单个文件例如“打开 PDF”用ActivityResultContracts.OpenDocument()也是一样的记得在返回结果里带上读权限。3.5 跨应用打开 content:// URI 时权限怎么传递还有一个高频 EACCES 场景不是发生在自己 App 内部而是 App 之间传文件。场景典型如下你的 App 通过 FileProvider 把一个文件分享给微信或 QQ对方却在content://Uri 上读取失败报 EACCES。根因通常是你在启动目标 Intent 时没有传递 URI 读授权。正确写法val uri FileProvider.getUriForFile(context, $packageName.fileprovider, file) val intent Intent(Intent.ACTION_SEND).apply { type image/* putExtra(Intent.EXTRA_STREAM, uri) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) }FLAG_GRANT_READ_URI_PERMISSION是把临时读授权授予给目标 App 的关键。同理如果是自己写代码启动ActivityResultContracts.GetContent()系统返回的 Uri 已经带上了临时授权但如果你在这个 Activity 不可见之后还去异步线程读这个 Uri临时授权有可能已经失效。所以要么在回调里马上把数据读完要么用takePersistableUriPermission持久化。4. 一次 open failed 实际排错全过程4.1 现场targetSdk 29 升到 Android 11 手机上报错先说下背景。项目原来 targetSdk 28因为应用市场要求升级团队把 targetSdk 提到了 29于是requestLegacyExternalStorage也没配本来以为小版本升级影响不大。上线之后陆续有用户在 Android 11 手机上反馈“图片保存失败”日志打回来就是java.io.IOException: open failed: EACCES (Permission denied) at java.io.File.createNewFile(File.java:958)奇怪的是Android 10 手机和 Android 9 手机一切正常。这就让人很头大同一个 APK为什么 Android 11 上挂、Android 10 没事4.2 从权限到路径的逐层排查我把排查过程复现一遍这里每一步都很关键第一步检查运行时权限。用adb shell pm check-permission android.permission.WRITE_EXTERNAL_STORAGE pkg查了一下权限是 granted。排除了权限没申请的情况。第二步看代码路径。业务代码保存图片用的是Environment.getExternalStorageDirectory() /test然后File.createNewFile()。问题来了/storage/emulated/0/test属于共享存储根目录不是应用专属目录也不是系统能通过 MediaStore 管理的媒体目录。这种路径在 Android 11 分区存储下系统根本不承认这个目录里存在“允许你创建的文件”。第三步用 strace 看系统调用。在 root 过的测试机上跑了一下能看到 openat 返回 EACCES 之前FUSE daemon 已经拒绝了访问。这说明问题出在 Android 的存储策略层不是 Linux 权限位。第四步确认 targetSdk 影响。同事把 targetSdk 临时降到 28 测试同一台手机竟然不报错了。这让我彻底确定系统在targetSdk 29后把分区存储策略拉起来了而老代码没做任何适配。第五步修复。最终没有用requestLegacyExternalStorage因为后续还得升 targetSdk 30/31早晚要还的债。我直接把保存逻辑改成了 MediaStore.Downloads 插入方案因为保存的是图片也考虑过 MediaStore.Images但业务希望文件出现在 Download 里方便用户手动找所以就用了 Downloads。val values ContentValues().apply { put(MediaStore.Downloads.DISPLAY_NAME, ${System.currentTimeMillis()}.jpg) put(MediaStore.Downloads.MIME_TYPE, image/jpeg) if (Build.VERSION.SDK_INT Build.VERSION_CODES.Q) { put(MediaStore.Downloads.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS) } } val uri contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values) contentResolver.openOutputStream(uri!!).use { it.write(bytes) }改完之后同一台 Android 11 手机老用户不再报 EACCES新文件可以在系统下载列表和相册里正常看到。4.3 为什么我不建议用“加一个 WRITE 权限”来糊弄网上很多老代码的修复方案是“动态申请WRITE_EXTERNAL_STORAGE就行了”我在 Android 11 上实验过这个权限在分区存储下并不会让你获得任意目录的写权限。你依然只能在 MediaStore 分配的 content Uri 里写数据或者在自己专属目录里写。所以如果有人告诉你“只要申请权限就好”那说明他还没有真正踩过 Android 11 的坑。4.4 用 adb 快速验证修复效果修复完不要直接看用户反馈先在本地模拟一遍# 安装新包授予权限如果有的话 adb install -r app-release.apk adb shell pm grant com.example.app android.permission.READ_EXTERNAL_STORAGE # 清掉旧数据模拟新用户 adb shell pm clear com.example.app # 启动应用触发保存 adb shell am start -n com.example.app/.MainActivity # 验证 Download 目录下是否生成了文件 adb shell ls -l /sdcard/Download/如果能在/sdcard/Download/下看到新文件说明 MediaStore 插入和写入链路是通的。如果这里能成功但实际业务还有 EACCES那就得回头检查是不是有另一条老代码链路没适配干净比如缩略图生成、附件上传这些地方可能各自都有一份 File 操作。5. 排错时容易漏掉的关键细节5.1 明明申请了权限为什么 checkSelfPermission 通过了还是报错分两种情况。第一种是权限确实通过了但操作的不是公共媒体目录而是根目录下自定义文件夹。这种情况分区存储策略不认你的权限常见于老 SDK 下载 SDK 写在根目录的/Download之外的目录。修复方式是改用 MediaStore 或者 SAF。第二种是用户之前拒绝过权限并且勾选了“不再询问”。此时checkSelfPermission返回 false你再次requestPermissions不会有任何弹窗回调直接返回 denied。很多 App 在这里没有做引导权限一直是拒绝状态随后业务代码继续执行 File 操作返回 Permission denied。建议在回调 denied 且shouldShowRequestPermissionRationale为 false 时跳转应用详情设置页val intent Intent( Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse(package:$packageName) ) startActivity(intent)5.2 FileProvider 配置错误导致跨应用读取报 EACCES常见坑有两个。一个是在file_paths.xml里把路径配置错了。比如文件实际放在context.filesDir/pdfs/xxx.pdf但你配置的是external-path path. nameexternal /external-path对应的是/storage/emulated/0不是内部filesDir。用 FileProvider 生成 Uri 时系统是拿真实路径去匹配的匹配不上就会在运行时抛IllegalArgumentException: Failed to find configured root。如果这种异常被上层吞掉接收方最终看到的可能就是打开失败。另一个坑是getUriForFile生成的 Uri 本身没问题但你在传给第三方 App 时忘记加intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)。第三方拿到的 content Uri 没有临时授权一读就是 EACCES。这是我见过最多的两种“FileProvider 引起的权限拒绝”而且它们的报错都不在你自己 App 的 Logcat 里而是在对方 App 的日志里定位成本很高。5.3 content Uri 和 File 混用造成的诡异 EACCES还有一类问题很隐蔽App 里部分代码已经迁移到了 MediaStore但还有另一部分老代码在用File。举个例子用 MediaStore 插入一条图片后很多旧代码会继续读MediaStore.Images.Media.DATA这一列拿到一个看似正常的绝对路径然后new File(path)去读。问题来了Android 10 分区存储下这条内容是你自己插入的但通过DATA列得到的那个 File 路径能不能直接用 File API 访问答案在 Android 11 上基本是不能的。即使在 Android 10 上侥幸可以也不代表你该这么写。正确做法是保存/返回时始终持有uri读取时只认 content UricontentResolver.openInputStream(uri)?.use { input - // 读取数据 }判断文件是否存在也不能直接File(path).exists()而是用contentResolver.getType(uri) ! null或者对 Uri 做openFileDescriptor探测。5.4 建议在每个 IO 失败点打印完整路径和具体异常最后分享一个我现在的习惯。只要代码里涉及文件读写关键节点都要打印带路径的日志try { file.outputStream().use { ... } } catch (e: IOException) { Log.e(TAG, write failed path${file.absolutePath} err${e.message}) }很多 EACCES 在没用统一封装的项目里会被上层当成网络错误或者“文件不存在”合并掉。等真正拿到日志时路径信息早就没了排查起来等于瞎猜。把路径和异常 message 打出来之后你能立刻判断是路由到了公共目录还是私有目录是在 MediaStore Uri 流程里还是 File 流程里这比对着报错代码一行行猜快得多。Android 10 和 Android 11 在存储权限上的变化本质上是用系统的“强制规范”倒逼开发者告别裸 File 操作。早期觉得恼火但适配完再看MediaStore、SAF、FileProvider 这套组合其实比当年到处拼路径靠谱得多。下次再看到 EACCES建议先把“是不是分区存储策略拦我”这个问题排在权限前面很多时候答案就藏在这里。
返回列表