篇章目标要点
音视频的开发现在是非常流行的,在移动端播放在线音视频是非常耗费流量的场景。因此一个良好的播放器要做到边听边存,相对于用户当前的播放进度保留缓冲余量,但是避免一次性将全部文件缓冲,在缓存余量不足时能够恢复缓存。播放器设计已有很多示例,此篇文章不会阐述播放器如何开发,重点内容是基于动态代理如何实现缓存控制。
动态代理概念
按照《Java编程思想》一书中的定义,代理是基本的设计模式之一,它是你为了提供额外的或不同的模块,而插入的用来代替“实际”对象的对象,代理通常充当着中间人的角色。动态代理可以动态的创建代理并动态的处理对所代理方法的调用。
方案基本架构
一个MediaPlayer其支持直接加载播放网络音频,也支持播放本地音频文件。在设置播放音频时会调用MediaPlay的setDataSource方法传入网络音频url,本文研究基于动态代理,将网络音频逐步缓存到本地硬盘,满足播放条件后将本地音频的url传入setDataSource中,并且能够根据播放的进度实时下载一部分的缓存数据。
1.主要构成
ControllerService:播放控制的服务,接收控制命令,发送播放信息,以及代理的控制
AudioPlayer:封装MediaPlayer播放器,播放音频资源,并且定期通知播放进度
AudioCacheHandler:对AudioPlayer部分方法实现代理
AudioCache:缓存控制模块,将网络音频url转为本地音频url,反馈给播放器;接收播放进度,根据播放进度发现缓存余量不足时启动恢复下载;监听缓存进度,发现缓存余量足够保障播放功能时停止下载
FileDownload:下载功能模块,提供下载和暂停功能,支持适时保持下载进度状态,支持读取下载进度状态进行断点续传
主要流程说明
1. 动态代理拦截入口
使用动态代理的目的是将网络音频url转为本地的音频文件路径,因此将设置播放网络音频方法作为动态代理的切入点。其基本流程如下
2. 动态代理实现类
在动态代理实现类AudioCacheHandler中将拦截play方法,在其中将调用缓存控制模块,将入参中的网络音频url转为本地音频文件的url,并且启动文件下载任务,阻塞当前线程。
3. 缓存控制模块逻辑
缓存控制模块将基于设定的规则,反馈下载音频在本地的url路径,音频在本地url路径不能满足播放条件之前,将阻塞线程,并且调用下载模块下载文件;待下载进度满足播放条件后,将解除阻塞,通知播放器缓存完成。缓存模块能够从动态代理类中获得当前的播放进度,根据播放进度分析缓存余量不足时,将恢复缓存;同时监听下载模块的缓存进度,当缓存余量足够播放时,将停止缓存,避免流量过度消耗。
4. 下载模块
提供下载和暂停功能,支持适时保持下载进度状态,支持读取下载进度状态进行断点续传。其基本功能构成如下
主要代码介绍
1. 动态代理的实现类
需要实现InvocationHandler接口,当中拦截play方法,播放进度,播放器资源释放
/**
* 代理实现类
* 拦截play方法,将网络音频url转为本地音频url
* 为了节省流量目标缓存位置仅大于播放位置一定余量即可
*/
public class AudioCacheHandler implements InvocationHandler {
private static final String TAG = "AudioCacheHandler";
private Object mObject;
private AudioCache mAudioCache = new AudioCache();
public AudioCacheHandler(Object object) {
mObject = object;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
Log.d(TAG, "proxy = "+proxy.getClass().getName()+",method name = "+method.getName());
//判断当执行的是setDataSource方法时,执行缓存逻辑和缓存监听
if(method.getName().equals("play")){
//为Audio注册监听缓存进度
mAudioCache.setBufferStatus(((IPlayerFactory)proxy).onBufferListener());
//将网络url转为本地路径
for(int i = 0; i < args.length;i++){
Object arg = args[i];
if((arg instanceof PlayInfoBean) && (((PlayInfoBean)arg).getUrl()).contains("http")){
String localUrl = mAudioCache.reviseDataSource(((PlayInfoBean)arg).getUrl().toString());
((PlayInfoBean)arg).setUrl(localUrl);
Log.d(TAG, "reset data source");
args[i] = arg;
break;
}
}
}else if(method.getName().equals("onProgressRatio")){
//判断播放器在更新进度时,将进度信息写入AudioCache中,用于匹配合适的缓冲位置
if(proxy instanceof IPlayerFactory){
for(int i = 0; i < args.length;i++){
if(args[i] instanceof Float){
mAudioCache.setProgress((Float) args[i]);
break;
}
}
}
}else if(method.getName().equals("release")){
//监听播放器准备释放时,移除AudioCache内部监听对象,避免内存泄漏
if(proxy instanceof IPlayerFactory){
mAudioCache.removeBufferStatus();
}
}
//执行原方法
Object result = method.invoke(this.mObject, args);
return result;
}
}
创建动态代理,在播放控制的服务中进行
//被代理对象
AudioPlayer audioPlayer = new AudioPlayer();
//创建InvocationHandler
AudioCacheHandler invocationHandler = new AudioCacheHandler(audioPlayer);
//代理对象
mAudioPlayerProxy = (IPlayerFactory)Proxy.newProxyInstance(Thread.currentThread().getContextClassLoader(), audioPlayer.getClass().getInterfaces(), invocationHandler);
2. 缓存控制模块代码
这部分的作用概况起来就是判断什么时候应该恢复下载,什么时候应该暂停下载,将网络url转为本地路径也是在此处完成。
/**
* 音频文件边听边存<P/>
* 根据播放进度,发现缓存余量不足时,恢复下载动作<P/>
* 监听下载进度,发现下载余量足够时,停止下载动作<P/>
*/
public class AudioCache implements IDownloadListener{
//本地音频缓存路径
private static final String LOCAL_FILE_DIR = "/data/data/" + AudioCache.class.getPackage().getName()+"/cache";
//当前播放进度
private float mProgress;
//超前缓存系数,超出播放进度15%的缓存
private static final float PRE_CACHE_RATIO = 0.15f;
//缓存不足系数
private static final float LOW_CACHE_RATIO = 0.05f;
//当前下载暂停标志位,true为正在下载,false为停止下载
private boolean mDownloadRunning = true;
//通知缓存进度的接口
private IBufferStatus mBufferStatus;
//当缓存进度达到最低阈值将通知播放器ready,单次下载仅通知一次;
private boolean mReadyFlag = false;
private Object mBlockObject = new Object();
//下载任务
private FileDownload mCurrentTask;
private static final String TAG = "AudioCache";
/**
* 内部处理逻辑,先查找本地有无记录
* 有记录则直接将网络url转为本地path路径
* 无记录则执行下载动作
* @param url 音频网络链接
*/
public String reviseDataSource(@NonNull String url) {
Log.d(TAG, "revise audio cache");
//查找本地对应的url链接
String localUrl = checkLocalSource(url);
//如无本地记录,则执行下载
String urlFileName = url.substring(url.lastIndexOf("/")+1);
String localPath = LOCAL_FILE_DIR + "/" + urlFileName;
mCurrentTask = new FileDownload.Builder().setUrl(url).setLocalPath(localPath).setDownloadListener(AudioCache.this).build();
mCurrentTask.start();
synchronized (mBlockObject){
try{
mBlockObject.wait(5000);
}catch (InterruptedException e){
e.printStackTrace();
}
}
Log.d(TAG,"thread notified");
return "file://" + localPath;
}
/**
* 根据网络url查找本地的缓存记录
* @param url 网络音频链接
* @return 本地音频文件链接
*/
private String checkLocalSource(String url){
String urlFileName = url.substring(url.lastIndexOf("/")+1);
File dir = new File(LOCAL_FILE_DIR);
if(!dir.exists()){
dir.mkdir();
}
File file = new File(LOCAL_FILE_DIR + "/" + urlFileName);
if(file.exists()){
//本地已有记录
return file.getAbsolutePath();
}
return null;
}
@Override
public void paused(int soFarBytes, int totalBytes) {
//暂停
mDownloadRunning = false;
}
/**
* 监听当前下载进度<P/>
* 需控制缓存进度在播放进度往前不超过30s,主要目的是决定停止下载
* @param soFarBytes 当前缓存大小
* @param totalBytes 文件总大小
*/
@Override
public void progress(int soFarBytes, int totalBytes) {
Log.d(TAG, "download process audio cache sofar bytes = "+soFarBytes+", totalBytes = "+totalBytes+" , mProgress = "+mProgress);
onBufferingProgress(1.00f * soFarBytes / totalBytes);
float hopeCache = (mProgress + LOW_CACHE_RATIO)*totalBytes;
Log.d(TAG, "hope cache = "+hopeCache+", cache is not enough = "+(hopeCache > soFarBytes)+", ready flag = "+mReadyFlag);
if(totalBytes == soFarBytes && soFarBytes > 0){
Log.d(TAG, "has local memory and file is completed");
if(mProgress <= 0){
mBlockObject.notifyAll();
}
mBufferStatus.onBufferReady();
mReadyFlag = true;
return;
}else if(((mProgress + PRE_CACHE_RATIO)*1.00f*totalBytes <= soFarBytes) && (soFarBytes < totalBytes)){
//1.缓存余量已经足够时执行停止
Log.d(TAG,"task pause");
mCurrentTask.pause();
if(mProgress <= 0 ){
Log.d(TAG, "block object notify");
mBlockObject.notifyAll();
}
mBufferStatus.onBufferReady();
mReadyFlag = true;
}
}
@Override
public void error(Throwable e) {
}
@Override
public void completed() {
Log.d(TAG, "audio cache complete");
onBufferingProgress(1);
mBufferStatus.onBufferReady();
}
/**
* 监听当前曲目播放百分比<P/>
* 用以决定是否开启继续加载<P/>
* @param progress
*/
public void setProgress(float progress) {
Log.d(TAG , "play progress = "+progress+" , " +
"current bytes = "+mCurrentTask.getSmallFileSoFarBytes()+", " +
"total bytes = "+mCurrentTask.getSmallFileTotalBytes());
mProgress = progress;
if(mCurrentTask.getSmallFileTotalBytes() <= mCurrentTask.getSmallFileSoFarBytes()){
return;
}
if(((mProgress + LOW_CACHE_RATIO)*mCurrentTask.getSmallFileTotalBytes() > mCurrentTask.getSmallFileSoFarBytes())
&& (mCurrentTask.getSmallFileTotalBytes() > mCurrentTask.getSmallFileSoFarBytes())){
//缓存已经不足
Log.d(TAG, "cache is not enough");
if(!mCurrentTask.isRunning()){
Log.d(TAG, "notice on buffering wait");
mCurrentTask.start();
onBufferingWait();
}
}
}
public void setBufferStatus(IBufferStatus bufferStatus) {
Log.d(TAG,"set buffer status = "+bufferStatus.getClass().getName());
mBufferStatus = bufferStatus;
mProgress = 0;
}
public void removeBufferStatus() {
mBufferStatus = null;
}
private void onBufferingProgress(float progress){
if(null != mBufferStatus){
mBufferStatus.onBuffering(progress);
}
}
private void onBufferingWait(){
if(null != mBufferStatus){
mBufferStatus.onBufferWait();
}
}
}
3. 下载模块代码
下载模块内部基于HttpUrlConnection进行网络访问,内部根据保持的上次下载位置,实现断点续传。
/**
* 恢复下载的逻辑,无法通过解除线程阻塞使其恢复在stream内容的拷贝工作上,故整个暂停工作无需设置线程阻塞<P/>
* 恢复下载,需要重新执行HttpUrlConnection连接,只是设置从记忆的位置开始下载<P/>
*/
public void start(){
Log.d(TAG , "start task");
reset();
String name = mUrl.substring(mUrl.lastIndexOf("/")+1);
File file = new File(localPath);
if(mMMKV.containsKey(name) && file.exists()){
ProgressMemoryBean memoryBean = JSON.parseObject(mMMKV.decodeString(name), ProgressMemoryBean.class);
if(memoryBean.mDownloadFinished){
//已有记录且下载完毕
Log.d(TAG,"has record and download finished");
totalBytes = memoryBean.mDownloadProgress;
sofarBytes = totalBytes;
mDownloadListener.progress(sofarBytes, totalBytes);
mDownloadListener.completed();
return;
}else{
//未下载完毕
Log.d(TAG,"has record but download not finished");
sofarBytes = memoryBean.mDownloadProgress;
}
}
Schedulers.computation().scheduleDirect(new Runnable() {
@Override
public void run() {
mActionThread = Thread.currentThread();
Log.d(TAG, "create thread , state = "+mActionThread.getState());
try{
URL url = new URL(mUrl);
HttpURLConnection connection = (HttpURLConnection)url.openConnection();
connection.setConnectTimeout(5*1000);
connection.setRequestMethod("GET");
long fileSize = connection.getContentLength();
totalBytes = (int)fileSize;
//将写入位置调整至上次的下载位置
RandomAccessFile localFile = new RandomAccessFile(file, "rw");
localFile.setLength(fileSize);
Log.d(TAG,"song name = "+name+",total size is "+totalBytes+", last file size = "+sofarBytes+",access file size = "+localFile.length());
localFile.seek(sofarBytes);
//下载文件
writeData(connection, localFile, sofarBytes);
connection.disconnect();
}catch (MalformedURLException e){
mDownloadListener.error(new Throwable("MalformedURLException"));
e.printStackTrace();
}catch (IOException e){
e.printStackTrace();
mDownloadListener.error(new Throwable("IOException"));
}finally {
mMMKV.putString(name, JSON.toJSONString(mProgressMemoryBean));
}
}
});
}
支持暂停下载的代码片段,暂停下载时将跳出拷贝数据的循环,结束本次的连接
while ((hasRead = inputStream.read(buffer)) > 0){
file.write(buffer, 0, hasRead);
hasLength += hasRead;
//控制通知进度的频率
if(hasLength - sofarBytes > 1024*100){
mDownloadListener.progress(hasLength , totalBytes);
sofarBytes = hasLength;
mProgressMemoryBean.mDownloadProgress = sofarBytes;
Log.d(TAG, "total length = "+totalBytes+",download length = "+hasLength);
}
//因为暂停中断
if(!mAction){
sofarBytes = hasLength;
mProgressMemoryBean.mDownloadProgress = sofarBytes;
Log.d(TAG,"download block set file length = "+sofarBytes);
mDownloadListener.paused(sofarBytes , totalBytes);
break;
}
}
效果展示
可以实现较好的缓存和播放的状态跟随
蓝色为缓存进度
紫色为播放进度
学习心得
本文所述的边听边存的场景虽然是基于在线音频的播放而开发,但是在现实意义上对于在线视频的播放更加具有意义,针对播放视频的场景,上述的代码和逻辑也是可以完全适用的。由于作者能力有限,可能存在纰漏,欢迎提出交流。