从ContentProvider提取:处理相册、文件管理器返回的URI

好,咱们继续聊MediaExtractor。前面几章我们都在处理本地文件路径,也就是那种直接传一个/sdcard/video.mp4进去就能用的场景。但实际开发中,你从相册选视频、从文件管理器选文件,拿到的可不是文件路径,而是一个URI。

这个URI长什么样?大概是content://media/external/video/media/12345这样。你直接把这种URI扔给MediaExtractor的setDataSource(String)方法?嗯,它会直接报错。为什么?因为MediaExtractor不认识ContentProvider的URI协议。

我个人习惯把这个问题分成两个层面来解决:一是怎么拿到正确的文件描述符,二是怎么处理那些“不老实”的URI——比如Google Photos返回的那种临时URI。

URI的两种常见来源

先看看我们平时会碰到哪些URI。我整理了一下,大概分两类:

来源 URI示例 特点
系统相册 content://media/external/images/media/123 持久化URI,可重复打开
文件管理器 content://com.android.externalstorage.documents/document/primary%3AVideo%2Ftest.mp4 临时授权,用完即失效
Google Photos content://com.google.android.apps.photos.contentprovider/... 需要特殊处理,可能不支持直接fd

你看,同样是content://开头,但背后的行为完全不同。我在项目中遇到过最坑的一次,就是用户从Google Photos选了一个视频,我直接用openFileDescriptor去拿fd,结果返回null。后来才发现,Google Photos返回的URI根本不允许你直接读文件描述符,你得先把它拷贝到应用缓存目录。

核心方案:通过ContentResolver获取文件描述符

说白了,处理ContentProvider URI的核心就一句话:不要直接传URI给MediaExtractor,而是通过ContentResolver拿到一个文件描述符(FileDescriptor),再传给MediaExtractor

代码怎么写?看下面:

// 假设你从onActivityResult拿到了uri
Uri videoUri = data.getData();

// 通过ContentResolver打开文件描述符
ContentResolver resolver = getContentResolver();
try {
    ParcelFileDescriptor pfd = resolver.openFileDescriptor(videoUri, "r");
    if (pfd != null) {
        FileDescriptor fd = pfd.getFileDescriptor();
        
        // 把fd传给MediaExtractor
        MediaExtractor extractor = new MediaExtractor();
        extractor.setDataSource(fd);
        
        // 别忘了用完关闭
        // extractor.release();
        // pfd.close();
    }
} catch (FileNotFoundException e) {
    e.printStackTrace();
}

这里有个细节要注意:openFileDescriptor的第二个参数是访问模式,对于读取视频来说,传"r"就够了。别传"rw",有些ContentProvider会拒绝写入权限。

关键点:MediaExtractor的setDataSource(FileDescriptor)方法不会帮你关闭fd。你必须在提取完成后手动关闭ParcelFileDescriptor。否则会出现文件句柄泄漏,尤其是在循环处理多个视频时。

避坑指南:临时URI的处理

我曾经在做一个视频剪辑App时,遇到一个诡异的问题:用户从文件管理器选了一个视频,第一次提取成功了,第二次再选同一个视频就崩溃了。查了半天才发现,文件管理器返回的URI是临时授权的,一旦你的Activity处理完这次请求,授权就失效了。

怎么判断一个URI是不是临时的?你可以检查一下Intent的flag:

// 检查是否有临时读取权限
if ((intent.getFlags() & Intent.FLAG_GRANT_READ_URI_PERMISSION) != 0) {
    // 这是临时授权,建议立即拷贝到本地
    copyUriToCache(videoUri);
}

对于临时URI,我的建议是:拿到URI后立刻拷贝到应用缓存目录,然后再用本地文件路径去创建MediaExtractor。这样既避免了授权过期的问题,也提高了后续读取速度。

小技巧:拷贝URI内容到本地时,别忘了检查文件大小。有些相册App返回的URI可能指向一个超大文件(比如4K视频),直接拷贝到缓存目录可能会撑爆你的应用缓存。我一般会先通过ContentResolver查询文件大小,超过100MB就提示用户或者走流式处理。

获取URI的元数据

有时候你不仅需要视频的二进制数据,还需要知道它的文件名、大小、MIME类型等信息。这些信息也可以通过ContentResolver从URI中查询出来:

// 查询URI的元数据
String[] projection = {
    MediaStore.Video.Media.DISPLAY_NAME,
    MediaStore.Video.Media.SIZE,
    MediaStore.Video.Media.MIME_TYPE,
    MediaStore.Video.Media.DURATION
};

Cursor cursor = resolver.query(videoUri, projection, null, null, null);
if (cursor != null && cursor.moveToFirst()) {
    String fileName = cursor.getString(0);
    long fileSize = cursor.getLong(1);
    String mimeType = cursor.getString(2);
    long duration = cursor.getLong(3);
    
    // 这些信息可以用来做后续处理
    cursor.close();
}

你想想看,有了这些元数据,你就可以在提取之前先判断一下:这个视频是不是太大了?格式支不支持?时长是不是太短了?提前过滤掉不合适的视频,能省不少事。

完整的处理流程

我把整个流程画了一张图,方便你理解:

从ContentProvider提取视频的完整流程 收到URI 是否临时授权? 拷贝到缓存目录 直接打开fd 创建MediaExtractor 提取音视频轨道

这张图把整个决策流程讲得很清楚了。核心就是先判断URI的授权类型,再决定是直接打开fd还是先拷贝到本地。我个人更倾向于统一先拷贝到缓存目录,虽然多了一步IO操作,但胜在稳定可靠,不会出现授权过期的问题。

关于权限的提醒

注意:从Android 10(API 29)开始,系统对存储权限的管理更加严格。即使你声明了READ_EXTERNAL_STORAGE权限,也不一定能直接读取所有URI。正确的做法是使用ActivityResultContracts.OpenDocumentActivityResultContracts.GetContent来获取URI,系统会自动帮你处理好权限问题。

嗯,这里还要提一句:有些国产ROM对ContentProvider的实现有差异。我在某款手机上遇到过openFileDescriptor返回的fd是只读的,但MediaExtractor却需要可读可写(虽然它实际上只读)。解决方案是在打开fd时指定"r"模式,如果不行就试试"rw"——虽然这听起来有点奇怪,但确实能解决问题。

好了,关于从ContentProvider提取视频的内容就讲到这里。你只要记住:URI不是文件路径,别直接传;用ContentResolver拿fd;临时URI先拷贝到本地。这三点做到位了,基本上不会出大问题。


公众号:蓝海资料掘金营,微信deep3321