Android实现播放本地视频(附带源码)

一、项目介绍

1. 背景与动机

在当下短视频、直播和本地媒体播放盛行的时代,几乎所有类型的 App 都或多或少涉及视频播放功能。特别是教育类、社交类、新闻类等 App,经常需要将本地存储的视频(拍摄内容、缓存视频、离线包)播放给用户。相比网络流媒体,本地视频播放具有以下优势:

  • 启动速度快:免去网络加载与缓冲,用户体验更流畅

  • 离线播放:即使无网络或网络不稳定,也能播放已下载的内容

  • 文件管理:可以借助系统媒体库或 FileProvider 访问私有/公有目录

本教程将从Android 原生方案VideoViewMediaPlayer + SurfaceView/TextureView)到第三方方案(ExoPlayer)的实现细节,手把手教你如何:

  1. 扫描设备或 SD 卡中的视频文件

  2. 使用 VideoView 快速集成本地视频播放

  3. 结合 MediaPlayerTextureView 实现更高自由度的布局与渲染

  4. 使用谷歌开源的 ExoPlayer 加载本地视频并处理各种格式

  5. 管理生命周期、处理音视频焦点、截取首帧、快进快退

  6. 封装为 LocalVideoPlayer 组件,支持 XML 与代码双模式调用

2. 功能目标

  • 扫描本地视频:读取外部存储或应用私有目录中的视频列表

  • 列表展示:使用 RecyclerView 显示视频封面、名称、时长

  • 点击播放:点击列表项后在页面内或全屏播放对应本地视频

  • 控制面板:提供播放/暂停、进度条、快进快退、静音/音量调节

  • 生命周期管理:自动暂停/恢复,避免内存泄漏

  • 可选扩展:截图、倍速播放、手势调节亮度与音量


二、相关知识

  1. Android 媒体架构

    • MediaPlayer:系统原生 API,支持绝大多数容器格式(MP4、3GP、MKV 等),但定制能力有限

    • VideoView:封装了 MediaPlayer + SurfaceView,集成简单,但 UI 定制受限

    • ExoPlayer:谷歌开源,模块化设计,支持 DASH、HLS、自定义渲染器,更灵活

  2. 渲染视图

    • SurfaceView:独立的渲染层,性能高,但不能叠加其他 View(Z 顺序受限)

    • TextureView:在普通 View 树中渲染,可叠加动画与 UI 控件,自定义性强

  3. 读取本地文件

    • 6.0+ 动态申请 READ_EXTERNAL_STORAGE 权限

    • 使用 MediaStore.Video.Media 扫描外部存储,也可使用 FileProvider 访问私有目录

  4. 生命周期管理

    • Activity/FragmentonPause() 中暂停播放,onResume() 恢复;

    • 避免 MediaPlayerExoPlayer 在后台继续占用资源

  5. UI 控件

    • RecyclerView + 自定义 Adapter 展示视频列表

    • 播放控制面板:使用 SeekBarImageButtonTextView 等组合

  6. 高性能优化

    • 缓存视频帧(首帧)用于封面显示

    • 使用 ExoPlayerSimpleCache 做磁盘缓存

    • 使用硬件加速与适当的缓冲配置减少卡顿


三、实现思路

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 为首选,演示如何在 ActivityFragment 中完整集成;并补充 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
  }
}

六、代码解读

  1. 权限与扫描

    • MainActivity 中动态申请 READ_EXTERNAL_STORAGE 权限;

    • 通过 MediaStore.Video.Media 查询外部存储视频,并封装为 LocalVideo 列表,提交给 RecyclerView.Adapter

  2. 列表展示

    • VideoAdapter 使用 Glide 或 ThumbnailUtils 异步加载视频首帧作为封面;

    • 点击列表项启动 PlayerActivity,传递 video_uri

  3. 视频播放

    • PlayerActivity 使用 ExoPlayer:

      • 创建 ExoPlayer 实例,绑定给布局中的 PlayerView

      • 构造 MediaItem.fromUri(uri)prepare() 播放;

      • onPause()/onResume() 管理播放状态,onDestroy() 释放资源。

  4. 布局与组件

    • activity_main.xmlitem_video.xml 分别负责列表与列表项布局;

    • activity_player.xml 使用 ExoPlayer 提供的 PlayerView,内置控制面板;


七、性能与优化

  1. 封面缓存

    • 使用 Glide 加载视频缩略图会进行内存与磁盘缓存,流畅展示列表;

  2. ExoPlayer 缓冲

    • 可通过 DefaultLoadControl 自定义缓冲配置,平衡启动延迟与卡顿率;

  3. 生命周期感知

    • Fragment 中使用 playback 结合 viewLifecycleOwner.lifecycle 管理;

  4. 内存泄漏防范

    • player.release() 要在 onDestroy() 中调用,避免 Activity 持有引用;

  5. 离线缓存

    • ExoPlayer 的 CacheDataSourceSimpleCache 可实现离线视频缓存;


八、项目总结与拓展

项目总结

本文从本地视频扫描、列表展示,到 ExoPlayer 播放的完整流程,构建了一个高度可复用的本地视频播放器。涵盖了权限管理、UI 列表、视频封面、播放控制、生命周期管理等关键细节。

拓展方向

  1. 倍速播放:ExoPlayer 支持 exo.setPlaybackParameters(PlaybackParameters(speed))

  2. 画中画模式:在支持 API 26+ 上结合 PictureInPictureParams 实现小窗播放;

  3. 手势控制:自定义手势检测,滑动调整进度、亮度、音量;

  4. 画面剪裁:使用 TextureView + Matrix 对视频画面做缩放、裁剪;

  5. 注释/弹幕:叠加 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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值