
1. 先搞清楚content uri 和文件路径到底差在哪1.1 从 FileUriExposedException 说起做 Android 开发的应该都有印象从 targetSdk 24对应 Android 7.0开始应用之间共享文件再也不能直接把file://路径塞进 Intent 了否则系统会直接抛FileUriExposedException把你 App 打崩。苹果安卓两端同步收紧文件访问权限Google 当时给的标准方案就是用FileProvider生成content://形式的 uri 授权给你要共享的另一个应用。这块本身不复杂在 manifest 里注册一个 provider然后在代码里调FileProvider.getUriForFile()拿到一个长得像content://com.example.fileprovider/my_images/IMG_0001.jpg的 uri配合Intent.FLAG_GRANT_READ_URI_PERMISSION或者FLAG_GRANT_WRITE_URI_PERMISSION一起发给目标 Activity 或 Service 就完事了。真正让人挠头的是接收方拿到这个 content uri 之后往往并不打算只是调一下ContentResolver.openInputStream()读点数据而是想把整个文件交给 native 层的库去处理比如我们项目里的 FFmpeg。FFmpeg 是一个 C 语言写的多媒体处理框架它的输入接口avformat_open_input()认的是路径字符串可以是本地文件路径、http://、rtmp://这类协议也可以是一个已经打开的 fd 映射出来的路径。但content://这个 scheme 它是不认的传进去多半直接报错返回。1.2 content uri 的本质它不是一个文件路径很多人踩坑的根源是把 content uri 和文件路径混为一谈了。file:///sdcard/DCIM/xxx.mp4指向的是文件系统上一个真实存在的节点解析完路径就是/sdcard/DCIM/xxx.mp4可以直接 open()。但content://authority/path/id不是一个真实路径它更像一个抽象寻址协议系统拿到这个 uri 之后会去找到对应的 ContentProvider然后调用 provider 实现的openFile()、openAssetFile()或者openInputStream()来拿到数据流或者文件描述符。拿我项目里的场景举例用户从系统相册选了一段视频我们通过ActivityResultContracts.PickVisualMedia拿到的就是一个 content uri。我先直接试了最蠢的办法——把uri.toString()当成路径传给 FFmpeg结果可想而知avformat_open_input()返回了AVERROR(ENOENT)对应错误码 -1330794744本质是“文件不存在”。FFmpeg 内部去 open 这个“路径”的时候系统并没有给它解析 content uri 的能力它就是一个普通的 open() 调用自然找不到文件。这就引出了核心问题怎么让一个只认路径的 native 库吃到 ContentProvider 吐出来的数据下面把这几年实战下来验证过的思路、代码和坑全部梳理一遍。2. 为什么不把 content uri 直接丢给 ffmpeg2.1 FFmpeg 的输入层只认识“能打开的东西”先看看 FFmpeg 打开输入的机制。avformat_open_input()的第一参数AVFormatContext **ps第二参数const char *url这个 url 可以是文件路径、网络地址也可以带自定义协议前缀如pipe:、fd:。底层通过avio层去解析和读取数据如果是普通文件路径就调open()拿 fd 然后read()如果是网络协议就调 socket 相关函数如果是自定义协议需要你自己注册AVInputFormat或者给AVIOContext提供回调。问题就出在content://这个 scheme 上。FFmpeg 的探测逻辑是先去匹配已知协议前缀http、rtmp、file、pipe 等匹配不到就尝试当成文件路径去 open。content uri 显然不是它认识的协议于是它老老实实把整个字符串当成路径去 open结果自然失败。我用一句生活化的话总结FFmpeg 就像一个只看得懂“门牌号”的快递员content uri 是一张存了经纬度的电子地图坐标你把坐标纸递给他说“去送快递”他当然找不到门。得有人先把坐标换算成门牌号或者直接把货搬到门口。2.2 直接传 Uri.getPath() 的典型失败现场有人会说那我不传 uri.toString()传uri.getPath()呢首先content://authority/path/id的 getPath() 返回的是/path/id这依然不是一个真实存在的文件系统路径。拿到它去 open还是会报No such file or directory。还有一个常见操作是拿uri.getLastPathSegment()去拼一个/data/data/包名/cache/xxx假路径这更离谱纯属自欺欺人文件系统上根本不存在这个文件。我在好几个技术群里看到有人试了各种花活儿把 content uri 拼到file://后面、先file:// getPath、甚至试图Uri.decode()一下再传全都没有用。因为问题不在字符串长啥样而在于 ContentProvider 的数据必须通过 Binder 跨进程去 ContentResolver 拉取FFmpeg 作为一个纯 native 库压根没有 Android 的 ContentResolver 概念这条路是死的。3. 三种主流解决思路对比把这个问题的核心想明白之后解决办法其实就三条路。我按实际项目里推荐程度排序来讲。3.1 方案A拷贝到缓存目录再转稳妥、兼容性最好思路最简单既然 FFmpeg 认路径那就先把 content uri 对应的数据完整拷到 App 自己的 cache 目录得到一个真实路径再把路径交给 FFmpeg。这种方案最稳几乎没有兼容性坑适用于绝大多数场景尤其是视频本地转码、压缩、拼接这类需要反复随机访问文件的操作。缺点也明显多了一倍磁盘 IO大文件会慢而且需要临时磁盘空间。一个 2GB 的视频拷贝就得花十几秒甚至更久还得确保磁盘剩余空间足够。3.2 方案B走 /proc/self/fd/ 伪路径高效但有限制条件Android 是基于 Linux 内核的而 Linux 下/proc/self/fd/目录里放着当前进程所有已打开文件描述符的符号链接。如果你通过ContentResolver.openFileDescriptor()拿到了一个ParcelFileDescriptor就能拿到一个真实的 fd 数字然后拼出路径/proc/self/fd/123这个路径是可以直接被 open() 的因为它本质上指向的不是磁盘上的路径而是“当前进程里 123 号 fd 对应那个打开的文件”。这个方法的好处是零拷贝fd 直接复用不做磁盘写入速度非常快特别适合大文件转码。但有几个前提openFileDescriptor()要求 ContentProvider 本身支持返回可读的 fd大部分相册、文件管理器都支持但某些流式 provider比如加密文件、网络缓存流、特殊格式转换不支持会抛异常或返回 null。通过/proc/self/fd/N打开的文件行为上跟源 fd 共享同一个文件偏移。如果 FFmpeg 内部做了 seek而 fd 本身不可 seek比如管道/流式 fd会出问题。生命周期管理极容易翻车。fd 在 native 层被 open 后Java 层的 ParcelFileDescriptor 一 close这个 fd 就没了FFmpeg 后面读数据就会报错。3.3 方案C自定义 AVIO 回调适合大文件流式场景如果你不想拷贝、又拿不到稳定 fd比如遇到只支持openInputStream()的 provider那就得走 FFmpeg 的AVIOContext自定义输入回调写一个read_packet()函数里面调ContentResolver.openInputStream()的read()把这个回调挂在AVFormatContext-pb上FFmpeg 就会通过你的回调拉数据。这个方案最灵活内存可控理论上可以边下边播边转适合超大文件或需要流式处理的场景。代价是代码复杂度高而且要自己处理缓冲、seek、EOF 逻辑。avio_alloc_context()分配内存、注册回调、设置AVIOContext-seekable标志……一环扣一环出了问题很难排查。三种方案对比如下方案实现成本速度磁盘占用兼容性适用场景A. 拷贝缓存低慢多一次完整 IO高两倍空间最好绝大多数本地转码/压缩/拼接B. /proc/self/fd中快零拷贝无依赖 provider 支持 fd大文件、磁盘紧张的场景C. 自定义 AVIO高快流式无最灵活视实现而定流式拉取、超大文件、特殊 provider实际项目里我建议默认先上方案A稳定省心等确实遇到大文件性能瓶颈再针对性优化为方案B方案C只在你需要写一个通用播放器/转码器框架时考虑。4. 实操方案A完整代码与参数细节这是最稳妥的一条路我把它每一步都拆开讲。以 Kotlin 为例假设你已经拿到了 content uri。4.1 获取文件名与打开输入流首先不能直接用uri.getLastPathSegment()当文件名因为很多 provider 返回的路径段是数字 ID比如content://media/external/video/media/12345拿到的 lastPathSegment 是12345没有扩展名。正确姿势是通过 ContentResolver 的OpenableColumns.DISPLAY_NAME查询原始文件名fun queryDisplayName(context: Context, uri: Uri): String? { var name: String? null context.contentResolver.query( uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null )?.use { cursor - if (cursor.moveToFirst()) { val index cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) if (index 0) { name cursor.getString(index) } } } return name }注意两点第一cursor 和流都要用use确保关闭否则会泄漏第二如果 provider 没有实现这个列有些第三方 provider 偷懒没写index 会是 -1此时要兜底自己拼一个文件名比如System.currentTimeMillis().toString() .mp4。拿到文件名后还要处理一下特殊字符。文件名里如果包含/或\拼路径时会导致目录层级错乱一定记得替换val safeName displayName.replace(Regex([\\\\/:*?\|]), _)4.2 流式写入缓存目录接下来就是打开输入流往 App 私有 cache 目录写入。这里直接放完整代码fun copyUriToCache(context: Context, uri: Uri): String? { val displayName queryDisplayName(context, uri) ?: temp_${System.currentTimeMillis()} val safeName displayName.replace(Regex([\\\\/:*?\|]), _) val cacheFile File(context.cacheDir, ffmpeg_input_$safeName) // 如果文件已存在先删除避免残留脏数据 if (cacheFile.exists()) { cacheFile.delete() } return try { context.contentResolver.openInputStream(uri)?.use { input - FileOutputStream(cacheFile).use { output - val buffer ByteArray(DEFAULT_BUFFER_SIZE) // 8KB var bytesRead: Int var totalBytes 0L while (input.read(buffer).also { bytesRead it } ! -1) { output.write(buffer, 0, bytesRead) totalBytes bytesRead } // 确保文件落盘避免突然断电/进程被杀导致文件不完整 output.fd.sync() } cacheFile.absolutePath } ?: run { null } } catch (e: Exception) { cacheFile.delete() null } }写这段有几点经验值得说缓冲数组大小用 8KB 起步实测系统相册里的大视频文件8KB 和 64KB 的吞吐差距不大瓶颈在 ContentProvider 的 Binder 调用上所以不用盲目开大。拷贝完务必 sync 一下。我遇到过直接写完就交给 FFmpeg结果 FFmpeg 探测时文件还没完全落盘报Invalid data一查是内核页缓存没刷下去加上fd.sync()之后就好了。异常路径记得删除残留文件。如果拷到一半磁盘满了留下一个半截文件在 cache 目录里下次再拷不会自动覆盖同名文件代码里已先删。4.3 把缓存路径交给 FFmpeg路径拿到手之后剩下就是正常 FFmpeg 操作了。比如用ffmpeg-kit这类 SDKval command arrayOf( -i, cachePath, -c:v, copy, -c:a, copy, outputPath ) FFmpegKit.executeAsync(command) { session - if (session.returnCode.isSuccess) { // 转码成功 } else { // 看 session.failStackTrace } }如果是自己编译的 FFmpeg ndk 库C 层就是标准的AVFormatContext *fmt_ctx NULL; if (avformat_open_input(fmt_ctx, path, NULL, NULL) 0) { // 失败处理 }有一点必须强调如果目标文件很大比如 4GB 视频这种全量拷贝方案吃时间也吃空间转码完成后一定要记得清理 cache。但别在 FFmpeg 还拿着这个路径的时候删异步回调里删除相对安全因为 FFmpeg 已经打开并读取完文件了。5. 实操方案B fd 路径的完整链路如果你确实不想拷贝方案B值得认真掌握。它的完整链路看起来简单但细节很多我挨个讲。5.1 openFileDescriptor 拿 fd第一步通过ContentResolver.openFileDescriptor()拿到一个ParcelFileDescriptorval pfd context.contentResolver.openFileDescriptor(uri, r) ?: return val fd pfd.fd val fdPath /proc/self/fd/$fd这里r表示只读。如果你的场景还需要写比如 FFmpeg 边转边输出到同一个文件可以传rw但绝大多数读取场景r就够了。拿到 fd 后拼出伪路径/proc/self/fd/{fd}把这个路径传给 FFmpegval command arrayOf( -i, fdPath, ... )由于/proc/self/fd/N是一个符号链接指向真实的已打开文件FFmpeg open 这个路径时内核会直接让它访问到同一个文件等于 FFmpeg 拿到了原始 fd 的“副本”。5.2 生命周期管理detachFd 与 close 的血泪教训这是方案B最容易踩雷的地方。ParcelFileDescriptor是 Java 层持有 fd 生命周期的一个包装它 close 的时候底层 fd 也会被关闭。而你的 FFmpeg 是异步任务可能在执行几秒甚至几十秒期间 Java 层 pfd 如果被回收了fd 就可能变成无效的FFmpeg 再 read 就会报EBADF或者直接崩溃。我的建议是调用 FD 路径后Java 层的 pfd 先不要 close等 FFmpeg 任务结束后再关。但如果 FFmpeg 是异步执行你就要小心 pfd 泄漏。可以这样处理val pfd contentResolver.openFileDescriptor(uri, r) ?: return val fd pfd.detachFd() // detachFd 之后pfd 不再拥有 fdJava 层 close 也不会关闭这个 fd // 这个 fd 现在的生命周期由 FFmpeg 和操作系统管理用detachFd()把 fd 从 Java 对象上“摘”下来这样 pfd 被 GC 或者手动 close 都不会影响 fd 数字。但摘下来之后你必须保证 FFmpeg 那边会负责 close——如果 FFmpeg 只是 open 之后读了一部分就不关了fd 就永远泄漏了。严谨的写法是在 FFmpeg 执行结束后手动清 fdpfd?.detachFd() // 执行 FFmpeg // 结束后 if (fd 0) { try { FileInputStream(FileDescriptor()).fd?.let { /* 这里拿不到原始 fd */ } } catch (_: Exception) {} }严格意义上detachFd()之后你没法再通过 Java 层的 API 关掉它了只能靠 FFmpeg 自己 close或者再构造一个ParcelFileDescriptor.fromFd()包一层再关。所以我的建议是先别急着 detachFd让 FFmpeg 工作期间 pfd 一直持有 fd任务结束再pfd.close()最省心。只有当你明确 FFmpeg 内部会正确关闭 fd 时才用 detach 手动管。5.3 无扩展名时的格式探测问题还有个坑/proc/self/fd/123这个路径没有.mp4后缀FFmpeg 探测格式时少了一个重要参考信息。虽然 FFmpeg 会通过读取文件头探测真实格式但个别情况下码流不规范、分离器不标准会失败报Invalid data found when processing input。解决办法是给 FFmpeg 增加探测时间和探测大小val command arrayOf( -probesize, 5000000, -analyzeduration, 10000000, -i, fdPath, ... )-probesize单位是字节默认好像是 5000KB 还是多少加大到 5MB 以上-analyzeduration单位是微秒加大到 10 秒给 FFmpeg 更多耐心。实测下来多数场景可以解决。如果加了参数还是不行说明这个 fd 不是 seekable 的此时 FFmpeg 没法回头重新探测基本无解只能回到方案A。6. 常见问题与排查实录下面这些都是我实际开发中真实遇到并解决过的问题整理成速查表方便排查。现象根因解决方案FileUriExposedException 崩溃targetSdk 24 还直接传 file://改用 FileProvider content uriavformat_open_input 返回 -1330794744content uri 当路径直传拷贝缓存 or fd 方案FFmpeg 报 No such file or directory/proc/self/fd/N对应的 fd 已关闭检查 pfd 生命周期任务结束前别 closeopenFileDescriptor 抛 SecurityException没有拿到 uri 读取授权Intent 里加 FLAG_GRANT_READ_URI_PERMISSIONopenFileDescriptor 返回 nullprovider 不支持 fd 方式退回方案A拷贝ffmpeg 报 Invalid data文件未完全落盘/不可 seek加 sync、加大 probesize、或走拷贝文件拷完发现大小跟源文件不一致读取流中途异常被忽略校验总字节数失败删缓存cache 目录空间不足大视频占太多提前检查可用空间或换方案B/C转码后文件时间基准不对音画不同步部分容器对时间基敏感指定-video_track_timescale或转成标准封装6.1 openFileDescriptor 抛 SecurityException这个我遇到好几次通常发生在你用一个 Activity 接收到某个 App 分享视频然后把它交给一个 Service 或者异步线程去处理的时候。FLAG_GRANT_READ_URI_PERMISSION的授权有效期只到接收方所在的任务栈结束为止如果你把 uri 塞进 Intent 传给另一个 Activity或跨进程绑定 Service临时授权可能已经失效。解决方式有两种一种是所有涉及该 uri 的组件都放到同一个进程、同一个 Activity 栈内处理另一种是在onCreate阶段立刻通过contentResolver.takePersistableUriPermission()申请持久授权前提是 Intent 里有FLAG_GRANT_PERSISTABLE_URI_PERMISSION。后者适合长期保存“用户选中的文件”供以后使用的场景。6.2 转码过程中文件被占用或权限被回收如果你在 FFmpeg 读取 fd 方案的中间Java 层做了一些收尾工作比如关 Activity、清 cache非常容易把 fd 搞失效。我自己的经验是FFmpeg 任务执行期间不要碰任何与源 uri 相关的资源包括 pfd、ContentResolver、临时授权等全部等回调里再说。另外记得方案A拷出来的缓存文件是 App 私有目录系统不会自动清如果转码大量文件cache 会越来越大。建议定期按时间清理cacheDir/ffmpeg_input_*.mp4或者转完就删。6.3 大文件拷贝容易 OOM用 Buffer 分页很多人写拷贝时图省事直接input.readBytes()一把梭大文件直接内存爆炸。上面 4.2 的代码用的是固定缓冲循环内存占用永远是 8KB不会随文件大小增长。原理很简单read 到一块写一块写盘后再去读下一块跟水管接力一样永远只有一小段水在管子里。如果传输速度慢可以考虑把 Buffer 加到 1MB 配合 BufferedInputStream 减少 Binder 次数实测几百 MB 文件用 1MB 缓冲可以省不少时间。但不要无限加大ContentProvider 的 Binder 传输本身有大小限制超过 1MB 的单次 read 可能直接报TransactionTooLargeException。6.4 关联热搜FFmpeg 推流到 SRS 存在延迟这个内容不在本文核心范围但既然很多人在搜我顺带说一句如果你用ffmpeg -i contentUri转码后推流到 SRS延迟偏高通常不是 content uri 读取这个环节造成的而是推流参数设置不合理。常见是 GOP 太大-g默认可能 250 甚至更大导致解码端要缓存一整组 GOP 才能播放以及没有设置-tune zerolatencyx264 编码器或-fflags nobuffer。如果是做低延迟直播建议 GOP 控制在 1~2 秒开zerolatency再配合-flush_packets 1。如果是 Flv 封装还要考虑-flvflags no_duration_filesize。这些跟“content uri 读取”是两条线别混在一起排查。6.5 实测对比不同 ROM 的差异最后说个玄学问题。同样的代码在 Pixel 模拟器上跑得好好的在小米某些机型上openFileDescriptor老是失败甚至返回了一个可读但 seek 不了的 fd。这通常是 ROM 厂商魔改了 MediaProvider或者底层文件系统是 FUSE 挂载导致的。遇到这种情况不要死磕 fd 方案果断切方案A。在“能用”和“优雅”之间永远先保能用。7. 避坑总结与个人建议这几年的经验下来我自己的开发准则是默认方案A性能不够再换B方案C只在写通用框架时用。方案A虽然看起来笨但它把 content uri 到文件路径的转换做得干干净净后续 FFmpeg 行为和本地文件完全一致不需要考虑 fd 生命周期、seekable、探测失败这些破事。对于绝大多数业务场景——用户选个视频转码压缩、拼接、加滤镜——那点拷贝时间用户根本感知不到稳定才是第一位的。只有当文件特别大、或者拷贝时间明显影响体验时才考虑方案B并且一定要把 fd 生命周期管好。有一次我在一个播放器项目里偷懒fd 没等 FFmpeg 结束就释放了结果用户播到一半画面冻结log 里全是Read error at offset...。排查了半天才意识到是 pfd 被 GC 提前回收了加了持有引用之后就好了。方案C是最复杂也最有意思的适合想做通用视频处理引擎的团队。但说实话如果需求只是“把相册视频丢给 FFmpeg 处理一下”上自定义 AVIO 纯属给自己找事除非你要做流式边下边转这类高级功能。最后分享一个实用小技巧不管用哪种方案FFmpeg 执行之前都先做一次格式探测。你可以用av_probe_input_format3()快速读取文件头判断是不是真的视频文件避免把一张 PNG 或一个损坏文件丢进转码流程既浪费时间又让用户看到莫名其妙的错误提示。这个探测的成本极低性价比很高。