Android动态代理模式实现一个可边听边存的播放器

篇章目标要点

音视频的开发现在是非常流行的,在移动端播放在线音视频是非常耗费流量的场景。因此一个良好的播放器要做到边听边存,相对于用户当前的播放进度保留缓冲余量,但是避免一次性将全部文件缓冲,在缓存余量不足时能够恢复缓存。播放器设计已有很多示例,此篇文章不会阐述播放器如何开发,重点内容是基于动态代理如何实现缓存控制。

动态代理概念

按照《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;
    }
}

效果展示

可以实现较好的缓存和播放的状态跟随
蓝色为缓存进度
紫色为播放进度
在这里插入图片描述

学习心得

本文所述的边听边存的场景虽然是基于在线音频的播放而开发,但是在现实意义上对于在线视频的播放更加具有意义,针对播放视频的场景,上述的代码和逻辑也是可以完全适用的。由于作者能力有限,可能存在纰漏,欢迎提出交流。

本图文内容来源于网友网络收集整理提供,作为学习参考使用,版权属于原作者。
THE END
分享
二维码
< <上一篇
下一篇>>