You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Android Kotlin+ExoPlayer视频有效观看进度持久化问题求助

解决ExoPlayer有效观看进度持久化与续播问题

你的核心问题是没有在合适的时机读写持久化数据,导致重启后所有进度变量重置为初始值。下面分别提供轻量的SharedPreferences方案和适合多视频的Room方案,直接修改你的代码即可生效。


一、SharedPreferences 快速实现(适合单/少量视频)

步骤说明

  1. 用视频ID作为存储键的前缀,区分不同视频的进度数据
  2. 在视频初始化时读取历史数据,恢复visitedSeconds、totalPlaybackTime、播放位置
  3. 在页面暂停/销毁时保存当前进度数据
  4. 实现续播逻辑:读取上次播放位置并跳转

修改后的完整代码

class ViewWatchLessonsActivity(
    private val context: Context
) {

    val binding = ActivityWatchLessonsBinding.inflate(LayoutInflater.from(context))

    private val handler = Handler(Looper.getMainLooper())
    private val player = ExoPlayer.Builder(context).build()
    private val textViewProgress = binding.txtProgressNumber
    private val progressBar = binding.progressBar
    // 新增:SharedPreferences实例
    private val sp = context.getSharedPreferences("VideoProgress", Context.MODE_PRIVATE)

    private var videoDuration: Long = 0
    private var totalPlaybackTime: Long = 0
    private var isUserSeeking: Boolean = false
    private var progress: Int = 0
    private val visitedSeconds = mutableSetOf<Int>()
    // 新增:记录当前视频ID,用于区分存储
    private lateinit var currentVideoId: String


    fun initVideoView(videoAddress: Int) {
        currentVideoId = videoAddress.toString()
        binding.playerViewOfLessons.player = player

        val mediaItem = MediaItem.fromUri("android.resource://${context.packageName}/${videoAddress}")
        player.setMediaItem(mediaItem)
        player.prepare()

        // 新增:读取历史进度数据
        loadProgressData()

        player.addListener(object : Player.Listener {
            override fun onPlaybackStateChanged(state: Int) {
                when (state) {
                    Player.STATE_READY -> {
                        videoDuration = player.duration
                        // 新增:续播逻辑,跳转到上次播放位置
                        val lastPosition = sp.getLong("${currentVideoId}_position", 0)
                        if (lastPosition > 0) {
                            player.seekTo(lastPosition)
                        }
                        updateProgressBar()
                    }
                    Player.STATE_ENDED -> {
                        handler.removeCallbacksAndMessages(null)
                    }
                }
            }

            override fun onPositionDiscontinuity(reason: Int) {
                if (reason == Player.DISCONTINUITY_REASON_SEEK) {
                    isUserSeeking = true // 修正:用户开始跳转时标记为true,避免统计跳转的片段
                }
            }
        })
    }

    private fun updateProgressBar() {
        handler.post(object : Runnable {
            @SuppressLint("SetTextI18n")
            override fun run() {
                val currentPosition = player.currentPosition

                if (!isUserSeeking) {
                    val currentSecond = (currentPosition / 1000).toInt()

                    if (!visitedSeconds.contains(currentSecond)) {
                        visitedSeconds.add(currentSecond)
                        totalPlaybackTime += 1000
                        // 可选:实时保存,避免崩溃丢失数据
                        saveProgressData()
                    }

                    progress = (totalPlaybackTime / videoDuration.toFloat() * 100).toInt()
                    textViewProgress.text = "$progress/100%"
                    progressBar.progress = progress
                }
                handler.postDelayed(this, 1000)
            }
        })
    }

    // 新增:读取历史进度数据
    private fun loadProgressData() {
        totalPlaybackTime = sp.getLong("${currentVideoId}_total", 0)
        progress = sp.getInt("${currentVideoId}_progress", 0)
        // 解析保存的visitedSeconds字符串为Set
        val visitedStr = sp.getString("${currentVideoId}_visited", "") ?: ""
        if (visitedStr.isNotEmpty()) {
            visitedSeconds.addAll(visitedStr.split(",").map { it.toInt() })
        }
    }

    // 新增:保存进度数据到SharedPreferences
    private fun saveProgressData() {
        sp.edit()
            .putLong("${currentVideoId}_total", totalPlaybackTime)
            .putInt("${currentVideoId}_progress", progress)
            .putString("${currentVideoId}_visited", visitedSeconds.joinToString(","))
            .putLong("${currentVideoId}_position", player.currentPosition)
            .apply()
    }

    fun onPause() {
        handler.removeCallbacksAndMessages(null)
        player.pause()
        // 暂停时保存数据
        saveProgressData()
    }

    fun onResume() {
        updateProgressBar()
    }

    fun onDestroy() {
        handler.removeCallbacksAndMessages(null)
        // 销毁前最终保存
        saveProgressData()
        player.release()
    }

}

关键修正点

  • 修正了isUserSeeking的逻辑:用户开始跳转时标记为true,避免统计跳转过程的片段(原代码只在跳转结束时设为false,逻辑反了)
  • 用视频ID作为存储键前缀,支持多视频的进度区分
  • 将visitedSeconds集合转为字符串存储,解决SharedPreferences无法直接存Set的问题
  • 在STATE_READY时自动跳转到上次播放位置,实现续播

二、Room 数据库实现(适合大量视频场景)

如果需要管理多个视频的进度,SharedPreferences会显得杂乱,推荐用Room数据库存储。

1. 定义实体类

@Entity(tableName = "video_progress")
data class VideoProgress(
    @PrimaryKey val videoId: String, // 视频唯一标识,比如你的videoAddress转字符串
    val totalPlaybackTime: Long,
    val progress: Int,
    val visitedSeconds: String, // 同样用逗号分隔的字符串存储已观看秒数
    val lastPosition: Long // 上次播放位置
)

2. 定义Dao接口

@Dao
interface VideoProgressDao {
    @Query("SELECT * FROM video_progress WHERE videoId = :videoId")
    suspend fun getProgress(videoId: String): VideoProgress?

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun saveProgress(progress: VideoProgress)
}

3. 定义数据库类

@Database(entities = [VideoProgress::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
    abstract fun videoProgressDao(): VideoProgressDao

    companion object {
        @Volatile
        private var INSTANCE: AppDatabase? = null

        fun getInstance(context: Context): AppDatabase {
            return INSTANCE ?: synchronized(this) {
                val instance = Room.databaseBuilder(
                    context.applicationContext,
                    AppDatabase::class.java,
                    "app_database"
                ).build()
                INSTANCE = instance
                instance
            }
        }
    }
}

4. 修改ViewWatchLessonsActivity中的读写逻辑

替换SharedPreferences的读写为Room的Coroutine调用:

// 替换SharedPreferences实例为Room相关
private val db = AppDatabase.getInstance(context)
private val progressDao = db.videoProgressDao()

// 读取数据改为
private fun loadProgressData() {
    CoroutineScope(Dispatchers.IO).launch {
        val savedProgress = progressDao.getProgress(currentVideoId)
        savedProgress?.let {
            totalPlaybackTime = it.totalPlaybackTime
            progress = it.progress
            val visitedStr = it.visitedSeconds
            if (visitedStr.isNotEmpty()) {
                visitedSeconds.addAll(visitedStr.split(",").map { it.toInt() })
            }
            // 续播位置在主线程设置
            withContext(Dispatchers.Main) {
                if (it.lastPosition > 0) {
                    player.seekTo(it.lastPosition)
                }
            }
        }
    }
}

// 保存数据改为
private fun saveProgressData() {
    CoroutineScope(Dispatchers.IO).launch {
        val progressEntity = VideoProgress(
            videoId = currentVideoId,
            totalPlaybackTime = totalPlaybackTime,
            progress = progress,
            visitedSeconds = visitedSeconds.joinToString(","),
            lastPosition = player.currentPosition
        )
        progressDao.saveProgress(progressEntity)
    }
}

常见问题排查

  1. 存储后读取不到:检查键名是否和视频ID绑定,避免不同视频数据覆盖
  2. 续播不生效:确保在player.prepare()之后调用seekTo,且读取的lastPosition不为0
  3. 多线程问题:Room操作必须在子线程执行,所以要用Coroutine或AsyncTask

内容的提问来源于stack exchange,提问作者mah beh

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.19 07:17:02