Android实现基于http协议的文件下载
概述
网络编程中文件的上传下载是最常见的场景,本着不重复造轮子的原则,日常工作如果遇到相关问题我们首先想到的可能是从网上找现成的代码直接拿来用,很少去关心具体是如何实现的,可能也是没时间去研究别人如何实现。如果代码能够满足我们现阶段的要求,则万事大吉,但是如果使用代码的过程中出现意想不到的问题,我们解决起来可能会比较麻烦,因为代码不是我们自己写的,对代码不熟,不能快速的查找问题的原因。个人认为不重复造轮子的前提是你必须有能力造一个相同的轮子,这样在使用别人的代码时才能更加得心应手。
最近工 作需要在Android里面实现文件的上传和下载功能,当然为了快速实现我也是从网上找了别人的代码直接拿来用了,期间也根据自己的需求做了适当的修改,现在就拿出来给大家分享一下。
- 原理分析
Android应用与服务进行交互一般是通过Http请求的方式进行,文件下载也同样通过Http请求,一般情况下我们通过请求一个文件的url地址,服务端返回一个文件流,我们通过读取文件流的方式将文件内容再以流的方式写到本地的文件中,这就是文件下载的基本过程。但是如果要下载的文件较大时我们一般需要采用分块下载(或叫做多线程下载)的方式,以减少下载过程中出现错误的可能性。之前我们一个http请求返回整个文件的文件流,现在我们需要分多次请求,每个请求返回的文件流只能读取文件的一部分。在客户端每次请求的时候需要携带关于块的信息,即本次请求是要下载文件的哪个部分,然后服务器通过解析只返回文件的一部分,然后客户端将每个请求的结果进行汇总,即进行文件的合并,最终得到一个完整的文件。
在http协议1.1中新增了一个Range头参数,这是目前实现多线程下载的核心所在。Range的使用方式为“Range: bytes=0-1”表示下载文件的前两个字节,即从0个字节到第1个字节,一共两个字节。接下来我们先看一下如何使用java实分块下载的功能。- 代码实现
首先代码第一个行获取一个HttpURLConnection连接对象,然后设置连接的超时时为5s,请求方式为get方式,接收文件的类型,语言,请求的来源编码方式。代码第10行为关键代码设置请求头中的Range参数值,参数值的信息需要我们根据文件分块的大小,和当前线程请求的是第几块计算出一个范围。所有的请求参数设置完成之后我们通过调用getInputStream方法获取返回的文件流。这就是请求发送的实现,下面我们看一下文件流获取之后合并文件的实现。1: HttpURLConnection http = (HttpURLConnection) downUrl.openConnection(); // 开启HttpURLConnection连接2: http.setConnectTimeout(5 * 1000); // 设置连接超时时间为5秒钟3: http.setRequestMethod("GET"); // 设置请求的方法为GET4: http.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, */*"); // 设置客户端可以接受的返回数据类型5: http.setRequestProperty("Accept-Language", "zh-CN"); // 设置客户端使用的语言问中文6: http.setRequestProperty("Referer", downUrl.toString()); // 设置请求的来源,便于对访问来源进行统计7: http.setRequestProperty("Charset", "UTF-8"); // 设置通信编码为UTF-88: int startPos = block * (threadId - 1) + downloadedLength;// 开始位置9: int endPos = block * threadId - 1;// 结束位置10: http.setRequestProperty("Range", "bytes=" + startPos + "-" + endPos);// 设置获取实体数据的范围,如果超过了实体数据的大小会自动返回实际的数据大小11: 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)"); // 客户端用户代理12: http.setRequestProperty("Connection", "Keep-Alive"); // 使用长连接13: http.setRequestProperty("Accept-Encoding", "gzip");14: InputStream inStream = http.getInputStream(); // 获取远程连接的输入流
代码第一行构造了一个GZIPInputStream对象,因为我们在请求时使用了gzip压缩,然后创建一个1024*2大小的缓冲区用于读取文件内容,文件合并的关键在于使用RandomAccessFile,它可以任意的访问文件的位置进行读写操作。代码第6行我们创建一个RandomAccessFile的对象,然后将文件流的位置跳转到startPos的位置,即我们请求文件时Range头参数值中的起始位置,我们在执行写入操作时将会从当前位置开始,这样每个线程在文件写入时都从不同的位置进行,不会相互影响。然后一直循环读取文件内容,并将读取的内容写入到文件中。最后读取结束后关闭文件流和网络流。1: GZIPInputStream gzipInput = new GZIPInputStream(inStream);2: int bufferSize = 1024 * 2;3: byte[] buffer = new byte[bufferSize]; // 设置本地数据缓存的大小为2Kb4: int offset = 0; // 设置每次读取的数据量5: print("Thread " + this.threadId + " starts to download from position " + startPos); // 打印该线程开始下载的位置6: RandomAccessFile threadFile = new RandomAccessFile(this.saveFile, "rwd");7: threadFile.seek(startPos); // 文件指针指向开始下载的位置8: while (!downloader.getExited() && (offset = gzipInput.read(buffer, 0, bufferSize)) != -1) { // 但用户没有要求停止下载,同时没有到达请求数据的末尾时候会一直循环读取数据9: threadFile.write(buffer, 0, offset); // 直接把数据写到文件中10: downloadedLength += offset; // 把新下载的已经写到文件中的数据加入到下载长度中11: downloader.update(this.threadId, downloadedLength);// 把该线程已经下载的数据长度更新到内存哈希表中12: downloader.append(offset); // 把新下载的数据长度加入到已经下载的数据总长度中13: }// 该线程下载数据完毕或者下载被用户停止14: print("Thread " + this.threadId + " have download:" + downloadedLength);15: threadFile.close();
16: gzipInput.close();
这样整个文件下载的过程就实现了,从文件的分块请求到最后的文件合并整个过程。但是这还不算完,为了完成一个相对完整的文件下载模块,现在我们有一些功能没有实现,如断点续传,文件分块大小的计算,不同下载线程的调度等,我们还要许多功能需要完善。- 文件下载器的实现
首先我们引入一个文件下载器FileDownloader的概念,它作为一个功能相对完整和独立的模块,它的提供的接口就是下载文件,在它的内部封装了文件分块下载的功能,这些对于用户来讲都是不可见的,用户在使用时只需要构建一个FileDownloader的对象,然后调用它的download方法即可实现文件下载。首先我们看一下关于FileDownloader的使用示例:
然后我们看一下FileDownloader的具体实现,首先看一下FileDownloader的包含的字段:1: //构造一个文件下载器,context表示上下文对象,downloadUrl表示下载文件的地址,fileSaveDir表示本地保存的路径,threadNum表示启用线程数量2: FileDownloader fileDownloader=new FileDownloader(context, downloadUrl, fileSaveDir, threadNum)3: //开始下载,listener用于监听下载的进度4: fileDownloader.download(listener);
其中fileService主要用于保存和读取文件下载进度,封装的是对Sqlite的操作,这个会在下面详细解释。threads为DownThread类型的数组,其中DownloadThread为我们自定义的线程用于下载文件某个分块。data为一个ConcurrentHashMap类型,key为线程的id,value为该线程已经下载的文件大小。block为文件分块的大小。downloadUrl为文件的下载路径。threadErrorCount为SparseIntArray类型,用于统计线程下载过程中出现的错误次数,key为线程id,value为出现的错误次数。1: private static final String TAG = "FileDownloader"; // 设置标签,方便Logcat日志记录2: private static final int RESPONSEOK = 200; // 响应码为200,即访问成功3: private Context context; // 应用程序的上下文对象4: private FileDownloadLogService fileService; // 获取本地数据库的业务Bean5: private boolean exited; // 停止下载标志6: private int downloadedSize = 0; // 已下载文件长度7: private int fileSize = 0; // 原始文件长度8: private DownloadThread[] threads; // 根据线程数设置下载线程池9: private File saveFile; // 数据保存到的本地文件10: private Map<Integer, Integer> data = new ConcurrentHashMap<Integer, Integer>(); // 缓存各线程下载的长度11: private int block; // 每条线程下载的长度12: private String downloadUrl; // 下载路径13: // 记录线程的错误次数,防止由于网络错误一直重复创建线程14: private SparseIntArray threadErrorCount = new SparseIntArray();
接下来我们看一下FileDownloader构造方法的实现:
构造函数中主要用于初始化FileDownloader的字段,首先构造一个HttpUrlConnection对象查看一下downloadUrl是否有效,然后读取文件的大小,接下来通过fileService读取本地Sqlite中文件下载记录,查看是否有未完成的下载记录,如果存在的话则读取原来的下载进度,在原来的基础上进行下载。代码第50行计算文件下载分块的大小,用文件大小除以下载线程的数量。1: /**2: * 构建文件下载器3: *4: * @param downloadUrl 下载路径5: * @param fileSaveDir 文件保存目录6: * @param threadNum 下载线程数7: */8: public FileDownloader(Context context, String downloadUrl, File fileSaveDir, int threadNum) {9: try {10: this.context = context; // 对上下文对象赋值11: this.downloadUrl = downloadUrl; // 对下载的路径赋值12: fileService = new FileDownloadLogService(this.context); // 实例化数据操作业务Bean,此处需要使用Context,因为此处的数据库是应用程序私有13: URL url = new URL(this.downloadUrl); // 根据下载路径实例化URL14: if (!fileSaveDir.exists())15: fileSaveDir.mkdirs(); // 如果指定的文件不存在,则创建目录,此处可以创建多层目录16: this.threads = new DownloadThread[threadNum]; // 根据下载的线程数创建下载线程池17: HttpURLConnection conn = (HttpURLConnection) url.openConnection(); // 建立一个远程连接句柄,此时尚未真正连接18: conn.setConnectTimeout(5 * 1000); // 设置连接超时时间为5秒19: conn.setRequestMethod("GET"); // 设置请求方式为GET20: conn.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, */*"); // 设置客户端可以接受的媒体类型21: conn.setRequestProperty("Accept-Language", "zh-CN"); // 设置客户端语言22: conn.setRequestProperty("Referer", downloadUrl); // 设置请求的来源页面,便于服务端进行来源统计23: conn.setRequestProperty("Charset", "UTF-8"); // 设置客户端编码24: 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)"); // 设置用户代理25: conn.setRequestProperty("Connection", "Keep-Alive"); // 设置Connection的方式26: conn.connect(); // 和远程资源建立真正的连接,但尚无返回的数据流27: printResponseHeader(conn); // 答应返回的HTTP头字段集合28: if (conn.getResponseCode() == RESPONSEOK) { // 此处的请求会打开返回流并获取返回的状态码,用于检查是否请求成功,当返回码为200时执行下面的代码29: this.fileSize = conn.getContentLength();// 根据响应获取文件大小30: if (this.fileSize <= 0)31: throw new RuntimeException("Unkown file size "); // 当文件大小为小于等于零时抛出运行时异常32:
33: String filename = getFileName(conn);// 获取文件名称34: this.saveFile = new File(fileSaveDir, filename);// 根据文件保存目录和文件名构建保存文件35: Map<Integer, Integer> logdata = fileService.getData(downloadUrl);// 获取下载记录36:
37: if (logdata.size() > 0) {// 如果存在下载记录38: for (Map.Entry<Integer, Integer> entry : logdata.entrySet())39: // 遍历集合中的数据40: data.put(entry.getKey(), entry.getValue());// 把各条线程已经下载的数据长度放入data中41: }
42:
43: if (this.data.size() == this.threads.length) {// 如果已经下载的数据的线程数和现在设置的线程数相同时则计算所有线程已经下载的数据总长度44: for (int i = 0; i < this.threads.length; i++) { // 遍历每条线程已经下载的数据45: this.downloadedSize += this.data.get(i + 1); // 计算已经下载的数据之和46: }
47: print("已经下载的长度" + this.downloadedSize + "个字节"); // 打印出已经下载的数据总和48: }
49:
50: this.block = (this.fileSize % this.threads.length) == 0 ? this.fileSize / this.threads.length : this.fileSize / this.threads.length + 1; // 计算每条线程下载的数据长度51: }
52: else {53: print("服务器响应错误:" + conn.getResponseCode() + conn.getResponseMessage()); // 打印错误54: throw new RuntimeException("server response error "); // 抛出运行时服务器返回异常55: }
56: }
57: catch (Exception e) {58: print(e.toString()); // 打印错误59: throw new RuntimeException("Can‘t connection this url"); // 抛出运行时无法连接的异常60: }
61: }
然后我们看一下download方法的实现:
1: /**2: * 开始下载文件3: *4: * @param listener 监听下载数量的变化,如果不需要了解实时下载的数量,可以设置为null5: * @return 已下载文件大小6: * @throws Exception7: */8: public int download(DownloadProgressListener listener) throws Exception { // 进行下载,并抛出异常给调用者,如果有异常的话9: try {10: if (this.saveFile.exists() && this.saveFile.length() == this.fileSize && this.downloadedSize == 0)// 如果文件存在,并且大小一致,则不进行重复下载;downloadedSize!=0表示上次下载未完成11: {
12: return this.fileSize;13: }
14: RandomAccessFile randOut = new RandomAccessFile(this.saveFile, "rwd");15:
16: if (this.fileSize > 0)17: randOut.setLength(this.fileSize); // 设置文件的大小18: randOut.close(); // 关闭该文件,使设置生效19: URL url = new URL(this.downloadUrl); // A URL instance specifies the20: if (this.data.size() != this.threads.length) { // 如果原先未曾下载或者原先的下载线程数与现在的线程数不一致21: this.data.clear(); // Removes all elements from this Map,22: // leaving it empty.23: for (int i = 0; i < this.threads.length; i++) { // 遍历线程池24: this.data.put(i + 1, 0);// 初始化每条线程已经下载的数据长度为025: }
26: this.downloadedSize = 0; // 设置已经下载的长度为027: }
28: for (int i = 0; i < this.threads.length; i++) {// 开启线程进行下载29: int downloadedLength = this.data.get(i + 1); // 通过特定的线程ID获取该线程已经下载的数据长度30: if (downloadedLength < this.block && this.downloadedSize < this.fileSize) {// 判断线程是否已经完成下载,否则继续下载31: this.threads[i] = new DownloadThread(this, url, this.saveFile, this.block, this.data.get(i + 1), i + 1); // 初始化特定id的线程32: this.threads[i].setPriority(7); // 设置线程的优先级33: this.threads[i].start(); // 启动线程34: }
35: else {36: this.threads[i] = null; // 表明在线程已经完成下载任务37: }
38: }
39: fileService.delete(this.downloadUrl); // 如果存在下载记录,删除它们,然后重新添加40: fileService.save(this.downloadUrl, this.data); // 把已经下载的实时数据写入数据库41: boolean notFinished = true;// 下载未完成42: while (notFinished) {// 循环判断所有线程是否完成下载43: Thread.sleep(1000);
44: notFinished = false;// 假定全部线程下载完成45: for (int i = 0; i < this.threads.length; i++) {46: if (this.threads[i] != null && !this.threads[i].isFinished()) {// 如果发现线程未完成下载47: notFinished = true;// 设置标志为下载没有完成48: if (this.threads[i].getDownloadedLength() == -1) {// 如果下载失败,再重新在已经下载的数据长度的基础上下载49: threadErrorCount.put(i, threadErrorCount.get(i) + 1);// 线程错误数加150: if (threadErrorCount.get(i) >= 3)// 错误次数超过3次51: {
52: throw new Exception("下载失败!");53: }
54: this.threads[i] = new DownloadThread(this, url, this.saveFile, this.block, this.data.get(i + 1), i + 1); // 重新开辟下载线程55: this.threads[i].setPriority(7); // 设置下载的优先级56: this.threads[i].start(); // 开始下载线程57: }
58: }
59: }
60: this.fileService.update(this.downloadUrl, this.data); // 更新数据库中指定线程的下载长度61: if (listener != null)62: listener.onDownload(this.downloadedSize);// 通知目前已经下载完成的数据长度63: }
64: if (downloadedSize >= this.fileSize)65: fileService.delete(this.downloadUrl);// 下载完成删除记录66: }
67: catch (Exception e) {68: print(e.toString()); // 打印错误69: if (downloadedSize == 0) {70: saveFile.delete();
71: }
72: throw new Exception("下载失败!"); // 抛出文件下载异常73: }
74: return this.downloadedSize;75: }
- 总结
郑重声明:本站内容如果来自互联网及其他传播媒体,其版权均属原媒体及文章作者所有。转载目的在于传递更多信息及用于网络分享,并不代表本站赞同其观点和对其真实性负责,也不构成任何其他建议。