Android文件系统安全与权限控制:从Scoped Storage到FileProvider实战
上周有个同事找我帮看一个现场问题同一份App在Android 11平板上往公共下载目录写文件有些机型能写成有些直接抛FileNotFoundException。代码放到Android 8的老机器上跑得好好的怎么换到高版本就崩了我第一反应不是翻业务代码而是先过了一遍Android文件系统和权限控制的那套家底——这是几乎所有Android开发同行到了一定阶段都会撞上的墙。这个项目标题其实把核心说透了Android文件系统安全与权限控制本质上就是给应用数据上锁。你平时写的getExternalFilesDir()、MediaStore、content://这些路径背后的锁不止一把而是从Linux内核到Framework再到应用层层层叠叠好几把。这篇文章就按我实际排查和改造的经验把这套机制从头到尾捋一遍适合正在做targetSdk适配、分区存储迁移、以及被FileNotFoundException折磨的开发者参考。1. 为什么Android文件系统这么难管1.1 权限不是“你要我就给”而是“隔离为先”很多新手对Android权限的第一印象是“在Manifest里声明一下或者运行时弹个窗申请一下”。但文件系统这块Android的底层逻辑从来不是“你要我就给”而是“先隔离再按需开口子”。Linux系统本身就有完整的文件权限体系rwx、owner、group核心是“用户隔离”。Android在这个基础上把每个应用当成一个独立的Linux用户来对待每个应用安装后分配一个唯一的UID。你在/data/data/包名下看到的那些文件owner就是那个UID。应用A没有权限去读应用B的私有目录这不是靠上层代码约束的而是内核层面直接拒绝了这是整个Android文件系统安全的第一把锁。但只有UID隔离还不够用户还要看照片、传文档、存下载文件。于是Android在原生Linux权限之上做了第二层抽象应用沙箱与存储分区。应用有自己私有的内部存储也有通过权限和系统服务才能访问的共享存储。你写的每一个文件系统都要先问一句这个文件归谁管、放在哪个域、对方是谁、能不能跨域访问。这套机制越改越严从早期的WRITE_EXTERNAL_STORAGE一刀切到Android 10的Scoped Storage分区存储再到Android 13的READ_MEDIA_IMAGES细分权限本质都是把“开口子”这件事做得更精细。1.2 从Linux VFS到 /data 分区一张图看懂文件是怎么落地的这里得补一点底层知识。Android虽然是个移动操作系统但它的存储核心依然是Linux内核的那套VFSVirtual File System也就是虚拟文件系统层。你在应用里调FileOutputStream.write()数据不是直接怼到闪存芯片上而是先经过VFS再落到具体的文件系统实现比如早期的ext4、后来手机厂商爱用的F2FS最后才到块设备层。VFS最大的作用是把“文件系统实现”和“应用程序调用”解耦。应用写文件用的是/data/user/0/包名这样的逻辑路径内核通过挂载表把路径翻译成某个分区里的实际inode。这也是为什么你可以把/data分区格式化成ext4也可以格式化成f2fs应用层代码完全不用改。但这里有一个开发容易忽略的点你写文件后调用close()不代表数据已经真正落盘。VFS层有页缓存数据可能还在内存里系统崩溃或者意外断电就可能丢。这就是fsync存在的意义。Android的SQLite默认就把journal_mode设为WAL并定期做checkpoint底层就是依赖这种文件系统机制。所以你在做关键数据保存时哪怕不直接调fsync也要用FileOutputStream.getFD().sync()或者干脆用Room这类自带安全机制的框架别图省事只flush()。还有一点Android在开机过程中会依次挂载各种分区/system、/vendor、/data等各有各的mount flags。/data分区通常以nosuid、nodev等参数挂载防止普通应用创建特殊文件绕过权限。这种细节看起来跟应用开发无关但一旦你要做Root检测、系统分区写保护之类的功能就得明白这套挂载权限体系。2. 应用沙箱Android给数据上的第一把锁2.1 UID隔离与App数据目录把应用装进沙箱是Android从诞生起就在做的事。每个应用在安装时由PackageManager分配一个UID内核通过这个UID控制访问边界。你在Android Studio里用adb shell进到设备里执行ps -A | grep 包名能看到进程的u0_aXX这种用户标识其中的数字就是UID映射后的结果。应用私有数据目录有两条典型路径/data/data/包名/Android 8及以前常见通过Context.getFilesDir()拿到/data/user/0/包名/多用户功能引入后/data/user/0代表主用户下的应用目录getFilesDir()实际指向这里。这两个路径在物理上可能指向同一块区域但逻辑上完全不同。你自己测试时可以随便跑但千万别在代码里硬编码/data/data/包名去拼路径。一方面不同ROM的符号链接策略不一样另一方面多用户场景下会把数据串到别的用户头上。正确做法永远是context.getFilesDir()、context.getCacheDir()、context.getNoBackupFilesDir()。一段我封装过的代码片段大家可以对比着看// 推荐写法 File filesDir context.getFilesDir(); File cacheDir context.getCacheDir(); File noBackupDir context.getNoBackupFilesDir(); // 不推荐写法硬编码路径一旦系统改动或使用双开/分身功能直接翻车 File wrongDir new File(/data/data/ context.getPackageName() /files);上面这几个目录的语义差异也很关键filesDir用来放持久化数据会被系统备份带走cacheDir用来放临时缓存空间不足时系统有权限清掉noBackupDir放不方便备份的数据比如本地生成的密钥、登录态token。你如果拿cacheDir存用户的重要配置哪天系统清缓存之后用户直接掉登录这种Bug很难定位。2.2 内部存储与外部存储两种存储的“锁”不一样Android的“内部存储”和“外部存储”这对概念把很多人绕晕过。简单说内部存储指集成在设备里的、为每个应用私有分配的那一部分稳定性高、有沙箱保护外部存储则是一个“公共”文件区域可能是物理SD卡也可能只是/storage/emulated/0这个通过FUSE模拟出来的分区。这里有个很有意思的点现在绝大多数手机根本没有物理SD卡但/sdcard、/storage/emulated/0这些路径仍然存在因为系统用FUSE用户态文件系统把/data/media/0目录挂载成了一个“看起来像SD卡”的区域。/data/media是实际数据所在外部通过FUSE暴露给应用系统在FUSE层做权限拦截。对应用开发来说这种设计带来一个直接影响你在getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)下写文件物理上文件还是躺在/data/media附近但因为经过了FUSEfile://路径的访问要受Scoped Storage规则约束某些场景下你明明有路径直接new File却读不出东西。我碰到最多的误解是getExternalFilesDir()返回的路径在Android 10之后不需要权限很多人以为所有“外部”路径都不需要权限。实际上这个目录只是“应用专属外部目录”系统默认放行但你要访问/storage/emulated/0/Download下属于所有应用共享的文件那就得走MediaStore.Downloads或者申请MANAGE_EXTERNAL_STORAGE这种高敏感权限。锁的级别完全不一样。3. 分区存储Android 10之后最值得关注的权限拐点3.1 从一刀切读写到Scoped Storage的演变Android早期生态有多乱做过老开发的都有体会一个应用只要申请了WRITE_EXTERNAL_STORAGE几乎能读写外部存储里的所有文件。这个权限在国产ROM上经常被流氓软件拿去然后把你手机里的照片翻个底朝天。Google从Android 10开始强推分区存储Scoped Storage。核心思想是外部公共目录不再是一块谁都能进的广场而是分成几个“命名空间”图片、视频、音频、下载文件应用只能直接访问自己创建的文件以及用户主动通过系统文件选择器ACTION_OPEN_DOCUMENT或ACTION_GET_CONTENT授权的文件。想要批量访问相册里的所有图片只能通过MediaStore拿content://URI不能再拼/storage/emulated/0/DCIM/xxx.jpg这样的绝对路径。这个拐点是很多人代码崩掉的根源。你之前写死在数据库里的绝对路径在Android 10之后只是因为targetSdkVersion还低所以侥幸能用一旦你把targetSdk升到30以上立刻集体阵亡。3.2 MediaStore与直接文件路径的博弈MediaStore是系统提供的一个媒体目录数据库相册、音频、下载记录都通过它对外暴露。应用插入一条媒体记录系统会返回一个content://media/external/images/media/主键这样的URI文件物理存储由系统托管不暴露绝对路径。这里有三个开发要点写入新文件优先用MediaStore。比如你要保存一张截图把字节流写入content://对应的OutputStream然后通过ContentValues设置DISPLAY_NAME、MIME_TYPE、RELATIVE_PATH。这样生成的URI才是稳定的后续不管是自己读还是分享给别人都不会因为路径权限被卡。读文件用ContentResolver.openFileDescriptor()。拿到URI后用openFileDescriptor或者openInputStream不要试图解析成路径再用File。哪怕某些机型上能解析出绝对路径也不保证所有ROM都能访问。更新文件用ContentValues。修改图片的DATE_TAKEN、地名信息直接更新数据库字段比改写文件更可靠。但要注意Android 11之后你不能更新别人创建的文件的行数据只能更新自己创建或用户授权的文件。一个小陷阱RELATIVE_PATH中DIRECTORY_PICTURES和DIRECTORY_DOWNLOADS等常量是有实际字符串值的比如Pictures、Download。如果你在ContentValues里拼错了大小写系统可能直接插不进记录。建议用Environment.DIRECTORY_*常量而不是手写字符串。4. FileProvider让文件访问走“受控通道”4.1 为什么不能用file://ContentProvider做了什么应用之间传文件早期开发习惯是把文件存到缓存目录然后Intent.setDataAndType(Uri.fromFile(file), image/*)发给对方。从Android 7.0开始这种直接用file://的写法会立刻抛FileUriExposedException因为系统禁止把本地文件路径暴露给其他应用——别人拿到路径直接就能读你的内部数据。替代方案是FileProvider它是ContentProvider的一个官方子类作用是把文件访问转化为受控的content://URI由运行在发起方应用进程里的Provider负责打开文件。其他应用不直接碰路径而是通过ContentResolver拿到输入流权限由系统根据临时URI授权机制Intent.FLAG_GRANT_READ_URI_PERMISSION控制。这是我自己实际用的一套封装逻辑简化成下面这样fun shareFile(context: Context, file: File) { val uri FileProvider.getUriForFile( context, ${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) } context.startActivity(Intent.createChooser(intent, 分享文件)) }注意getUriForFile的authority必须是Manifest里配置的${applicationId}.fileprovider不能自己想当然换。包名混淆不会改这个字符串但如果你的applicationId和packageName不一致得格外小心。4.2 file_paths.xml配置的坑和混淆后的血泪教训FileProvider最麻烦的其实是路径映射配置。你要在res/xml/file_paths.xml里声明哪些路径允许被暴露paths files-path namefiles path./ cache-path namecache path./ external-files-path nameexternal_files path./ external-path nameexternal_storage path./ /paths每个标签都对应一类根目录files-path对应context.getFilesDir()cache-path对应context.getCacheDir()external-files-path对应context.getExternalFilesDir()external-path对应外部存储根目录/storage/emulated/0。坑一name并不是路径别名它是URI中的路径段。你写namefiles生成的URI就是content://授权名/files/子路径。如果把两个根目录的name写成一样的系统直接报IllegalArgumentException因为映射冲突了。坑二部分应用需要分享Bitmap但如果把文件从缓存目录移到files目录后忘了更新URI对方拿到的还是旧URI访问直接失败。路径变了必须重新getUriForFile。再说一个跟混淆相关的冷门问题。FileProvider在混淆时不能把构造方法随意改掉只能把androidx.core.content.FileProvider保持原样。如果你在ProGuard里配了-keep class androidx.core.content.FileProvider {*;}基本没事。但有人喜欢自己继承FileProvider并覆写方法这时候混淆就会把你的方法名改得乱七八糟导致系统obtainUri调用失败。我建议能用原版就别继承省得给自己埋雷。4.3 实测一条content://路径是怎么被解析的分享到微信、QQ时你会在日志里看到类似content://com.tencent.mobileqq.fileprovider/external_files/...这种东西。最近网上搜“content://开头的一长串路径”的人特别多说明很多开发者在调系统分享或相册时被这套URI搞懵了。我以content://com.tencent.wework.fileprovider/external_path/android/data/com.example/cache/share.jpg为例走一遍流程。第一段content://指定使用ContentProvider机制。com.tencent.wework.fileprovider目标应用的FileProvider authority系统通过它在PackageManager里找到对应的Provider组件。external_path对应file_paths.xml里的name字段系统拿它去匹配映射表中的真实根目录。android/data/com.example/cache/share.jpg剩下来的路径段拼接在根目录后面形成完整路径。在发起方应用里系统会校验目标Provider是否声明了grantUriPermissionstrue并且发起方是否有FLAG_GRANT_READ_URI_PERMISSION。两个条件缺一个接收方就会收到Permission Denial异常。所以你在做分享时一定要在addFlags里带上读权限别只设个type就完事。5. 实际操作给应用数据完成一次安全锁改造5.1 迁移清单targetSdk、权限声明、存储逻辑我最近接手项目时把App从targetSdk 28升到targetSdk 33经历了一整套存储逻辑改造。这个过程你要是按顺序来其实不复杂。第一步改build.gradle里的targetSdkVersion。很多人怕改完了弹出各种权限被拒其实Android系统对低版本targetSdk应用有“兼容模式”保护改完以后很多老行为会直接失效比如不再允许写公共目录。所以必须先清楚targetSdk升上去老的特权就没了。第二步梳理Manifest权限。原来读写文件用的READ_EXTERNAL_STORAGE和WRITE_EXTERNAL_STORAGE在Android 13上要按媒体类型细分比如READ_MEDIA_IMAGES只管图片。如果应用只做图片编辑就申请READ_MEDIA_IMAGES不要申请一个笼统的读权限这样审核更顺、用户也更好理解。第三步把所有new File(/storage/emulated/0/...)的硬编码路径全部替换掉。改成通过MediaStore插入新文件或者通过ContentResolver读取已有文件。下面是我的一个改造小抄旧方案新方案适用场景Environment.getExternalStorageDirectory()getExternalFilesDir()应用专属外部文件/storage/emulated/0/DCIM直接写MediaStore.Images插入保存图片到相册File.listFiles()扫描公共目录MediaStore查询 ContentResolver遍历批量获取媒体文件file://分享FileProvider.getUriForFile()跨应用文件分享5.2 备份与恢复数据迁移容易忽略的安全细节文件系统安全不只是“谁能访问”还有“出了事能不能救回来”。Android 12开始对Auto Backup做了较大调整开发者可以在AndroidManifest.xml里配android:fullBackupContent、android:dataExtractionRules这些属性决定哪些目录参与云备份哪些目录排除在外。这里要说的关键是不要把密钥、token这类东西放在默认备份清单里。系统备份会上传云端等于把你的私密数据复制了一份到别人手里。我在项目上吃过亏本地生成的shared_prefs里存了用户设备指纹和加密盐结果被系统自动备份走查了半天才发现备份文件在另一台测试机上被恢复出来了。保守做法禁止自动备份或者把敏感目录排除掉。示例application android:allowBackupfalse /application如果业务上确实需要备份就只允许备份数据库排除SharedPreferences和files目录里的敏感文件data-extraction-rules cloud-backup exclude domainsharedpref path./ exclude domainfile pathkeys// /cloud-backup /data-extraction-rules另外一个容易被忽略的点getFilesDir()下如果存了超大文件备份和恢复时会拖慢应用启动因为系统会在后台恢复数据。建议把生成的、可以被重建的缓存类文件放到getCacheDir()或者getExternalFilesDir()下并考虑在dataExtractionRules里排除它们。5.3 一个完整示例保存、读取、分享文件我贴一个我自己项目里用过的“保存文件到下载目录再分享”的完整闭环代码不长但覆盖了前面说的关键点。// 保存文件到公共下载目录 fun saveToDownloads(context: Context, data: ByteArray, displayName: String) { val values ContentValues().apply { put(MediaStore.Downloads.DISPLAY_NAME, displayName) put(MediaStore.Downloads.MIME_TYPE, application/octet-stream) put(MediaStore.Downloads.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS) } val collection MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) val uri context.contentResolver.insert(collection, values) ?: throw IllegalStateException(插入MediaStore失败) context.contentResolver.openOutputStream(uri)?.use { output - output.write(data) } Toast.makeText(context, 已保存到下载目录, Toast.LENGTH_SHORT).show() } // 读取刚刚保存的文件用于展示或二次处理 fun readDownload(context: Context, uri: Uri): ByteArray? { return context.contentResolver.openInputStream(uri)?.use { input - input.readBytes() } } // 分享文件给第三方应用 fun shareByUri(context: Context, uri: Uri) { val intent Intent(Intent.ACTION_SEND).apply { type context.contentResolver.getType(uri) ?: */* putExtra(Intent.EXTRA_STREAM, uri) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } context.startActivity(Intent.createChooser(intent, 分享)) }注意我把RELATIVE_PATH写成了Environment.DIRECTORY_DOWNLOADS它对应的字符串是Download。如果你要保存到子目录可以写成Download/MyAppLogs系统会自动创建目录。但是有一些ROM优化不完善插入时目录名带特殊字符会失败所以文件名里尽量避免空格和中文符号。6. 常见问题与排查实录6.1 文件明明在目录里却报FileNotFoundException这类问题十有八九是路径权限问题不是文件不存在。我自己踩过的典型场景是某台手机相册里能看到图片但File(uri.path)却打不开流或者通过MediaStore.Images.Media.DATA查询出来的路径放到File里去读报异常。原因有两个层面一是/storage/emulated/0是FUSE挂载底层真实路径可能是/data/media/0。你拿到的“路径”是给用户层看的虚拟路径但直接跟内核打交道时FileAPI要走VFS权限校验失败就会被拒二是Scoped Storage限制应用自己往MediaStore插入的记录你能访问但别的应用插入的媒体记录你没有权限读它的原始流。遇到这种情况用ContentResolver.openFileDescriptor最稳别信绝对路径。6.2 MediaStore插入成功但相册看不到插入MediaStore后相册里迟迟不出现新图片这是开发中常见的“假成功”。多半是MIME_TYPE和文件名后缀不匹配比如图片数据存成了JPEG字节但DISPLAY_NAME写成了.png。系统媒体扫描器会校验文件头发现不匹配就丢弃这条记录。另外一个原因是有些ROM的媒体数据库有缓存插入后用MediaScannerConnection.scanFile主动触发一次扫描。虽然MediaStore.insert理论上会自动通知MediaProvider但国产ROM上偶尔抽风主动扫描一下总没坏处。最后拍摄/保存的图片如果放在Pictures/之外的目录比如Download/部分相册应用默认不显示这倒不是权限问题是应用自身的过滤策略选择“所有照片”视图就能看到。6.3 FileProvider路径找不到、Open failed: ENOENT在应用之间分享文件时对面应用报Open failed: ENOENT (No such file or directory)很多人第一反应是对方的问题其实是自己路径映射配错了。排查顺序先看生成的content://URI路径段是否匹配file_paths.xml里的name。比如你分享的是getExternalFilesDir(DIRECTORY_PICTURES)下的文件配置里却只写了files-path那系统会在内部找/data/data/包名/files/...自然找不到。要换成external-files-path并在这个标签下暴露Pictures子目录。其次检查是否在addFlags里给了读权限。跨应用读取时没有FLAG_GRANT_READ_URI_PERMISSION系统直接SecurityException。最后确认FileProvider在AndroidManifest.xml里的authorities和应用里传入的字符串一致少写一个点都会爆Unable to find provider。6.4 权限申请被拒的常见原因速查表我把业务里反复出现的权限问题整理成一张表方便大家对照定位症状可能原因处理思路申请WRITE_EXTERNAL_STORAGE被直接拒绝targetSdk 29该权限已失效改用MediaStore或MANAGE_EXTERNAL_STORAGE相册图片读不出内容只申请了READ_MEDIA_IMAGES但访问的是视频按访问类型申请对应媒体权限分享文件给对方对方秒退URI没有FLAG_GRANT_READ_URI_PERMISSIONaddFlags补上授权再启动Intent写文件报EACCES (Permission denied)尝试直接写公共目录的绝对路径写入应用专属外部目录或走MediaStore出现Permission Denial: opening providerFileProvider未声明grantUriPermissions在Provider节点加android:grantUriPermissionstrue查询不到其它应用创建的媒体分区存储限制不允许随意扫描公共区使用系统文件选择器让用户手动选文件我还想多说一句千万别为了省事把自己的App加上MANAGE_EXTERNAL_STORAGE权限。这个权限在Google Play上审核极严国产应用市场也开始收紧。一旦被认定为滥用轻则下架重则开发者账号被封。我见过不止一个团队为了快速绕过分区存储加了这个权限最后连上架都成问题这种“偷懒”的代价根本不划算。结尾我踩过的坑和一个小建议最后分享一点个人体会。我最早做Android存储开发时习惯性地把文件操作都当成“IO问题”来处理路径对不对、流关没关、字节够不够。后来被几个线上Bug磨了几个月才意识到文件系统安全与权限控制不是单个API的问题而是一整套分层模型内核的UID隔离、FUSE的路径拦截、Provider的临时授权、MediaStore的数据库管理每一层都有自己的一套锁。做存储适配我现在的习惯顺序是先确认targetSdkVersion再梳理权限声明然后把所有直接暴露绝对路径的代码改成ContentResolver或FileProvider最后不管数据多不重要都跑一遍真机读写和跨应用分享测试。这套流程虽然听起来麻烦但真能帮你避开90%的存储坑。另外建议团队在项目里建一个“存储访问层”把MediaStore、FileProvider、文件路径这些细节全部封装在底层业务层只传数据模型。这样即使Android再出新版本、再调整分区存储规则你只需要改一个文件而不是把全项目的File调用翻个底朝天。这个内容后续还可以扩展的方向比如SAFStorage Access Framework里DocumentFile的使用、Android 15之后对共享存储的进一步限制、以及怎么在data partitioning下设计一套云同步的增量备份方案。你如果正在被文件权限问题折磨照着这篇文章里的清单一项项排查大部分问题应该都能当场解决。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →