文章

基于 Media3 的视频缓存与完整文件导出实践

基于 Media3 的视频缓存与完整文件导出实践

在视频播放场景里,我们经常会遇到两类需求:一类是播放时尽量复用本地缓存,减少重复网络请求;另一类是当缓存已经足够完整时,将缓存内容导出成一个完整的视频文件,供离线使用或后续处理。

Media3 已经提供了缓存、下载、播放器接入等基础能力,但“把缓存导出为一个完整文件”这件事,仍然需要业务层做一次封装。本文结合一套可落地的实践方案,系统梳理以下两个问题:

  1. 如何用 Media3 配置视频缓存。
  2. 如何把缓存中的视频导出为一个完整文件。

一、整体思路

整个方案可以拆成三层:

  • 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 文件。因此导出的基本流程是:

  1. 根据 cacheKey 拿到所有 cachedSpans
  2. position 排序
  3. 依次读取每个 span 对应的缓存文件
  4. 顺序写入临时文件
  5. 写完之后再重命名成目标文件

完整实现如下:

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. 永远用临时文件导出

不要直接写目标文件,正确做法一定是:

  1. 先写 xxx.mp4.temp
  2. 完成后再重命名为 xxx.mp4

这样即使中途取消或异常退出,也不会留下一个伪装成成功结果的损坏文件。

4. onlyReadCache 的适用场景

如果你的播放器和后台下载器会同时接触同一份缓存,建议认真评估是否要把播放器设置成只读缓存。

适合 onlyReadCache = true 的典型场景是:

  • 后台完整下载任务统一负责写缓存
  • 前台播放器优先读缓存,未命中时再走网络
  • 避免“播放器写缓存”和“下载器写缓存”同时竞争同一资源

十、总结

用 Media3 做视频缓存并不难,真正需要补齐的是“缓存完整性判断”和“缓存导出文件”这两层业务能力。

落地时可以记住下面四个关键点:

  • 同一个缓存目录,只创建一个 SimpleCache
  • 播放、预加载、导出必须统一 cacheKey
  • 导出时按 span 顺序读取并写入临时文件
  • 同一个视频导出过程必须加锁

如果只是为了提升播放体验,接入 SimpleCache + CacheDataSource 就已经足够。 如果还需要离线文件能力,那么在此基础上增加 DownloadManager + exportCacheFile(),就可以形成一套比较完整、稳定的 Media3 视频缓存方案。

本文由作者按照 CC BY 4.0 进行授权