系列更到第十六篇,前面我们把首页、课程列表、课程详情这些页面一个个啃了下来,说实话,项目做到这一步,最让人觉得“有底气”的还是视频播放界面。博学谷是一个在线学习类的Android练手项目,从分类浏览到课程详情,所有铺垫最后都要汇到视频播放这一屏。这篇文章我就把第十六章的实现过程完整记录一遍,包括页面布局怎么排、VideoView怎么接、播放器生命周期怎么处理,还有我在调试里踩过的坑。如果你是从系列第一章跟着做的,到这一章应该能明显感觉到项目开始“完整”了;如果你是半路看到这篇,视频播放这屏的实现思路也可以直接搬到你自己的项目里。
1. 视频播放界面要做什么?——需求拆解与技术选型
1.1 这个页面在整个项目里的位置
博学谷做到第十六章,前面的页面已经覆盖了用户从“进入App”到“选中课程”的全过程。首页是入口,课程列表解决“去哪找”的问题,详情页解决“这课值不值得学”的问题。可用户一旦点了“开始学习”,真正承载学习行为的其实是视频播放页面。
所以这个页面不是简单摆一个播放器就完了。它要承担三件事:第一,把视频流畅地播出来;第二,把课程章节信息展示出来,让用户知道自己在学哪一节;第三,提供章节切换的入口,因为用户学完一节之后不会退出页面再重新进,而是直接在同一屏里切到下一节。
在项目里这个页面叫做视频播放界面,但实际上它更像是一个“课程学习中心”。从产品角度看,它是整个学习闭环的最后一公里。从技术角度看,它是目前整个项目里涉及系统组件最多的一页,除了常规的控件绑定之外,还需要处理多媒体播放、生命周期回调、网络视频源加载、列表与播放器的联动。这一章做完,项目的完成度会一下子提升一大截。
1.2 从使用场景倒推功能清单
我在动手写代码之前习惯先列功能清单,不列清楚就开工会很被动。视频播放这个页面我从使用场景倒推出了下面几个必须项:
用户从课程详情页点“播放”进入,首先看到视频区域,视频自动播放是最自然的交互,不用用户再点一下播放键。视频下方要展示当前课程名称和更新时间,这相当于给用户一个明确的“当前位置”提示。再往下是章节列表,当前章节要有选中状态,点击其他章节能切换视频。播放过程中用户可能会退出页面、按Home键、锁屏,这些情况下播放器必须暂停或者释放资源,不然很容易出现“退出页面还在响声音”的尴尬问题。
可选功能我暂时砍掉了,比如倍速播放、全屏切换、离线下载。原因很简单,这一章的定位是把播放链路跑通,把这些外围功能都堆上来会拖慢节奏。等核心播放稳定了,再扩展不迟。
1.3 VideoView还是MediaPlayer:选型时我在考虑什么
在Eclipse项目里做视频播放,绕不开两个方案:直接用MediaPlayer配合SurfaceView,或者用VideoView封装好的播放器。
MediaPlayer加SurfaceView自由度更高,播放器控制、画面显示、监听回调都能自己掌控,适合对播放器有深度定制需求的场景。但代价是需要自己管理Surface生命周期,处理画面尺寸适配,写一堆底层回调。博学谷这个项目里没有DRM、没有直播、没有复杂的音视频同步需求,就是一个点播场景,用MediaPlayer属于杀鸡用牛刀。
VideoView本质上就是MediaPlayer和SurfaceView的封装,内部帮你处理好了SurfaceHolder的变化事件,也内置了音频焦点和部分生命周期管理。在这个项目里,我们的核心目标是快速把播放链路打通,同时保证代码清晰可维护,所以VideoView是更合理的选择。
MediaController我也没有自己写。它是系统自带的播放控制条,包含播放暂停按钮、进度条、当前时间/总时长、全屏和退出按钮等基础控件。现在的界面风格不一定符合审美,但胜在功能完整、接入成本极低。这一章先用它把交互跑通,后续如果设计稿有明确的样式要求,再基于接口重新定制也不迟。
2. 界面搭建:XML布局与控件的真实写法
2.1 布局结构:视频、信息、章节列表三层怎么排
视频播放界面的布局我用了RelativeLayout做根容器,从上到下分成三段。第一段是VideoView,高度设成240dp,宽度铺满屏幕。为什么固定240dp而不是比例适配?因为视频画面的宽高比在不同资源里不一样,用固定高度可以避免布局在视频加载前后反复跳动。想严格适配的话,可以在视频准备完成后根据视频宽高动态调整VideoView的高度,这一章先用固定值保证稳定。
第二段是一个纵向LinearLayout,放在VideoView下面,里面放课程标题和更新时间两个TextView。这段信息区不需要太复杂,标题字号用18sp,颜色深灰,时间字号12sp,颜色浅灰,主次分明。
第三段是章节ListView,宽度铺满,高度占满剩余空间,用layout_below锚定在信息区的下方。列表的每个item我用了简单的两行样式:章节名加时长,这样做的好处是列表高度错落有致,用户扫一眼就能知道这章大概要学多久。
2.2 MediaController与VideoView的配合方式
MediaController的接入非常简单但有个关键细节要注意。videoView.setMediaController(mediaController)只是把控制器关联到了播放器,还有一个setAnchorView方法决定控制器显示在哪个视图上。如果不调用setAnchorView,控制器在弹出时可能出现位置不对甚至不显示的诡异情况。
控制器默认是自动隐藏的,播放过程中过几秒就会淡出,点击视频区域会重新唤出。这个行为对视频观看场景来说是合理的,用户不会希望控制条一直挡着画面。MediaController内部还集成了进度条的拖动逻辑,拖动进度条时会自动暂停播放,松手后根据拖到的位置继续播放,这个交互省了我们不少事。
有一点我建议在项目里调一下:MediaController默认的背景是半透明的黑色,在某些ROM上看起来会比较生硬。但在Eclipse项目阶段,系统自带样式是足够用的,不必为这一点花太多时间。
2.3 完整布局代码与参数解读
下面是我在项目中实际使用的activity_video_player.xml布局文件:
<?xml version="1.0" encoding="utf-8"?> <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:background="#FFFFFF"> <VideoView android:id="@+id/video_view" android:layout_width="match_parent" android:layout_height="240dp" android:layout_alignParentTop="true" android:keepScreenOn="true" /> <LinearLayout android:id="@+id/ll_info" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_below="@id/video_view" android:orientation="vertical" android:padding="12dp"> <TextView android:id="@+id/tv_course_title" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="18sp" android:textColor="#333333" android:text="课程标题" /> <TextView android:id="@+id/tv_update_time" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="4dp" android:textSize="12sp" android:textColor="#999999" android:text="更新时间" /> </LinearLayout> <ListView android:id="@+id/lv_chapter_list" android:layout_width="match_parent" android:layout_height="match_parent" android:layout_below="@id/ll_info" android:divider="#EEEEEE" android:dividerHeight="1dp" /> </RelativeLayout>这个布局有几个细节值得说明。android:keepScreenOn="true"的作用是视频播放时屏幕保持常亮,避免用户看课看到一半屏幕自动锁屏。这个属性放在VideoView上非常合适,因为它随页面生命周期自动生效,不需要额外在Java代码里管理唤醒锁。
章节ListView用了match_parent作为高度,它会在信息区下面自动撑满剩余空间。注意ListView和它下面的其他控件不要用padding去硬凑间距,用layout_below锚定是最稳妥的方式。
3. 播放逻辑实现:从拿到视频地址到顺利出画面
3.1 视频地址怎么传进来
用户从课程详情页进入视频播放页,最起码要把课程标识和视频地址传过来。我在Intent里放三个参数:courseId、videoUrl、courseTitle。其中videoUrl是视频播放的直链地址,courseTitle用于顶部标题展示。
这里有一个容易被忽略的点:视频地址如果是中文文件名,直接在Intent里传递通常没问题,但如果从服务器返回的地址带中文参数,最好先做一次URL编码处理,否则VideoView在解析时会因为非法字符而加载失败。我在项目里写了这样一个简单的兼容逻辑:
String rawUrl = intent.getStringExtra("videoUrl"); try { videoUrl = URLDecoder.decode(rawUrl, "UTF-8"); } catch (UnsupportedEncodingException e) { videoUrl = rawUrl; }这个步骤看起来小,却避免了很多低级的加载问题。生产环境中视频地址一般经过签名和鉴权,URL里经常带着时间戳、token、加密参数,这些参数在拼接时特别容易出问题。
3.2 播放器初始化和自动播放
拿到视频地址后,初始化顺序很关键。我先创建MediaController并绑定到VideoView,再调setVideoURI设置播放源,最后注册OnPreparedListener实现自动播放。
mediaController = new MediaController(this); videoView.setMediaController(mediaController); mediaController.setAnchorView(videoView); videoView.setVideoURI(Uri.parse(videoUrl)); videoView.setOnPreparedListener(new MediaPlayer.OnPreparedListener() { @Override public void onPrepared(MediaPlayer mp) { findViewById(R.id.pb_loading).setVisibility(View.GONE); videoView.start(); } });OnPreparedListener的作用是告诉我们在视频准备好之前不要调用start(),否则播放器会处于未就绪状态,可能直接抛异常。很多新手喜欢在onCreate里毫无条件地直接调start(),这在视频地址失效或者网络慢的情况下会崩溃得非常难看。
我在布局里放了一个默认隐藏的进度圈ProgressBar,在onCreate开始加载时设为可见,等onPrepared回调后再隐藏。这样做的好处是用户能看到“页面正在加载视频”的状态,而不是盯着一块黑屏干等。
3.3 播放、暂停、进度拖动和章节切换
播放暂停和进度拖动这些交互,在VideoView加MediaController的组合下基本是零成本。MediaController自己处理了按钮点击、进度拖动、时间更新,VideoView内部也会响应这些操作。
真正需要我们写代码的是章节切换。章节列表用ListView来展示,在Adapter里记录当前选中项的位置,选中项的背景色和文字颜色做一个高亮效果。点击某个章节时,我先调videoView.stopPlayback()把旧的播放会话清掉,再重新setVideoURI加载新地址:
chapterListView.setOnItemClickListener(new AdapterView.OnItemClickListener() { @Override public void onItemClick(AdapterView<?> parent, View view, int position, long id) { ChapterItem item = (ChapterItem) parent.getItemAtPosition(position); // 先停止当前播放 videoView.stopPlayback(); // 更新标题和选中状态 tvCourseTitle.setText(item.getName()); adapter.setSelectedPosition(position); adapter.notifyDataSetChanged(); // 加载新视频 showLoading(); videoView.setVideoURI(Uri.parse(item.getVideoUrl())); videoView.setOnPreparedListener(new MediaPlayer.OnPreparedListener() { @Override public void onPrepared(MediaPlayer mp) { hideLoading(); videoView.start(); } }); } });这中间有个隐藏坑:连续快速点击不同章节时,上一次异步准备可能还没完成,新的准备又开始了,最终播放的内容可能是旧章节的。可以在点击时用一个boolean类型的isChangingChapter标记位,如果正在切换中就不响应新的点击,避免竞态问题。
3.4 生命周期管理:退后台、跳转、销毁
视频播放页面比普通页面更敏感的地方在于生命周期管理。用户在播放过程中随时可能退出页面、按Home键返回桌面、接电话或者锁屏。如果不处理这些情况,最直接的结果就是“页面退出了,声音还在响”,这个体验非常糟糕。
@Override protected void onPause() { super.onPause(); if (videoView != null && videoView.isPlaying()) { videoView.pause(); isPausedByLifecycle = true; } } @Override protected void onResume() { super.onResume(); if (videoView != null && isPausedByLifecycle) { videoView.start(); isPausedByLifecycle = false; } } @Override protected void onDestroy() { super.onDestroy(); if (videoView != null) { videoView.stopPlayback(); } }onPause里暂停播放,onResume里恢复播放,onDestroy里彻底停止。这里我说的“暂停”不是用户手动暂停,而是生命周期触发的暂停,所以要加一个标志位区分。如果用同一个暂停状态混过去,用户手动暂停后切到后台再切回来,视频会自动播放,这就不对了。
关于持久化播放位置,如果产品有“再次进入页面继续上次播放位置”的需求,可以在onPause里记录videoView.getCurrentPosition(),然后存到SharedPreferences或数据库。在onPrepared回调里通过seekTo()跳转到对应位置。
4. 调试实录:视频播放章节最典型的四个问题
4.1 模拟器上视频一直加载不出来的地址问题
这个问题我估计做过视频播放的人都会遇到,尤其第一次在Android模拟器上调试时。模拟器里访问开发本机的服务不能直接填localhost或127.0.0.1,要用模拟器专用的宿主机地址10.0.2.2。我当时一度以为视频源有问题,折腾了半天才发现是地址写错了。
如果视频地址走的是http协议,记得在AndroidManifest.xml里声明INTERNET权限。这个权限漏掉的话,视频加载时会一直停留在缓冲状态,也没有明确的错误提示。
| 调试项 | 检查点 | 常见错误 |
|---|---|---|
| 模拟器访问宿主机 | 使用10.0.2.2代替localhost | 填成127.0.0.1导致连接失败 |
| 网络权限 | 声明INTERNET权限 | 漏声明导致一直缓冲 |
| 视频格式 | 服务器需支持HTTP Range请求 | 不支持导致进度条拖不动 |
| URL编码 | 中文参数需URL编码 | 未编码导致加载失败 |
4.2 横竖屏切换导致视频重新加载
视频播放页面默认没有做横竖屏适配的话,用户在播放过程中旋转屏幕,Activity会被销毁重建。重建之后视频从头播放,之前看到的进度全丢了,而且布局也可能被拉伸得很难看。
处理方法有两种。简单粗暴的是一句话把你的页面固定为竖屏,在AndroidManifest.xml里给VideoPlayerActivity声明screenOrientation属性:
<activity android:name=".VideoPlayerActivity" android:screenOrientation="portrait" />如果产品明确需要横屏播放,那就要做真正的适配了。可以在Manifest里声明configChanges="orientation|screenSize|keyboardHidden",然后在onConfigurationChanged里手动调整VideoView尺寸和布局方向,同时在重建之前记录播放位置,重建后通过seekTo恢复。
对博学谷这个项目来说,我当时的处理是固定竖屏。原因是章节列表在横屏下会压缩得非常窄,用户体验并不好,横屏播放适合纯视频观看场景,等后面做独立全屏播放页再加。
4.3 缓冲加载时黑屏和卡顿的处理
视频播放过程中网络波动是常事,一旦缓冲区耗尽,画面会卡住甚至黑屏。MediaController自带一个缓冲进度条,但那个条很小,用户不一定会注意到。更常见的现象是用户觉得“视频卡死了”,然后反复点击播放按钮,反而把状态搞得更乱。
我当时采了一个相对简单的方案:在VideoView上覆盖一层半透明遮罩,遮罩里放一个居中ProgressBar和“缓冲中”的文字。这层遮罩默认隐藏,监听MediaPlayer的缓冲回调来控制显示和隐藏。VideoView提供了setOnInfoListener接口,其中MEDIA_INFO_BUFFERING_START和MEDIA_INFO_BUFFERING_END两个事件可以用来判断缓冲开始和结束。
不过要提醒一句:这个回调在不同Android版本和不同ROM上的触发时机不太稳定,如果发现遮罩该显示不显示,不用太纠结,核心原则是“没有拿到稳定视频画面之前不要误导用户以为已经播放正常了”。
另外还有一个简单但很有效的优化:如果视频源支持的话,可以同时传入一个极低码率的缩略地址,在正式视频加载过程中先播放缩略画面,等高清流准备好了再切换。这种方案适合有流媒体服务器的场景,本地静态视频文件不需要这么做。
4.4 连续切换视频时的资源释放问题
章节列表连续切换时如果不注意资源释放,会遇到两个问题:一是视频画面卡在上一节课的最后一帧,新视频画面迟迟不出来;二是内存占用飙升,频繁切换几十次后页面明显变卡。
videoView.stopPlayback()是彻底释放播放器资源的入口。调用之后VideoView内部的MediaPlayer会被release,SurfaceHolder保留。再次加载新视频时直接setVideoURI就可以。我建议在每次切换新章节前都先调stopPlayback,然后立即把界面上的标题、选中态同步好,再进入加载流程。
内存优化方面有个经验值:如果一个页面里频繁加载大体积MP4文件,而每次切换都重新解析视频头信息,GC会非常频繁。可以在加载新视频前主动调用System.gc(),虽然不保证立即生效,但能缓解一点压力。更重要的是确保Adapter里的item不要持有播放器引用,列表复用时很容易出现旧itemView残留的问题。
5. 写在最后:这一章的实践心得
这一章做下来,我最大的体会是视频播放页面不能只当成一个“带控件的页面”来写,它本质上是一个需要仔细管理状态的页面。普通页面生命周期写错了最多闪一下屏,播放器生命周期写错了就是声音错乱、进度丢失、黑屏卡死这些严重影响用户信任感的问题。
在实际操作中,我建议你把播放器相关的代码单独拆成一个工具类或者一个基类,把初始化、加载、暂停、恢复、释放这一整套流程封装好。博学谷项目里我暂时把播放逻辑放在Activity里,是为了让刚开始接触Android项目的学生更直观地看到调用关系,等你把这个流程吃透了,再拆出来会更顺手。
最后一个实用小技巧:调试视频播放时,别只盯着模拟器,有机会就在真机上跑一遍。模拟器编解码能力和真机差异很大,很多在模拟器上正常播放的视频,放到低端真机上可能音画不同步,甚至直接因为硬件解码不支持而黑屏。视频这块的坑,永远比你预想的多,多留出一些容错的空间,页面才能真正扛得住真实用户的使用场景。