做安卓开发,文件分享是个绕不开的需求。内容提供者这个系列写到第4篇,题目叫“在应用之间共享文件”,本质上就是把ContentProvider从数据库、键值对的查询,扩展到文件流的跨应用读取。真正的主角是FileProvider,它是ContentProvider的一个子类,用来把应用私有目录里的文件包装成一个content://开头的Uri,再通过Intent把它交给别的应用。它解决的问题很具体:你想把自家App生成的PDF、图片、分享给微信、邮件或者某个文档应用,又不想把文件复制到公共存储目录,更不想因为暴露真实路径而崩溃。适合的读者是那种已经会写Activity、Intent,甚至写过几个ContentProvider查询接口,但一遇到跨应用传文件就“只见代码、不懂原理”的开发者。这篇就把从配置到收发、从坑到排查,完整讲透。
1. 内容提供者与文件共享的整体思路
1.1 为什么直接暴露文件路径行不通
很多新手一开始会写出这样的代码:拿到一个File对象,然后直接构造一个Uri.fromFile(file),丢给Intent的setData,以为分享完成了。在安卓7.0之前,这个方案跑得挺好,因为系统内部对file://路径没有做强制拦截。但从Android 7.0(API 24)开始,你把file://类型的Uri跨应用暴露出去,系统会立刻抛出FileUriExposedException,让你的应用当场闪退。
这背后不只是安全策略收紧那么一句话。你想想,一个file:///storage/emulated/0/Download/xxx.pdf这样的Uri,暴露的是文件在文件系统里的真实物理路径。接收方拿到这个路径,不仅可以直接打开文件,还可能顺藤摸瓜去猜你应用目录下其他文件的路径。就算某些渠道没崩溃,不同App之间对同一个路径的可读性也不一样,很多国产ROM还有额外的存储权限限制,导致file://路径经常“这个应用能读、那个应用读不了”。
所以系统逼你做的不是“别发文件”,而是“换一种方式发文件”。FileProvider就是官方给出的标准答案。
1.2 FileProvider在内容提供者体系里的角色
前面几篇内容提供者的文章,讲的是你继承ContentProvider,自己实现query、insert、update、delete这些方法,再配合getType、openFile等能力,对外提供结构化数据。FileProvider不一样,它直接继承自ContentProvider,框架已经把核心逻辑实现好了,你不需要重写一堆CRUD方法,只需要做两件事:在Manifest里注册它,在XML里声明哪些目录允许被分享。
它的工作逻辑可以理解成“代理映射”。真实文件可能放在/data/data/你的包名/cache/report.pdf,但对外暴露的Uri却是content://你的authority/共享别名/report.pdf。其他应用拿到这个Uri后,系统会通过Provider的openFile或者openAssetFile方法,在内部帮你把content://映射回真实的文件描述符。这样接收方从头到尾都接触不到真实路径,拿到的只是一个抽象的资源标识。
这个过程很像你去中介租房,中介告诉你“去这个地址领钥匙”,而不是直接把房东家里的保险柜密码告诉你。你可以进去住,但你不知道保险柜在哪,也不能顺手把房东家的其他房间打开。
1.3 一次完整共享链路的四个环节
要把一次跨应用文件共享讲清楚,得把链路拆开看。整个流程可以用四个环节概括:
- 第一步,共享方确定要分享的文件,构造FileProvider专用的映射配置,让它生成一个
content://形式的Uri。 - 第二步,共享方构造Intent时,把Uri塞进
setData或EXTRA_STREAM,同时给Intent加上授权标志,比如FLAG_GRANT_READ_URI_PERMISSION。 - 第三步,系统收到这个带授权标志的Intent后,会去找uri对应的Provider,然后基于Intent里的目标组件,给接收方临时授予数据访问权限。
- 第四步,接收方拿到Intent后,不直接对路径做文件操作,而是用
ContentResolver.openInputStream(uri)的方式读取内容流,用完即走。
这里面最关键的点是“授权跟着Intent走”。文件权限不是给你应用所有组件都用,而是只给接收方那个特定组件临时打开一次。你切到另一个界面,想再读这个Uri,权限可能早就失效了。这也是临时授权机制的精髓:最小化暴露面。
2. 搭建共享能力:两大核心配置
2.1 Manifest 注册 FileProvider
要使用FileProvider,第一步自然是在AndroidManifest.xml里声明。下面这份配置是我经常用的模板,关键点都用注释标了出来:
<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>几个配置项容易踩坑,我逐个说。android:name要写FileProvider的全限定类名,不要自己写错包名路径。android:authorities是这个Provider在系统里的唯一标识,相当于它的“门牌号”,接收方在解析Uri时靠的就是host部分去匹配它。我习惯用${applicationId}.fileprovider这样的写法,因为你开debug包时applicationId可能会带.debug后缀,动态拼接可以避免不同构建类型下的冲突。
android:exported必须写成false。有人会困惑:Provider不导出,接收方能访问吗?能。因为exported=false只是意味着其他应用不能直接通过Provider的类名或隐式方式调用,但系统通过Intent临时授权机制仍然可以把权限颁发给接收方。android:grantUriPermissions="true"是允许这个Provider支持Uri权限授予,一旦设置成false,你加再多Flag都白搭。
meta-data那一段是用来关联后面的XML路径配置文件的。这里的android:resource指向res/xml/file_paths.xml,文件名可以自己起,但resource引用必须对得上。
2.2 file_paths.xml 路径映射规则
路径映射XML是整个文件共享里最需要理解的部分。我见过不少人在这段配置上卡了很久,原因是不清楚各个标签到底对应哪个目录。
一个标准的file_paths.xml长这样:
<?xml version="1.0" encoding="utf-8"?> <paths xmlns:android="http://schemas.android.com/apk/res/android"> <files-path name="internal_files" path="." /> <cache-path name="cache_files" path="." /> <external-path name="external_files" path="." /> <external-files-path name="external_app_files" path="." /> <external-cache-path name="external_cache_files" path="." /> </paths>这五个标签对应的目录是这样的:
| 标签名 | 匹配的目录 | 典型路径 |
|---|---|---|
| files-path | Context.getFilesDir() | /data/user/0/包名/files |
| cache-path | Context.getCacheDir() | /data/user/0/包名/cache |
| external-path | Environment.getExternalStorageDirectory() | /storage/emulated/0 |
| external-files-path | Context.getExternalFilesDir() | /storage/emulated/0/Android/data/包名/files |
| external-cache-path | Context.getExternalCacheDir() | /storage/emulated/0/Android/data/包名/cache |
这里的path表示这个根目录下还允许暴露哪个相对路径。path="."表示整个根目录都可以被共享。path="images"就表示只有根目录下的images子目录可以被共享,其他子目录里的文件就算你手动调用FileProvider.getUriForFile也会抛异常。我建议不要图省事全写".",尤其是external-path。因为external-path的根目录是整个外置存储,一旦映射全部放开,你手机里所有公共存储里的文件都在Provider的可暴露范围内。虽然接收方还需要临时授权才能读,但你这等于自己把门开大了。实践上我更推荐精细化配置。
2.3 路径配置的坑:cache-dir与files-dir
路径配置最大的坑,不是找不到XML,而是你明明配了目录,却仍然报Failed to find configured root。这个异常的意思是:你传给getUriForFile的文件,所在的根路径和你XML里配置的任何一个根都对不上。
举个例子。你的文件放在context.getCacheDir()返回的目录下,结果你在XML里只写了files-path,那必然报错。因为cacheDir对应的是cache-path,不是files-path。很多人把getFilesDir()和getCacheDir()搞混,这两个目录虽然都在应用私有数据目录下,但它们是不同的子目录,映射标签也完全不同。
还有一种常见情况:你写的是path="reports/",但实际文件在cache/reports/2024/report.pdf,如果FileName里面带子目录,只要path指向的是reports这个根,子目录不需要在XML里继续声明,因为FileProvider匹配的是“根”而不是完整路径。只要你文件的绝对路径能对应到某个映射根目录下,就能通过。
我在实际项目里还遇到过一种隐蔽问题:某台设备上因为多用户机制(应用双开、工作资料)导致文件真实路径带了一个用户ID段,比如/data/user/10/包名/cache。但FileProvider映射是框架基于Context的目录自动关联的,getCacheDir()本身就已经带了用户ID,所以正常用不会有问题。如果你自己拼接了一份绝对路径去喂给getUriForFile,就很容易因为路径对不上而翻车。记住一句话:要分享的文件对象,必须来自Context相关的目录API或你的业务代码在对应目录下创建的真实File,而不是手拼字符串。
3. 代码实操:从构造Uri到调用方读取
3.1 共享方:构造content:// Uri
配置完成后,核心代码反而很薄。生成Uri只需要一行:
val file = File(context.cacheDir, "reports/report.pdf") val uri = FileProvider.getUriForFile(context, "包名.fileprovider", file)这里第二个参数就是你在Manifest里配置的authorities,必须完全一致。如果配置不一致,系统会直接抛异常,提示“无法找到authority对应的Provider”。
uri.toString()打出来会是这样的:
content://com.example.app.fileprovider/reports/report.pdf前面是content://,host部分是authorities,reports这一段来自XML里的name属性,report.pdf是真实文件名。注意,名字别名可以随便起,不一定和真实目录名一致。很多项目会把name定义为"cache_reports"这样更语义化的名字,因为接收方打印这个Uri的时候能看到它,别名清晰一点,排查问题会方便很多。
构造完Uri之后,有一个很容易被忽略的点:File本身必须是真实存在的文件,并且调用方有文件读权限。如果文件不存在,getUriForFile不会立刻报错,但接收方一旦尝试openInputStream就会收到FileNotFoundException。而且这个异常还不一定发生在你的日志里,而是在对方App的日志里,排查时很容易一头雾水。所以构造Uri之前,自己先做一次file.exists()判断是最起码的防御。
3.2 授权与Intent传递
生成Uri只是第一步,你必须把访问权限通过Intent授予接收方。标准写法是:
val intent = Intent(Intent.ACTION_SEND).apply { setDataAndType(uri, "application/pdf") addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } startActivity(Intent.createChooser(intent, "分享到"))这里有两个细节要特别留意。第一个是setDataAndType而不是先setType再setData。很多分享场景走的是EXTRA_STREAM而不是setData,这时候不要忘记给Intent加上putExtra(Intent.EXTRA_STREAM, uri)。但不管用哪种方式,接收方最终能访问Uri的前提都是Intent上带着授权Flag。如果只设置了FLAG_GRANT_READ_URI_PERMISSION,接收方只能读文件描述,而不能修改文件内容。如果你是想共享一个对方可编辑的文件,就得追加FLAG_GRANT_WRITE_URI_PERMISSION。我个人的建议是:能只读就只读,不要默认加写权限。
还有一个原理层面的细节:授权Flag会在ActivityResult返回时自动被清理。系统在Intent流转过程中记录了这个授权关系,当接收方的页面返回后,临时授权就会失效。这带来一个很隐蔽的问题:如果你在Activity回调里再次尝试用同一个Intent对象去访问那个Uri,很可能会拿到SecurityException。处理方式是:不要缓存跨页面过久的Uri,尽量在单次生命周期内消费掉,或者后续如果需要继续访问,就让接收方调用ContentResolver.takePersistableUriPermission来持久化授权。
3.3 接收方:用ContentResolver读取内容流
接收方的代码没什么神秘的地方。它拿到Uri后,直接走内容解析器:
val inputStream = contentResolver.openInputStream(uri) inputStream?.use { stream -> val targetFile = File(cacheDir, "downloaded.pdf") targetFile.outputStream().use { output -> stream.copyTo(output) } }这里有个习惯值得培养:接收方尽量不要依赖Uri.getPath()去做文件名和路径判断,因为这个Path对于不同Provider来说可能不是真实路径,而是Provider自定义的路径结构。你能保证的是用contentResolver.openInputStream或openFileDescriptor去把内容读取出来。
还有一个常见误解:接收方通过临时授权拿到的不是文件路径,而是一个可以打开的流。有些新手拿到Uri后,先uri.path,再File(path)去操作,这套流程在FileProvider下多半会失败。因为FileProvider对外开放的path里包含的是XML里配置的name别名,不是真实文件路径。从设计上,FileProvider就是要让你“别直接操作路径”,而是通过流或者ParcelFileDescriptor来访问。
4. 多文件共享与临时授权细节
4.1 ClipData 解决多文件分享
一次只发一个文件,用setDataAndType就够了。但实际业务里经常要发一组图片,或者一个PDF加一个Excel。这时候要走Intent的多数据通道。
代码上最稳妥的做法是使用ClipData:
val uris = listOf(uri1, uri2, uri3) val clipData = ClipData.newUri(contentResolver, "shared_files", uris[0]) for (i in 1 until uris.size) { clipData.addItem(ClipData.Item(uris[i])) } val intent = Intent(Intent.ACTION_SEND_MULTIPLE).apply { clipData = clipData addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) type = "image/*" putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(uris)) }注意FLAG_GRANT_READ_URI_PERMISSION加在Intent上,会对这个Intent携带的所有Uri都生效,包括ClipData里的每一个Item。这也是一种很常见的授权范围写法。单独使用EXTRA_STREAM而不使用ClipData在部分设备上也能工作,但系统对Intent携带Uri的扫描范围在不同版本上行为有差异,ClipData是更规范的做法。
多文件场景里真正烦人的是MimeType。因为一组文件可能类型不一样,type只能设置一个。如果你把类型写死成image/*,接收方拿到PDF就会识别异常。我的处理思路是:如果多个文件类型不一致,统一用application/octet-stream或者*/*,或者在发送前根据主要文件类型做判断,不要一股脑写死。
4.2 不经过Intent的临时授权
还有一种场景不依赖Intent:你不想启动新的Activity,只想给某个应用的一个组件提前授权。这就要用grantUriPermission。
val uri = FileProvider.getUriForFile(context, "包名.fileprovider", file) context.grantUriPermission("com.target.app", uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)用完以后记得撤销授权,否则这个App会一直保有这个Uri的访问资格:
context.revokeUriPermission("com.target.app", uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)这种写法适合什么场景?举个例子:你的App需要把一个文件交给系统打印服务、云备份服务、或者一个后台任务类型的应用。这种场景没有前台Activity帮你携带Flag,授权关系需要你自己管理。还有一个注意点:grantUriPermission的Uri必须是Provider返回的标准化Uri,你用Uri.parse手拼一个一摸一样的字符串往往不行,因为系统内部还要做路径匹配,要直接用getUriForFile的返回值。
4.3 文件生命周期与共享安全
文件共享里面有个很多人不重视的问题:FileProvider共享的是文件的句柄权限,不是你的文件生命周期。对方拿到Uri并成功打开流之后,如果你原地把这个文件删了,对方读出来的内容可能是截断的,或者直接报错。反过来,如果对方持有Uri但一直没读取,你删了文件以后再让对方读,也可能失败。在设计业务的时候,要约定好“文件生成—分享—清理”的时序,不要在用户可能还没完成下载的时候就把临时文件清掉。
另一个安全习惯是权限最小化。如果需求只是让对方查看文件,就不要在Intent加FLAG_GRANT_WRITE_URI_PERMISSION。多一个权限就多一份风险,接收方一旦恶意,可以拿着写权限改写你的文件内容。还有,文件内容最好按会话或者任务打上标记,避免在同一个目录下堆积大量无名临时文件。我一般会为每次分享单独生成一个子目录,任务结束之后整体删除。
5. 常见问题与排查技巧实录
5.1 FileUriExposedException 还在报错
这是从file://切换到content://的过程中最经典的报错。日志里通常会看到:
file:///storage/emulated/0/xxx.jpg exposed beyond app through Intent.getData()如果你已经按照上面的方式配置了FileProvider,却仍然报这个错,先检查两点。第一,Intent里的Uri到底是不是content://开头。有些代码在设置setDataAndType之前,已经用Intent.setData(Uri.fromFile(file))覆盖了Uri,导致最终发送的还是file://。第二,加授权Flag的时机。addFlags必须在调用startActivity之前,如果你在onActivityResult里又改了Intent再发送,原始Intent上的授权就没了,接收方可能收不到授权,但你自己发送的Uri不会被拦截。真正会崩溃的是直接发送Uri.parse("file://...")给startActivity,这种属于漏改。
5.2 Failed to find configured root
这个异常是FileProvider配置问题里最常出现的。具体日志形如:
java.lang.IllegalArgumentException: Failed to find configured root that contains /data/user/0/com.example.app/files/tmp/a.pdf排查思路很简单:
- 第一步,打印出文件的绝对路径。
- 第二步,看这个绝对路径落在哪个根目录下。
- 第三步,确认XML里配置了对应的根标签,以及path设置是否覆盖该路径。
举个例子,如果文件在context.getFilesDir()下,就要用files-path;在getCacheDir()下就要用cache-path。这两个目录很容易搞混,因为它们在磁盘上都是/data/user/0/包名/的子目录,但映射标签完全不同。如果你不确定某个目录对应哪个标签,最简单的办法是看它来自哪个Context API:getFilesDir()对应files-path,getCacheDir()对应cache-path,getExternalFilesDir()对应external-files-path,getExternalCacheDir()对应external-cache-path。
还有一个小概率问题:权限或文件隔离环境导致路径带了一些奇怪的段,比如双开应用。这种场景下尽量不要依赖你脑补的绝对路径,直接用Context的API返回的File对象来定位。
5.3 对方App打不开文件,却看不到任何异常
这类问题一般不是崩溃,而是对方Activity打开了但显示“无法查看文件”。常见原因有两个。
第一个是MimeType不对。比如你把PDF的type写成了text/plain,接收方的预览组件可能拿着这个类型去尝试文本渲染。类型不匹配时,很多App会直接选择不处理这个Uri。解决办法是使用MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension)来推断类型,PDF就映射成application/pdf,图片就映射成image/jpeg。
第二个原因是Uri在传递过程中被截断或二次包装了。很多业务方在发送前会自己封装一层Uri,比如把content://转成字符串存到数据库,再用的时候Uri.parse回来。这个操作本身没问题,但如果你parse的是一个被截断的字符串,Uri就坏掉了。检查办法是在发送端把所有Uri打印出来,在接收端也打印,对比两个值是否完全一致。接收方视角用uri.scheme、uri.host、uri.path三个字段去判断结构,比打印整个Uri更可靠。
5.4 权限失效与授权打不上
有时候接收方拿到了Uri,但调用openInputStream时报SecurityException。这种“授权没生效”的问题,九成出在authorities不一致或Flag没带上。
排查的时候分三步走。先确认共享方生成的Uri的host部分和接收方解析到的Uri的host部分完全一致,这里不区分大小写但必须精确匹配。再确认Intent上是否真的携带了FLAG_GRANT_READ_URI_PERMISSION。这里有个隐蔽坑:如果Intent的package字段被指定了目标包名,而你又通过Intent.createChooser包装了一次,授权可能落在原始Intent上,而不是ChooserIntent上。这个情况在不同安卓版本里行为还不一样,最简单的规避方法是把Flag同时加到原始Intent和返回的ChooserIntent上,或者直接不手动指定目标包名。
另外还遇到过一种情况:同一个Provider在同一个应用里声明了两次,比如基础模块和业务模块各自引了一份Manifest配置,导致authorities不同,生成的Uri的host部分和接收方解析时匹配的是另一个Provider实例。这种用AAPT相关工具看最终合并的Manifest就能发现,查的时候先看生成的apk里androidmanifest里provider节点到底有哪几个。
我再整理一份问题速查表,方便你排查:
| 现象 | 常见原因 | 处理方向 |
|---|---|---|
| FileUriExposedException | 代码仍在使用Uri.fromFile | 全部改用FileProvider生成content:// Uri |
| Failed to find configured root | XML标签或path配置不匹配 | 核对文件真实根目录与标签对应关系 |
| SecurityException: Permission Denial | 授权Flag缺失或authorities不一致 | 检查Intent上的FLAG和Provider配置 |
| FileNotFoundException | 源文件不存在或已被删除 | 生成Uri前检查文件是否存在 |
| 对方无法打开文件 | MimeType不正确 | 根据扩展名动态推断MimeType |
| 分享后再次使用Uri失效 | 临时授权已过期 | 使用takePersistableUriPermission持久化 |
| Uri的host与预期不一致 | 多模块provider合并冲突 | 检查合并后的Manifest与gradle配置 |
6. 顺着这条路还能做什么
6.1 用ContentProvider实现自定义“虚拟文件”
FileProvider解决的是“把本地文件暴露出去”,而ContentProvider的能力远不止于此。你可以实现自己的openFile方法,在方法参数里拿到外界请求的Uri,然后不从一个真实文件而是从内存、网络流里生成内容。比如一个云盘类应用,可以注册一个ContentProvider,对外暴露content://cloud盘/photo/1这样的Uri,收到openFile调用后返回一段内存流,让相册应用直接把云端图片作为本地文件打开。这其实就是很多云盘“本地虚拟盘”功能的核心思路。
你要是想深入理解,可以把FileProvider当成一个“把文件路径翻译成内容Uri”的简单实现,而你自己写的Provider,就是“把任意业务数据翻译成内容Uri”的加强版。它俩共享同一套content://协议和Uri权限体系。
6.2 跟系统文件选择器的搭配使用
还有另外一种共享路径,不经过你自己注册Provider:使用系统文件选择器,通过Intent.ACTION_OPEN_DOCUMENT或ACTION_CREATE_DOCUMENT让用户挑文件。这种方式适合“你也不知道具体文件在哪,需要用户自己做主”的场景。选择器返回的Uri是由系统DocumentsUI的Provider提供的,权限管理也由系统负责。你在自己的应用里,同样通过ContentResolver去读取。两者不冲突,我一般按需求区分:App自己生成的文件优先用FileProvider,用户主动选择的文件优先走系统文件选择器。
6.3 大文件流式传输的优化思路
最后说一个容易被忽视的性能问题。很多人在接收方直接写:
val bytes = contentResolver.openInputStream(uri)?.readBytes()小文件没问题,但文件一大,内存就扛不住了。我处理大文件时,一般直接用流的拷贝方式,分块读写,或者用contentResolver.openFileDescriptor(uri, "r")拿到ParcelFileDescriptor,再用文件通道做拷贝。这样既能享受Provider带来的权限隔离,又不至于因为一次性加载整个文件造成OOM。具体分块大小,实践中用8KB到64KB的缓冲区都行,看你的业务场景和数据量决定。
最后说点个人体会
我在处理某次跨应用“生成报告并分享”的需求时,最初图省事直接传file://,结果在适配测试时崩溃了好几次。后来老老实实把FileProvider配好,又因为cache-path和files-path弄混,卡了整整一个下午。这事给我的教训是:文件共享的方案并不难,难的是对路径、权限、授权范围这些基础概念的理解。
最后分享一个小技巧:把FileProvider的authorities定义成Kotlin里的顶层常量,所有生成Uri的地方都走同一个工具方法,比如fun shareUri(context: Context, file: File): Uri。这样你只要在这个工具方法里统一处理路径不存在的判断、Uri打印等逻辑,排查问题时看日志会顺畅很多。调试阶段把uri.toString()打出来,跟接收方收到的字符串做对比,大多数“共享失败”的谜底都能在几秒钟内解开。