Android实现基于http协议的文件下载

来源:互联网 发布:php销售管理系统 编辑:程序博客网 时间:2024/05/02 01:33
  1. 概述 

            网络编程中文件的上传下载是最常见的场景,本着不重复造轮子的原则,日常工作如果遇到相关问题我们首先想到的可能是从网上找现成的代码直接拿来用,很少去关心具体是如何实现的,可能也是没时间去研究别人如何实现。如果代码能够满足我们现阶段的要求,则万事大吉,但是如果使用代码的过程中出现意想不到的问题,我们解决起来可能会比较麻烦,因为代码不是我们自己写的,对代码不熟,不能快速的查找问题的原因。个人认为不重复造轮子的前提是你必须有能力造一个相同的轮子,这样在使用别人的代码时才能更加得心应手。

             最近工作需要在Android里面实现文件的上传和下载功能,当然为了快速实现我也是从网上找了别人的代码直接拿来用了,期间也根据自己的需求做了适当的修改,现在就拿出来给大家分享一下。

  2. 原理分析 

              Android应用与服务进行交互一般是通过Http请求的方式进行,文件下载也同样通过Http请求,一般情况下我们通过请求一个文件的url地址,服务端返回一个文件流,我们通过读取文件流的方式将文件内容再以流的方式写到本地的文件中,这就是文件下载的基本过程。但是如果要下载的文件较大时我们一般需要采用分块下载(或叫做多线程下载)的方式,以减少下载过程中出现错误的可能性。之前我们一个http请求返回整个文件的文件流,现在我们需要分多次请求,每个请求返回的文件流只能读取文件的一部分。在客户端每次请求的时候需要携带关于块的信息,即本次请求是要下载文件的哪个部分,然后服务器通过解析只返回文件的一部分,然后客户端将每个请求的结果进行汇总,即进行文件的合并,最终得到一个完整的文件。

            在http协议1.1中新增了一个Range头参数,这是目前实现多线程下载的核心所在。Range的使用方式为“Range: bytes=0-1”表示下载文件的前两个字节,即从0个字节到第1个字节,一共两个字节。接下来我们先看一下如何使用java实分块下载的功能。
  3. 代码实现
    HttpURLConnection http = (HttpURLConnection) downUrl.openConnection(); // 开启HttpURLConnection连接http.setConnectTimeout(5 * 1000); // 设置连接超时时间为5秒钟http.setRequestMethod("GET"); // 设置请求的方法为GEThttp.setRequestProperty("Accept", "image/gif, image/jpeg, image/pjpeg, image/pjpeg, application/x-shockwave-flash, application/xaml+xml, application/vnd.ms-xpsdocument, application/x-ms-xbap, application/x-ms-application, application/vnd.ms-excel, application/vnd.ms-powerpoint, application/msword, */*"); // 设置客户端可以接受的返回数据类型http.setRequestProperty("Accept-Language", "zh-CN"); // 设置客户端使用的语言问中文http.setRequestProperty("Referer", downUrl.toString()); // 设置请求的来源,便于对访问来源进行统计http.setRequestProperty("Charset", "UTF-8"); // 设置通信编码为UTF-8int startPos = block * (threadId - 1) + downloadedLength;// 开始位置int endPos = block * threadId - 1;// 结束位置http.setRequestProperty("Range", "bytes=" + startPos + "-" + endPos);// 设置获取实体数据的范围,如果超过了实体数据的大小会自动返回实际的数据大小http.setRequestProperty("User-Agent", "Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 5.2; Trident/4.0; .NET CLR 1.1.4322; .NET CLR 2.0.50727; .NET CLR 3.0.04506.30; .NET CLR 3.0.4506.2152; .NET CLR 3.5.30729)"); // 客户端用户代理http.setRequestProperty("Connection", "Keep-Alive"); // 使用长连接http.setRequestProperty("Accept-Encoding", "gzip");InputStream inStream = http.getInputStream(); // 获取远程连接的输入流

      首先代码第一个行获取一个HttpURLConnection连接对象,然后设置连接的超时时为5s,请求方式为get方式,接收文件的类型,语言,请求的来源编码方式。代码第10行为关键代码设置请求头中的Range参数值,参数值的信息需要我们根据文件分块的大小,和当前线程请求的是第几块计算出一个范围。所有的请求参数设置完成之后我们通过调用getInputStream方法获取返回的文件流。这就是请求发送的实现,下面我们看一下文件流获取之后合并文件的实现。 
     这样整个文件下载的过程就实现了,从文件的分块请求到最后的文件合并整个过程。但是这还不算完,为了完成一个相对完整的文件下载模块,现在我们有一些功能没有实现,如断点续传,文件分块大小的计算,不同下载线程的调度等,我们还要许多功能需要完善。
     代码第一行构造了一个GZIPInputStream对象,因为我们在请求时使用了gzip压缩,然后创建一个1024*2大小的缓冲区用于读取文件内容,文件合并的关键在于使用RandomAccessFile,它可以任意的访问文件的位置进行读写操作。代码第6行我们创建一个RandomAccessFile的对象,然后将文件流的位置跳转到startPos的位置,即我们请求文件时Range头参数值中的起始位置,我们在执行写入操作时将会从当前位置开始,这样每个线程在文件写入时都从不同的位置进行,不会相互影响。然后一直循环读取文件内容,并将读取的内容写入到文件中。最后读取结束后关闭文件流和网络流。 
  4. 文件下载器的实现
            首先我们引入一个文件下载器FileDownloader的概念,它作为一个功能相对完整和独立的模块,它的提供的接口就是下载文件,在它的内部封装了文件分块下载的功能,这些对于用户来讲都是不可见的,用户在使用时只需要构建一个FileDownloader的对象,然后调用它的download方法即可实现文件下载。首先我们看一下关于FileDownloader的使用示例:
    //构造一个文件下载器,context表示上下文对象,downloadUrl表示下载文件的地址,fileSaveDir表示本地保存的路径,threadNum表示启用线程数量FileDownloader fileDownloader=new FileDownloader(context, downloadUrl, fileSaveDir, threadNum)//开始下载,listener用于监听下载的进度fileDownloader.download(listener);
     然后我们看一下FileDownloader的具体实现,首先看一下FileDownloader的包含的字段:
    private static final String TAG = "FileDownloader"; // 设置标签,方便Logcat日志记录private static final int RESPONSEOK = 200; // 响应码为200,即访问成功private Context context; // 应用程序的上下文对象private FileDownloadLogService fileService; // 获取本地数据库的业务Beanprivate boolean exited; // 停止下载标志private int downloadedSize = 0; // 已下载文件长度private int fileSize = 0; // 原始文件长度private DownloadThread[] threads; // 根据线程数设置下载线程池private File saveFile; // 数据保存到的本地文件private Map<Integer, Integer> data = new ConcurrentHashMap<Integer, Integer>(); // 缓存各线程下载的长度private int block; // 每条线程下载的长度private String downloadUrl; // 下载路径// 记录线程的错误次数,防止由于网络错误一直重复创建线程private SparseIntArray threadErrorCount = new SparseIntArray();

       其中fileService主要用于保存和读取文件下载进度,封装的是对Sqlite的操作,这个会在下面详细解释。threads为DownThread类型的数组,其中DownloadThread为我们自定义的线程用于下载文件某个分块。data为一个ConcurrentHashMap类型,key为线程的id,value为该线程已经下载的文件大小。block为文件分块的大小。downloadUrl为文件的下载路径。threadErrorCount为SparseIntArray类型,用于统计线程下载过程中出现的错误次数,key为线程id,value为出现的错误次数。  接下来我们看一下FileDownloader构造方法的实现:
    /** * 构建文件下载器 *  * @param downloadUrl 下载路径 * @param fileSaveDir 文件保存目录 * @param threadNum 下载线程数 */public FileDownloader(Context context, String downloadUrl, File fileSaveDir, int threadNum) {try {this.context = context; // 对上下文对象赋值this.downloadUrl = downloadUrl; // 对下载的路径赋值fileService = new FileDownloadLogService(this.context); // 实例化数据操作业务Bean,此处需要使用Context,因为此处的数据库是应用程序私有URL url = new URL(this.downloadUrl); // 根据下载路径实例化URLif (!fileSaveDir.exists())fileSaveDir.mkdirs(); // 如果指定的文件不存在,则创建目录,此处可以创建多层目录this.threads = new DownloadThread[threadNum]; // 根据下载的线程数创建下载线程池HttpURLConnection conn = (HttpURLConnection) url.openConnection(); // 建立一个远程连接句柄,此时尚未真正连接conn.setConnectTimeout(5 * 1000); // 设置连接超时时间为5秒conn.setRequestMethod("GET"); // 设置请求方式为GETconn.setRequestProperty("Accept", "image/gif, image/jpeg, image/pjpeg, image/pjpeg, application/x-shockwave-flash, application/xaml+xml, application/vnd.ms-xpsdocument, application/x-ms-xbap, application/x-ms-application, application/vnd.ms-excel, application/vnd.ms-powerpoint, application/msword, */*"); // 设置客户端可以接受的媒体类型conn.setRequestProperty("Accept-Language", "zh-CN"); // 设置客户端语言conn.setRequestProperty("Referer", downloadUrl); // 设置请求的来源页面,便于服务端进行来源统计conn.setRequestProperty("Charset", "UTF-8"); // 设置客户端编码conn.setRequestProperty("User-Agent", "Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 5.2; Trident/4.0; .NET CLR 1.1.4322; .NET CLR 2.0.50727; .NET CLR 3.0.04506.30; .NET CLR 3.0.4506.2152; .NET CLR 3.5.30729)"); // 设置用户代理conn.setRequestProperty("Connection", "Keep-Alive"); // 设置Connection的方式conn.connect(); // 和远程资源建立真正的连接,但尚无返回的数据流printResponseHeader(conn); // 答应返回的HTTP头字段集合if (conn.getResponseCode() == RESPONSEOK) { // 此处的请求会打开返回流并获取返回的状态码,用于检查是否请求成功,当返回码为200时执行下面的代码this.fileSize = conn.getContentLength();// 根据响应获取文件大小if (this.fileSize <= 0)throw new RuntimeException("Unkown file size "); // 当文件大小为小于等于零时抛出运行时异常String filename = getFileName(conn);// 获取文件名称this.saveFile = new File(fileSaveDir, filename);// 根据文件保存目录和文件名构建保存文件Map<Integer, Integer> logdata = fileService.getData(downloadUrl);// 获取下载记录if (logdata.size() > 0) {// 如果存在下载记录for (Map.Entry<Integer, Integer> entry : logdata.entrySet())// 遍历集合中的数据data.put(entry.getKey(), entry.getValue());// 把各条线程已经下载的数据长度放入data中}if (this.data.size() == this.threads.length) {// 如果已经下载的数据的线程数和现在设置的线程数相同时则计算所有线程已经下载的数据总长度for (int i = 0; i < this.threads.length; i++) { // 遍历每条线程已经下载的数据this.downloadedSize += this.data.get(i + 1); // 计算已经下载的数据之和}print("已经下载的长度" + this.downloadedSize + "个字节"); // 打印出已经下载的数据总和}this.block = (this.fileSize % this.threads.length) == 0 ? this.fileSize / this.threads.length : this.fileSize / this.threads.length + 1; // 计算每条线程下载的数据长度}else {print("服务器响应错误:" + conn.getResponseCode() + conn.getResponseMessage()); // 打印错误throw new RuntimeException("server response error "); // 抛出运行时服务器返回异常}}catch (Exception e) {print(e.toString()); // 打印错误throw new RuntimeException("Can't connection this url"); // 抛出运行时无法连接的异常}}
            构造函数中主要用于初始化FileDownloader的字段,首先构造一个HttpUrlConnection对象查看一下downloadUrl是否有效,然后读取文件的大小,接下来通过fileService读取本地Sqlite中文件下载记录,查看是否有未完成的下载记录,如果存在的话则读取原来的下载进度,在原来的基础上进行下载。代码第50行计算文件下载分块的大小,用文件大小除以下载线程的数量。
    然后我们看一下download方法的实现:
    /** * 开始下载文件 *  * @param listener 监听下载数量的变化,如果不需要了解实时下载的数量,可以设置为null * @return 已下载文件大小 * @throws Exception */public int download(DownloadProgressListener listener) throws Exception { // 进行下载,并抛出异常给调用者,如果有异常的话try {if (this.saveFile.exists() && this.saveFile.length() == this.fileSize && this.downloadedSize == 0)// 如果文件存在,并且大小一致,则不进行重复下载;downloadedSize!=0表示上次下载未完成{return this.fileSize;}RandomAccessFile randOut = new RandomAccessFile(this.saveFile, "rwd");if (this.fileSize > 0)randOut.setLength(this.fileSize); // 设置文件的大小randOut.close(); // 关闭该文件,使设置生效URL url = new URL(this.downloadUrl); // A URL instance specifies the// location of a resource on// the internet as specified by// RFC 1738if (this.data.size() != this.threads.length) { // 如果原先未曾下载或者原先的下载线程数与现在的线程数不一致this.data.clear(); // Removes all elements from this Map,// leaving it empty.for (int i = 0; i < this.threads.length; i++) { // 遍历线程池this.data.put(i + 1, 0);// 初始化每条线程已经下载的数据长度为0}this.downloadedSize = 0; // 设置已经下载的长度为0}for (int i = 0; i < this.threads.length; i++) {// 开启线程进行下载int downloadedLength = this.data.get(i + 1); // 通过特定的线程ID获取该线程已经下载的数据长度if (downloadedLength < this.block && this.downloadedSize < this.fileSize) {// 判断线程是否已经完成下载,否则继续下载this.threads[i] = new DownloadThread(this, url, this.saveFile, this.block, this.data.get(i + 1), i + 1); // 初始化特定id的线程this.threads[i].setPriority(7); // 设置线程的优先级,Thread.NORM_PRIORITY// = 5 Thread.MIN_PRIORITY =// 1 Thread.MAX_PRIORITY =// 10this.threads[i].start(); // 启动线程}else {this.threads[i] = null; // 表明在线程已经完成下载任务}}fileService.delete(this.downloadUrl); // 如果存在下载记录,删除它们,然后重新添加fileService.save(this.downloadUrl, this.data); // 把已经下载的实时数据写入数据库boolean notFinished = true;// 下载未完成while (notFinished) {// 循环判断所有线程是否完成下载Thread.sleep(1000);notFinished = false;// 假定全部线程下载完成for (int i = 0; i < this.threads.length; i++) {if (this.threads[i] != null && !this.threads[i].isFinished()) {// 如果发现线程未完成下载notFinished = true;// 设置标志为下载没有完成if (this.threads[i].getDownloadedLength() == -1) {// 如果下载失败,再重新在已经下载的数据长度的基础上下载threadErrorCount.put(i, threadErrorCount.get(i) + 1);// 线程错误数加1if (threadErrorCount.get(i) >= 3)// 错误次数超过3次{throw new Exception("下载失败!");}this.threads[i] = new DownloadThread(this, url, this.saveFile, this.block, this.data.get(i + 1), i + 1); // 重新开辟下载线程this.threads[i].setPriority(7); // 设置下载的优先级this.threads[i].start(); // 开始下载线程}}}this.fileService.update(this.downloadUrl, this.data); // 更新数据库中指定线程的下载长度if (listener != null)listener.onDownload(this.downloadedSize);// 通知目前已经下载完成的数据长度}if (downloadedSize >= this.fileSize)fileService.delete(this.downloadUrl);// 下载完成删除记录}catch (Exception e) {print(e.toString()); // 打印错误if (downloadedSize == 0) {saveFile.delete();}throw new Exception("下载失败!"); // 抛出文件下载异常}return this.downloadedSize;}
    在download方法中我们在下载文件之前首先判断本地是否存在相同的文件,这里我们只是简单的根据文件名称和文件大小是否一致来判断可能不太严谨(大家可以自行完善)。然后是使用RandomAccessFile创建一个相同大小的空文件。然后开启DownThread进行分块下载。线程启动之后我们在while循环中每隔一秒钟更新一下进度信息。
    最后我们看一下DownloadThread的实现:
    import java.io.File;import java.io.InputStream;import java.io.RandomAccessFile;import java.net.HttpURLConnection;import java.net.URL;import java.util.zip.GZIPInputStream;import android.util.Log;/** * 下载线程,根据具体下载地址、保持到的文件、下载块的大小、已经下载的数据大小等信息进行下载 *  * @author Wang Jialin *  */public class DownloadThread extends Thread {private static final String TAG = "DownloadThread"; // 定义TAG,方便日子的打印输出private File saveFile; // 下载的数据保存到的文件private URL downUrl; // 下载的URLprivate int block; // 每条线程下载的大小private int threadId = -1; // 初始化线程id设置private int downloadedLength; // 该线程已经下载的数据长度private boolean finished = false; // 该线程是否完成下载的标志private FileDownloader downloader; // 文件下载器public DownloadThread(FileDownloader downloader, URL downUrl, File saveFile, int block, int downloadedLength, int threadId) {this.downUrl = downUrl;this.saveFile = saveFile;this.block = block;this.downloader = downloader;this.threadId = threadId;this.downloadedLength = downloadedLength;}@Overridepublic void run() {if (downloadedLength < block) {// 未下载完成try {HttpURLConnection http = (HttpURLConnection) downUrl.openConnection(); // 开启HttpURLConnection连接http.setConnectTimeout(5 * 1000); // 设置连接超时时间为5秒钟http.setRequestMethod("GET"); // 设置请求的方法为GEThttp.setRequestProperty("Accept", "image/gif, image/jpeg, image/pjpeg, image/pjpeg, application/x-shockwave-flash, application/xaml+xml, application/vnd.ms-xpsdocument, application/x-ms-xbap, application/x-ms-application, application/vnd.ms-excel, application/vnd.ms-powerpoint, application/msword, */*"); // 设置客户端可以接受的返回数据类型http.setRequestProperty("Accept-Language", "zh-CN"); // 设置客户端使用的语言问中文http.setRequestProperty("Referer", downUrl.toString()); // 设置请求的来源,便于对访问来源进行统计http.setRequestProperty("Charset", "UTF-8"); // 设置通信编码为UTF-8int startPos = block * (threadId - 1) + downloadedLength;// 开始位置int endPos = block * threadId - 1;// 结束位置http.setRequestProperty("Range", "bytes=" + startPos + "-" + endPos);// 设置获取实体数据的范围,如果超过了实体数据的大小会自动返回实际的数据大小http.setRequestProperty("User-Agent", "Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 5.2; Trident/4.0; .NET CLR 1.1.4322; .NET CLR 2.0.50727; .NET CLR 3.0.04506.30; .NET CLR 3.0.4506.2152; .NET CLR 3.5.30729)"); // 客户端用户代理http.setRequestProperty("Connection", "Keep-Alive"); // 使用长连接http.setRequestProperty("Accept-Encoding", "gzip");InputStream inStream = http.getInputStream(); // 获取远程连接的输入流GZIPInputStream gzipInput = new GZIPInputStream(inStream);int bufferSize = 1024 * 2;byte[] buffer = new byte[bufferSize]; // 设置本地数据缓存的大小为2Kbint offset = 0; // 设置每次读取的数据量print("Thread " + this.threadId + " starts to download from position " + startPos); // 打印该线程开始下载的位置RandomAccessFile threadFile = new RandomAccessFile(this.saveFile, "rwd");threadFile.seek(startPos); // 文件指针指向开始下载的位置while (!downloader.getExited() && (offset = gzipInput.read(buffer, 0, bufferSize)) != -1) { // 但用户没有要求停止下载,同时没有到达请求数据的末尾时候会一直循环读取数据threadFile.write(buffer, 0, offset); // 直接把数据写到文件中downloadedLength += offset; // 把新下载的已经写到文件中的数据加入到下载长度中downloader.update(this.threadId, downloadedLength);// 把该线程已经下载的数据长度更新到内存哈希表中downloader.append(offset); // 把新下载的数据长度加入到已经下载的数据总长度中}// 该线程下载数据完毕或者下载被用户停止print("Thread " + this.threadId + " have download:" + downloadedLength);threadFile.close();gzipInput.close();if (downloader.getExited()) {print("Thread " + this.threadId + " has been paused");}else {print("Thread " + this.threadId + " download finish:" + downloadedLength);}this.finished = true; // 设置完成标志为true,无论是下载完成还是用户主动中断下载}catch (Exception e) { // 出现异常this.downloadedLength = -1; // 设置该线程已经下载的长度为-1print("Thread " + this.threadId + ":" + e); // 打印出异常信息}}}/** * 打印信息 *  * @param msg 信息 */private static void print(String msg) {Log.i(TAG, msg); // 使用Logcat的Information方式打印信息}/** * 下载是否完成 *  * @return */public boolean isFinished() {return finished;}/** * 已经下载的内容大小 *  * @return 如果返回值为-1,代表下载失败 */public long getDownloadedLength() {return downloadedLength;}}
     DownloadThread继承自java的Thread类,在run方法中执行下载操作,这里面的代码即前面说到的文件下载的一般过程,这里就不再赘述。这里值得注意的地方是不要在while循环中执行耗时的操作,代码的最初版本中每当读取写入一段文件内容之后都会将当前的下载进度写入到Sqlite中,这会严重影响文件的下载速度,现在我们的优化方法是将文件下载进度暂时保存在内存中,然后由FileDownloader负责将下载进度写入到Sqlite中,这样就不会影响到DownloadThread的下载速度。

     
  5. 总结
    整个文件下载过程对比较简单,这里我们不仅实现了文件的下载,并且提供一个通用的文件下载模块。接下来我还会分享一个关于文件上传过程的文章,设计的思想和文件下载的过程基本是一致的。
    第一次写博客,还请大家多多指教!



    代码下载
0 0
原创粉丝点击