培训平台移动端离线学习方案设计与实现:视频缓存、断点续传与进度同步的工程实践

举报
Jucai_SZ 发表于 2026/08/17 10:07:36 2026/08/17
【摘要】 企业培训平台 60% 以上的移动端学习发生在通勤、出差等弱网或无网环境下。用户点下"离线下载"后可能在地铁里看完一整门课,但出地铁后学习进度丢失、视频缓存被系统清理、重新下载又从头开始——这些体验问题会直接导致员工放弃离线学习。本文拆解一套在 iOS 和 Android 双端落地的离线学习方案,覆盖视频分片缓存、断点续传、本地进度持久化、离线/在线进度冲突合并四个核心模块。

一、问题拆解:移动端离线学习的四个"坑"

离线学习看起来就是"把视频下载到手机里,没网也能看",但实际落地时每个环节都有移动端特有的工程挑战。

第一个坑:视频缓存不是"下载一个文件"那么简单。 一门课程可能有 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 为什么不用"当前进度"而用"事件溯源"

假设用户在离线状态下发生了这些操作:

  1. 打开第 3 节,看到 12:30,关闭 App
  2. 重新打开,从 12:30 继续,看到 18:00,跳到第 5 节
  3. 第 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:00T3: 平板上看完了第 1-5 节(第 5 节看到 03:00T4: 手机恢复网络,同步进度
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 里,必须存在 DocumentsApplication 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% 的用户痛点。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。