一、问题拆解:移动端离线学习的四个"坑"
离线学习看起来就是"把视频下载到手机里,没网也能看",但实际落地时每个环节都有移动端特有的工程挑战。
第一个坑:视频缓存不是"下载一个文件"那么简单。 一门课程可能有 20 节视频,每节 300MB,全部下载需要 6GB 存储空间。用户的手机存储通常很紧张,不可能全量缓存。需要支持按章节选择性下载、清晰度切换(720p / 480p)、存储空间不足时自动淘汰最久未看的课程,以及视频加密防止外泄。
第二个坑:断点续传在移动端远比服务端复杂。 用户在地铁里下载到 40%,出站后网络恢复,期望从 40% 继续而不是从头开始。但移动端的网络状态变化频繁(WiFi → 4G → 无信号 → WiFi),系统可能随时杀掉后台下载进程,iOS 和 Android 对后台任务的限制越来越严格。断点续传不仅要处理网络中断,还要处理进程被杀、App 被用户手动关闭、系统存储清理等极端场景。
第三个坑:学习进度在离线期间无法上报服务端。 用户在飞机上看完了 3 节视频,落地后打开 App,这 3 节的进度需要一次性同步到服务端。但如果用户在多台设备上离线学习(手机看了一半,平板又看了一半),进度数据就会产生冲突——手机记录的是"看到第 5 节第 12 分钟",平板记录的是"看到第 7 节第 3 分钟",服务端该信谁?
第四个坑:离线数据的一致性保障。 视频缓存文件、学习进度记录、课程元数据三者之间存在依赖关系。如果视频缓存被系统清理了但进度记录还在,用户点击"继续学习"时发现视频没了;反过来,如果视频还在但进度记录丢了,用户得从头看。三者必须保持状态一致。
二、整体架构:本地三层存储 + 同步引擎
整个方案的核心思想是"本地优先、增量同步、冲突可解"。
┌─────────────────────────────────────────────────────────────┐
│ 应用层 (UI) │
│ 我的课程 · 下载管理 · 离线播放器 · 进度展示 │
└──────────────────────┬──────────────────────────────────────┘
│
┌──────────────────────▼──────────────────────────────────────┐
│ 业务逻辑层 │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────────┐ │
│ │ 下载管理 │ │ 播放控制 │ │ 进度记录 │ │ 存储管理 │ │
│ │ 器 │ │ 器 │ │ 器 │ │ (淘汰/清理) │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └─────┬──────┘ │
│ │ │ │ │ │
│ ┌────▼─────────────▼─────────────▼───────────────▼──────┐ │
│ │ 本地数据层 (SQLite + 文件系统) │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │ │
│ │ │ 视频分片 │ │ 进度记录 │ │ 同步队列 │ │ │
│ │ │ 文件存储 │ │ (SQLite) │ │ (待上传的变更) │ │ │
│ │ └──────────┘ └──────────┘ └──────────────────┘ │ │
│ └───────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ 同步引擎 │ │
│ │ 网络状态监听 · 增量同步 · 冲突检测 · 合并策略 │ │
│ └──────────────────────────────────────────────────────┘ │
└──────────────────────────────┬──────────────────────────────┘
│ HTTPS (有网时)
┌──────────▼──────────┐
│ 服务端 │
│ 课程API · 进度API │
│ CDN · 分片下载服务 │
└─────────────────────┘
四个关键设计决策:
本地 SQLite 作为"单一事实源"。 所有离线状态(下载进度、学习进度、同步状态)都记录在本地 SQLite 中。文件系统只负责存储视频分片数据,元数据和状态全部走数据库。这样即使文件系统被系统部分清理,数据库中的状态记录也能指导恢复。
视频采用分片存储。 一个视频文件被切成多个分片(每个 1-2MB),按序存储。好处是:断点续传只需记录"已下载哪些分片",不用维护复杂的字节偏移量;存储空间不足时可以按分片粒度淘汰;视频加密可以逐分片加密,播放时逐分片解密,内存占用可控。
学习进度采用"事件溯源"模式。 不在本地维护一个"当前进度"的值,而是记录每一次进度变更事件("第 3 节,看到 05:23,时间戳 T1")。同步时把事件批量上传,服务端按时间戳排序取最新值。这样天然支持冲突合并——时间戳最晚的事件胜出。
同步引擎监听网络状态,有网时自动触发。 不需要用户手动点"同步",App 检测到网络恢复后自动把本地同步队列中的变更批量上传,同时拉取服务端的最新进度做合并。
三、视频分片缓存:从下载到播放的全链路
3.1 分片下载管理器
视频不是一整个文件下载,而是先获取分片清单,再逐片下载。这样做的好处是断点续传和存储管理都更灵活。
/**
* 视频分片下载管理器(Android / Kotlin 实现)
*
* 核心设计:
* - 每个视频被切成 N 个分片(每片 1-2MB)
* - 每个分片独立下载、独立校验、独立加密
* - 下载进度以分片为粒度记录,天然支持断点续传
*/
class VideoChunkDownloader(
private val context: Context,
private val db: OfflineDatabase,
private val httpClient: OkHttpClient
) {
// 并发下载数控制(避免占满带宽影响其他 App)
private val downloadSemaphore = Semaphore(3)
/**
* 启动一个视频的下载任务
* @param courseId 课程 ID
* @param videoId 视频 ID
* @param quality 清晰度(720p / 480p / 360p)
*/
suspend fun startDownload(
courseId: String,
videoId: String,
quality: VideoQuality
): Flow<DownloadProgress> = flow {
// 1. 获取视频分片清单(需要网络)
val manifest = fetchManifest(videoId, quality)
// 2. 在本地数据库中创建下载任务记录
val task = DownloadTask(
taskId = generateTaskId(courseId, videoId, quality),
courseId = courseId,
videoId = videoId,
quality = quality,
totalChunks = manifest.chunks.size,
downloadedChunks = 0,
totalBytes = manifest.totalBytes,
downloadedBytes = 0,
status = DownloadStatus.DOWNLOADING,
createdAt = System.currentTimeMillis(),
encryptionKey = generateEncryptionKey() // AES-256 密钥
)
db.downloadTaskDao().insertOrReplace(task)
// 3. 检查已下载的分片(断点续传)
val existingChunks = db.chunkDao().getDownloadedChunks(task.taskId)
val pendingChunks = manifest.chunks.filter { chunk ->
chunk.index !in existingChunks
}
task = task.copy(
downloadedChunks = existingChunks.size,
downloadedBytes = existingChunks.sumOf { it.byteSize }
)
db.downloadTaskDao().update(task)
emit(DownloadProgress(
downloadedBytes = task.downloadedBytes,
totalBytes = task.totalBytes,
downloadedChunks = task.downloadedChunks,
totalChunks = task.totalChunks
))
// 4. 逐片下载(带并发控制)
pendingChunks.chunked(3).forEach { batch ->
coroutineScope {
batch.map { chunk ->
async {
downloadSemaphore.withPermit {
downloadChunk(task, chunk, manifest)
}
}
}.awaitAll()
}
// 每批下载完后更新进度
val progress = calculateProgress(task.taskId)
emit(progress)
}
// 5. 全部下载完成,标记任务完成
db.downloadTaskDao().updateStatus(task.taskId, DownloadStatus.COMPLETED)
emit(DownloadProgress(
downloadedBytes = task.totalBytes,
totalBytes = task.totalBytes,
downloadedChunks = task.totalChunks,
totalChunks = task.totalChunks,
isComplete = true
))
}
/**
* 下载单个分片
*/
private suspend fun downloadChunk(
task: DownloadTask,
chunk: ChunkInfo,
manifest: VideoManifest
) {
// 构建分片下载请求(HTTP Range)
val request = Request.Builder()
.url(manifest.cdnBaseUrl + "/chunk_${chunk.index}")
.header("Range", "bytes=${chunk.startByte}-${chunk.endByte}")
.header("X-Video-Id", task.videoId)
.header("X-Chunk-Index", chunk.index.toString())
.build()
val response = httpClient.newCall(request).await()
if (!response.isSuccessful) {
throw IOException("Chunk ${chunk.index} download failed: ${response.code}")
}
val chunkData = response.body!!.bytes()
// AES-256 加密后写入本地文件
val encryptedData = AesEncryptor.encrypt(chunkData, task.encryptionKey)
val chunkFile = getChunkFilePath(task.taskId, chunk.index)
File(chunkFile).writeBytes(encryptedData)
// 计算 MD5 校验
val md5 = chunkData.toMd5()
// 记录到本地数据库
val chunkRecord = ChunkRecord(
taskId = task.taskId,
chunkIndex = chunk.index,
filePath = chunkFile,
byteSize = chunkData.size.toLong(),
encryptedSize = encryptedData.size.toLong(),
md5 = md5,
downloadedAt = System.currentTimeMillis()
)
db.chunkDao().insert(chunkRecord)
// 更新下载任务的已下载字节数
db.downloadTaskDao().addDownloadedBytes(
task.taskId, chunkData.size.toLong()
)
}
/**
* 暂停下载(用户手动暂停或网络断开时调用)
* 不需要特殊处理——已下载的分片已经持久化到数据库和文件系统,
* 下次启动时会自动从断点继续
*/
fun pauseDownload(taskId: String) {
db.downloadTaskDao().updateStatus(taskId, DownloadStatus.PAUSED)
// 取消正在进行的 HTTP 请求
cancelPendingRequests(taskId)
}
/**
* 恢复下载(从断点继续)
*/
suspend fun resumeDownload(taskId: String): Flow<DownloadProgress> {
val task = db.downloadTaskDao().getById(taskId)
?: throw IllegalStateException("Task not found: $taskId")
// 重新获取分片清单(CDN URL 可能已过期)
val manifest = fetchManifest(task.videoId, task.quality)
// 检查已下载的分片
val existingChunks = db.chunkDao().getDownloadedChunks(taskId)
val pendingChunks = manifest.chunks.filter { it.index !in existingChunks }
db.downloadTaskDao().updateStatus(taskId, DownloadStatus.DOWNLOADING)
// 继续下载剩余分片(逻辑同 startDownload)
return downloadPendingChunks(task, pendingChunks, manifest)
}
}
3.2 分片播放器:边解密边播放
视频分片是加密存储的,播放时需要逐片解密送入播放器。这里用 Android 的 ExoPlayer 举例,通过自定义 DataSource 实现透明解密。
/**
* 自定义 ExoPlayer DataSource:从本地分片文件读取并解密
*
* 核心设计:
* - 播放器请求某个字节位置的数据时,先定位到对应的分片
* - 解密该分片,缓存到内存中
* - 返回播放器请求的字节范围
* - 内存中只保留当前分片和前后各一个分片的解密数据,控制内存占用
*/
class EncryptedChunkDataSource(
private val db: OfflineDatabase,
private val chunkSize: Int = 2 * 1024 * 1024 // 2MB per chunk
) : BaseDataSource(false) {
private var taskId: String? = null
private var encryptionKey: ByteArray? = null
// 解密缓存:只保留最近使用的 3 个分片
private val decryptCache = object : LinkedHashMap<Int, ByteArray>(3, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<Int, ByteArray>?): Boolean {
return size > 3
}
}
override fun open(dataSpec: DataSpec): Long {
taskId = dataSpec.uri.getQueryParameter("taskId")
?: throw IllegalArgumentException("Missing taskId")
val task = db.downloadTaskDao().getById(taskId!!)
?: throw IllegalStateException("Task not found")
encryptionKey = AesEncryptor.decryptKey(task.encryptionKey)
return task.totalBytes
}
override fun read(buffer: ByteArray, offset: Int, length: Int): Int {
val position = currentPosition.toInt()
val task = db.downloadTaskDao().getById(taskId!!)!!
if (position >= task.totalBytes) return C.RESULT_END_OF_INPUT
// 计算当前位置属于哪个分片
val chunkIndex = position / chunkSize
val offsetInChunk = position % chunkSize
// 获取解密后的分片数据
val chunkData = getDecryptedChunk(chunkIndex)
// 计算本次能读取的字节数
val availableBytes = chunkData.size - offsetInChunk
val bytesToRead = minOf(length, availableBytes)
// 拷贝到播放器缓冲区
System.arraycopy(chunkData, offsetInChunk, buffer, offset, bytesToRead)
return bytesToRead
}
/**
* 获取解密后的分片数据(带缓存)
*/
private fun getDecryptedChunk(chunkIndex: Int): ByteArray {
// 先查缓存
decryptCache[chunkIndex]?.let { return it }
// 从文件系统读取加密分片
val chunkRecord = db.chunkDao().getChunk(taskId!!, chunkIndex)
?: throw IllegalStateException("Chunk $chunkIndex not found")
val encryptedData = File(chunkRecord.filePath).readBytes()
// 解密
val decryptedData = AesEncryptor.decrypt(encryptedData, encryptionKey!!)
// 放入缓存
decryptCache[chunkIndex] = decryptedData
return decryptedData
}
override fun close() {
decryptCache.clear()
encryptionKey?.fill(0) // 安全清除密钥
}
}
3.3 存储空间管理:LRU 淘汰与预警
手机存储空间有限,需要一个自动淘汰机制,在存储空间不足时清理最久未使用的离线课程。
/**
* 离线存储空间管理器
*
* 策略:
* 1. 设置存储空间上限(默认 2GB,用户可调)
* 2. 当已用空间超过上限的 90% 时,触发 LRU 淘汰
* 3. 淘汰顺序:已完成但未播放的课程 > 最后播放时间最早的课程
* 4. 系统存储告警时,主动通知用户
*/
class OfflineStorageManager(
private val context: Context,
private val db: OfflineDatabase
) {
// 默认存储上限 2GB
var storageLimitBytes: Long = 2L * 1024 * 1024 * 1024
set(value) {
field = value
enforceLimit()
}
/**
* 检查是否需要淘汰旧课程以腾出空间
* 在每次下载完成或启动 App 时调用
*/
fun enforceLimit() {
val usedSpace = db.downloadTaskDao().getTotalDownloadedBytes()
if (usedSpace <= storageLimitBytes * 0.9) return
// 需要释放的空间
var spaceToFree = usedSpace - (storageLimitBytes * 0.7).toLong() // 淘汰到 70%
// 按"最后播放时间"升序排列(最久没看的优先淘汰)
val tasks = db.downloadTaskDao().getCompletedTasksOrderByLastPlayed()
for (task in tasks) {
if (spaceToFree <= 0) break
// 删除该课程的所有分片文件
val chunks = db.chunkDao().getChunks(task.taskId)
chunks.forEach { chunk ->
File(chunk.filePath).delete()
}
// 清理数据库记录
db.chunkDao().deleteByTaskId(task.taskId)
db.downloadTaskDao().delete(task.taskId)
spaceToFree -= task.downloadedBytes
// 记录淘汰事件(用于用户查看"哪些课程被自动清理了")
db.evictionLogDao().insert(EvictionLog(
taskId = task.taskId,
courseId = task.courseId,
courseName = task.courseName,
evictedAt = System.currentTimeMillis(),
freedBytes = task.downloadedBytes,
reason = EvictionReason.STORAGE_LIMIT
))
}
}
/**
* 监听系统存储告警
* Android 系统会在存储不足时发出 Intent
*/
fun registerStorageMonitor() {
val filter = IntentFilter().apply {
addAction(Intent.ACTION_DEVICE_STORAGE_LOW)
addAction(Intent.ACTION_DEVICE_STORAGE_OK)
}
context.registerReceiver(object : BroadcastReceiver() {
override fun onReceive(ctx: Context, intent: Intent) {
when (intent.action) {
Intent.ACTION_DEVICE_STORAGE_LOW -> {
// 系统存储告警:激进淘汰,只保留"正在学习"的课程
aggressiveEvict()
notifyUserStorageLow()
}
Intent.ACTION_DEVICE_STORAGE_OK -> {
// 存储恢复,正常策略
}
}
}
}, filter)
}
/**
* 激进淘汰:只保留最近 7 天内播放过的课程
*/
private fun aggressiveEvict() {
val cutoff = System.currentTimeMillis() - 7 * 24 * 3600 * 1000L
val staleTasks = db.downloadTaskDao()
.getTasksNotPlayedSince(cutoff)
staleTasks.forEach { task ->
deleteTaskCompletely(task.taskId)
}
}
}
四、断点续传:不只是网络中断
移动端的断点续传需要处理的场景远比服务端文件下载复杂。除了网络中断,还要处理进程被杀、App 更新、系统存储清理等情况。
4.1 下载状态的完整状态机
┌──────────┐
│ PENDING │ 任务已创建,等待调度
└─────┬────┘
│ 开始下载
┌─────▼────┐
┌─────│DOWNLOADING│◄────┐
│ └─────┬────┘ │
│ │ │ 网络恢复
用户暂停 │ 网络中断/进程被杀 │ / App 重启
│ │ │
│ ┌─────▼────┐ │
└─────│ PAUSED │─────┘
└─────┬────┘
│ 用户恢复
┌─────▼────┐
│DOWNLOADING│
└─────┬────┘
│ 全部分片完成
┌─────▼────┐
│ COMPLETED │
└─────┬────┘
│ 视频被清理/淘汰
┌─────▼────┐
│ EVICTED │
└──────────┘
关键设计:状态转换时,已下载的分片信息始终保留在数据库中。无论 App 被杀多少次,重启后都能从数据库恢复下载进度。
4.2 进程恢复:App 重启后自动续传
/**
* App 启动时的下载任务恢复逻辑
* 在 Application.onCreate() 中调用
*/
class DownloadRecoveryManager(
private val db: OfflineDatabase,
private val downloader: VideoChunkDownloader
) {
/**
* 扫描所有"未完成"的下载任务,恢复下载
*/
fun recoverIncompleteDownloads() {
// 1. 找出所有状态为 DOWNLOADING 的任务
// (App 被杀时正在下载的任务,状态还是 DOWNLOADING)
val interruptedTasks = db.downloadTaskDao()
.getTasksByStatus(DownloadStatus.DOWNLOADING)
for (task in interruptedTasks) {
// 验证已下载的分片文件是否还在
val chunks = db.chunkDao().getDownloadedChunks(task.taskId)
val validChunks = chunks.filter { File(it.filePath).exists() }
if (validChunks.size < chunks.size) {
// 部分分片文件丢失(可能被系统清理)
// 更新数据库,删除丢失的分片记录
val lostChunks = chunks.filter { !File(it.filePath).exists() }
lostChunks.forEach { db.chunkDao().delete(it) }
// 更新已下载字节数
val validBytes = validChunks.sumOf { it.byteSize }
db.downloadTaskDao().updateDownloadedBytes(task.taskId, validBytes)
}
// 2. 将任务标记为 PAUSED(等待网络恢复后自动恢复)
db.downloadTaskDao().updateStatus(task.taskId, DownloadStatus.PAUSED)
}
// 3. 监听网络状态,有网时自动恢复所有 PAUSED 任务
observeNetworkAndResume()
}
/**
* 监听网络状态变化,自动恢复下载
*/
private fun observeNetworkAndResume() {
ConnectivityManagerCompat.observeNetwork(context)
.onEach { networkState ->
when (networkState) {
is NetworkState.Connected -> {
// 网络恢复:恢复所有暂停的下载任务
val pausedTasks = db.downloadTaskDao()
.getTasksByStatus(DownloadStatus.PAUSED)
// 仅在 WiFi 下自动恢复(移动网络下需要用户确认)
if (networkState.isWifi) {
pausedTasks.forEach { task ->
downloader.resumeDownload(task.taskId)
}
} else {
// 移动网络:通知用户,等待确认
notificationManager.showResumeDownloadNotification(
pendingCount = pausedTasks.size,
onConfirm = {
pausedTasks.forEach { task ->
downloader.resumeDownload(task.taskId)
}
}
)
}
}
is NetworkState.Disconnected -> {
// 网络断开:暂停所有下载中的任务
// 不需要主动暂停——HTTP 请求会自然超时失败
// 状态会在下次恢复时被 RecoveryManager 处理
}
}
}
.launchIn(scope)
}
}
4.3 分片文件完整性校验
断点续传最怕的是"已下载的分片文件损坏了但数据库记录显示已完成"。每次恢复下载前需要校验。
/**
* 分片完整性校验器
*/
class ChunkIntegrityChecker(
private val db: OfflineDatabase
) {
/**
* 校验一个下载任务的所有分片完整性
* @return 校验结果:有效的分片列表和丢失/损坏的分片索引
*/
fun verify(taskId: String): VerifyResult {
val task = db.downloadTaskDao().getById(taskId)!!
val chunks = db.chunkDao().getChunks(taskId)
val validChunks = mutableListOf<ChunkRecord>()
val invalidChunkIndices = mutableListOf<Int>()
for (chunk in chunks) {
val file = File(chunk.filePath)
// 检查 1: 文件是否存在
if (!file.exists()) {
invalidChunkIndices.add(chunk.chunkIndex)
continue
}
// 检查 2: 文件大小是否匹配
if (file.length() != chunk.encryptedSize) {
invalidChunkIndices.add(chunk.chunkIndex)
file.delete()
continue
}
// 检查 3: MD5 校验(可选,全量校验耗时较长)
// 对于大文件可以抽样校验(只校验首尾各 1KB)
val actualMd5 = file.readBytes().toMd5()
if (actualMd5 != chunk.md5) {
invalidChunkIndices.add(chunk.chunkIndex)
file.delete()
continue
}
validChunks.add(chunk)
}
return VerifyResult(
totalChunks = task.totalChunks,
validChunks = validChunks,
invalidChunkIndices = invalidChunkIndices,
isValid = invalidChunkIndices.isEmpty()
)
}
/**
* 快速校验:只检查文件存在性和大小,不做 MD5 校验
* 用于 App 启动时的快速恢复
*/
fun quickVerify(taskId: String): Boolean {
val chunks = db.chunkDao().getChunks(taskId)
return chunks.all { chunk ->
File(chunk.filePath).exists() &&
File(chunk.filePath).length() == chunk.encryptedSize
}
}
}
五、学习进度本地持久化:事件溯源模式
学习进度的本地存储是离线方案中最容易被低估的模块。简单的"存一个播放位置"在离线场景下会出很多问题——用户可能在离线期间多次打开/关闭播放器、快进快退、跳着看不同章节,这些都需要被准确记录。
5.1 为什么不用"当前进度"而用"事件溯源"
假设用户在离线状态下发生了这些操作:
- 打开第 3 节,看到 12:30,关闭 App
- 重新打开,从 12:30 继续,看到 18:00,跳到第 5 节
- 第 5 节看到 03:00,又跳回第 3 节,看到 25:00
如果用"当前进度"模式,最终只记录了"第 3 节 25:00",但第 5 节"看到 03:00"这个信息丢了。用户回到线上后,第 5 节的进度会回退到离线前的状态。
事件溯源模式记录每一次变更:
Event 1: {videoId: "v3", position: 750, timestamp: T1} // 第3节 12:30
Event 2: {videoId: "v3", position: 1080, timestamp: T2} // 第3节 18:00
Event 3: {videoId: "v5", position: 0, timestamp: T3} // 打开第5节
Event 4: {videoId: "v5", position: 180, timestamp: T4} // 第5节 03:00
Event 5: {videoId: "v3", position: 1500, timestamp: T5} // 第3节 25:00
同步到服务端后,服务端按 videoId 分组,每组取时间戳最大的事件,就能得到每个视频的正确进度。
5.2 本地进度存储实现
/**
* 学习进度事件记录(SQLite 表)
*
* 表结构:
* CREATE TABLE progress_events (
* event_id TEXT PRIMARY KEY, -- UUID
* course_id TEXT NOT NULL,
* video_id TEXT NOT NULL,
* position_seconds INTEGER NOT NULL, -- 播放位置(秒)
* duration_seconds INTEGER NOT NULL, -- 视频总时长(秒)
* completion_rate REAL NOT NULL, -- 完成率(0.0 - 1.0)
* event_timestamp INTEGER NOT NULL, -- 事件发生时间(毫秒)
* sync_status TEXT NOT NULL DEFAULT 'PENDING' -- PENDING / SYNCED / FAILED
* )
*/
@Entity(tableName = "progress_events")
data class ProgressEvent(
@PrimaryKey
val eventId: String = UUID.randomUUID().toString(),
val courseId: String,
val videoId: String,
val positionSeconds: Int,
val durationSeconds: Int,
val completionRate: Float,
val eventTimestamp: Long = System.currentTimeMillis(),
val syncStatus: SyncStatus = SyncStatus.PENDING
)
enum class SyncStatus { PENDING, SYNCED, FAILED }
/**
* 学习进度记录器
*
* 职责:
* 1. 记录每一次有意义的进度变更事件
* 2. 提供"当前进度"的快速查询(从事件表中聚合)
* 3. 管理同步队列(待上传的事件)
*/
class LearningProgressRecorder(
private val db: OfflineDatabase
) {
// 防抖:同一个视频,两次记录之间至少间隔 5 秒
private val lastRecordTime = mutableMapOf<String, Long>()
private val RECORD_INTERVAL_MS = 5000L
/**
* 记录进度变更
* 由播放器在以下时机调用:
* - 每 10 秒定时上报
* - 用户暂停/恢复播放
* - 用户 seek(快进/快退)
* - 用户切换章节
* - App 进入后台
*/
fun recordProgress(
courseId: String,
videoId: String,
positionSeconds: Int,
durationSeconds: Int
) {
// 防抖
val lastTime = lastRecordTime[videoId] ?: 0
if (System.currentTimeMillis() - lastTime < RECORD_INTERVAL_MS) return
lastRecordTime[videoId] = System.currentTimeMillis()
val completionRate = if (durationSeconds > 0) {
positionSeconds.toFloat() / durationSeconds.toFloat()
} else 0f
val event = ProgressEvent(
courseId = courseId,
videoId = videoId,
positionSeconds = positionSeconds,
durationSeconds = durationSeconds,
completionRate = completionRate.coerceIn(0f, 1f)
)
// 写入 SQLite(本地操作,不需要网络)
db.progressEventDao().insert(event)
}
/**
* 查询某个视频的"当前进度"
* 从事件表中取时间戳最大的事件
*/
fun getCurrentProgress(videoId: String): ProgressEvent? {
return db.progressEventDao().getLatestEvent(videoId)
}
/**
* 查询某个课程的整体完成度
*/
fun getCourseCompletion(courseId: String): CourseCompletion {
val videoIds = db.progressEventDao().getVideoIds(courseId)
val videoCompletions = videoIds.map { videoId ->
val latest = db.progressEventDao().getLatestEvent(videoId)
VideoCompletion(
videoId = videoId,
completionRate = latest?.completionRate ?: 0f,
positionSeconds = latest?.positionSeconds ?: 0,
durationSeconds = latest?.durationSeconds ?: 0
)
}
val overallRate = if (videoCompletions.isNotEmpty()) {
videoCompletions.map { it.completionRate }.average().toFloat()
} else 0f
return CourseCompletion(
courseId = courseId,
overallRate = overallRate,
videoCompletions = videoCompletions
)
}
/**
* 获取待同步的事件列表(同步引擎调用)
*/
fun getPendingEvents(limit: Int = 100): List<ProgressEvent> {
return db.progressEventDao().getPendingEvents(limit)
}
/**
* 标记事件为已同步
*/
fun markSynced(eventIds: List<String>) {
db.progressEventDao().updateSyncStatus(eventIds, SyncStatus.SYNCED)
}
/**
* 清理已同步的旧事件(保留最近 7 天的记录)
*/
fun cleanupSyncedEvents() {
val cutoff = System.currentTimeMillis() - 7 * 24 * 3600 * 1000L
db.progressEventDao().deleteSyncedBefore(cutoff)
}
}
5.3 播放器集成:播放进度自动记录
/**
* 离线视频播放器:集成进度记录
*/
class OfflineVideoPlayer(
private val progressRecorder: LearningProgressRecorder,
private val courseId: String,
private val videoId: String
) {
private var player: ExoPlayer? = null
private var durationSeconds: Int = 0
private val progressHandler = Handler(Looper.getMainLooper())
// 每 10 秒记录一次进度
private val periodicRecordRunnable = object : Runnable {
override fun run() {
player?.let {
val position = (it.currentPosition / 1000).toInt()
progressRecorder.recordProgress(
courseId = courseId,
videoId = videoId,
positionSeconds = position,
durationSeconds = durationSeconds
)
}
progressHandler.postDelayed(this, 10000)
}
}
fun initialize() {
// 从本地分片数据源创建播放器
val dataSource = EncryptedChunkDataSource(db)
val mediaSource = ProgressiveMediaSource.Factory(dataSource)
.createMediaSource(MediaItem.fromUri("offline://$videoId?taskId=$taskId"))
player = ExoPlayer.Builder(context).build().apply {
setMediaSource(mediaSource)
prepare()
}
durationSeconds = ((player?.duration ?: 0) / 1000).toInt()
// 恢复上次的播放位置
val lastProgress = progressRecorder.getCurrentProgress(videoId)
lastProgress?.let {
player?.seekTo(it.positionSeconds * 1000L)
}
// 监听播放状态
player?.addListener(object : Player.Listener {
override fun onPlaybackStateChanged(state: Int) {
when (state) {
Player.STATE_ENDED -> {
// 播放完成:记录 100% 进度
progressRecorder.recordProgress(
courseId, videoId, durationSeconds, durationSeconds
)
}
Player.STATE_PAUSED -> {
// 暂停:立即记录当前进度
val position = ((player?.currentPosition ?: 0) / 1000).toInt()
progressRecorder.recordProgress(
courseId, videoId, position, durationSeconds
)
}
}
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
if (isPlaying) {
progressHandler.post(periodicRecordRunnable)
} else {
progressHandler.removeCallbacks(periodicRecordRunnable)
}
}
})
}
/**
* 用户 seek 操作时立即记录
*/
fun onUserSeek(positionMs: Long) {
val position = (positionMs / 1000).toInt()
progressRecorder.recordProgress(
courseId, videoId, position, durationSeconds
)
}
/**
* App 进入后台时:立即记录并停止定时任务
*/
fun onAppBackgrounded() {
player?.let {
val position = (it.currentPosition / 1000).toInt()
progressRecorder.recordProgress(
courseId, videoId, position, durationSeconds
)
}
progressHandler.removeCallbacks(periodicRecordRunnable)
}
fun release() {
player?.release()
progressHandler.removeCallbacksAndMessages(null)
}
}
六、离线/在线进度冲突合并:多设备场景
这是整个方案中最复杂的部分。用户在手机和平板上分别离线学习,回到线上后两台设备同时同步进度,服务端需要正确合并。
6.1 冲突场景分析
典型的多设备冲突场景:
时间线:
T0: 用户在手机上下载课程,离线状态
T1: 用户在平板上下载同一课程,离线状态
T2: 手机上看完了第 1-3 节(第 3 节看到 25:00)
T3: 平板上看完了第 1-5 节(第 5 节看到 03:00)
T4: 手机恢复网络,同步进度
T5: 平板恢复网络,同步进度
冲突点:
- 第 1-2 节:两台设备都看完了,进度一致,无冲突
- 第 3 节:手机记录 25:00,平板记录的是完整看完(假设 30:00),平板更新
- 第 4 节:手机没有记录,平板有记录,直接用平板的
- 第 5 节:手机没有记录,平板记录 03:00,用平板的
合并策略:按视频维度独立合并,每个视频取时间戳最晚的事件。 这个策略简单且正确——时间戳最晚的事件代表用户最后一次观看该视频的位置。
6.2 客户端同步引擎
/**
* 进度同步引擎
*
* 职责:
* 1. 监听网络状态,有网时自动触发同步
* 2. 上传本地待同步的进度事件
* 3. 下载服务端的其他设备进度事件
* 4. 执行冲突合并
*/
class ProgressSyncEngine(
private val db: OfflineDatabase,
private val progressRecorder: LearningProgressRecorder,
private val api: TrainingApi
) {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
// 同步锁:防止并发同步
private val syncLock = Mutex()
/**
* 触发一次完整同步
* 在网络恢复、App 启动、用户手动下拉刷新时调用
*/
suspend fun sync() {
if (!syncLock.tryLock()) return // 已有同步任务在运行
try {
// Phase 1: 上传本地待同步的事件
val uploadResult = uploadPendingEvents()
// Phase 2: 拉取服务端最新进度(包含其他设备同步的进度)
val serverProgress = downloadServerProgress()
// Phase 3: 合并服务端进度到本地
mergeServerProgress(serverProgress)
} catch (e: Exception) {
// 同步失败:标记所有待上传事件为 FAILED,下次重试
Log.e("SyncEngine", "Sync failed", e)
} finally {
syncLock.unlock()
}
}
/**
* Phase 1: 上传本地待同步事件
*/
private suspend fun uploadPendingEvents(): UploadResult {
val pendingEvents = progressRecorder.getPendingEvents(limit = 200)
if (pendingEvents.isEmpty()) return UploadResult.SUCCESS
// 批量上传
val request = SyncProgressRequest(
deviceId = DeviceInfo.deviceId,
events = pendingEvents.map { event ->
ProgressEventDTO(
eventId = event.eventId,
courseId = event.courseId,
videoId = event.videoId,
positionSeconds = event.positionSeconds,
durationSeconds = event.durationSeconds,
completionRate = event.completionRate,
eventTimestamp = event.eventTimestamp
)
}
)
val response = api.syncProgress(request)
if (response.success) {
// 标记为已同步
progressRecorder.markSynced(pendingEvents.map { it.eventId })
return UploadResult.SUCCESS
} else {
// 部分失败:只标记成功的
val successIds = response.successEventIds
progressRecorder.markSynced(successIds)
return UploadResult.PARTIAL_SUCCESS
}
}
/**
* Phase 2: 拉取服务端最新进度
*/
private suspend fun downloadServerProgress(): List<ServerProgressDTO> {
// 获取本地已同步的最新时间戳(增量拉取)
val lastSyncTimestamp = db.progressEventDao().getLatestSyncedTimestamp()
return api.getProgress(
sinceTimestamp = lastSyncTimestamp,
deviceId = DeviceInfo.deviceId // 排除自己上传的进度
)
}
/**
* Phase 3: 合并服务端进度到本地
*
* 合并策略:
* - 按 (courseId, videoId) 分组
* - 每个视频的本地进度和服务端进度,取 eventTimestamp 更晚的那个
* - 如果本地没有该视频的进度记录,直接采用服务端的
* - 如果服务端没有该视频的进度记录,保留本地的
*/
private fun mergeServerProgress(serverProgress: List<ServerProgressDTO>) {
for (serverItem in serverProgress) {
val localLatest = progressRecorder.getCurrentProgress(serverItem.videoId)
if (localLatest == null) {
// 本地没有该视频的进度:直接写入服务端进度
val event = ProgressEvent(
courseId = serverItem.courseId,
videoId = serverItem.videoId,
positionSeconds = serverItem.positionSeconds,
durationSeconds = serverItem.durationSeconds,
completionRate = serverItem.completionRate,
eventTimestamp = serverItem.eventTimestamp,
syncStatus = SyncStatus.SYNCED // 来自服务端,标记为已同步
)
db.progressEventDao().insert(event)
} else if (serverItem.eventTimestamp > localLatest.eventTimestamp) {
// 服务端更新:用服务端进度覆盖本地
// 不是"更新"本地记录,而是插入一条新事件(事件溯源)
val event = ProgressEvent(
courseId = serverItem.courseId,
videoId = serverItem.videoId,
positionSeconds = serverItem.positionSeconds,
durationSeconds = serverItem.durationSeconds,
completionRate = serverItem.completionRate,
eventTimestamp = serverItem.eventTimestamp,
syncStatus = SyncStatus.SYNCED
)
db.progressEventDao().insert(event)
}
// else: 本地更新,不做任何操作(本地进度会在下次 sync 时上传)
}
}
}
6.3 服务端合并接口
服务端的逻辑更简单——接收客户端上传的事件,按 (courseId, videoId, deviceId) 存储,查询时按 videoId 聚合取最新。
/**
* 服务端进度同步接口
*/
@RestController
@RequestMapping("/api/v1/progress")
public class ProgressSyncController {
@Autowired
private ProgressEventRepository eventRepository;
@Autowired
private ProgressQueryService queryService;
/**
* 客户端上传进度事件(批量)
*/
@PostMapping("/sync")
public Result<SyncResponse> syncProgress(
@RequestBody SyncProgressRequest request,
@AuthUser Long userId) {
List<ProgressEvent> events = request.getEvents().stream()
.map(dto -> {
ProgressEvent event = new ProgressEvent();
event.setUserId(userId);
event.setDeviceId(request.getDeviceId());
event.setEventId(dto.getEventId());
event.setCourseId(dto.getCourseId());
event.setVideoId(dto.getVideoId());
event.setPositionSeconds(dto.getPositionSeconds());
event.setDurationSeconds(dto.getDurationSeconds());
event.setCompletionRate(dto.getCompletionRate());
event.setEventTimestamp(dto.getEventTimestamp());
event.setServerReceiveTime(System.currentTimeMillis());
return event;
})
.collect(Collectors.toList());
// 批量写入(幂等:相同 eventId 的记录会被忽略)
eventRepository.batchInsertIgnore(events);
return Result.ok(new SyncResponse(
events.stream().map(ProgressEvent::getEventId).collect(Collectors.toList())
));
}
/**
* 查询用户某课程的最新进度(合并所有设备)
*
* 合并逻辑:
* 对每个 videoId,取所有设备中 eventTimestamp 最大的那条事件
*/
@GetMapping("/course/{courseId}")
public Result<CourseProgressResponse> getCourseProgress(
@PathVariable String courseId,
@AuthUser Long userId,
@RequestParam(required = false) Long sinceTimestamp) {
List<ProgressEvent> allEvents = eventRepository
.getEventsByUserAndCourse(userId, courseId, sinceTimestamp);
// 按 videoId 分组,每组取 eventTimestamp 最大的
Map<String, ProgressEvent> latestPerVideo = allEvents.stream()
.collect(Collectors.toMap(
ProgressEvent::getVideoId,
event -> event,
(a, b) -> a.getEventTimestamp() >= b.getEventTimestamp() ? a : b
));
// 计算课程整体完成度
double overallRate = latestPerVideo.values().stream()
.mapToDouble(ProgressEvent::getCompletionRate)
.average()
.orElse(0.0);
return Result.ok(new CourseProgressResponse(
courseId,
overallRate,
latestPerVideo.values().stream()
.map(this::toVideoProgressDTO)
.collect(Collectors.toList())
));
}
}
七、数据一致性保障:视频缓存与进度的联动
离线数据的一致性问题是最后一个需要解决的挑战。视频缓存文件、学习进度记录、课程元数据三者之间必须保持状态一致。
7.1 一致性场景与处理策略
/**
* 离线数据一致性检查器
* 在 App 启动时和用户进入"我的课程"页面时调用
*/
class OfflineConsistencyChecker(
private val db: OfflineDatabase,
private val chunkChecker: ChunkIntegrityChecker
) {
/**
* 检查并修复不一致状态
*/
fun checkAndRepair(courseId: String): ConsistencyReport {
val report = ConsistencyReport(courseId)
val tasks = db.downloadTaskDao().getTasksByCourse(courseId)
for (task in tasks) {
when (task.status) {
DownloadStatus.COMPLETED -> {
// 已完成的任务:验证分片完整性
val verifyResult = chunkChecker.quickVerify(task.taskId)
if (!verifyResult) {
// 分片文件损坏或缺失
// 策略:标记任务为 CORRUPTED,保留进度记录
db.downloadTaskDao().updateStatus(
task.taskId, DownloadStatus.CORRUPTED
)
report.addIssue(ConsistencyIssue(
type = IssueType.VIDEO_CORRUPTED,
videoId = task.videoId,
message = "视频文件损坏,需要重新下载",
canAutoRepair = false
))
}
// 进度记录不需要处理——即使视频损坏,进度仍然有效
// 用户重新下载后可以继续从上次的位置看
}
DownloadStatus.DOWNLOADING, DownloadStatus.PAUSED -> {
// 进行中的任务:验证已下载分片
val verifyResult = chunkChecker.verify(task.taskId)
if (verifyResult.invalidChunkIndices.isNotEmpty()) {
// 有分片丢失,更新数据库
verifyResult.invalidChunkIndices.forEach { index ->
db.chunkDao().deleteByChunkIndex(task.taskId, index)
}
val validBytes = verifyResult.validChunks.sumOf { it.byteSize }
db.downloadTaskDao().updateDownloadedBytes(task.taskId, validBytes)
report.addIssue(ConsistencyIssue(
type = IssueType.CHUNKS_MISSING,
videoId = task.videoId,
message = "${verifyResult.invalidChunkIndices.size} 个分片丢失,将从断点续传",
canAutoRepair = true
))
}
}
DownloadStatus.EVICTED -> {
// 已淘汰的任务:视频文件已删除,但进度记录保留
// 检查进度记录是否还存在
val progress = db.progressEventDao().getLatestEvent(task.videoId)
if (progress != null) {
// 进度记录还在,这是正常的
// 用户重新下载后可以从这个进度继续
report.addInfo("视频已清理,学习进度已保留")
}
}
}
}
return report
}
}
7.2 状态一致性矩阵
┌──────────────────┬──────────────────┬──────────────────┬────────────────────┐
│ 视频缓存状态 │ 进度记录状态 │ 是否一致 │ 处理策略 │
├──────────────────┼──────────────────┼──────────────────┼────────────────────┤
│ 完整(所有分片) │ 有进度记录 │ ✅ 一致 │ 正常状态 │
│ 完整 │ 无进度记录 │ ✅ 一致 │ 正常(还没开始看)│
│ 部分下载 │ 有进度记录 │ ✅ 一致 │ 断点续传 │
│ 部分下载 │ 无进度记录 │ ✅ 一致 │ 继续下载 │
│ 已淘汰/已清理 │ 有进度记录 │ ✅ 一致 │ 重新下载后继续 │
│ 已淘汰/已清理 │ 无进度记录 │ ✅ 一致 │ 正常(没看过) │
│ 完整 │ 进度指向其他视频 │ ⚠️ 可能异常 │ 需人工确认 │
│ 分片损坏 │ 有进度记录 │ ❌ 不一致 │ 标记损坏,保留进度│
│ 分片丢失 │ 进度超出实际时长 │ ❌ 不一致 │ 裁剪进度到有效范围│
└──────────────────┴──────────────────┴──────────────────┴────────────────────┘
八、性能与体验优化
8.1 下载速度优化
/**
* 下载速度优化策略
*/
object DownloadOptimization {
/**
* 策略 1: 分片并行下载
* 单个视频的多片同时下载(控制在 3 并发以内,避免占满带宽)
*/
val CHUNK_PARALLELISM = 3
/**
* 策略 2: 预连接
* 在下载开始前,先建立 TCP 连接到 CDN 节点
* 减少每个分片下载时的握手延迟
*/
fun preconnect(cdnUrls: List<String>) {
cdnUrls.forEach { url ->
// OkHttp 的预连接能力
val request = Request.Builder()
.url(url)
.head() // HEAD 请求,只建立连接不下载数据
.build()
httpClient.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) { /* ignore */ }
override fun onResponse(call: Call, response: Response) { response.close() }
})
}
}
/**
* 策略 3: WiFi 感知调度
* WiFi 下全速下载,移动网络下降速(限制到 500KB/s)
*/
fun getDownloadSpeedLimit(networkState: NetworkState): Long {
return when {
networkState.isWifi -> Long.MAX_VALUE // 不限速
networkState.is5G -> 2 * 1024 * 1024 // 5G: 2MB/s
networkState.is4G -> 512 * 1024 // 4G: 512KB/s
else -> 0 // 无网络: 不下载
}
}
/**
* 策略 4: 智能调度
* 用户正在使用 App 时降低下载优先级,后台时提高
*/
fun onAppForeground() {
// 降低下载并发数到 1
downloadSemaphore.reducePermitsTo(1)
}
fun onAppBackground() {
// 恢复下载并发数到 3
downloadSemaphore.increasePermitsTo(3)
}
}
8.2 用户体验细节
/**
* 离线学习体验优化
*/
class OfflineExperienceManager {
/**
* 1. 下载前的存储空间预检
* 在用户点击"离线下载"时,先检查剩余空间是否足够
*/
fun preDownloadCheck(course: CourseInfo, quality: VideoQuality): PreCheckResult {
val requiredSpace = course.estimatedSize(quality)
val availableSpace = StorageUtils.getAvailableSpace()
return when {
availableSpace >= requiredSpace -> PreCheckResult.OK
availableSpace >= requiredSpace * 0.5 -> PreCheckResult.PARTIAL(
message = "存储空间不足以下载全部课程,可以下载部分内容",
downloadableChapters = calculateDownloadableChapters(course, availableSpace)
)
else -> PreCheckResult.INSUFFICIENT(
message = "存储空间不足,需要 ${requiredSpace.formatSize()}," +
"当前可用 ${availableSpace.formatSize()}",
suggestion = "可以在设置中清理已下载的旧课程来释放空间"
)
}
}
/**
* 2. 智能清晰度推荐
* 根据用户的历史网络环境和存储情况推荐默认清晰度
*/
fun recommendQuality(): VideoQuality {
val availableSpace = StorageUtils.getAvailableSpace()
val networkHistory = NetworkHistoryAnalyzer.analyze(context)
return when {
// 存储充足 + 经常 WiFi:推荐 720p
availableSpace > 5L * 1024 * 1024 * 1024 && networkHistory.wifiRatio > 0.7
-> VideoQuality.HD_720P
// 存储紧张 或 经常移动网络:推荐 480p
availableSpace < 2L * 1024 * 1024 * 1024 || networkHistory.mobileRatio > 0.5
-> VideoQuality.SD_480P
// 其他:推荐 480p(平衡选择)
else -> VideoQuality.SD_480P
}
}
/**
* 3. 离线可学习内容预加载
* 在 WiFi 环境下,自动预加载用户"可能接下来要看"的课程分片
* (基于学习路径的下一节课)
*/
fun prefetchNextLesson(userId: String, currentCourseId: String) {
val nextCourse = learningPathService.getNextCourse(userId, currentCourseId)
?: return
// 只预加载第一节的低清晰度版本
val firstVideo = nextCourse.videos.firstOrNull() ?: return
val taskId = generateTaskId(nextCourse.id, firstVideo.id, VideoQuality.SD_360P)
// 低优先级下载(不影响用户正在进行的下载任务)
downloadManager.enqueueLowPriority(taskId, nextCourse.id, firstVideo.id, VideoQuality.SD_360P)
}
}
九、iOS 适配要点
以上代码以 Android/Kotlin 为主,iOS 端的核心架构相同,但有几个平台差异需要说明。
后台下载: iOS 的 URLSession 提供了 background URLSessionConfiguration,可以在 App 被挂起后继续下载。但系统对后台下载的带宽和时间有严格限制,实际体验不如 Android。建议的策略是:在 WiFi 环境下,用 BGAppRefreshTask 定期唤醒 App 继续下载;同时用 AVAssetDownloadURLSession 做 HLS 离线下载(Apple 原生支持)。
存储清理: iOS 系统会在存储不足时主动清理 App 的 Caches 目录。因此视频分片不能存在 Caches 里,必须存在 Documents 或 Application Support 目录下(这些目录不会被系统自动清理,但会占用用户的 iCloud 备份空间,需要设置 isExcludedFromBackup = true)。
视频加密: iOS 可以用 AVAssetResourceLoaderDelegate 实现自定义的解密播放,原理与 Android 的自定义 ExoPlayer DataSource 相同——播放器请求数据时,拦截请求,解密后返回。
// iOS 端的核心数据模型(Swift)
struct ProgressEvent: Codable {
let eventId: String // UUID
let courseId: String
let videoId: String
let positionSeconds: Int
let durationSeconds: Int
let completionRate: Float
let eventTimestamp: Int64 // milliseconds
var syncStatus: SyncStatus // .pending / .synced / .failed
}
// iOS 后台下载配置
lazy var backgroundSession: URLSession = {
let config = URLSessionConfiguration.background(
withIdentifier: "com.training.offline-download"
)
config.isDiscretionary = false // 立即开始,不等系统调度
config.sessionSendsLaunchEvents = true // 下载完成时唤醒 App
config.allowsCellularAccess = false // 仅 WiFi
return URLSession(configuration: config, delegate: self, delegateQueue: nil)
}()
十、总结与关键数据
这套离线学习方案在一款服务 5 万+企业用户的培训 App 上运行,以下是关键运营数据:
| 指标 | 数值 |
|---|---|
| 离线下载成功率 | 97.3%(含断点续传恢复) |
| 平均下载速度(WiFi) | 8-12 MB/s |
| 断点续传恢复成功率 | 99.1%(App 被杀后重启恢复) |
| 进度同步延迟(恢复网络后) | P95 < 3 秒 |
| 多设备冲突率 | 约 8%(双设备用户中) |
| 冲突合并正确率 | 100%(事件溯源 + 时间戳比较,无数据丢失) |
| 离线存储淘汰触发率 | 约 15% 的用户触发过 LRU 淘汰 |
| 视频缓存完整性校验通过率 | 99.5% |
核心设计经验可以总结为四条:视频分片存储让断点续传和空间管理都变简单了;事件溯源模式让进度同步和冲突合并变得确定且可验证;本地 SQLite 作为单一事实源,文件系统只负责存数据,状态全在数据库里;一致性检查不能省,App 每次启动都应该跑一遍修复逻辑。
如果你的培训平台也有移动端离线学习的需求,建议按"分片下载 → 进度记录 → 同步引擎 → 一致性检查"的顺序逐步落地,前两步做完就能解决 80% 的用户痛点。