☰
Android Uri 转文件路径全解:从 FileUriExposedException 到私有目录兼容方案
2026/10/16 5:21:31 网站建设 项目流程

如果你在开发中接到一个 Android 的 Uri,第一反应是不是直接uri.getPath()拿路径,然后丢给文件操作?如果你正在维护一个跑了好几年的老项目,这种写法大概率还留在代码里。但你会发现,在 Android 7.0 以上的机器上,这套“土办法”越来越不受用了:有时候拿到的是null,有时候拿到的是一个看似真实但根本打不开的路径,严重的直接从相册选个图去分享就直接崩溃。

这篇文章要解决的,就是上面这个痛点的完整版。我会把 Uri 拿到手之后怎么解析、怎么取真实路径、怎么兼容 Android 7.0 以上各种花式行为讲清楚,并给出一套可以直接复制到项目里的工具类和避坑清单。适用对象包括:还在维护老项目的 Android 开发者、做文件上传/多媒体处理功能的人、以及所有被FileUriExposedException和空路径折磨过的人。

1. 为什么一提“Uri 转文件路径”就容易翻车

1.1 我被一张图片折腾的旧案底

早期接到一个模拟项目 X 的需求:从系统相册选一张图片,拿到 Uri 后转成真正的文件路径,再交给底层图像算法去处理。我当时第一版代码写得很顺手,核心逻辑就三行:

String path = uri.getPath(); File file = new File(path);

这样写在当时确实能跑,因为五六年前的 Android 版本里,很多 intent 返回的 Uri 还是file:///storage/emulated/0/DCIM/Camera/xxx.jpg的形式,getPath()直接给你一个真实路径。但换到 7.0 以后的测试机上,问题开始批量出现:有的机器返回的是content://media/external/images/media/12345这种 MediaStore 形式的 Uri,getPath()返回的是一段没有真实文件对应的逻辑路径;有的机器上读取文件直接报FileUriExposedException;还有的机器上路径虽然对,但应用根本没有权限读那个路径。

这背后不是“某个厂商魔改”的问题,而是 Android 系统在 7.0 之后大幅度加固了“文件访问模型”。你没跟上这个变化,自然会在各种机型上翻车。

1.2 Android 7.0 这只“拦路虎”到底拦在哪

先厘清一个重要时间点:Android 7.0(API 24)是一个无法回避的分水岭。从它开始,系统对file://Uri 的跨应用分享做了严格限制。如果你在 Intent 里直接塞一个file://路径给别的应用,系统会直接抛FileUriExposedException,这个异常是运行时崩溃级别的,没有“降级兼容”的余地。

当年我调过一个影像处理功能,从相机拍照后把file://路径给另一个应用做裁剪,在 6.0 的测试机上一切正常,换到 7.0 的机器上一点预览就闪退。日志里明明白白写着那个异常,解决方案只有一条:改用content://加临时授权的方式传递文件。这也是FileProvider被频繁使用的原因。

也就是说,从 7.0 开始,“Uri 转文件路径”这个操作本身的意义就变了:你手里拿到的绝大多数 Uri 都已经不是物理路径的替身,而是一个“内容访问句柄”。你不能拿句柄去当路径用,得想办法通过句柄去打开真实内容。

1.3 Android 10 之后的路径概念变化

如果说 7.0 是分享模型的分水岭,那 Android 10(API 29)之后就是存储模型的分水岭。作用域存储(Scoped Storage)开始强制推行之后,应用直接拿一个/storage/emulated/0/DCIM/...路径去读媒体文件,很多场景下是没有权限的。

我见过不少项目在适配时做了个很别扭的操作:用MediaStore的_data列或DATA字段去“还原真实路径”。这个字段确实存在过,Android 10 之后系统代码里已经标记为废弃,部分机型上查询出来的是null,部分机型查询出来的是一个“代表项”,但实际文件根本不在那个位置上。除非你还有特殊权限去绕过限制,否则这个老套路已经不可靠了。

正因如此,我在后面会给出一个替代思路:与其挖空心思去“还原”一个可能根本不可用的物理路径,不如干脆把 Uri 指向的内容复制到应用自己的私有目录里,再返回那个私有路径。这个路径百分百真实、百分百可读写,也不会被作用域存储卡住。

2. 动手之前先分清 Uri 的基本盘

2.1 file 与 content,两套完全不同的取路逻辑

拿 Java 里的Uri对象来说,它只是字符串的封装,但不同 scheme 代表完全不同的协议。写解析工具前,第一步一定是看清scheme,是file还是content。这两者的处理逻辑天差地别:

  • file://Uri:去掉协议头之后,剩下的基本就是文件绝对路径,和本地文件系统直接对应。这种 Uri 在 7.0 之前很多场景会直接返回,现在多见于应用自己打包好的资源或者一些老第三方库传出来的数据。
  • content://Uri:它不指向某个具体物理位置,而是指向一个“内容提供者”。这个内容提供者可能是一张数据库里的记录,也可能是一个文档树的节点,甚至可能是一个纯内存流。遇到这种 Uri,直接剖开取 path 完全是南辕北辙。

换句话说,遇到content://Uri,你的目标不是“从 Uri 字符串里抠出路径”,而是“通过 ContentResolver 拿到真实内容”。这个问题想通之后,很多兼容性写法就自然而然地出来了。

2.2 各类 Uri 的真实长相与指纹

我按平时的使用场景,整理几种常见的 Uri 长相,方便你对照判断:

来源场景典型 Uri 形态scheme
系统相册选图片content://media/external/images/media/123456content
下载管理器选文件content://com.android.providers.downloads.documents/document/123content
文件管理器选文件content://com.android.externalstorage.documents/document/primary%3ADownload%2Ftest.pdfcontent
应用内通过 FileProvider 分享content://你的包名.fileProvider/external_files/DCIM/xxx.jpgcontent
老版本系统或自建库返回file:///storage/emulated/0/DCIM/Camera/IMG_20240101_123456.jpgfile

注意表格里有一个重要细节:文件管理器返回的 Uri 里带着%3A、%2F这种 URL 编码,如果不做解码就拼接路径,绝对会翻车。这也是为什么我不推荐“拆字符串取 path”的原因之一,你永远不知道对方传过来的 Uri 有没有编码、用什么方式编码。

2.3 拿到 Uri 先做这 3 个判断

写解析逻辑之前,建议先做一个最小判断集合,能避免 80% 的后续判断混乱:

private void inspectUri(Uri uri) { if (uri == null) { // 空 Uri,直接处理异常 } String scheme = uri.getScheme(); if ("file".equals(scheme)) { // 走文件路径分支 } else if ("content".equals(scheme)) { // 走内容解析分支 } else { // 其他 scheme,比如 android.resource、http 等,按需求处理 } }

很多新手会忽略uri == null的判断,实际项目里因为部分厂商 ROM 的 bug,真的可能返回 null。另外uri.getScheme()也可能返回 null,所以判断条件里用"file".equals(scheme)而不是scheme.equals("file"),可以顺手避免空指针。

3. 四种主流“取路径”姿势拆解

3.1 土办法:直接 getPath()

这是最原始的一招:

String path = uri.getPath();

在纯file://Uri 的场景下,它返回的值是正确的绝对路径,我这几年使用中验证过无数次。但它有两个明显局限:

  • 遇到content://Uri,返回的不是真实路径。比如系统相册返回的content://media/external/images/media/12345,getPath()返回的是/external/images/media/12345,这个路径只是 MediaStore 里的一条逻辑路径,不是文件系统里的真实地址。
  • 遇到带编码的参数,返回的值里可能还带%2F之类的转义,需要额外处理。

所以这个方案我的结论是:只适合做file分支的兜底,不建议拿到 Uri 就直接调getPath()。

3.2 旧案底:查询 _data 列,能跑但不稳

早年很多博客会教这个姿势,用 ContentResolver 去查询 MediaStore 的_data或DATA字段,拿到“真实”路径:

public static String getDataColumn(Context context, Uri uri) { if (!"content".equals(uri.getScheme())) { return null; } Cursor cursor = null; try { cursor = context.getContentResolver().query( uri, new String[]{"_data"}, null, null, null ); if (cursor != null && cursor.moveToFirst()) { int index = cursor.getColumnIndex("_data"); if (index >= 0) { return cursor.getString(index); } } } catch (Exception e) { // 捕获查询失败 } finally { if (cursor != null) { cursor.close(); } } return null; }

这段代码我可以说非常眼熟,但你们要明白它的适用边界:它依赖系统提供方在查询结果中暴露_data列。问题在于,不是所有 ContentProvider 都会暴露这一列。我测过不少第三方文件管理器、网盘类应用,它们的 provider 在query里返回的列可能只有DISPLAY_NAME和SIZE,根本没有_data。更麻烦的是 Android 10 之后这个字段在媒体数据库里已经不可信赖,查询出来可能是null或者一个被屏蔽的假路径。

我的建议是:别在核心代码里依赖这个方案,除非你确定你的 Uri 一定来自系统 MediaStore,并且你的最低支持版本在 Android 9 以下。

3.3 拿名字:OpenableColumns 查询显示名称

真正靠谱的第一步,是利用OpenableColumns这个常量类去取文件名。它能取的字段不多,主要是DISPLAY_NAME和SIZE,但这两个字段几乎是所有正常 ContentProvider 都会返回的。

public static String getDisplayName(Context context, Uri uri) { if (uri == null || !"content".equals(uri.getScheme())) { return null; } String displayName = null; Cursor cursor = context.getContentResolver().query(uri, null, null, null, null); if (cursor != null) { try { if (cursor.moveToFirst()) { int nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME); if (nameIndex >= 0) { displayName = cursor.getString(nameIndex); } } } finally { cursor.close(); } } return displayName; }

注意:查询参数用得是null而不是限定列名,这是为了最大程度兼容不同实现。有些 provider 如果你只查DISPLAY_NAME它会正常返回,但个别实现会返回空游标。我用null把整行查出来,再从结果里取名字,兼容性最好。

拿到文件名之后,你就有了构造目标路径的基础。这样做的意义是:不管底层 Uri 是来自相册、下载列表还是某个网盘,至少文件的名字是可靠的。

3.4 正解:把 Uri 内容复制到私有目录

这是我在 Android 7.0 以上项目里最推荐的一招:不追求“找到 Uri 背后那个原始物理路径”,而是通过 ContentResolver 打开 Uri 的输入流,把文件内容复制到应用自己的私有目录,然后返回这个私有目录的绝对路径。

public static File copyUriToFile(Context context, Uri uri, File target) throws IOException { InputStream input = context.getContentResolver().openInputStream(uri); if (input == null) { throw new IOException("无法从 Uri 打开输入流"); } try (InputStream is = input; OutputStream os = new FileOutputStream(target)) { byte[] buffer = new byte[8192]; int len; while ((len = is.read(buffer)) != -1) { os.write(buffer, 0, len); } } return target; }

为什么要优先拷贝而不是直接解析原始路径?原因有三个:

  • 物理路径不稳定。系统会清理媒体数据库、文件可能被移动或重命名,你之前拿到的路径随时失效。
  • 权限不受控制。即使你通过某种方式拿到了/storage/emulated/0/...下的路径,Android 10 以上直接访问仍然可能被拒绝,拷贝却不受影响,因为访问动作发生在 ContentResolver 层面,只要对方授权了这个 Uri,你就有权限读。
  • 第三方 provider 的 Uri 根本没有对应物理文件。比如某些应用返回的 Uri 内容是在线缓存、加密流,根本不存在一个可解析的文件路径,但照样能通过 openInputStream 读出来。

4. 推荐方案的完整实现与场景取舍

4.1 一个能直接上生产的解析工具类

把上面的核心函数串起来,我写了一个通用工具类,可以处理大多数实际业务需求。这个工具类在我最近两个实际项目里都是直接放进去用的,整体结构比较朴素,但胜在稳定:

public class UriPathHelper { public static String resolveRealPath(Context context, Uri uri) { if (uri == null) { return null; } String scheme = uri.getScheme(); if ("file".equals(scheme)) { return uri.getPath(); } if ("content".equals(scheme)) { File target = createCachedFile(context, uri); if (target != null) { return target.getAbsolutePath(); } } return null; } private static File createCachedFile(Context context, Uri uri) { String displayName = getDisplayName(context, uri); if (displayName == null || displayName.isEmpty()) { displayName = uri.getLastPathSegment(); } displayName = sanitizeFileName(displayName); File cacheDir = new File(context.getCacheDir(), "uri_cache"); if (!cacheDir.exists()) { cacheDir.mkdirs(); } File target = new File(cacheDir, displayName); try { return copyUriToFile(context, uri, target); } catch (IOException e) { return null; } } public static String sanitizeFileName(String name) { if (name == null || name.isEmpty()) { return "temp_" + System.currentTimeMillis(); } return name.replaceAll("[\\/:*?\"<>|]", "_"); } }

这里有三个细节很关键:

  • sanitizeFileName用来过滤文件名里的非法字符。系统相册和文件管理器返回的文件名大多是正常的,但部分第三方应用会把文件名拼成带空格、带冒号、带斜杠的怪格式,如果你不清理,new File(cacheDir, displayName)可能会创建出层级完全不对的路径。
  • 文件存在同名冲突时,我直接覆盖写入。如果你的业务对“保留旧文件”有要求,建议拼一个时间戳后缀再写。
  • 如果你处理的是对缓存敏感的大视频,或者你后续要把文件再次分享给别人,哪怕只是传到服务器,缓存目录里文件仍然可行,不过你要考虑清理时机。这个点下一节展开。

4.2 缓存目录还是外部私有目录

上面代码里用了context.getCacheDir(),这是绝大多数场景的理想选择。但我也遇到过一种情况:用户从相册选了一个很大的视频,系统把文件复制到 cache 目录后,视频处理器要它做转码,整个过程持续很久,如果系统因为存储空间紧张清理了 cache 目录,转码就会中断。

这种场景我更建议用getExternalFilesDir(null)或者getExternalFilesDir(Environment.DIRECTORY_PICTURES)这样的目录。它们同样是应用私有目录,不用申请任何存储权限,但不会被系统随意清理。

File targetFile = new File(context.getExternalFilesDir(null), "selected_" + System.currentTimeMillis() + ".jpg");

我个人的经验判断标准是:临时预览、临时上传场景用 cache;需要跨会话保存、后续多次读取、涉及长任务处理的用 externalFilesDir。不要把文件复制到公共目录的DCIM或Download,那会产生不必要的权限申请,而且同学之间项目交接的时候很容易被人误判为“乱写外部存储”。

4.3 遇到超大文件和视频时怎么办

复制方案也有它的痛点:文件越大,耗时越长。对于几百兆的视频,从相册 copy 到私有目录可能要好几秒,而且会占双倍空间。这时候我一般会做两个剪枝处理:

  • 先查询OpenableColumns.SIZE,拿到文件大小,如果大于预设阈值,就走另一套逻辑。比如对视频可以直接用ContentResolver.openFileDescriptor拿到字节流,直接投递给解码器,不一定要落地成完整文件。
  • 如果确实需要落成文件,建议加一个进度提示,别让用户干等。

获取大小的代码非常简单,顺带展示一下:

public static long getFileSize(Context context, Uri uri) { if (uri == null || !"content".equals(uri.getScheme())) { return -1; } Cursor cursor = null; try { cursor = context.getContentResolver().query(uri, null, null, null, null); if (cursor != null && cursor.moveToFirst()) { int sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE); if (sizeIndex >= 0) { return cursor.getLong(sizeIndex); } } } catch (Exception e) { // 忽略 } finally { if (cursor != null) { cursor.close(); } } return -1; }

注意SIZE列有的 provider 返回的是字符串类型,用getLong有些实现会直接抛异常,稳妥一点可以先判断cursor.getType(sizeIndex)再决定调用getLong还是getString。我实际排查中就遇到过某个第三方网盘 provider 把大文件大小返回成字符串的情况,第一次测试直接崩了。

5. 反向需求:Android 7.0 以上怎么把自己的文件变成 Uri

5.1 FileProvider 的基本配置

你在处理“通过 Uri 获取路径”时,其实还有一个镜像问题:如果对方给你分享文件,或者你要把应用私有文件分享给其他应用,你就得走反向流程,把 file 路径包装成 content Uri。Android 7.0 以上如果你想继续在 Intent 里传file://,等待你的就是上面含泪提到的FileUriExposedException。

标准解法是FileProvider。它是 androidx/core 里提供的 ContentProvider 子类,通过配置把应用私有目录映射成一个可供他人访问的 content Uri。

在 AndroidManifest 里注册:

<application> <provider android:name="androidx.core.content.FileProvider" android:authorities="${applicationId}.fileProvider" android:exported="false" android:grantUriPermissions="true"> <meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" /> </provider> </application>

${applicationId}会被替换实际包名,exported必须为 false,grantUriPermissions必须为 true。如果你要兼容旧版 AndroidX 依赖,meta-data 的 name 也可能是android.support.FILE_PROVIDER_PATHS,两个名字都见过,不要改错。

5.2 从 file 路径到 content Uri 的封装

然后在 res/xml 下放 file_paths.xml:

<paths> <files-path name="my_files" path="." /> <cache-path name="my_cache" path="." /> <external-files-path name="my_external_files" path="." /> </paths>

files-path对应getFilesDir(),cache-path对应getCacheDir(),external-files-path对应getExternalFilesDir()。如果你要把整个外部存储根目录也映射出来,还有external-path可以用,但出于安全考虑,我一般不建议暴露那么大范围。

转换代码:

File photoFile = new File(getExternalFilesDir(null), "share.jpg"); Uri contentUri = FileProvider.getUriForFile(this, getPackageName() + ".fileProvider", photoFile);

这里注意:authorities字符串要和 Manifest 里配置一致,我在实际项目里经常看到有人把getPackageName() + ".fileProvider"写成"包名.fileProvider",结果因为多渠道打包或者 debug/release 包名不同而找不到 provider。

5.3 授予临时访问权限的细节

FileProvider 只负责生成 content Uri,真正让对方能读内容的是临时授权:

Intent shareIntent = new Intent(Intent.ACTION_SEND); shareIntent.setType("image/jpeg"); shareIntent.putExtra(Intent.EXTRA_STREAM, contentUri); shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); startActivity(Intent.createChooser(shareIntent, "分享"));

这里有一个容易踩的坑:许多项目只给Intent加了这个 flag,但如果接收方没有在 Intent 解析阶段拿到权限,后续访问依然会失败。标准的做法是同时在ClipData里带上这个 Uri,因为系统在跨应用传递 uri 权限时有专门针对 ClipData 的授权逻辑。你可以这样处理:

shareIntent.setClipData(ClipData.newRawUri("", contentUri)); shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);

这两个操作是同等的保护,加上之后第三方应用才能稳定读取。

6. 方案对比与避坑速查

6.1 四种方法对比表

为了让你参考更方便,我把前面的几种方案整理成一张表:

方案原理7.0 以上可用性长时间可用性适用场景
getPath()直接从 Uri 字符串取路径仅 file:// 场景可用低,路径易失效解析自己可控的 file Uri
查询_data列从 MediaStore 读取真实路径部分机型可用低,Android 10 以后不可靠只兼容旧系统、仅媒体文件
OpenableColumns 查名从 provider 元数据取文件名高高配合复制方案使用
复制到私有目录通过 ContentResolver 读内容后落盘高高,路径持久绝大多数业务场景

从这张表里可以得到一个清晰结论:如果你在开发一个全新功能,直接采用“OpenableColumns 查名 + 复制到私有目录”的组合,整体兼容性最稳。

6.2 常见问题与排查思路

我在实际过程中遇到过不少疑难杂症,逐个列出来,方便你们排查时对照。

问题一:getPath() 返回 null。这个不算 bug,说明你拿到的 Uri 可能不是标准file://,或者 Uri 本身是空对象。排查时先打日志看 scheme,如果 scheme 是content,直接转用复制方案。

问题二:从相册选图后复制文件抛FileNotFoundException。通常是 Uri 授权过期或持有时间过长。如果你把一个相册 Uri 存下来过了一天才去读取,访问权限基本会失效。正确的做法是获取到 Uri 后立即解析/复制,不要留到后续再说。如果你一定要持久化使用,可以在首次拿到 Uri 时调用context.getContentResolver().takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)申请持久授权,但这需要当初返回 Uri 的 Intent 里有对应 flag。

问题三:复制出来的文件名全是乱码或丢失扩展名。这种情况多出现在DISPLAY_NAME查询不到、退回到uri.getLastPathSegment()时。getLastPathSegment()拿到的可能是12345这种纯数字,也可能是带 URL 编码的字符串。我建议拿到名字后先做一次URLDecoder.decode(name, "UTF-8"),再过滤非法字符。

问题四:同一个文件名被反复覆盖。用户连续选两张同名文件时,复制到同一个目标路径会覆盖前一张。如果你业务需要全部保留,在目标文件名里加上时间戳:

String finalName = System.currentTimeMillis() + "_" + displayName;

问题五:分享文件给其他应用时对方闪退。多半是你没有用 FileProvider,直接传了 file:// Uri。换成 content:// 之后一般就好了。如果换成 content:// 还是闪退,优先检查 provider 的 authorities 和 file_paths 路径是否匹配。

6.3 自己做项目时的几点独家建议

最后分享几个我从重复踩坑里总结出来的小经验。

如果你做的是一个对外接口或者 SDK,不要暴露“直接获取 Uri 原始路径”的方法。因为不同厂商、不同 provider 的行为差异巨大,你永远不知道调用者会传什么 Uri 进来。最好的做法是暴露一个“获取文件”的方法,内部直接把它转成应用私有文件路径,对外统一返回 File 对象或者私有路径。

测试时一定要覆盖多个来源。我习惯准备一张来自系统相册的图、一个来自下载管理器的文件、一个来自第三方文件管理器的文件,分别测完才算基本验证通过。只测相册,顺序上经常会漏掉_data列不存在或者 getLastPathSegment 带编码的问题。

如果你做的是音视频处理相关功能,尽量把目标文件放进getExternalFilesDir而不是getCacheDir。我踩过一个大坑:生成一个较大的临时视频文件放到 cache 目录里,转码到一半被系统清理,导致前端拿到损坏文件。虽然概率不大,但一旦触发,定位起来非常费劲。

另外还有一个小技巧:如果目标 Uri 本身来自你控制的 FileProvider,那你拿到的 content Uri 里通常还有自己定义的路径名,比如content://包名.fileProvider/my_files/temp.jpg,这时候可以直接从uri.getPathSegments()里找到文件名,不需要再走复制方案。判断依据是uri.getAuthority()是否等于你配置的authorities字符串。

if (("包名.fileProvider").equals(uri.getAuthority())) { // 自己应用分享的 Uri,直接把后段取出来拼到 getFilesDir() }

这个捷径适合自己做分享闭环的场景,能省一次 IO 拷贝,值得记住。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询