`
jaychang
  • 浏览: 731488 次
  • 性别: Icon_minigender_1
  • 来自: 嘉兴
社区版块
存档分类
最新评论

多线程断点续传

阅读更多

在annegu写的多线程断点续传实践的基础上做了一些修改与重构,annegu写的多线程断点续传实践文章的地址:http://annegu.iteye.com/blog/427397

 

实现的主要功能有:1.任意个线程分段下载2.断点续传3.临时文件合并成所需文件,并删除临时文件

 

一、首先看一个常量类,用于定义下载状态,下载目录:

 

import java.io.File;

/**
 * 下载相关信息常量类
 * 
 * @author jaychang
 *
 */
public class DownloadConstant {
	/** 下载默认路径 ,为当前使用系统的用户的Downloads的目录*/
	public final static String DOWNLOAD_DIRECTORY = System.getProperty("user.home")+File.separator+"Downloads";

	/** 下载已经完成 */
	public final static String DOWNLOAD_HAS_FINISHED = "DOWNLOAD_HAS_FINISHED";

	/** 下载出现错误 */
	public final static String DOWNLOAD_ERROR = "DOWNLOAD_ERROR";
}

   二、请求头设置

 

import java.net.URLConnection;

/**
 * 请求头设置工具类
 * 
 * @author jaychang
 * 
 */
public class RequestHeaderUtil {
	/**
	 * 模拟发送HTTP请求
	 * 
	 * @param con
	 *            URLConnection
	 */
	public static void setHeader(URLConnection conn) {
		conn
				.setRequestProperty(
						"User-Agent",
						"Mozilla/5.0 (X11; U; Linux i686;en-US; rv:1.9.0.3) Gecko/2008092510 Ubuntu/8.04 (hardy) Firefox/3.0.3");
		conn
				.setRequestProperty("Accept-Language",
						"en-us,en;q=0.7,zh-cn;q=0.3");
		conn.setRequestProperty("Accept-Encoding", "aa");
		conn.setRequestProperty("Accept-Charset",
				"ISO-8859-1,utf-8;q=0.7,*;q=0.7");
		conn.setRequestProperty("Keep-Alive", "300");
		conn.setRequestProperty("Connection", "keep-alive");
		conn.setRequestProperty("If-Modified-Since",
				"Fri, 02 Jan 2009 17:00:05 GMT");
		conn.setRequestProperty("If-None-Match", "\"1261d8-4290-df64d224\"");
		conn.setRequestProperty("Cache-Control", "max-age=0");
	}
}

 

    该方法有个URLConnection类型的参数,有些网站为了安全起见,会对请求的http连接进行过滤,因此为 了伪装这个http的连接请求,我们给httpHeader穿一件伪装服。下面的setHeader方法展示了一些非常常用的典型的httpHeader 的伪装方法。比较重要的 有:Uer-Agent模拟从Ubuntu的firefox浏览器发出的请求;Referer模拟浏览器请求的前一个触发页面,例如从skycn站点来下 载软件的话,Referer设置成skycn的首页域名就可以了;Range就是这个连接获取的流文件的起始区间。

 

   三、线程下载管理类

 

import java.io.IOException;
import java.net.URL;
import java.net.URLConnection;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

import cn.com.servyou.mutithreaddown.contants.DownloadConstant;
import cn.com.servyou.mutithreaddown.util.MergeFileUtil;
import cn.com.servyou.mutithreaddown.util.RequestHeaderUtil;

/**
 * 下载管理类
 * 
 * @author jaychang
 * 
 */
public class DownloadManager {
	/**默认的线程数,当然可以改成使用配置文件进行设置*/
	public final static int THREAD_TOTAL_NUM = 10;
	/** 文件总大小 */
	private long contentLength;
	/** 起始索引数组,记录相应线程下载段的起始位置 */
	private long[] startPoints = new long[THREAD_TOTAL_NUM];
	/** 终止索引数组 ,记录相应线程下载段的终止位置*/
	private long[] endPoints = new long[THREAD_TOTAL_NUM];
	/** URL */
	private String urlStr;
	/** 编码 */
	@SuppressWarnings("unused")
	private final static String DEFAULT_ENCODING = "GBK";

	public String getUrlStr() {
		return urlStr;
	}

	public void setUrlStr(String urlStr) {
		this.urlStr = urlStr;
	}

        public DownloadManager(String urlStr){
		this.urlStr = urlStr;
	}

	public void download() throws IOException {
		URL url = new URL(urlStr);
		URLConnection conn = url.openConnection();
		RequestHeaderUtil.setHeader(conn);
		// 下载文件的文件名
		String fileAllName = urlStr.substring(urlStr.lastIndexOf("/") + 1);
		// 下载文件的大小
		contentLength = conn.getContentLength();
		// 计算每个线程需下载的大小
		long contentLengthPerThread = contentLength / THREAD_TOTAL_NUM;
		System.out.println("Toltal bytes is " + contentLength);
		System.out.println("Every thread need read bytes is "
				+ contentLengthPerThread);
		ExecutorService exec = Executors.newCachedThreadPool();
		CountDownLatch latch = new CountDownLatch(THREAD_TOTAL_NUM);
		for (int i = 0; i < THREAD_TOTAL_NUM; i++) {
			startPoints[i] = contentLengthPerThread * i;
			endPoints[i] = (i == THREAD_TOTAL_NUM - 1) ? contentLength - 1
					: contentLengthPerThread * (i + 1) - 1;
			DownloadThread downloadThread = new DownloadThread();
			downloadThread.setContentLength(contentLength);
			// 设置下载片段起始位置
			downloadThread.setStartPoint(startPoints[i]);
			// 设置下载片段结束位置
			downloadThread.setEndPoint(endPoints[i]);
			// 设置下载文件的全名
			downloadThread.setFileAllName(fileAllName);
			downloadThread.setUrlStr(urlStr);
			downloadThread.setThreadIndex(i + 1);
			downloadThread.setLatch(latch);
			// 线程开始执行
			exec.execute(downloadThread);
		}
		try {
			// 等待CountdownLatch信号为0,表示所有子线程都执行结束
			latch.await(100000, TimeUnit.MILLISECONDS);
			exec.shutdown();
			// 把分段下载下来的临时文件中的内容写入目标文件中。
			MergeFileUtil.merge(DownloadConstant.DOWNLOAD_DIRECTORY + "/"
					+ fileAllName, fileAllName);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
	}
}
 

   下面对以上代码做一个解释:

 

    首先说明一下该类的几个常量,及属性,THREAD_TOTAL_NUM也不用多说了,contentLength为下载文件的总的字节数,startPoint[THREAD_TOTAL_NUM]存着每个线程所负责要下载的片段的起始位置,endPoint[THREAD_TOTAL_NUM]就不用说了,urlStr必须是http协议的url,且结尾是文件名+文件后缀名(fileName.type),编码暂时还未用到,先放着。

    其次,说下CountdownLatch,CountdownLatch就是一个计数器,就像一个拦截的栅栏,用await()方法来把栅栏关上,线程就跑不下去了,只有等计数器减为0的时候,栅栏才会自动打开,被暂停的线程才会继续运行。CountdownLatch的应用场景可以有很多,分段下载就是一个很好的例子。

 

 四、下载线程类

 

import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.concurrent.CountDownLatch;

import cn.com.servyou.mutithreaddown.contants.DownloadConstant;
import cn.com.servyou.mutithreaddown.util.RequestHeaderUtil;

/**
 * 下载线程类
 * 
 * @author jaychang
 * 
 */
public class DownloadThread extends Thread {
	/** 该线程所需下载文件片段的开始位置 */
	private int threadIndex;
	private long startPoint;
	/** 该线程所需下载的文件片段的结束位置 */
	private long endPoint;
	/** 下载文件的总大小 */
	private long contentLength;
	/** 文件全名 */
	private String fileAllName;
	/** 文件的url地址 */
	private String urlStr;
	/** 缓冲大小 */
	private final static int BUFFER_READ_SIZE = 8096;
	/** 临时文件后缀名 */
	private final static String FILE_TYPE = "tmp";
	/** 初始化下载状态 */
	private String status = DownloadConstant.DOWNLOAD_ERROR;
	/** CountDownLatch 用作与主线程同步,等到所有线程都执行完毕,则主线程开始对临时文件进行拼装 */
	private CountDownLatch latch;

	public DownloadThread() {
		super();
	}

	/**
	 * 构造器
	 * 
	 * @param threadIndex
	 *            线程索引
	 * @param startPoint
	 *            下载开始位置
	 * @param endPoint
	 *            下载结束位置
	 * @param contentLength
	 *            文件总的大小
	 * @param fileAllName
	 *            文件全名
	 * @param urlStr
	 *            url字符串
	 * @param latch
	 *            CountDownLatch
	 */
	public DownloadThread(int threadIndex, long startPoint, long endPoint,
			long contentLength, String fileAllName, String urlStr,
			CountDownLatch latch) {
		super();
		this.threadIndex = threadIndex;
		this.startPoint = startPoint;
		this.endPoint = endPoint;
		this.contentLength = contentLength;
		this.fileAllName = fileAllName;
		this.urlStr = urlStr;
		this.latch = latch;
		start();
	}

	/**
	 * 下载文件部分内容,并存于临时文件中
	 */
	public void run() {
		int indexOfPoint = fileAllName.lastIndexOf(".");
		String fileName = fileAllName.substring(0, indexOfPoint);
		String tempFileName = fileName + "_" + threadIndex + "." + FILE_TYPE;
		File downloadDir = new File(DownloadConstant.DOWNLOAD_DIRECTORY + "/"
				+ fileAllName);
		if (!downloadDir.exists()) {
			downloadDir.mkdirs();
		}
		File tempFile = new File(downloadDir, tempFileName);
		boolean isExist = tempFile.exists();
		if (isExist) {
			long localContentLength = tempFile.length();
			processDownload(tempFileName, localContentLength);
		} else {
			processDownload(tempFileName, 0);
		}
	}

	/**
	 * 处理该线程的下载任务
	 * 
	 * @param tempFileName
	 *            临时文件名
	 * @param localContentLength
	 *            临时文件大小
	 */
	public void processDownload(String tempFileName, long localContentLength) {
		HttpURLConnection conn = null;
		BufferedInputStream in = null;
		BufferedOutputStream out = null;
		// 该线程需下载的字节片段未下载完
		if (localContentLength < endPoint - startPoint + 1) {
			try {
				conn = (HttpURLConnection) (new URL(urlStr)).openConnection();
				conn.setAllowUserInteraction(true);
				// 设置连接超时时间为10000ms
				conn.setConnectTimeout(10000);
				// 设置读取数据超时时间为10000ms
				conn.setReadTimeout(100000);
				RequestHeaderUtil.setHeader(conn);
				// 设置请求头读取字节的范围,即断点起始位置
				long startPos = startPoint + localContentLength;
				conn.setRequestProperty("Range", "bytes=" + startPos + "-"
						+ endPoint);
				System.out.println("Thread" + threadIndex + ": " + " startPos="
						+ startPos + " endPos=" + endPoint + "  NeedReadBytes="
						+ (endPoint - startPos + 1));
				int responseCode = conn.getResponseCode();
				if (HttpURLConnection.HTTP_OK == responseCode) {
					System.out.println("HTTP_OK");
				} else if (HttpURLConnection.HTTP_PARTIAL == responseCode) {
					System.out.println("HTTP_PARTIAL");
				} else if (HttpURLConnection.HTTP_CLIENT_TIMEOUT == responseCode) {
					System.out.println("HTTP_CLIENT_TIMEOUT");
				}
				File directory = new File(DownloadConstant.DOWNLOAD_DIRECTORY
						+ "/" + fileAllName);
				in = new BufferedInputStream(conn.getInputStream());
				out = new BufferedOutputStream(new FileOutputStream(new File(
						directory, tempFileName), true));
				long count = 0;
				byte[] b = new byte[BUFFER_READ_SIZE];
				int len = -1;
				// 需要读取的字节数
				long needReadBytes = endPoint - startPos + 1;
				while ((len = in.read(b)) != -1) {
					count += len;
					if (count > needReadBytes) {
						System.out.println("Current read " + len + " Thread "
								+ threadIndex + " has readed " + count
								+ " bytes!");
						System.out.println("Thread " + threadIndex
								+ " finished!");
						break;
					}
					out.write(b, 0, len);
				}
				// 设置最终该线程的下载状态
				this.status = count >= needReadBytes ? DownloadConstant.DOWNLOAD_HAS_FINISHED
						: DownloadConstant.DOWNLOAD_ERROR;
				// 线程池计数减1,表示线程池中该线程的任务已结束
				latch.countDown();
			} catch (IOException e) {
				e.printStackTrace();
			} finally {
				if (in != null)
					try {
						in.close();
					} catch (IOException e1) {
						e1.printStackTrace();
					}
				if (out != null)
					try {
						out.close();
					} catch (IOException e) {
						e.printStackTrace();
					}
			}
		} else if (localContentLength >= endPoint - startPoint + 1) {
			System.out.println("Thread " + (threadIndex + 1) + ","
					+ "needReadBytes = " + (endPoint - startPoint));
			this.status = DownloadConstant.DOWNLOAD_HAS_FINISHED;
			latch.countDown();
		}
	}

	public long getStartPoint() {
		return startPoint;
	}

	public void setStartPoint(long startPoint) {
		this.startPoint = startPoint;
	}

	public long getEndPoint() {
		return endPoint;
	}

	public void setEndPoint(long endPoint) {
		this.endPoint = endPoint;
	}

	public long getContentLength() {
		return contentLength;
	}

	public void setContentLength(long contentLength) {
		this.contentLength = contentLength;
	}

	public String getFileAllName() {
		return fileAllName;
	}

	public void setFileAllName(String fileAllName) {
		this.fileAllName = fileAllName;
	}

	public int getThreadIndex() {
		return threadIndex;
	}

	public void setThreadIndex(int threadIndex) {
		this.threadIndex = threadIndex;
	}

	public String getUrlStr() {
		return urlStr;
	}

	public void setUrlStr(String urlStr) {
		this.urlStr = urlStr;
	}

	public String getStatus() {
		return status;
	}

	public void setStatus(String status) {
		this.status = status;
	}

	public CountDownLatch getLatch() {
		return latch;
	}

	public void setLatch(CountDownLatch latch) {
		this.latch = latch;
	}
}
 

 

 该类的属性中有threadIndex,用于定义临时文件,临时文件定义为"文件名_threadIndex.后缀名"

 

五、临时文件合并工具类。

      现在每个分段的下载线程都顺利结束了,也都创建了相应的临时文件,接下来在主线程中会对临时文件进行合并,并写入目标文件,最后删除临时文件。

 

import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.Arrays;
import java.util.Comparator;

/**
 * 临时文件合并工具类
 * 
 * @author jaychang
 * 
 */
public class MergeFileUtil {
	/** 下划线分隔符 */
	public final static String UNDER_LINE = "_";
	/** 点分隔符 */
	public final static String POINT = ".";

	/**
	 * 合并临时文件
	 * 
	 * @param filePath
	 *            临时文件路径
	 * @param fileAllName
	 * @throws IOException
	 */
	public static void merge(String filePath, String fileAllName)
			throws IOException {
		File dir = new File(filePath);
		BufferedOutputStream out = null;
		BufferedInputStream in = null;
		File[] fileList = dir.listFiles();
		// 对临时文件进行排序,按照顺序写到输出流中,排序规则为按照文件名的threadIndex(fileAllName_'threadIndex'.type)排序
		Arrays.sort(fileList, new Comparator<File>() {
			public int compare(File fileOne, File fileAnother) {
				String fileNameOne = fileOne.getName();
				String fileNameAnother = fileAnother.getName();
				int lastIndexOfUnderLineOne = fileNameOne
						.lastIndexOf(UNDER_LINE);
				int lastIndexOfPointOne = fileNameOne.lastIndexOf(POINT);
				int lastIndexOfUnderLineAnother = fileNameAnother
						.lastIndexOf(UNDER_LINE);
				int lastIndexOfPointAnother = fileNameAnother
						.lastIndexOf(POINT);
				int one = Integer.parseInt(fileNameOne.substring(
						lastIndexOfUnderLineOne + 1, lastIndexOfPointOne));
				int another = Integer.parseInt(fileNameAnother.substring(
						lastIndexOfUnderLineAnother+1, lastIndexOfPointAnother));
				return one - another;
			}
		});
		// 由临时文件拼装成的最终文件
		File destFile = new File(dir, fileAllName);
		if (!destFile.exists()) {
			destFile.createNewFile();
		}
		out = new BufferedOutputStream(new FileOutputStream(destFile, true));
		for (File file : fileList) {
			// 过滤非临时文件
			if (file.getName().indexOf("tmp") < 0)
				continue;
			// 读取临时文件,按文件编号(处理该文件的线程索引)顺序写入最终生成的文件
			in = new BufferedInputStream(new FileInputStream(file));
			byte[] b = new byte[8196];
			int len = -1;
			while ((len = in.read(b)) != -1) {
				out.write(b, 0, len);
			}
			if (in != null) {
				in.close();
			}
			// 删除临时文件
			file.delete();
		}
		if (out != null) {
			out.close();
		}
	}
}
 

    遍历文件下载的临时文件,并对临时文件数组进行排序,排序按照前面讲到的threadIndex,因为之前定义临时文件名称为"文件名_threadIndex.后缀名"。

 

 

 

 

 

2
3
分享到:
评论
7 楼 jaychang 2010-07-26  
DownloadThread类的processDownload方法最后加
else if (localContentLength >= endPoint - startPoint + 1) {
System.out.println("Thread " + (threadIndex + 1) + ","
+ "needReadBytes = " + (endPoint - startPoint));
this.status = DownloadConstant.DOWNLOAD_HAS_FINISHED;
latch.countDown();
}
可解决
6 楼 jaychang 2010-07-26  
纠正。。。刚才我说的话,有误“再次执行程序时,如果还是原先的url,可以断点续传”,再看下这个问题
5 楼 jaychang 2010-07-26  
menuhin 写道
在上传时能否做到继点续传???

可以,但是现在是强行结束程序,正是现在我觉得头痛的问题,暂时没有好的办法让其在下载过程中停止。再次执行程序时,如果还是原先的url,可以断点续传
4 楼 menuhin 2010-07-26  
在上传时能否做到继点续传???
3 楼 jaychang 2010-07-26  
非常感谢1楼的兄弟哈,对于这个问题“是强行结束吗 不过我多试了几次后,文件最后好像不合并了”,忘记加了两句话,即当每个线程都把自己那一部分下载完后,应该将线程计数减1,没减的话,主线程会一直等待下载线程,因为计数器一直都不为0了,所以在DownloadThread类的processDownload方法末尾加两句话:
                 this.status = DownloadConstant.DOWNLOAD_HAS_FINISHED;
               latch.countDown();
2 楼 jaychang 2010-07-26  
watervip 写道
怎么测试断点续传啊?

是强行结束吗 不过我多试了几次后,文件最后好像不合并了

能否添加个个界面?


你说的对,暂时是只能强行结束,有点恶心哈,多谢你的建议哈,我在检查下
1 楼 watervip 2010-07-25  
怎么测试断点续传啊?

是强行结束吗 不过我多试了几次后,文件最后好像不合并了

能否添加个个界面?

相关推荐

    android多线程断点续传

    在Android开发中,多线程断点续传是一项重要的技术,尤其在处理大文件下载时。这个技术的主要目的是提高下载效率并确保用户可以中断或恢复下载过程。以下将详细讲解多线程断点续传的概念、实现原理以及在Android中的...

    Android多线程断点续传下载

    在Android应用开发中,实现多线程断点续传下载是一项重要的技术,它涉及到网络编程、文件操作以及并发处理等多个方面。以下将详细介绍这个主题的相关知识点。 首先,我们需要理解“多线程”。在Android系统中,为了...

    FTP、HTTP 多线程断点续传下载文件.rar

    标题中的“FTP、HTTP 多线程断点续传下载文件.rar”暗示了这是一个关于网络协议(FTP和HTTP)在实现多线程下载时如何支持断点续传功能的资源包。这个压缩文件可能包含了一个或者多个示例程序、文档或教程,用于解释...

    c#多线程断点续传

    在提供的压缩包文件中,可能包含了一个名为“download”的示例项目,这可能是用于演示上述多线程断点续传功能的源代码。通过分析和运行这个项目,你可以更直观地理解这两种技术的结合应用。 总之,多线程和断点续传...

    java多线程断点续传下载

    Java多线程断点续传下载是一个复杂但实用的技术,尤其在处理大文件或网络不稳定时,能够提高用户体验并优化资源利用。以下是对这个主题的详细解析: **1. Java多线程** Java多线程是指在一个Java应用程序中同时执行...

    java ftp 多线程 断点续传等知识

    而"多线程"和"断点"这两个文件名可能是指相关示例代码或文档,可以进一步帮助你理解和实践Java FTP的多线程下载和断点续传。 在实际应用中,还需要考虑其他因素,如错误处理、网络状况的监控、文件完整性检查等。...

    android多线程断点续传下载

    在Android开发中,多线程断点续传下载是一项重要的技术,它允许用户在暂停、关闭应用或设备重启后从上次中断的地方继续下载大文件,提高用户体验并节省网络资源。以下将详细介绍这一技术的关键知识点: 1. **多线程...

    Xutils框架进行多线程断点续传(详细注解)

    在这个主题中,我们将专注于使用Xutils实现多线程断点续传的功能,这对于大文件下载尤其重要。 首先,断点续传是一种在网络不稳定或者设备意外断电的情况下,能够从上次中断的位置继续下载的技术。它通过保存已下载...

    多线程断点续传(基于HTTP协议).zip_http 断点上传_http 断点续传_多线程断点续传_断点上传_断点续传

    同时,"【成品】多线程断点续传工具.jar"可能是一个可执行的Java应用程序,它提供了用户界面或者命令行工具,让用户可以方便地进行断点续传的文件上传。 多线程断点续传进一步提高了文件传输的效率。通过将文件分割...

    java多线程断点续传[借鉴].pdf

    总结来说,Java实现的多线程断点续传涉及的技术点包括: 1. 并发编程:使用`ExecutorService`、`CountDownLatch`进行线程管理和同步。 2. 文件操作:分析和合并临时文件,实现断点续传。 3. 网络I/O:通过`...

    Android多线程断点续传下载+在线播放音乐

    在Android应用开发中,实现“多线程断点续传下载+在线播放音乐”涉及到多个关键技术,主要包括网络编程、文件操作、多线程处理、内存管理以及多媒体播放等。以下是对这些知识点的详细阐述: 1. **多线程下载**: ...

    多线程断点续传下载实现

    在Android开发中,多线程断点续传技术是一种提高下载效率并优化用户体验的重要方法。这一技术主要应用于大型文件的下载场景,如游戏、应用程序或高清视频等,它允许用户在下载过程中随时暂停,之后能从中断的地方...

    点对点多线程断点续传的实现

    在点对点网络中实现多线程断点续传,主要涉及以下几个关键技术点: 1. **网络连接与通信协议**:P2P网络通常使用TCP或UDP协议进行通信。TCP保证了数据的可靠传输,适合断点续传,而UDP则提供了更高的传输速度,但...

    java实现FTP多线程断点续传

    ### Java实现FTP多线程断点续传:深入解析与技术要点 在现代软件开发中,数据传输是一项基本且关键的任务,特别是在处理大文件时,断点续传功能显得尤为重要。断点续传允许在网络连接中断后恢复传输,避免了重新...

    Android多线程断点续传下载网络上的音/视频等各种文件

    在Android开发中,实现多线程断点续传下载网络上的音视频文件是一项重要的技能,尤其对于提升用户体验和节省用户流量至关重要。断点续传允许用户在暂停或因网络问题中断下载后,从上次停止的位置继续,而多线程则能...

    一个支持多线程断点续传功能的Android下载工具.zip

    在Android平台上,开发一款支持多线程断点续传功能的下载工具是一项技术挑战,它涉及到网络编程、文件处理以及线程管理等多个方面。这款名为"DownloadHelper"的项目,显然是一个致力于解决这些问题的开源解决方案。 ...

    安卓多线程断点续传

    在安卓开发中,多线程断点续传是一项重要的技术,尤其在处理大文件下载时,它可以提高下载效率,同时确保在下载过程中如果中断,可以从上次停止的地方继续,避免了数据的重复下载。本文将详细讲解安卓多线程断点续传...

Global site tag (gtag.js) - Google Analytics