
做安卓开发文件分享是个绕不开的需求。内容提供者这个系列写到第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.0API 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:nameandroidx.core.content.FileProvider android:authorities${applicationId}.fileprovider android:exportedfalse android:grantUriPermissionstrue meta-data android:nameandroid.support.FILE_PROVIDER_PATHS android:resourcexml/file_paths / /provider /application几个配置项容易踩坑我逐个说。android:name要写FileProvider的全限定类名不要自己写错包名路径。android:authorities是这个Provider在系统里的唯一标识相当于它的“门牌号”接收方在解析Uri时靠的就是host部分去匹配它。我习惯用${applicationId}.fileprovider这样的写法因为你开debug包时applicationId可能会带.debug后缀动态拼接可以避免不同构建类型下的冲突。android:exported必须写成false。有人会困惑Provider不导出接收方能访问吗能。因为exportedfalse只是意味着其他应用不能直接通过Provider的类名或隐式方式调用但系统通过Intent临时授权机制仍然可以把权限颁发给接收方。android:grantUriPermissionstrue是允许这个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 version1.0 encodingutf-8? paths xmlns:androidhttp://schemas.android.com/apk/res/android files-path nameinternal_files path. / cache-path namecache_files path. / external-path nameexternal_files path. / external-files-path nameexternal_app_files path. / external-cache-path nameexternal_cache_files path. / /paths这五个标签对应的目录是这样的标签名匹配的目录典型路径files-pathContext.getFilesDir()/data/user/0/包名/filescache-pathContext.getCacheDir()/data/user/0/包名/cacheexternal-pathEnvironment.getExternalStorageDirectory()/storage/emulated/0external-files-pathContext.getExternalFilesDir()/storage/emulated/0/Android/data/包名/filesexternal-cache-pathContext.getExternalCacheDir()/storage/emulated/0/Android/data/包名/cache这里的path表示这个根目录下还允许暴露哪个相对路径。path.表示整个根目录都可以被共享。pathimages就表示只有根目录下的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()搞混这两个目录虽然都在应用私有数据目录下但它们是不同的子目录映射标签也完全不同。还有一种常见情况你写的是pathreports/但实际文件在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部分是authoritiesreports这一段来自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的多数据通道。代码上最稳妥的做法是使用ClipDataval 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 APIgetFilesDir()对应files-pathgetCacheDir()对应cache-pathgetExternalFilesDir()对应external-files-pathgetExternalCacheDir()对应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:// UriFailed to find configured rootXML标签或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()打出来跟接收方收到的字符串做对比大多数“共享失败”的谜底都能在几秒钟内解开。