22、自定义数据源:实现MediaDataSource接口,从加密文件或数据库读取
说实话,做音视频开发这么多年,最让我头疼的往往不是编解码本身,而是数据怎么喂给解码器。尤其是当你手里的视频文件被加密了,或者数据存在数据库里,标准的MediaExtractor就傻眼了。它只认文件路径和文件描述符,别的都不认。
那怎么办?自己动手,丰衣足食。Android提供了一个叫MediaDataSource的接口,说白了就是让你自定义数据源。你只要实现这个接口,MediaExtractor就能从任何地方读取数据——加密文件、内存缓存、数据库BLOB字段,甚至网络流都行。
MediaDataSource 接口长什么样?
先看看这个接口到底要我们实现什么。其实就三个方法:
public abstract class MediaDataSource implements Closeable {
// 读取数据到buffer,返回实际读取的字节数
public abstract int readAt(long position, byte[] buffer, int offset, int size);
// 返回数据总大小,-1表示未知
public abstract long getSize();
// 关闭资源
public abstract void close();
}
嗯,接口很简单。但坑都在细节里。我一个个说。
第一个方法:readAt
这个方法最关键。MediaExtractor会不断调用它来读取数据。注意参数:position是绝对偏移量,不是相对位置。也就是说,每次调用都从文件开头算起。
我在项目中遇到过一个问题:第一次调用readAt时,position可能是0,但第二次可能直接跳到1000。你不能假设它会顺序读取。所以每次都要根据position去定位数据源。
核心要点:readAt必须支持随机访问。不能假设读取顺序是连续的。
返回值也很讲究。返回-1表示读取失败,返回0表示读到文件末尾。但注意,返回的字节数可以小于请求的size,这不算错误。比如你请求读1024字节,但文件只剩100字节了,那就返回100。
第二个方法:getSize
这个方法返回数据总长度。MediaExtractor用它来做一些预处理,比如计算时长、定位关键帧等。
如果你返回-1,表示大小未知。MediaExtractor也能工作,但某些功能会受限。比如seek操作可能不准,因为不知道文件边界在哪。
我个人习惯是尽量返回准确大小。如果实在不知道,比如数据是动态生成的,那就返回-1,但要做好心理准备——某些播放器可能会卡顿。
第三个方法:close
这个没什么好说的,释放资源。文件流、数据库游标、网络连接,都在这里关掉。
实战:从加密文件读取
假设你有一个AES加密的视频文件,直接给MediaExtractor肯定不行。我们来写一个解密的数据源。
public class DecryptMediaDataSource extends MediaDataSource {
private final InputStream encryptedStream;
private final Cipher cipher;
private final long fileSize;
public DecryptMediaDataSource(String filePath, byte[] key) throws Exception {
File file = new File(filePath);
this.fileSize = file.length();
this.encryptedStream = new FileInputStream(file);
// 初始化AES解密
SecretKeySpec keySpec = new SecretKeySpec(key, "AES");
this.cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
cipher.init(Cipher.DECRYPT_MODE, keySpec,
new IvParameterSpec(getIVFromFile(filePath)));
}
@Override
public int readAt(long position, byte[] buffer, int offset, int size) throws IOException {
// 定位到加密文件的对应位置
// 注意:加密后的位置和原始位置可能不对应
// 这里简化处理,实际需要处理块对齐
encryptedStream.getChannel().position(position);
byte[] encrypted = new byte[size];
int read = encryptedStream.read(encrypted, 0, size);
if (read <= 0) return read;
try {
byte[] decrypted = cipher.update(encrypted, 0, read);
System.arraycopy(decrypted, 0, buffer, offset, decrypted.length);
return decrypted.length;
} catch (Exception e) {
return -1;
}
}
@Override
public long getSize() {
return fileSize; // 这里返回加密文件大小,实际解密后大小可能不同
}
@Override
public void close() throws IOException {
encryptedStream.close();
}
}
注意:上面的代码是简化版。实际加密文件读取要考虑块对齐问题。AES加密是按16字节块处理的,所以position必须是16的倍数。我曾经在这个坑里爬了整整一天——解密出来的数据全是乱码,最后发现是位置没对齐。
实战:从数据库读取
有时候视频数据存在SQLite的BLOB字段里。比如聊天软件里的短视频,直接存数据库了。这时候怎么播放?
public class DatabaseMediaDataSource extends MediaDataSource {
private final byte[] videoData;
public DatabaseMediaDataSource(Context context, long messageId) {
// 从数据库读取整个BLOB到内存
Cursor cursor = context.getContentResolver().query(
Uri.parse("content://com.example/video/" + messageId),
new String[]{"video_data"},
null, null, null
);
if (cursor != null && cursor.moveToFirst()) {
this.videoData = cursor.getBlob(0);
cursor.close();
} else {
this.videoData = new byte[0];
}
}
@Override
public int readAt(long position, byte[] buffer, int offset, int size) {
if (position >= videoData.length) {
return -1; // 超出范围
}
int available = (int) Math.min(size, videoData.length - position);
System.arraycopy(videoData, (int) position, buffer, offset, available);
return available;
}
@Override
public long getSize() {
return videoData.length;
}
@Override
public void close() {
// 不需要关闭什么,但可以清空引用
}
}
你看,这个实现就简单多了。因为数据已经在内存里了,readAt直接拷贝就行。但要注意——如果视频文件很大,比如几百MB,全加载到内存会OOM的。我建议只对小于10MB的视频用这种方式。
使用方式
写好了数据源,怎么用?很简单:
MediaExtractor extractor = new MediaExtractor();
MediaDataSource dataSource = new DecryptMediaDataSource(filePath, key);
extractor.setDataSource(dataSource);
// 后面就跟普通文件一样用了
extractor.selectTrack(0);
MediaFormat format = extractor.getTrackFormat(0);
// ... 解码播放
注意,setDataSource方法接受的是MediaDataSource对象,不是文件路径。一旦设置成功,MediaExtractor内部就会通过你的数据源来读取数据。
性能考量
说实话,自定义数据源肯定比直接读文件慢。因为多了一层函数调用。尤其是解密操作,每次readAt都要做加解密,性能开销不小。
我建议做两件事来优化:
- 加缓存:在数据源内部维护一个LRU缓存,避免频繁读取底层存储
- 批量读取:MediaExtractor通常一次只读几千字节,你可以预读更多,减少IO次数
小技巧:如果数据源是网络流,记得在readAt里做超时处理。我曾经遇到过网络卡住,导致整个播放器卡死的情况。加个3秒超时,至少能让播放器优雅地报错。
流程图:自定义数据源的工作流程
避坑指南
最后,分享几个我踩过的坑:
- 线程安全:MediaExtractor可能会在多个线程中调用readAt。我建议加个锁,或者用ThreadLocal。我当初没注意,结果偶发性的崩溃查了好久。
- 返回值别乱写:readAt返回0不代表错误,代表没读到数据。返回-1才是错误。别搞混了。
- getSize要准确:如果返回的大小比实际数据小,MediaExtractor可能读不全。如果返回的比实际大,它可能会一直读到文件末尾之外,然后你返回-1,它才知道结束了。
- close可能被多次调用:做好幂等处理,别重复释放资源。
好了,自定义数据源就这些内容。说白了就是一层适配,把各种奇怪的数据源包装成MediaExtractor能理解的样子。掌握了这个,你就能处理绝大多数非标准的数据来源了。
公众号:蓝海资料掘金营,微信deep3321