尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Android外部存储挂载正常但mkdirs失败:原因与排查全指南

Android外部存储挂载正常但mkdirs失败:原因与排查全指南 先说结论在 Android 开发里Environment.getExternalStorageState()返回MEDIA_MOUNTED只代表系统认为外部存储设备已经挂载好、具备读写条件它不保证你接下来对某个具体路径的mkdirs()就一定能成功。这个“状态正常但操作失败”的窗口期我见过太多人踩过坑尤其是做文件下载、相册备份、日志导出这类功能时线上反馈“无法保存”的工单一上来第一眼判断是MEDIA_MOUNTED再一查mkdirs()返回 false人就直接懵了。我用几年的时间在多个项目里反复遇到这个问题也帮团队排查过不少类似 Case。这篇文章就好好掰扯一下为什么挂载状态对了目录还是创建失败这里面有哪些容易忽略的原因以及遇到这种情况该怎么一步步排查。内容适合 Android 应用开发、SDK 开发、以及所有需要在外部存储上创建文件的技术同学尤其建议被这类问题困扰过的人仔细看一遍。1. 先搞清楚MOUNTED 和 mkdirs 各自做了什么1.1 MOUNTED 只是“存储卡状态正常”的信号Android 里说的外部存储是一套由 vold 守护进程管理的存储体系。无论是内置的 emulated 存储还是插入的物理 SD 卡系统都会在启动、插拔、格式化等时机执行挂载操作。挂载完成后Environment.getExternalStorageState()会返回一个字符串状态其中MEDIA_MOUNTED表示“设备已挂载且可以对文件进行读写”。值得强调的是这个状态是“全局存储状态”不是“某个目录的专属授权”。它反映的是块设备层面的挂载结果比如分区是否成功挂到某个挂载点、文件系统是否可读写。至于你这个 App 对某个路径有没有权限、路径本身合不合法、目录层级在文件系统层面能不能创建MEDIA_MOUNTED完全管不着。我看过很多同事写的代码几乎都是一个固定模板if (Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState())) { File dir new File(Environment.getExternalStorageDirectory(), myApp/cache); boolean result dir.mkdirs(); if (!result) { // 走到这里就开始一头雾水 } }问题就出在这个假设上认为MEDIA_MOUNTED等于“我可以在任何位置创建目录”。实际上从系统返回挂载成功到你真正执行文件操作之间隔着好几层检查任何一层出问题操作都会失败。1.2 mkdirs 的失败并不是只有“磁盘只读”一种原因File.mkdirs()是 Java 层提供的文件操作 API它的语义是“创建此抽象路径名指定的目录包括所有必需但不存在的父目录”。注意这里有个细节它不只创建目标目录还会递归补全父目录。这个方法返回 booleantrue 表示创建成功或者目录已经存在false 表示创建失败而且它不像FileOutputStream那样会抛 IOException失败原因非常不透明。很多人以为 mkdirs 返回 false 就是“磁盘只读了”其实原因可以非常多样目标路径的某个父级组件是一个已存在的文件而不是目录。路径中包含文件系统不允许的字符或者路径长度超出限制。应用没有对应路径的写权限。文件系统层面报错比如 inode 耗尽、只读错误、FUSE 守护进程异常。路径处于某个特殊挂载点下而该挂载点对当前进程不可写。还有一个非常隐蔽的问题mkdirs()在某些情况下会“部分成功又整体失败”。比如它已经创建了a/b但在创建a/b/c时失败这个时候已经创建出来的目录不会被回滚。你下一次再调mkdirs()如果因为某种原因它认为父路径已经存在就只尝试创建最后一级结果可能又不一样。这就导致同样的代码第一次失败第二次竟然成功了排查时非常迷惑。1.3 “状态 操作”之间还有权限和策略的夹层再往深一层说MEDIA_MOUNTED和mkdirs()之间还存在一个看不见的夹层应用沙箱、权限策略、SELinux、存储重定向。Android 从 6.0 开始有了运行时权限从 10 开始有了分区存储从 11 又进一步加强了包可见性和存储限制。这些策略的介入让“存储状态可写”和“特定应用可写”完全变成两回事。打个比方一个仓库大门的“营业中”灯亮着不代表你进入仓库后想在哪面墙上钉钉子都可以仓库管理员、货架位置、消防通道都是约束。MEDIA_MOUNTED就是那盏灯mkdirs()就是在墙上钉钉子灯亮着但钉子钉不进去太常见了。2. 导致目录创建失败的几类典型原因2.1 路径本身就不对File 对象指向的不是合法目录我排查过很多“mkdirs 失败”的问题第一类原因其实是最蠢的路径写错了或者路径里混入了不该有的符号。常见的路径问题有这么几种目标路径的父级是文件比如你已经有一个文件/sdcard/test然后又想创建/sdcard/test/subdir。系统无法在“文件”下面再创建目录mkdirs()直接返回 false。这种情况最容易发生在下载文件名和目录名冲突的场景。路径末尾带了空格或特殊字符FAT32 和 exFAT 的限制会比 ext4 严格某些字符比如*、?、、、|在 Windows 风格的文件系统里是非法字符在 Android 的 FUSE 层经过透传后也可能会创建失败。路径长度超限虽然 Android 的底层 Linux 支持很长的路径但单层目录名通常被限制在 255 字节整个路径在部分文件系统上也不能超过 4096 字节。一些 App 喜欢把时间戳、会话ID、用户ID拼到目录名里拼着拼着就超了。根路径用错有些代码写死/sdcard/xxx在某些定制 ROM 上/sdcard指向的可能是/data/media/0但实际的挂载路径是/storage/emulated/0。直接写死路径很容易在厂商定制机上踩到挂载点不一致的问题。这些路径问题MEDIA_MOUNTED是看不出来的。状态只是告诉你存储设备可用不会告诉你目标路径的层级结构是否合法。2.2 权限问题静态权限和运行时权限都没到位权限问题是“挂载正常但创建失败”最常见的原因而且分为好几个层次。如果你做的是 Android 6.0 以下的老项目只要在 Manifest 里声明了WRITE_EXTERNAL_STORAGE就行。但现在的应用基本都要适配到 API 23 以上这时候WRITE_EXTERNAL_STORAGE属于危险权限必须在运行时动态申请。要是只在 Manifest 里声明而没有在代码里请求权限那么在 Android 6.0 设备上写外部存储的路径时mkdirs()就会静默失败。更麻烦的情况是“权限申请了但实际被拒绝”。现在很多手机系统尤其是国产 ROM对权限管理做了深度定制。用户可能在系统设置里关闭了“存储权限”或者选择了“仅在使用中允许”又或者 App 退到后台后权限被系统自动回收。这些情况下checkSelfPermission返回的可能是 granted但真正执行写操作时依然会被底层拒绝因为系统在 FUSE 层做了更细的管控。还有一种情况是“权限被授予但是授予给了错的东西”。比如申请的是READ_EXTERNAL_STORAGE写目录时却需要WRITE_EXTERNAL_STORAGE又比如因为 targetSdkVersion 太高系统已经默认开启分区存储直接写公共目录本来就不该用WRITE_EXTERNAL_STORAGE而是应该走 MediaStore。在这个前提下即便你有权限用File直接创建公共目录也可能被限制。2.3 存储空间或文件系统问题容量、inode、只读挂载设备本身也可能有问题这类问题属于“环境故障”不是代码逻辑问题但会让你误以为是代码问题。存储空间不足当分区剩余空间为 0或者可用 inode 数量为 0 时创建目录会失败。inode 是什么简单理解就是文件系统用来记录文件和目录元数据的“索引卡”目录本身也要占用一个 inode。如果一个分区里文件数量极多哪怕剩余空间看着还有几百 MB也可能因为 inode 耗尽而无法创建新的目录。这种情况在一些低端机上特别常见App 频繁写小文件最终把 inode 消耗殆尽。文件系统只读虽然系统状态显示为MEDIA_MOUNTED但底层挂载参数可能是roread-only。有时候是 vold 挂载时检测到文件系统异常自动降级为只读有时候是物理 SD 卡存在坏块内核重新挂载成了只读。这种状态下mkdirs()必然返回 false。文件系统损坏常见于物理 SD 卡。FAT32/exFAT 的分区表、文件分配表出现问题后VFS 层可能仍然能完成挂载读操作正常但一写就报 I/O 错误。这种情况下你在 Java 层拿到的只有 false不会收到明确的异常信息。加密存储未解锁一些设备支持文件级加密FBEFile-Based Encryption。开机后如果用户还没解锁屏幕某些目录比如/data/media的某些子目录在锁屏状态下是不可读写的此时即使外部存储状态是 MOUNTED你直接创建目录也很有可能会失败。2.4 分区存储Scoped Storage带来的新约束Android 10 开始系统强制开启分区存储Android 11 进一步收紧。分区存储的核心思想是App 不能随意在公共目录比如/storage/emulated/0/Pictures里直接用 File 路径创建文件必须通过 MediaStore API 插入媒体文件或者只能在自己的应用专属目录getExternalFilesDir里自由读写。这里有一个非常经典的坑你的 targetSdkVersion 是 29 或 30代码里还在用Environment.getExternalStorageDirectory() /MyApp的方式创建目录。在 Android 10 以上设备上你可能确实能看到目录创建成功因为系统做了兼容性适配应用专属目录会被隐式映射但在某些厂商定制 ROM 上这种行为就是直接失败。更隐蔽的是很多 SDK 会在自己的 Native 层调用mkdir系统调用而不是 Java 层mkdirs()。分区存储的策略在 Java 层有一些兼容逻辑但在 Native 层直接调用可能直接收到EACCES权限拒绝或者EPERM操作不允许最终把 false 层层返回到你的回调里。所以我一直建议如果你的业务目标目录是公共目录在 Android 10 上就老老实实用 MediaStore如果你的业务目标是应用专属目录用context.getExternalFilesDir()获取路径不要在路径里硬编码Android/data/包名。这个目录在系统升级、应用卸载重装、清除数据后都可能变化硬编码同样是 mkdirs 失败的隐患。2.5 其他隐蔽的原因FUSE 延迟、CDM 合法性、路径过长除了上面几类还有一些不那么容易想到的原因属于“高级玩家才会踩到”的坑。FUSE 延迟导致的状态不一致。Android 的 emulated 存储其实是通过 FUSEFilesystem in Userspace实现的。vold 挂载好之后FUSE daemon 才接管请求。如果你的 App 在开机后很短的时间内就发起目录创建请求FUSE 可能还没有完全就绪导致mkdir请求返回ENOTCONN或者EAGAIN。我在某款车机设备上遇到过这种情况冷启动后立刻执行文件初始化大概率失败等两三秒再执行就正常了。CDMCurrent Device Mount映射问题。系统里存储状态实际上是通过 StorageManager 维护的每个 Volume 都有自己的状态。多用户场景下不同用户看到的/storage/emulated其实指向不同的目录但getExternalStorageState()不会区分当前是哪个用户。如果你的 App 在“访客模式”或“多开空间”里运行会发现自己拿到的路径和实际权限不匹配mkdirs 也可能莫名其妙失败。路径过长。之前提过但还是要单独拿出来说。我遇到过日志模块拼接路径把整个会话信息全部塞进文件名最终总长度超过 255 字节底层 ext4 拒绝创建。在 Java 层报的是 false日志里什么都看不出来。OEM 定制 ROM 的 bug。某些国产 ROM 对 FUSE 做了魔改或者对 MediaProvider 有额外的权限控制。同样一段代码在原生系统上能创建成功在定制 ROM 上就失败。有一个典型的案例是某品牌手机在低电量模式下禁用文件写入导致所有目录创建都失败直到你打开“允许后台写入”开关才恢复。3. 实战如何一步步排查和解决3.1 排查流程从环境状态到具体操作遇到mkdirs()失败我的习惯是不要急着改代码先按下面的顺序做一轮“现场取证”。第一步确认当前用户和权限状态在 App 代码里打日志把用户 ID、包名、targetSdkVersion、运行时权限结果全部打出来Log.e(StorageCheck, uid Process.myUid()); Log.e(StorageCheck, targetSdk getApplicationInfo().targetSdkVersion); Log.e(StorageCheck, read checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE)); Log.e(StorageCheck, write checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE));如果是在 Android 10 且 targetSdk 29那么WRITE_EXTERNAL_STORAGE可能已经没用了别只看权限返回值。第二步检查存储状态的分层信息不要只看Environment.getExternalStorageState()还应该用StorageManager获取更详细的卷信息StorageManager sm getSystemService(StorageManager.class); StorageVolume volume sm.getStorageVolume(new File(path)); Log.e(StorageCheck, state volume.getState());volume.getState()可能返回mounted、mounted_ro、unmounted等状态。如果这里返回mounted_ro那不管外层状态是什么mkdirs()都注定失败。第三步检查目标路径的父级结构用 shell 命令确认一下路径的实际类型adb shell ls -ld /storage/emulated/0/MyApp如果ls显示这是一个文件而不是目录那么mkdirs()失败的原因不言自明。还可以用stat命令查看权限位和 SELinux 标签adb shell stat /storage/emulated/0SELinux 标签如果是u:object_r:media_rw_file:s0说明是存储相关上下文一般可写如果标签异常可能就是 SELinux 拦截了写入。第四步用 adb 直接复现写操作在命令行里直接尝试向目标路径写入一个测试文件或目录adb shell mkdir /storage/emulated/0/TestDir adb shell touch /storage/emulated/0/TestFile如果命令行里也失败说明问题出在系统层或文件系统层不是 App 层如果命令行成功但 App 里失败那就是权限或策略问题重点检查targetSdkVersion和分区存储适配情况。第五步抓取底层日志logcat里可能没有直接报错的细节但dmesg或logcat -b system里经常有 vold、fuse、keystore 的错误信息。比如当你看到类似E vold: Failed to create dir /storage/emulated/0/xxx: Read-only file system那就说明是只读挂载不是代码问题。看到E FuseDaemon: Permission denied说明是权限被底层拒绝。日志虽然难啃但很多问题的真正原因都在里面。3.2 一个更稳的目录创建工具类排查再多最终还是要落到代码层面。下面我给一个兼容性更好、日志更完整的目录创建工具核心思路是先做多级检查再执行创建失败后自动展开定位是哪一层出问题。public static boolean ensureExternalDir(Context context, File dir) { if (dir null) { Log.e(StorageUtil, dir is null); return false; } // 1. 检查外部存储状态 String state Environment.getExternalStorageState(); if (!Environment.MEDIA_MOUNTED.equals(state)) { Log.e(StorageUtil, external storage not mounted, state state); return false; } // 2. 检查运行时权限仅针对需要权限的场景 if (Build.VERSION.SDK_INT Build.VERSION_CODES.M) { if (context.checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) ! PackageManager.PERMISSION_GRANTED Build.VERSION.SDK_INT Build.VERSION_CODES.P) { Log.e(StorageUtil, no write permission); return false; } } // 3. 目录已存在且是目录直接成功 if (dir.exists()) { if (dir.isDirectory()) { return true; } Log.e(StorageUtil, target exists but is a file: dir.getAbsolutePath()); return false; } // 4. 检查父目录是否存在如果父目录是文件则无法创建 File parent dir.getParentFile(); if (parent ! null parent.exists() !parent.isDirectory()) { Log.e(StorageUtil, parent exists but is not dir: parent.getAbsolutePath()); return false; } // 5. 尝试创建目录 boolean result dir.mkdirs(); Log.e(StorageUtil, mkdirs result result path dir.getAbsolutePath()); if (!result) { // 尝试创建失败后检测到底哪一级失败了 File cur dir; while (cur ! null !cur.exists()) { cur cur.getParentFile(); } Log.e(StorageUtil, first existing ancestor: (cur null ? null : cur.getAbsolutePath())); } return result; }这个工具类好在哪第一它把“存储状态”“权限”“父级冲突”“层级检查”全部分开失败时能快速定位。第二它在失败后会回溯找出第一个存在的祖先目录这个信息在定位问题时非常有用。比如你发现第一个存在的祖先目录是/storage/emulated/0/Pictures而你希望创建的目录是/storage/emulated/0/Pictures/foo/bar现在foo创建失败那大概率是权限或策略因素而不是路径写错。多说一句不要在业务代码里到处直接调mkdirs()就把结果当最终结论。加个 try-catch 不现实因为这个方法不抛受检异常但你可以把失败信息记到日志系统里。线上问题排查时一句“mkdirs failed at pathxxx with firstExistingAncestoryyy”比什么都管用。3.3 案例复盘某相册 App 无法保存图片说一个真实项目里的案例更有代入感。有一款相册类 App用户反馈在 Android 11 手机上“保存图片到相册”一直失败。我们第一轮排查时代码里确实是先判断了MEDIA_MOUNTED然后mkdirs()准备创建/storage/emulated/0/Pictures/MyApp/然后通过 FileOutputStream 写文件。线上日志显示mkdirs()返回 true但 write 阶段抛了FileNotFoundException (Permission denied)。这说明问题不在创建目录而在写文件。但也有很多用户反馈mkdirs()直接返回 false这就说明了两种情况的原因不同。第一种情况mkdirs()返回 true 但写文件失败是因为 targetSdkVersion 是 29系统默认开启了分区存储公共目录用 File 直接写会被 MediaProvider 拦截虽然目录能建出来但往里面写文件没有权限。第二种情况mkdirs()返回 false是因为部分厂商 ROM 对Pictures这个公共目录做了额外限制App 在分区存储模式下尝试直接创建子目录被拒绝。最终我们的解决方案是Android 10 以上统一改用 MediaStore 的INSERT接口保存图片不再自己创建目录。对于非媒体类型的文件比如导出日志、备份包改用getExternalFilesDir()目录彻底绕开公共目录权限问题。这个案例说明一个道理同一个问题表象可能是两种完全不同的原因。不搞清楚底层机制只盯着mkdirs()的返回值很难真正解决问题。4. 常见问题速查表与避坑建议4.1 快速对照表我把实际开发中最容易遇到的情况整理成一张表方便你以后直接对照。现象可能原因排查方向MEDIA_MOUNTED后mkdirs()返回 false权限未授予Android 6.0检查运行时权限动态申请MEDIA_MOUNTED后创建公共目录失败分区存储限制Android 10改用 MediaStore 或getExternalFilesDir物理 SD 卡上创建目录失败SD 卡损坏、只读挂载、加密锁用StorageVolume.getState()查状态目录名看起来正常但仍失败路径中隐藏空格、非法字符、长度超限打印路径字节数逐字符检查有时候成功有时候失败FUSE 未就绪、空间不足、inode 耗尽加重试、检查StatFs可用块数重启后第一次运行失败FBE 加密目录未解锁等用户解锁后再执行存储相关操作某款国产机型上必现失败OEM ROM 定制存储策略抓取logcat和dmesg针对性适配4.2 几个我踩过的坑和心得第一个心得不要相信“第一次mkdirs()失败后可以马上再试一次”。我见过一些代码在mkdirs()返回 false 后不处理失败逻辑而是直接继续执行写文件结果自然也是失败。更糟的是有人会在失败后硬再试一次但这种做法偶尔会“成功”原因在于第一次调用虽然最终返回 false但过程中已经把父目录建好了。这种不确定行为很容易掩盖真正的问题正确的做法是失败后立即展开诊断。第二个心得在日志里一定要记录路径而且是绝对路径。我排查过太多线上问题日志只写了“mkdir failed”完全看不到是在哪个路径上失败。加上绝对路径后至少能判断是公共目录还是应用专属目录是不是写死了/sdcard这种路径。如果路径本身就有问题日志一眼就能看出来。第三个心得测试机不要只用模拟器或者某一种品牌的手机。存储相关的问题特别容易在不同 ROM 上出现差异模拟器基本测不出问题。尽量准备至少一台原生 Android 系统的设备比如 Pixel和一台国产主流机型两组测试结果对比着看很多问题立刻就清楚了。第四个心得Android 10 以上的公共目录操作能用 MediaStore 就别用 File。我知道很多老项目迁移成本高但这是大势所趋。如果你暂时不能完全迁移也建议至少对mkdirs()失败的情况做降级处理比如提示用户“存储权限不可用”或者跳转系统设置里的应用存储权限页面。把失败解释清楚比用户收到一个“保存失败”然后什么也做不了要强得多。4.3 最后再分享一个调试小技巧在mkdirs()返回 false 的时候除了看日志我还特别喜欢在命令行走一遍底层逻辑。你可以用adb shell am start -a android.intent.action.VIEW -d file:///storage/emulated/0/打开系统的文件管理器肉眼确认一下目标路径的实际情况。这个方法听起来原始但在很多诡异问题上真的能救急。还有一点针对某些很隐蔽的只读问题可以试试adb shell dumpsys mount | grep -A 5 emulated这条命令能看到 emulated 卷的实际挂载状态。如果里面出现了read-only的关键词那就别再折腾代码了问题在系统层和数据层让用户检查存储设备更实际。写在最后关于 MOUNTED 和 mkdirs 的关系我的理解始终是状态判断只是第一步不是免死金牌。你在写任何存储逻辑的时候心里都要有一条线——MEDIA_MOUNTED是“系统认为存储可用”mkdirs()才是“我的应用真正获得了路径创建能力”。这两者之间隔着权限、策略、文件系统状态、设备差异每一层都可能中断操作。所以不要只问“为什么挂载了还会失败”而是要问“挂载状态正常之后我的路径、权限、时机、设备环境是不是都没问题”。把这些都排查清楚存储相关的问题基本都能解决。
返回列表