基于 Media3 的视频缓存与完整文件导出实践
在视频播放场景里,我们经常会遇到两类需求:一类是播放时尽量复用本地缓存,减少重复网络请求;另一类是当缓存已经足够完整时,将缓存内容导出成一个完整的视频文件,供离线使用或后续处理。
Media3 已经提供了缓存、下载、播放器接入等基础能力,但“把缓存导出为一个完整文件”这件事,仍然需要业务层做一次封装。本文结合一套可落地的实践方案,系统梳理以下两个问题:
- 如何用 Media3 配置视频缓存。
- 如何把缓存中的视频导出为一个完整文件。
一、整体思路
整个方案可以拆成三层:
SimpleCache:负责缓存文件落盘。CacheDataSource.Factory:负责把缓存接入播放器读写链路。DownloadManager:负责完整预加载资源。
如果目标只是“边播边缓存”,只接入 SimpleCache + CacheDataSource 就够了。 如果目标还包括“离线导出完整视频”,则还需要:
- 对同一资源生成稳定的
cacheKey - 判断缓存是否已经完整
- 按缓存分片顺序导出到目标文件
- 对同一视频的导出过程加锁,避免并发冲突
二、为什么必须统一 CacheKey
很多视频地址都带动态参数,例如:
1
2
https://example.com/video/test.mp4?token=aaa&ts=111
https://example.com/video/test.mp4?token=bbb&ts=222
如果直接把完整 URL 当作缓存 key,那么同一视频在缓存系统里会被识别成两个资源,最终带来三个问题:
- 同一个视频无法命中已有缓存
- 相同资源会重复下载
- 导出时可能找不到预期缓存
因此,最稳妥的做法是自定义 CacheKeyFactory,在生成 key 时移除 query 参数,只保留稳定的主路径。
示例实现如下:
1
2
3
4
5
class StableVideoCacheKeyFactory : CacheKeyFactory {
override fun buildCacheKey(dataSpec: DataSpec): String {
return dataSpec.uri.buildUpon().clearQuery().build().toString()
}
}
三、缓存配置
1. 创建 SimpleCache
SimpleCache 有一个非常关键的约束:
同一个缓存目录只能对应一个 SimpleCache 实例。
如果在同一目录上重复创建多个实例,会直接抛异常。因此比较推荐的做法是,在应用层统一管理 SimpleCache,以目录路径为 key 做单例复用。
同时,缓存淘汰策略可以根据业务场景选择:
LeastRecentlyUsedCacheEvictor(maxBytes):适合播放器缓存,限制最大缓存空间。NoOpCacheEvictor():不自动淘汰,适合业务侧自行管理缓存生命周期。
完整示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
@OptIn(UnstableApi::class)
object Media3VideoCacheManager {
private val simpleCacheInstances = mutableMapOf<String, SimpleCache>()
private var appContext: Context? = null
fun init(context: Context) {
appContext = context.applicationContext
}
@Synchronized
fun getOrCreateSimpleCache(
cacheDir: File,
maxCacheSize: Long,
noCacheLimit: Boolean = false
): SimpleCache {
val context = requireNotNull(appContext) {
"Media3VideoCacheManager.init(context) must be called first."
}
val cachePath = cacheDir.absolutePath
simpleCacheInstances[cachePath]?.let { return it }
if (!cacheDir.exists()) {
cacheDir.mkdirs()
}
val cache = SimpleCache(
cacheDir,
if (noCacheLimit) NoOpCacheEvictor() else LeastRecentlyUsedCacheEvictor(maxCacheSize),
StandaloneDatabaseProvider(context)
)
simpleCacheInstances[cachePath] = cache
return cache
}
}
初始化方式:
1
2
3
4
5
6
7
8
val cacheDir = File(context.cacheDir, "media3_video_cache")
Media3VideoCacheManager.init(context)
val simpleCache = Media3VideoCacheManager.getOrCreateSimpleCache(
cacheDir = cacheDir,
maxCacheSize = 1024L * 1024L * 1024L,
noCacheLimit = false
)
2. 给播放器接入缓存
播放器侧的核心思路,是把 CacheDataSource.Factory 包装进 MediaSource.Factory。
如果你希望播放器边播边写缓存,可以保留默认写入逻辑。 如果你希望播放器只消费缓存、由后台下载任务统一负责写缓存,可以将 setCacheWriteDataSinkFactory(null) 置空,这样播放器就是“只读缓存”。
完整示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@JvmStatic
@JvmOverloads
fun createCacheMediaSourceFactory(
cache: SimpleCache,
headers: Map<String, String> = emptyMap(),
flags: Int = CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR,
cacheKeyFactory: CacheKeyFactory = StableVideoCacheKeyFactory(),
onlyReadCache: Boolean = false
): MediaSource.Factory {
val context = requireNotNull(appContext) {
"Media3VideoCacheManager.init(context) must be called first."
}
val httpFactory = DefaultHttpDataSource.Factory().apply {
if (headers.isNotEmpty()) {
setDefaultRequestProperties(headers)
}
}
val upstreamFactory = DefaultDataSource.Factory(context, httpFactory)
val cacheFactory = CacheDataSource.Factory()
.setCache(cache)
.setUpstreamDataSourceFactory(upstreamFactory)
.setCacheKeyFactory(cacheKeyFactory)
.setFlags(flags)
.apply {
if (onlyReadCache) {
setCacheWriteDataSinkFactory(null)
}
}
return DefaultMediaSourceFactory(cacheFactory)
}
接入 ExoPlayer:
1
2
3
4
5
6
7
8
val mediaSourceFactory = Media3VideoCacheManager.createCacheMediaSourceFactory(
cache = simpleCache,
onlyReadCache = false
)
val player = ExoPlayer.Builder(context)
.setMediaSourceFactory(mediaSourceFactory)
.build()
四、如何预加载完整视频
如果只是播放命中缓存,可以不使用 DownloadManager。 但如果你的目标是“完整导出一个离线文件”,最稳妥的方式还是先把视频完整预加载进缓存。
完整实现如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
@Synchronized
fun getDownloadManager(
cache: SimpleCache,
headers: Map<String, String> = emptyMap(),
parallelDownloads: Int = 3
): DownloadManager {
downloadManager?.let { return it }
val context = requireNotNull(appContext) {
"Media3VideoCacheManager.init(context) must be called first."
}
val httpFactory = DefaultHttpDataSource.Factory().apply {
if (headers.isNotEmpty()) {
setDefaultRequestProperties(headers)
}
}
downloadManager = DownloadManager(
context,
StandaloneDatabaseProvider(context),
cache,
DefaultDataSource.Factory(context, httpFactory),
Dispatchers.IO.asExecutor()
).apply {
requirements = Requirements(Requirements.NETWORK)
maxParallelDownloads = parallelDownloads
}
return requireNotNull(downloadManager)
}
@MainThread
@JvmStatic
@JvmOverloads
fun startPreload(
cache: SimpleCache,
url: String,
headers: Map<String, String> = emptyMap(),
preloadLength: Long = C.LENGTH_UNSET.toLong(),
cacheKeyFactory: CacheKeyFactory = StableVideoCacheKeyFactory(),
callback: MediaDownloadCallback? = null
): VideoDownloadListener? {
if (url.isBlank()) {
callback?.onDownloadFailed("URL is empty.")
return null
}
val uri = url.toUri()
if (uri.scheme == null || uri.scheme == "file" || uri.scheme == "content") {
callback?.onDownloadSuccess(url, url.mediaCacheKey(cacheKeyFactory))
return null
}
val cacheKey = url.mediaCacheKey(cacheKeyFactory)
val listener = callback?.let {
addDownloadListener(cache, url, headers, cacheKeyFactory, it)
}
val request = DownloadRequest.Builder(cacheKey, uri)
.setData(cacheKey.toByteArray())
.setCustomCacheKey(cacheKey)
.setByteRange(0, preloadLength)
.build()
getDownloadManager(cache, headers).addDownload(request)
getDownloadManager(cache, headers).resumeDownloads()
return listener
}
使用方式:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Media3VideoCacheManager.startPreload(
cache = simpleCache,
url = "https://example.com/video/test.mp4",
preloadLength = C.LENGTH_UNSET.toLong(),
callback = object : MediaDownloadCallback {
override fun onProgress(progress: Float, downloadBytes: Long, totalBytes: Long) {
println("download progress = $progress")
}
override fun onDownloadSuccess(mediaUrl: String, cacheKey: String?) {
println("download success")
}
override fun onDownloadFailed(errorMsg: String, mediaUrl: String?, cacheKey: String?) {
println("download failed: $errorMsg")
}
}
)
其中 preloadLength = C.LENGTH_UNSET 表示完整下载整个视频。
五、如何判断缓存是否已经完整
在真正导出文件前,最好先确认缓存已经完整。最常见的方法,是从 ContentMetadata 里拿到资源总大小,再使用 isCached(cacheKey, 0, contentLength) 判断整个区间是否已命中缓存。
示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
@JvmStatic
@JvmOverloads
fun isCachedAllFile(
cache: SimpleCache,
url: String,
cacheKeyFactory: CacheKeyFactory = StableVideoCacheKeyFactory()
): Boolean {
val cacheKey = url.mediaCacheKey(cacheKeyFactory)
val contentLength = cache.getContentMetadata(cacheKey)
.get(ContentMetadata.KEY_CONTENT_LENGTH, 0L)
if (contentLength <= 0L) return false
return cache.isCached(cacheKey, 0, contentLength)
}
如果只是想拿当前已缓存字节数,也可以这样写:
1
2
3
4
5
6
7
8
9
10
@JvmStatic
@JvmOverloads
fun getCachedLength(
cache: SimpleCache,
url: String,
cacheKeyFactory: CacheKeyFactory = StableVideoCacheKeyFactory()
): Long {
val cacheKey = url.mediaCacheKey(cacheKeyFactory)
return cache.getCachedLength(cacheKey, 0, Long.MAX_VALUE)
}
六、如何把缓存导出为一个完整文件
这是整套方案里最关键的一步。
Media3 的缓存本质上是按 span 分片存储的,并不会天然帮你合并成一个完整 MP4 文件。因此导出的基本流程是:
- 根据
cacheKey拿到所有cachedSpans - 按
position排序 - 依次读取每个 span 对应的缓存文件
- 顺序写入临时文件
- 写完之后再重命名成目标文件
完整实现如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
suspend fun exportCacheFile(
cache: SimpleCache,
url: String,
outputFile: File,
deleteFileIfExist: Boolean = true,
cacheKeyFactory: CacheKeyFactory = StableVideoCacheKeyFactory(),
progressListener: ((bytesCopied: Long, totalBytesExpected: Long, percent: Float) -> Unit)? = null
): File? {
if (url.isBlank()) return null
val outputDir = outputFile.parentFile
if (outputDir != null && !outputDir.exists() && !outputDir.mkdirs()) {
return null
}
val cacheKey = url.mediaCacheKey(cacheKeyFactory)
val tempOutputFile = File(outputFile.parentFile, outputFile.name + ".temp")
if (outputFile.exists()) {
if (!deleteFileIfExist) {
return outputFile
}
outputFile.delete()
}
if (tempOutputFile.exists()) {
tempOutputFile.delete()
}
val cachedSpans = cache.getCachedSpans(cacheKey).toMutableList()
if (cachedSpans.isEmpty()) return null
cachedSpans.sortBy { it.position }
val metadataLength = cache.getContentMetadata(cacheKey)
.get(ContentMetadata.KEY_CONTENT_LENGTH, 0L)
val totalExpectedLength = if (metadataLength > 0L) {
metadataLength
} else {
cachedSpans.last().position + cachedSpans.last().length
}
var totalBytesCopied = 0L
var outputStream: FileOutputStream? = null
try {
tempOutputFile.createNewFile()
outputStream = FileOutputStream(tempOutputFile)
val buffer = ByteArray(8 * 1024)
for (span in cachedSpans) {
if (!coroutineContext.isActive) {
throw CancellationException("Export cancelled before processing span")
}
if (!span.isCached || span.file == null) {
continue
}
var bytesToReadInSpan = span.length
FileInputStream(span.file).use { inputStream ->
while (bytesToReadInSpan > 0) {
if (!coroutineContext.isActive) {
throw CancellationException("Export cancelled during span copy")
}
val read = inputStream.read(buffer)
if (read == -1) break
val bytesToWrite = min(read.toLong(), bytesToReadInSpan).toInt()
outputStream.write(buffer, 0, bytesToWrite)
totalBytesCopied += bytesToWrite
bytesToReadInSpan -= bytesToWrite
val percent = if (totalExpectedLength > 0L) {
totalBytesCopied / totalExpectedLength.toFloat()
} else {
0f
}
progressListener?.invoke(
totalBytesCopied,
totalExpectedLength,
percent.coerceIn(0f, 1f)
)
}
}
}
outputStream.flush()
if (totalExpectedLength > 0L && tempOutputFile.length() != totalExpectedLength) {
tempOutputFile.delete()
return null
}
return if (tempOutputFile.renameTo(outputFile)) {
outputFile
} else {
tempOutputFile.delete()
null
}
} catch (e: CancellationException) {
tempOutputFile.delete()
return null
} catch (e: Exception) {
tempOutputFile.delete()
return null
} finally {
try {
outputStream?.close()
} catch (_: IOException) {
}
}
}
直接导出使用示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
val outputFile = File(context.getExternalFilesDir(null), "test.mp4")
CoroutineScope(Dispatchers.IO).launch {
val file = Media3VideoCacheManager.exportCacheFile(
cache = simpleCache,
url = "https://example.com/video/test.mp4",
outputFile = outputFile,
deleteFileIfExist = true
) { bytesCopied, totalBytesExpected, percent ->
println("export progress = $percent, $bytesCopied/$totalBytesExpected")
}
withContext(Dispatchers.Main) {
println("export result = ${file?.absolutePath}")
}
}
七、为什么同一个视频导出时要加锁
导出文件时,如果两个任务同时操作同一个视频,往往会出现这些问题:
- 同时写同一个输出文件
- 同时竞争同一个临时文件
- 导出进度互相干扰
- 最终文件内容错乱
因此,建议按 cacheKey 维度做互斥控制。不同视频可以并行导出,但同一个视频必须串行导出。
示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private val exportMutexes = ConcurrentHashMap<String, Mutex>()
private fun getOrPutMutex(
url: String,
cacheKeyFactory: CacheKeyFactory = StableVideoCacheKeyFactory()
): Mutex {
val cacheKey = url.mediaCacheKey(cacheKeyFactory)
var mutex = exportMutexes[cacheKey]
if (mutex == null) {
val newMutex = Mutex()
val oldMutex = exportMutexes.putIfAbsent(cacheKey, newMutex)
mutex = oldMutex ?: newMutex
}
return mutex
}
八、预加载完成后自动导出
在业务里,更常见的并不是“先手动判断,再手动导出”,而是:
- 发起完整预加载
- 下载完成后自动进入导出阶段
- 对外统一暴露总进度
示例实现:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
@MainThread
@JvmStatic
@JvmOverloads
fun preloadAndExportFile(
cache: SimpleCache,
url: String,
outputFile: File,
headers: Map<String, String> = emptyMap(),
deleteFileIfExist: Boolean = true,
cacheKeyFactory: CacheKeyFactory = StableVideoCacheKeyFactory(),
onProgressCallback: ((progress: Float) -> Unit)? = null,
onErrorCallback: ((errorMsg: String) -> Unit)? = null,
onSuccessCallback: ((file: File?) -> Unit)? = null
): Job? {
val uri = url.toUri()
if (uri.scheme == null || uri.scheme == "file" || uri.scheme == "content") {
val localFile = File(uri.path ?: url)
if (localFile.exists() && localFile.isFile) {
onSuccessCallback?.invoke(localFile)
} else {
onErrorCallback?.invoke("Local file not found: $url")
}
return null
}
val totalProgress = 105f
val exportJob = Job()
val listener = addDownloadListener(
cache = cache,
url = url,
headers = headers,
cacheKeyFactory = cacheKeyFactory,
callback = object : MediaDownloadCallback {
override fun onProgress(progress: Float, downloadBytes: Long, totalBytes: Long) {
onProgressCallback?.invoke(progress / totalProgress)
}
override fun onDownloadFailed(errorMsg: String, mediaUrl: String?, cacheKey: String?) {
exportJob.cancel(CancellationException("Preload failed: $errorMsg"))
onErrorCallback?.invoke(errorMsg)
}
override fun onDownloadSuccess(mediaUrl: String, cacheKey: String?) {
CoroutineScope(Dispatchers.IO + exportJob).launch {
val mutex = getOrPutMutex(url, cacheKeyFactory)
mutex.withLock {
try {
val resultFile = exportCacheFile(
cache = cache,
url = url,
outputFile = outputFile,
deleteFileIfExist = deleteFileIfExist,
cacheKeyFactory = cacheKeyFactory
) { _, _, percent ->
if (!isActive) {
throw CancellationException("Export cancelled")
}
val mergedProgress = (100 + 5 * percent) / totalProgress
onProgressCallback?.invoke(mergedProgress)
}
withContext(Dispatchers.Main) {
onSuccessCallback?.invoke(resultFile)
}
} catch (e: Exception) {
outputFile.delete()
withContext(Dispatchers.Main) {
onErrorCallback?.invoke("Export failed: ${e.message}")
}
}
}
}
}
}
)
preloadListeners[url.mediaCacheKey(cacheKeyFactory)] = listener
startPreload(
cache = cache,
url = url,
headers = headers,
preloadLength = C.LENGTH_UNSET.toLong(),
cacheKeyFactory = cacheKeyFactory
)
return exportJob
}
使用方式:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
val outputFile = File(context.getExternalFilesDir(null), "test.mp4")
Media3VideoCacheManager.preloadAndExportFile(
cache = simpleCache,
url = "https://example.com/video/test.mp4",
outputFile = outputFile,
deleteFileIfExist = true,
onProgressCallback = { progress ->
println("total progress = $progress")
},
onErrorCallback = { error ->
println("error = $error")
},
onSuccessCallback = { file ->
println("success = ${file?.absolutePath}")
}
)
这里把总进度人为拆成两段:
- 下载阶段:约占
0% ~ 95% - 导出阶段:约占
95% ~ 100%
这不是 Media3 的固定规则,而是一种比较适合 UI 展示的业务策略。
九、实践中的几个坑
1. 导出不等于转码
这里的“导出缓存为完整视频”,本质上是把缓存分片重新拼接成源文件字节流,并不是重新转码,也不是重封装。
换句话说,这套方案适用于:
- HTTP 直链视频
- 已被完整缓存的媒体资源
- 缓存字节流与原始媒体文件一致的场景
它并不负责:
- 修复坏帧
- 转码码率
- 转换封装格式
2. 最好再次校验导出文件完整性
即使所有 span 都已经写出,也建议在导出完成后再做一次完整性校验,例如:
- 文件长度是否等于
contentLength - 是否能被播放器正常打开
- 必要时做 hash 校验
3. 永远用临时文件导出
不要直接写目标文件,正确做法一定是:
- 先写
xxx.mp4.temp - 完成后再重命名为
xxx.mp4
这样即使中途取消或异常退出,也不会留下一个伪装成成功结果的损坏文件。
4. onlyReadCache 的适用场景
如果你的播放器和后台下载器会同时接触同一份缓存,建议认真评估是否要把播放器设置成只读缓存。
适合 onlyReadCache = true 的典型场景是:
- 后台完整下载任务统一负责写缓存
- 前台播放器优先读缓存,未命中时再走网络
- 避免“播放器写缓存”和“下载器写缓存”同时竞争同一资源
十、总结
用 Media3 做视频缓存并不难,真正需要补齐的是“缓存完整性判断”和“缓存导出文件”这两层业务能力。
落地时可以记住下面四个关键点:
- 同一个缓存目录,只创建一个
SimpleCache - 播放、预加载、导出必须统一
cacheKey - 导出时按 span 顺序读取并写入临时文件
- 同一个视频导出过程必须加锁
如果只是为了提升播放体验,接入 SimpleCache + CacheDataSource 就已经足够。 如果还需要离线文件能力,那么在此基础上增加 DownloadManager + exportCacheFile(),就可以形成一套比较完整、稳定的 Media3 视频缓存方案。