一、项目介绍
1. 背景与动机
在当下短视频、直播和本地媒体播放盛行的时代,几乎所有类型的 App 都或多或少涉及视频播放功能。特别是教育类、社交类、新闻类等 App,经常需要将本地存储的视频(拍摄内容、缓存视频、离线包)播放给用户。相比网络流媒体,本地视频播放具有以下优势:
-
启动速度快:免去网络加载与缓冲,用户体验更流畅
-
离线播放:即使无网络或网络不稳定,也能播放已下载的内容
-
文件管理:可以借助系统媒体库或 FileProvider 访问私有/公有目录
本教程将从Android 原生方案(VideoView
、MediaPlayer
+ SurfaceView
/TextureView
)到第三方方案(ExoPlayer)的实现细节,手把手教你如何:
-
扫描设备或 SD 卡中的视频文件
-
使用
VideoView
快速集成本地视频播放 -
结合
MediaPlayer
与TextureView
实现更高自由度的布局与渲染 -
使用谷歌开源的 ExoPlayer 加载本地视频并处理各种格式
-
管理生命周期、处理音视频焦点、截取首帧、快进快退
-
封装为
LocalVideoPlayer
组件,支持 XML 与代码双模式调用
2. 功能目标
-
扫描本地视频:读取外部存储或应用私有目录中的视频列表
-
列表展示:使用
RecyclerView
显示视频封面、名称、时长 -
点击播放:点击列表项后在页面内或全屏播放对应本地视频
-
控制面板:提供播放/暂停、进度条、快进快退、静音/音量调节
-
生命周期管理:自动暂停/恢复,避免内存泄漏
-
可选扩展:截图、倍速播放、手势调节亮度与音量
二、相关知识
-
Android 媒体架构
-
MediaPlayer
:系统原生 API,支持绝大多数容器格式(MP4、3GP、MKV 等),但定制能力有限 -
VideoView
:封装了MediaPlayer
+SurfaceView
,集成简单,但 UI 定制受限 -
ExoPlayer
:谷歌开源,模块化设计,支持 DASH、HLS、自定义渲染器,更灵活
-
-
渲染视图
-
SurfaceView
:独立的渲染层,性能高,但不能叠加其他 View(Z 顺序受限) -
TextureView
:在普通 View 树中渲染,可叠加动画与 UI 控件,自定义性强
-
-
读取本地文件
-
6.0+ 动态申请
READ_EXTERNAL_STORAGE
权限 -
使用
MediaStore.Video.Media
扫描外部存储,也可使用FileProvider
访问私有目录
-
-
生命周期管理
-
在
Activity/Fragment
的onPause()
中暂停播放,onResume()
恢复; -
避免
MediaPlayer
或ExoPlayer
在后台继续占用资源
-
-
UI 控件
-
RecyclerView
+ 自定义Adapter
展示视频列表 -
播放控制面板:使用
SeekBar
、ImageButton
、TextView
等组合
-
-
高性能优化
-
缓存视频帧(首帧)用于封面显示
-
使用
ExoPlayer
的SimpleCache
做磁盘缓存 -
使用硬件加速与适当的缓冲配置减少卡顿
-
三、实现思路
1. 扫描本地视频
-
在 App 启动或对应页面请求存储权限后,通过
MediaStore.Video.Media.query()
获取视频列表 -
封装为数据类
LocalVideo(val uri: Uri, val title: String, val duration: Long, val size: Long)
-
将列表提交给
RecyclerView.Adapter
渲染
2. 播放方案对比
方案 | 优点 | 缺点 |
---|---|---|
VideoView | 集成简单,最少代码 | UI 定制性差,SurfaceView 限制 Z 序 |
MediaPlayer+TextureView | 灵活布局,可叠加 UI | 需管理生命周期与渲染 |
ExoPlayer | 功能强大,支持更多格式与缓冲策略 | 依赖库较大,集成稍复杂 |
本教程将以 ExoPlayer 为首选,演示如何在 Activity
或 Fragment
中完整集成;并补充 VideoView
快速上手示例。
3. 播放组件封装
-
创建
LocalVideoPlayer
自定义 ViewGroup,内部包含PlayerView
(ExoPlayer UI)或TextureView
-
暴露方法:
setVideo(uri: Uri)
,play()
,pause()
,seekTo(ms: Long)
,以及release()
-
通过 XML 属性或代码动态控制全屏/嵌入模式
4. UI 交互与控制
-
列表页点击后进入详情页或弹出播放面板
-
控制面板可显示/隐藏,支持双击暂停/播放、拖动进度快进、手势上下滑调节音量/亮度
四、环境与依赖
// app/build.gradle
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
android {
compileSdkVersion 34
defaultConfig {
applicationId "com.example.localvideoplayer"
minSdkVersion 23
targetSdkVersion 34
}
buildFeatures { viewBinding true }
kotlinOptions { jvmTarget = "1.8" }
}
dependencies {
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.recyclerview:recyclerview:1.3.0'
implementation 'com.google.android.exoplayer:exoplayer:2.18.1'
implementation 'androidx.core:core-ktx:1.10.1'
}
五、整合代码
// =======================================================
// 文件: AndroidManifest.xml
// 描述: 申明读取存储权限
// =======================================================
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.localvideoplayer">
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<application
android:allowBackup="true"
android:label="LocalVideoPlayer"
android:theme="@style/Theme.AppCompat.Light.NoActionBar">
<activity android:name=".MainActivity" android:exported="true"/>
</application>
</manifest>
// =======================================================
// 文件: res/layout/activity_main.xml
// 描述: 主界面布局:RecyclerView 列表
// =======================================================
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/fl_root"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_videos"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
</FrameLayout>
// =======================================================
// 文件: res/layout/item_video.xml
// 描述: 视频列表项布局:封面 + 标题 + 时长
// =======================================================
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="horizontal"
android:padding="8dp"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageView
android:id="@+id/iv_thumb"
android:layout_width="120dp"
android:layout_height="80dp"
android:scaleType="centerCrop"/>
<LinearLayout
android:orientation="vertical"
android:layout_marginStart="8dp"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/tv_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="视频标题"
android:textSize="16sp"/>
<TextView
android:id="@+id/tv_duration"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="00:00"
android:layout_marginTop="4dp"/>
</LinearLayout>
</LinearLayout>
// =======================================================
// 文件: res/layout/activity_player.xml
// 描述: 播放界面:ExoPlayer PlayerView + 控制按钮
// =======================================================
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.exoplayer2.ui.PlayerView
android:id="@+id/player_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:use_controller="true"
app:controller_auto_show="true"/>
</FrameLayout>
// =======================================================
// 文件: LocalVideo.kt
// 描述: 本地视频数据类
// =======================================================
package com.example.localvideoplayer
import android.net.Uri
data class LocalVideo(
val uri: Uri,
val title: String,
val duration: Long,
val size: Long
)
// =======================================================
// 文件: VideoAdapter.kt
// 描述: RecyclerView Adapter
// =======================================================
package com.example.localvideoplayer
import android.content.Context
import android.media.ThumbnailUtils
import android.provider.MediaStore
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.example.localvideoplayer.databinding.ItemVideoBinding
class VideoAdapter(
private val ctx: Context,
private val items: List<LocalVideo>,
private val onClick: (LocalVideo) -> Unit
) : RecyclerView.Adapter<VideoAdapter.VH>() {
inner class VH(val bind: ItemVideoBinding) : RecyclerView.ViewHolder(bind.root)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
val b = ItemVideoBinding.inflate(LayoutInflater.from(ctx), parent, false)
return VH(b)
}
override fun onBindViewHolder(holder: VH, position: Int) {
val video = items[position]
holder.bind.tvTitle.text = video.title
holder.bind.tvDuration.text = formatDuration(video.duration)
// 使用 Glide 加载首帧缩略图
Glide.with(ctx)
.load(ThumbnailUtils.createVideoThumbnail(video.uri.path!!, MediaStore.Images.Thumbnails.MINI_KIND))
.into(holder.bind.ivThumb)
holder.bind.root.setOnClickListener { onClick(video) }
}
override fun getItemCount() = items.size
private fun formatDuration(ms: Long): String {
val totalSec = ms / 1000
val min = totalSec / 60
val sec = totalSec % 60
return String.format("%02d:%02d", min, sec)
}
}
// =======================================================
// 文件: MainActivity.kt
// 描述: 扫描本地视频并展示列表
// =======================================================
package com.example.localvideoplayer
import android.Manifest
import android.content.Intent
import android.database.Cursor
import android.net.Uri
import android.os.Bundle
import android.provider.MediaStore
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import com.example.localvideoplayer.databinding.ActivityMainBinding
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private val videos = mutableListOf<LocalVideo>()
private val adapter by lazy {
VideoAdapter(this, videos) { video ->
startActivity(Intent(this, PlayerActivity::class.java).apply {
putExtra("video_uri", video.uri.toString())
})
}
}
private val reqPerm = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
if (granted) loadVideos() else Toast.makeText(this, "权限被拒绝", Toast.LENGTH_SHORT).show()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.rvVideos.adapter = adapter
// 请求权限
if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE)
!= android.content.pm.PackageManager.PERMISSION_GRANTED) {
reqPerm.launch(Manifest.permission.READ_EXTERNAL_STORAGE)
} else loadVideos()
}
private fun loadVideos() {
val uri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI
val cols = arrayOf(
MediaStore.Video.Media._ID,
MediaStore.Video.Media.DISPLAY_NAME,
MediaStore.Video.Media.DURATION,
MediaStore.Video.Media.SIZE
)
val cursor: Cursor? = contentResolver.query(uri, cols, null, null,
"${MediaStore.Video.Media.DATE_ADDED} DESC")
cursor?.use {
val idIdx = it.getColumnIndexOrThrow(MediaStore.Video.Media._ID)
val nameIdx = it.getColumnIndexOrThrow(MediaStore.Video.Media.DISPLAY_NAME)
val durIdx = it.getColumnIndexOrThrow(MediaStore.Video.Media.DURATION)
val sizeIdx = it.getColumnIndexOrThrow(MediaStore.Video.Media.SIZE)
while (it.moveToNext()) {
val id = it.getLong(idIdx)
val contentUri = Uri.withAppendedPath(uri, id.toString())
videos += LocalVideo(
uri = contentUri,
title = it.getString(nameIdx),
duration = it.getLong(durIdx),
size = it.getLong(sizeIdx)
)
}
adapter.notifyDataSetChanged()
}
}
}
// =======================================================
// 文件: PlayerActivity.kt
// 描述: 使用 ExoPlayer 播放本地视频
// =======================================================
package com.example.localvideoplayer
import android.net.Uri
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import com.example.localvideoplayer.databinding.ActivityPlayerBinding
import com.google.android.exoplayer2.ExoPlayer
import com.google.android.exoplayer2.MediaItem
class PlayerActivity : AppCompatActivity() {
private lateinit var binding: ActivityPlayerBinding
private var player: ExoPlayer? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityPlayerBinding.inflate(layoutInflater)
setContentView(binding.root)
val uriStr = intent.getStringExtra("video_uri") ?: return
val uri = Uri.parse(uriStr)
initPlayer(uri)
}
private fun initPlayer(uri: Uri) {
player = ExoPlayer.Builder(this).build().also { exo ->
binding.playerView.player = exo
val item = MediaItem.fromUri(uri)
exo.setMediaItem(item)
exo.prepare()
exo.playWhenReady = true
}
}
override fun onPause() {
super.onPause()
player?.pause()
}
override fun onResume() {
super.onResume()
player?.play()
}
override fun onDestroy() {
super.onDestroy()
player?.release()
player = null
}
}
六、代码解读
-
权限与扫描
-
在
MainActivity
中动态申请READ_EXTERNAL_STORAGE
权限; -
通过
MediaStore.Video.Media
查询外部存储视频,并封装为LocalVideo
列表,提交给RecyclerView.Adapter
。
-
-
列表展示
-
VideoAdapter
使用 Glide 或ThumbnailUtils
异步加载视频首帧作为封面; -
点击列表项启动
PlayerActivity
,传递video_uri
。
-
-
视频播放
-
PlayerActivity
使用 ExoPlayer:-
创建
ExoPlayer
实例,绑定给布局中的PlayerView
; -
构造
MediaItem.fromUri(uri)
并prepare()
播放; -
在
onPause()
/onResume()
管理播放状态,onDestroy()
释放资源。
-
-
-
布局与组件
-
activity_main.xml
和item_video.xml
分别负责列表与列表项布局; -
activity_player.xml
使用 ExoPlayer 提供的PlayerView
,内置控制面板;
-
七、性能与优化
-
封面缓存
-
使用 Glide 加载视频缩略图会进行内存与磁盘缓存,流畅展示列表;
-
-
ExoPlayer 缓冲
-
可通过
DefaultLoadControl
自定义缓冲配置,平衡启动延迟与卡顿率;
-
-
生命周期感知
-
在
Fragment
中使用playback
结合viewLifecycleOwner.lifecycle
管理;
-
-
内存泄漏防范
-
player.release()
要在onDestroy()
中调用,避免 Activity 持有引用;
-
-
离线缓存
-
ExoPlayer 的
CacheDataSource
与SimpleCache
可实现离线视频缓存;
-
八、项目总结与拓展
项目总结
本文从本地视频扫描、列表展示,到 ExoPlayer 播放的完整流程,构建了一个高度可复用的本地视频播放器。涵盖了权限管理、UI 列表、视频封面、播放控制、生命周期管理等关键细节。
拓展方向
-
倍速播放:ExoPlayer 支持
exo.setPlaybackParameters(PlaybackParameters(speed))
; -
画中画模式:在支持 API 26+ 上结合
PictureInPictureParams
实现小窗播放; -
手势控制:自定义手势检测,滑动调整进度、亮度、音量;
-
画面剪裁:使用
TextureView
+Matrix
对视频画面做缩放、裁剪; -
注释/弹幕:叠加
RecyclerView
弹幕或Canvas
绘制注释;
九、FAQ
Q1:为何选择 ExoPlayer 而非 VideoView?
A1:ExoPlayer 功能更强大,支持自定义格式扩展、缓存策略、HLS/DASH 等高级特性,且可在 PlayerView
与自定义渲染视图间切换。
Q2:如何在 Fragment 中安全管理播放器?
A2:建议在 onViewCreated()
初始化,在 onDestroyView()
或 onStop()
释放播放器,并使用 viewLifecycleOwner.lifecycleScope
管理协程。
Q3:播放列表切换时如何保证流畅?
A3:可以使用 ExoPlayer 的 ConcatenatingMediaSource
将多个 MediaItem
串联,切换时无需重新 prepare。
Q4:如何获取视频真实分辨率?
A4:可通过 MediaMetadataRetriever
调用 retriever.extractMetadata(METADATA_KEY_VIDEO_WIDTH)
等获取宽高。
Q5:如何截取视频指定时间的帧?
A5:使用 MediaMetadataRetriever.getFrameAtTime(timeUs, OPTION_CLOSEST)
获取对应时间帧并转为 Bitmap
。