简介:这份资源是面向 Android 开发者的视频播放器框架源码包,基于 JiaoZiVideoPlayer 二次修改,适合需要在应用中集成在线与本地视频播放能力的中级开发者。它解决了列表滑动时播放状态难以管理、播放引擎切换繁琐等问题,支持一行代码将系统播放器替换为 Ijkplayer、Exoplayer、Vitamio 等,并内置完善的列表滑动检测、全屏与小窗口切换、重力感应自动全屏、Home 键暂停续播以及 WebView 嵌套本地视频控件等能力。压缩包共 193 个文件,以 72 个 xml 布局与配置、54 个 java 源码、41 个 png 与 8 个 jpg 图片资源为主,另含 gradle 构建脚本、说明文档与演示视频,整体约 570KB,结构紧凑便于阅读与二次开发。已有 474 人学习,读者可从中获取可运行的播放器工程、自定义 UI 与功能扩展思路,以及列表滑动、全屏嵌套等场景的参考实现。
1. 拿到一份改过的播放器源码,先别急着跑
一个很常见的场景:产品经理丢过来一句「短视频列表要能边滑边播,本地缓存也要能放」,你打开搜索框找播放器方案,翻到一份别人改过的 JiaoZiVideoPlayer 源码包,解压一看目录挺干净,但心里没底——这玩意儿到底能不能直接塞进现有工程?在线播放和本地播放是不是两套逻辑?改过的地方会不会埋了坑?
这份资源就是干这个的:一个基于 Java 的视频播放器框架,主打在线播放和本地播放两条链路,作者在原版基础上做了自己的修改。适合两类人:一类是刚接手播放需求、想找个能跑起来的底座快速验证的新手;另一类是嫌原生 MediaPlayer 太原始、想看看成熟封装怎么组织状态机和 UI 层的老手。下面按「它是什么 → 怎么跑起来 → 核心怎么实现 → 怎么改」的顺序拆,能抄的地方直接抄。
2. 项目定位与技术选型:为什么是封装层而不是裸 MediaPlayer
2.1 它解决的到底是什么问题
裸用 Android 的MediaPlayer或VideoView写播放,最烦的不是播放本身,而是围绕播放的一圈杂事:横竖屏切换时 Surface 重建、播放中来电打断、列表滑动时多个实例抢焦点、缓冲态和错误态的 UI 反馈、全屏小窗的切换。这些逻辑如果每个页面写一遍,代码会迅速烂掉。
JiaoZiVideoPlayer 的思路是把这些收进一个JzvdStd标准播放器视图里,对外只暴露「设置视频地址 → 开始播放 → 监听状态」三步。它的定位不是解码器,也不是硬解软解方案,而是一层播放器 UI 与生命周期封装。解码仍然交给系统MediaPlayer(部分场景可换 IJK),框架负责的是状态机、视图层级和交互。
这个定位决定了它的适用边界:常规的 MP4/HLS 在线播放、本地文件播放、列表内嵌播放都很合适;如果你要做 4K 硬解、自定义渲染管线、逐帧处理,那得往下换 ExoPlayer 或自己接 FFmpeg,这层封装反而碍事。
2.2 技术栈与依赖关系
从源码结构看,这是一个标准的 Android 原生工程,语言是 Java,构建走 Gradle。核心依赖很轻:
| 依赖项 | 作用 | 备注 |
|---|---|---|
| Android SDK (MediaPlayer) | 实际解码与播放 | 系统能力,无需额外引入 |
| AppCompat / Support | 基础 UI 组件 | 跟随工程版本 |
自定义Jzvd抽象类 | 播放器基类,管状态机 | 框架核心 |
JzvdStd | 标准皮肤实现 | 大多数页面直接用这个 |
| 可选 IJK 内核 | 替换默认解码 | 需要额外 so 库 |
选 Java 而不是 Kotlin,是这份源码的一个现实约束——它诞生的年代和大量存量工程都是 Java 栈,好处是复制进老项目零摩擦,坏处是新项目里混着写会有点割裂。我一般会把它当纯 Java 模块引入,业务层该用 Kotlin 用 Kotlin,互不干扰。
提示:源码里如果带了
ijkplayer相关的 so 目录,先确认你的工程 ABI 过滤配置,否则打包体积会莫名其妙涨一截。
2.3 目录结构长什么样
解压后大致是这么个骨架,理解它比逐行读代码更重要:
JiaoZiVideoPlayer-develop/ ├── app/ # 示例工程,跑起来看效果用 │ └── src/main/java/... # Demo Activity,各种播放场景 ├── jiaozivideoplayer/ # 框架本体(library module) │ └── src/main/java/cn/jzvd/ │ ├── Jzvd.java # 抽象基类,状态机 + 生命周期 │ ├── JzvdStd.java # 标准播放器视图 │ ├── JzvdMp4.java # 特定格式/场景的变体 │ └── ... # 各种 UI 变体与工具类 ├── build.gradle # 根构建脚本 └── settings.gradle # 模块声明app是给你照着改的样板间,jiaozivideoplayer才是要引进业务工程的部分。很多人一上来改app里的 Demo,改完发现业务工程里没效果——因为真正被依赖的是 library module,这个顺序别搞反。
3. 跑起来:从导入到第一个能播的页面
3.1 环境准备与工程导入
先把工程当普通 Android 项目处理。用 Android Studio 打开根目录,等 Gradle sync 完成。这一步常见的翻车点是 Gradle 版本和 AGP 版本对不上,老工程尤其明显。我的习惯是先看根build.gradle里的classpath 'com.android.tools.build:gradle:x.x.x',再对照本机 Studio 支持的 AGP 范围,不匹配就改工程而不是降 Studio。
// 根 build.gradle 片段,重点看这两处 buildscript { dependencies { // AGP 版本决定你能用哪个 Studio 打开 classpath 'com.android.tools.build:gradle:3.5.0' } }参数说明:classpath那行的版本号是整份工程能否 sync 成功的关键。如果报Unsupported class file major version,基本就是 JDK 版本高于 AGP 支持范围,把 Studio 的 Gradle JDK 调到 8 或 11 再试。这一步没有玄学,就是版本对齐。
sync 通过后,settings.gradle里应该能看到include ':app', ':jiaozivideoplayer'两个模块。如果只有app,说明 library 没被声明,后面业务工程也没法依赖它。
3.2 把 library 引进业务工程
有两种引法,看你的工程形态选:
第一种,源码依赖。把jiaozivideoplayer整个目录拷进你的工程根目录,然后在settings.gradle加一行include ':jiaozivideoplayer',业务 module 的build.gradle里加implementation project(':jiaozivideoplayer')。适合你要深度改框架的情况。
第二种,打成 aar 引。在 library module 执行./gradlew :jiaozivideoplayer:assembleRelease,产物在jiaozivideoplayer/build/outputs/aar/下,拷进libs/后implementation files('libs/jiaozivideoplayer-release.aar')。适合只当黑盒用、不打算改源码的情况。
// 业务 module 的 build.gradle dependencies { // 源码依赖,改框架时用这个 implementation project(':jiaozivideoplayer') // 或者 aar 依赖,二选一 // implementation files('libs/jiaozivideoplayer-release.aar') }这里有个血泪经验:两种方式别同时留着,否则会出现类重复(Duplicate class)编译错误。切换依赖方式时记得把另一种注释掉并 clean 一次。
3.3 最小可播页面
引入之后,第一个验证页面越简单越好。布局里放一个JzvdStd,Activity 里设地址、设标题、触发播放,三步。
<!-- activity_player.xml --> <cn.jzvd.JzvdStd android:id="@+id/jz_video" android:layout_width="match_parent" android:layout_height="220dp" />// PlayerActivity.java public class PlayerActivity extends AppCompatActivity { private JzvdStd jzVideo; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_player); jzVideo = findViewById(R.id.jz_video); // 设置在线地址与标题,第二个参数是标题文本 jzVideo.setUp("https://example.com/demo.mp4", "示例视频"); // 进入页面自动播放,不需要就删掉这行 jzVideo.startVideo(); } @Override protected void onPause() { super.onPause(); // 必须交给框架处理,否则后台还在响 Jzvd.releaseAllVideos(); } }逻辑说明:setUp(url, title)只做地址和元信息绑定,不触发播放;startVideo()才真正进入播放态。onPause里调Jzvd.releaseAllVideos()是框架的全局释放入口,漏掉这行最常见的现象是「按 Home 键后视频声音还在后台播」,这是新手第一个必踩的坑。
参数说明:setUp的第一个参数支持 http/https 的在线地址,也支持file://或本地绝对路径,这就是「在线 + 本地」两条链路在调用层的统一入口——框架内部根据 scheme 决定走网络还是走文件,业务层不用分支判断。
3.4 本地播放怎么切
本地播放不需要换控件,只换地址来源。常见做法是把 assets 里的视频拷到缓存目录再播,因为MediaPlayer对assets路径支持不稳定。
// 把 assets 里的视频落到 cacheDir,再交给播放器 private String copyAssetToCache(String assetName) throws IOException { File outFile = new File(getCacheDir(), assetName); if (outFile.exists()) return outFile.getAbsolutePath(); try (InputStream in = getAssets().open(assetName); OutputStream out = new FileOutputStream(outFile)) { byte[] buf = new byte[8192]; int len; while ((len = in.read(buf)) > 0) { out.write(buf, 0, len); } } return outFile.getAbsolutePath(); }拿到绝对路径后,jzVideo.setUp(path, "本地视频")即可。这里的关键点是路径必须是真实文件路径,不能是assets://这种伪协议,否则播放器会直接进错误态。缓存目录用getCacheDir()而不是外部存储,省掉权限申请的麻烦。
4. 核心模块拆解:状态机、视图层与生命周期
4.1 Jzvd 基类里的状态机
整个框架的骨架在Jzvd这个抽象类里,它维护了一套播放状态常量,所有 UI 表现都由当前状态驱动。理解这几个状态,比读一百行 UI 代码有用。
// Jzvd.java 中的状态定义(示意,字段名以源码为准) public static final int STATE_NORMAL = 0; // 初始 public static final int STATE_PREPARING = 1; // 准备中 public static final int STATE_PREPARED = 2; // 准备完成 public static final int STATE_PLAYING = 3; // 播放中 public static final int STATE_PAUSE = 4; // 暂停 public static final int STATE_AUTO_COMPLETE = 5; // 播放完成 public static final int STATE_ERROR = 6; // 错误逻辑说明:这套状态不是摆设,onStatePreparing、onStatePlaying这些回调里会同步切换进度条、封面、控制栏的可见性。你在业务层想加自定义 UI 反馈,正确姿势是重写对应的onStateXxx方法,而不是在播放按钮的点击回调里硬写。后者在「播放中被来电打断」这类异步场景下必然对不上。
参数说明:STATE_ERROR是排查问题的入口。框架在错误回调里通常会带上what和extra两个码,对应MediaPlayer的错误常量。看到what=1, extra=-1004基本是网络或地址问题,extra=-110多半是文件格式或路径不对。把这两个码打进日志,比盲猜快得多。
4.2 JzvdStd 的视图层级
JzvdStd继承自Jzvd,负责标准皮肤:封面图、标题栏、底部控制条、全屏按钮。它的布局是分层叠放的,从上到下大致是封面层、Surface 层、控制层。改 UI 时先搞清楚自己改的是哪一层。
// JzvdStd 初始化视图的典型结构(简化示意) @Override public void init(Context context) { super.init(context); // 封面图,播放前显示 posterImageView = findViewById(R.id.poster); // 标题 titleTextView = findViewById(R.id.title); // 底部控制条 bottomContainer = findViewById(R.id.bottom_container); // 绑定点击事件 startButton.setOnClickListener(this); fullscreenButton.setOnClickListener(this); }逻辑说明:init里做的是「找控件 + 绑事件」,真正的显隐切换在状态回调里。这意味着你如果要在控制条上加一个「倍速」按钮,正确做法是在布局里加控件、在init里findViewById、在点击回调里调MediaPlayer的播放速率接口,而不是去改状态机。
参数说明:posterImageView的封面图通过setUp之后的posterImageView.setImageResource(...)或图片加载库设置。注意封面在STATE_PLAYING之后会被隐藏,如果你发现封面不消失,八成是状态没走到播放态,先查地址是否可达。
4.3 生命周期与全局释放
框架提供了一个静态的releaseAllVideos(),这是它处理多实例的核心手段。列表里同时存在多个播放器时,靠这个保证同一时刻只有一个在响。
// Activity 生命周期里必须成对处理 @Override protected void onResume() { super.onResume(); // 部分版本需要在这里恢复,视源码而定 } @Override protected void onPause() { super.onPause(); Jzvd.releaseAllVideos(); // 全局释放,防止后台播放 } @Override public void onBackPressed() { if (Jzvd.backPress()) { return; // 框架消费了返回键(比如退出全屏) } super.onBackPressed(); }逻辑说明:Jzvd.backPress()返回 true 表示框架已经处理了这次返回(典型场景是全屏状态下按返回应该先退全屏而不是退出页面)。漏掉这段判断,用户在全屏播放时按返回会直接关掉页面,体验很突兀。
参数说明:releaseAllVideos()是静态方法,作用于所有实例,不需要传参。如果你的页面里有多个播放器且只想释放某一个,得用实例方法release(),但列表场景下全局释放更安全。
4.4 全屏切换的实现路径
全屏不是新开一个 Activity,而是在当前视图里把播放器容器重新挂载到全屏容器上,同时处理横竖屏。这是框架比较巧妙的一处设计。
// 全屏按钮点击后的典型处理(简化) public void onClickFullscreen() { // 保存当前视图状态 // 将播放器视图从原父容器移除 // 挂到全屏容器(通常是 decorView 下的一个 FrameLayout) // 请求横屏 setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE); }逻辑说明:因为播放器视图被「搬」到了全屏容器,Surface不会重建,播放不中断——这是它比「新开 Activity 播放」更顺滑的原因。代价是视图层级变复杂,你在业务层做findViewById时要意识到全屏态下控件可能已经换了父容器。
参数说明:横竖屏切换依赖 Activity 的configChanges配置。如果 manifest 里没声明orientation|screenSize,切全屏会触发 Activity 重建,播放状态丢失。这是排查「全屏后视频从头开始」问题的第一检查点。
5. 避坑与常见问题排查
5.1 现象:按 Home 键后声音还在播
原因:onPause里没有调用Jzvd.releaseAllVideos(),或者调了但页面被Fragment的懒加载逻辑绕过了。解决:确认释放调用在真正的前台生命周期里执行,Fragment 场景下用onPause而非onStop,并检查是否有多个播放器实例只释放了其中一个。
5.2 现象:列表滑动时多个视频同时出声
原因:列表复用时旧实例没释放,新实例又startVideo。解决:在onBindViewHolder或滑动回调里先Jzvd.releaseAllVideos()再给当前项setUp;同时给播放器加可见性判断,滑出屏幕的项主动release()。这是列表播放最经典的坑,没有捷径,就是释放时机要卡准。
5.3 现象:全屏切换后视频从头播放
原因:manifest 里 Activity 没配android:configChanges="orientation|screenSize|keyboardHidden",横竖屏切换触发重建。解决:补上该配置;如果业务上必须重建,那就在onSaveInstanceState里存播放进度,重建后seekTo恢复,但这属于补救不是根治。
5.4 现象:在线地址能播,本地文件报错
原因:传了assets://伪协议或content://URI 但框架内部按文件路径处理。解决:统一转成真实文件绝对路径再setUp,assets 资源先拷到getCacheDir();content://场景先用ContentResolver打开流落盘。路径这一层不统一,后面全是玄学错误。
5.5 现象:编译报 Duplicate class
原因:同时引了 aar 和源码 module,或者旧版本 jar 没清。解决:依赖方式二选一,执行./gradlew clean后重新 sync;如果还报,去.gradle缓存和libs/目录里翻有没有残留的旧包。
6. 二次开发:改皮肤、换内核与验证方法
改这份源码,我建议按「先验证再动刀」的顺序来,别一上来就改基类。
第一步,跑通 Demo 里的全部场景。app模块里通常有标准播放、列表播放、全屏、小窗几个示例,逐个点一遍,确认在你本机环境全部正常。这一步是建立基线,后面改出问题能快速定位是不是自己引入的。
第二步,改皮肤从布局入手而不是从 Java 入手。JzvdStd的布局文件在 library 的res/layout下,控制条、封面、标题都是独立控件。想换一套 UI,复制一份布局改名,再写一个继承JzvdStd的子类覆盖getLayoutId()返回新布局即可,基类一行不用动。
// 自定义皮肤:只换布局,不动状态机 public class MyPlayer extends JzvdStd { @Override public int getLayoutId() { return R.layout.my_player_layout; // 你自己的布局 } }参数说明:getLayoutId()是框架预留的扩展点,返回布局资源 id。注意新布局里必须保留基类init会findViewById的那些控件 id,否则运行时空指针。稳妥做法是复制原布局再改样式,别从零画。
第三步,换解码内核要谨慎。如果源码里带了 IJK 的接入代码,切换内核通常涉及Jzvd.setMediaInterface()之类的入口和 so 库的 ABI 配置。我的习惯是先只保留armeabi-v7a和arm64-v8a两个 ABI 验证,跑通再全量放开,避免一上来就被包体积和加载失败双重打击。
第四步,验证方法要可量化。改完播放器,至少验证四件事:在线地址首帧时间、本地文件起播时间、连续切换 20 次是否内存泄漏(用 Profiler 看MediaPlayer实例数是否回落)、后台切换回来能否续播。这四项过了,基本可以进业务联调。
| 改造点 | 改动位置 | 风险 |
|---|---|---|
| 换皮肤 | 布局 + 子类getLayoutId | 低,控件 id 要对齐 |
| 加控制按钮 | 布局 +init+ 点击回调 | 中,注意状态同步 |
| 换解码内核 | MediaInterface+ so 配置 | 高,ABI 与格式兼容 |
| 列表播放 | 业务层释放时机 | 中,易漏释放 |
最后说个习惯。我早期改这类播放器框架,图省事直接在基类里加业务判断,结果一次升级源码,所有改动全被覆盖,返工两天。从那以后我每次动框架前都先拉一个分支、把改动限制在子类和布局层,基类只读不改,升级时直接替换 library 目录再合并子类。这套流程救过我好几回。希望帮到你。
本文还有配套的精品资源,点击获取