1. 前言
在前面的博客中简单实现了Android单线程断点下载以及Android文件多线程下载,这篇将实现多线程断点下载。对于断点下载,我们知道主要是为了实现不重复下载上次下载过的数据文件内容,而和前面实践的区别在于我们将从单线程环境拓展到多线程环境中。下面简单整理下思路。
项目代码链接:https://github.com/baiyazi/AndroidDownloadUtils,可自取。
2. 设计思路
- 确定多线程环境下的线程数量;
- 确定每个线程自己所需要下载的数据范围;
每个线程下载的数据,使用临时文件进行存储,最终进行文件合并。(测试结果发现合并太耗时了,故而不考虑使用临时文件的方式。)- 记录每个线程下载了多少数据,即:记录下载进度;
- 设计回调接口;
3. 实现逻辑
这一次不再像之前的一样使用一个Java
文件来实现,因为这样代码耦合度太高了。这里就拆分为如下一些文件:
所有W
开头的均是本次的文件。至于SingleThreadBreakpointDownloader
、MultiThreadDownLoader
也就是单线程断点下载和多线程文件下载的代码。
对于文件多线程断点下载,这里还是使用线程池来实现,并将它封装到了一个类中,即WMultiThreadDownloaderConfig
:
public class WMultiThreadDownloaderConfig {
private int corePoolSize = Runtime.getRuntime().availableProcessors() + 1;
private int maximumPoolSize = Runtime.getRuntime().availableProcessors() + 1;
public WMultiThreadDownloaderConfig(){
}
public WMultiThreadDownloaderConfig(int corePoolSize, int maximumPoolSize){
this.corePoolSize = corePoolSize;
this.maximumPoolSize = maximumPoolSize;
}
public int getCorePoolSize() {
return corePoolSize;
}
public int getMaximumPoolSize() {
return maximumPoolSize;
}
public ThreadFactory getmThreadFactory() {
return mThreadFactory;
}
private ThreadFactory mThreadFactory = new ThreadFactory() {
private final AtomicInteger mCount = new AtomicInteger(1);
@Override
public Thread newThread(Runnable r) {
return new Thread(r, "Thread#" + mCount.getAndIncrement());
}
};
public Executor getExecutor(){
return new ThreadPoolExecutor(corePoolSize,
maximumPoolSize,
10L,
TimeUnit.SECONDS,
new LinkedBlockingDeque<>(),
mThreadFactory);
}
}
为了方便,这里设置最大线程和核心线程数目一样,都为可用CPU
数目+1。
然后,使用SharedPreferences
来存储每个线程下载了多少数据,即WDownloadSpHelper
:
public class WDownloadSpHelper {
private static final String TAG = "DownloadSpHelper";
private static Context context = null;
private static volatile SharedPreferences preferences = null;
private WDownloadSpHelper(){}
public static SharedPreferences getSharedPreferences(Context c){
if(preferences == null){
synchronized (WDownloadSpHelper.class){
if(preferences == null){
if(null == context && null != c){
preferences = c.getApplicationContext().
getSharedPreferences("WDownload", Context.MODE_PRIVATE);
context = c.getApplicationContext();
}else{
preferences = context.getApplicationContext().
getSharedPreferences("WDownload", Context.MODE_PRIVATE);
}
}
}
}
return preferences;
}
public static void storageDownloadPosition(int index, long pos){
SharedPreferences.Editor edit = preferences.edit();
edit.putLong("" + index, pos);
edit.apply();
}
public static long readDownloadPosition(int index){
return preferences.getLong("" + index, 0);
}
public static void deleteSpFile(){
Log.e(TAG, "正在删除Sharedpreferences文件。");
SharedPreferences.Editor edit = preferences.edit();
edit.clear();
edit.apply();
}
}
对于每个线程,我们需要确定自己所需要下载的数据范围,这里将每个线程需要下载的数据和当前现在的位置封装到WDownLoadFileInfo
:
public class WDownLoadFileInfo {
private String url; // 文件链接
private String cacheDir = "WCache"; // 文件缓存目录
private WFileSuffix suffix; // 文件后缀
private long startPosition, endPosition; // 需要下载的起始位置和结束位置
private long totalSize; // 文件总大小
private long currentPosition; // 当前下载到什么地方
private Context context;
private SharedPreferences preferences;
private int index;
private WDownLoadFileInfo(){}
public WDownLoadFileInfo(Context context, String url,
WFileSuffix suffix, long startPosition,
long endPosition, long totalSize, int index){
this.context = context;
this.url = url;
this.suffix = suffix;
this.startPosition = startPosition;
this.endPosition = endPosition;
this.totalSize = totalSize;
this.currentPosition = 0;
preferences = WDownloadSpHelper.getSharedPreferences(context);
this.index = index;
}
public void setCacheDir(String cacheDir){
this.cacheDir = cacheDir;
}
public String getCacheDir(){
return cacheDir;
}
public void addCurrentPosition(int index, long increment){
// todo 存储当前的线程下载的位置
this.currentPosition += increment;
WDownloadSpHelper.storageDownloadPosition(index, this.currentPosition);
}
public long getCurrentPosition(int index){
// todo 从sharedpreference中读取数据
long position = WDownloadSpHelper.readDownloadPosition(index);
this.currentPosition = position;
return position;
}
public String getUrl() {
return url;
}
public void setUrl(String url) {
this.url = url;
}
public WFileSuffix getSuffix() {
return suffix;
}
public void setSuffix(WFileSuffix suffix) {
this.suffix = suffix;
}
public long getStartPosition() {
return startPosition;
}
public void setStartPosition(long startPosition) {
this.startPosition = startPosition;
}
public long getEndPosition() {
return endPosition;
}
public void setEndPosition(long endPosition) {
this.endPosition = endPosition;
}
public long getTotalSize() {
return totalSize;
}
public void setTotalSize(long totalSize) {
this.totalSize = totalSize;
}
/**
* 获取存储文件的File对象
* @return File
*/
public File getFile(){
File file = buildPath(cacheDir);
String fileName = EncoderUtils.hashKeyFromUrl(this.url) + "." + suffix.getValue();
return new File(file, fileName);
}
/**
* 判断应用缓存目录下是否存在这个cacheDir目录,没有就创建。
* 同时,如果有SD卡,就优先存储在SD卡中。
* @param cacheDir 缓存目录
* @return 缓存目录File对象
*/
private File buildPath(String cacheDir) {
// 是否有SD卡
boolean flag = Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED);
// 如果有SD卡就存在外存,否则就位于这个应用的data/package name/cache目录下
final String cachePath;
if(flag) cachePath = context.getExternalCacheDir().getPath();
else cachePath = context.getCacheDir().getPath();
File directory = new File(cachePath + File.separator + cacheDir);
// 目录不存在就创建
if(!directory.exists()) directory.mkdirs();
return directory;
}
}
然后,定义一个回调接口和对应的一个抽象类的实现,即IWDownLoadListener
和WDownLoadListenerImpl
:
// IWDownLoadListener.java
public interface IWDownLoadListener {
void onSuccess(File file); // 下载成功
void onError(String msg); // 下载失败
void onProgress(long currentPos, long totalLength); // 监听下载进度【外部-用户】
void onListener(long currentPos, long totalLength); // 监听下载进度【内部-代码逻辑】
}
// WDownLoadListenerImpl.java
public abstract class WDownLoadListenerImpl implements IWDownLoadListener {
private static final String TAG = "DownLoadListenerImpl";
@Override
public void onListener(long currentPos, long totalLength) {
// todo 删除sharedpreferences文件
if(currentPos == totalLength){
WDownloadSpHelper.deleteSpFile();
}
onProgress(currentPos, totalLength);
}
}
然后,就可以创建下载线程WDownloadThread
:
public class WDownloadThread extends Thread{
private static final String TAG = "DownloadThread";
private long startPos, endPos, maxFileSize, currentPosition;
private File file;
private String url;
private IWDownLoadListener listener;
private int curIndex;
private WDownLoadFileInfo fileInfo;
private WDownloadThread(){}
public WDownloadThread(WDownLoadFileInfo fileInfo, int index) {
this.startPos = fileInfo.getStartPosition();
this.endPos = fileInfo.getEndPosition();
this.url = fileInfo.getUrl();
this.file = fileInfo.getFile();
this.maxFileSize = fileInfo.getTotalSize();
// currentPosition来自Sp文件中读取的数据大小
this.currentPosition = fileInfo.getCurrentPosition(index);
this.curIndex = index;
this.fileInfo = fileInfo;
}
public void setDownloadListener(IWDownLoadListener listener){
this.listener = listener;
}
@Override
public void run() {
if((startPos + currentPosition) == endPos){
return;
}
// 开始下载,需要重置下Pause字段
WDownloadControl.restart();
HttpURLConnection connection = null;
URL url_c = null;
InputStream inputStream = null;
try{
RandomAccessFile randomAccessFile = new RandomAccessFile(this.file, "rwd");
// 设置写入文件的开始位置
randomAccessFile.seek(this.startPos + this.currentPosition);
url_c = new URL(url);
connection = (HttpURLConnection) url_c.openConnection();
connection.setConnectTimeout(5 * 1000); // 5秒钟超时
connection.setRequestMethod("GET");
connection.setRequestProperty("Charset", "UTF-8");
connection.setRequestProperty("accept", "*/*");
connection.setRequestProperty("Range", "bytes=" + (this.startPos + this.currentPosition) +"-" + endPos);
Log.e(TAG, Thread.currentThread().getName() + "请求数据范围:bytes=" + (this.startPos + this.currentPosition) + "-" + endPos);
inputStream = connection.getInputStream();
if (connection.getResponseCode() == 206) {
byte[] buffer = new byte[1024 * 1024 * 10];
int len = -1;
while ((len = inputStream.read(buffer)) != -1) {
randomAccessFile.write(buffer, 0, len);
// todo 【下载进度】
this.fileInfo.addCurrentPosition(curIndex, len);
WDownloadProgress.addProgressVal(len);
if(null != listener) listener.onListener(WDownloadProgress.getProgressVal(), maxFileSize);
// todo 【Pause】
if(WDownloadControl.isIsPause()){
Log.d(TAG, "Download paused!");
if(connection != null) connection.disconnect();
if(inputStream != null) inputStream.close();
return;
}
}
}
}catch (IOException e){
Log.e(TAG, "Download bitmap failed.", e);
}finally {
try{
if(inputStream != null) inputStream.close();
}catch (IOException e){
e.printStackTrace();
}
if(connection != null) connection.disconnect();
}
}
}
最后就是WDownloadControl
和WDownloadProgress
:
// WDownloadControl.java
public class WDownloadControl {
private WDownloadControl(){}
private static volatile boolean isPause = false;
public static void pause() {
synchronized (WDownloadControl.class){
isPause = true;
}
}
public static void restart(){
synchronized (WDownloadControl.class){
isPause = false;
}
}
public static boolean isIsPause() {
return isPause;
}
}
// WDownloadProgress.java
public class WDownloadProgress {
private static volatile long progressVal = 0;
private WDownloadProgress(){}
public static void resetVal(){
synchronized (WDownloadThread.class){
progressVal = 0;
}
}
public static void addProgressVal(long val) {
synchronized (WDownloadThread.class){
progressVal = progressVal + val;
}
}
public static long getProgressVal(){
return progressVal;
}
public static void init(){
progressVal = 0;
}
}
上面两个类采用了类似的写法,主要为了保证多线程环境下对共享变量的更新同步。
至于WFileSuffix,就是一个文件后缀的枚举类:
public enum WFileSuffix{
EXE("exe"),
ZIP("zip"),
JPEG("jpeg"),
PNG("png"),
GIF("gif"),
MP4("mp4"),
MP3("mp3"),
PDF("pdf");
private String value; // 实际值
WFileSuffix(String value) {
this.value = value;
}
public String getValue(){
return this.value;
}
}
最后就是我们的核心,WMultiThreadBreakpointDownloader
类:
public class WMultiThreadBreakpointDownloader {
private static final String TAG = "MultiThreadBreakpointDownloader";
private String url;
private int connectionTimeout;
private String method = "GET";
private Context context;
private String cachePath = "imgs";
private WFileSuffix suffix;
private long totalLength;
private volatile boolean isPause = false;
private Executor executor;
private int maximumPoolSize;
private WMultiThreadBreakpointDownloader(){}
public WMultiThreadBreakpointDownloader(Context context){
connectionTimeout = 500; // 500毫秒
method = "GET";
this.context = context;
}
public WMultiThreadBreakpointDownloader url(String url){
this.url = url;
return this;
}
public WMultiThreadBreakpointDownloader fileSuffix(WFileSuffix fileSuffix){
this.suffix = fileSuffix;
return this;
}
public WMultiThreadBreakpointDownloader cacheDir(String dir){
this.cachePath = dir;
return this;
}
public WMultiThreadBreakpointDownloader(Builder builder){
this.url = builder.url;
this.connectionTimeout = builder.connectionTimeout;
this.context = builder.context;
this.cachePath = builder.cachePath;
this.suffix = builder.suffix;
WMultiThreadDownloaderConfig config = new WMultiThreadDownloaderConfig();
executor = config.getExecutor();
maximumPoolSize = config.getMaximumPoolSize();
}
public static class Builder {
private String url;
private int connectionTimeout;
private String cachePath = "imgs";
private Context context;
private WFileSuffix suffix;
public Builder(Context context){
this.context = context;
}
public Builder url(String url){
this.url = url;
return this;
}
public Builder timeout(int ms){
this.connectionTimeout = ms;
return this;
}
public Builder suffix(WFileSuffix suffix){
this.suffix = suffix;
return this;
}
public Builder cacheDirName(String cacheDirName){
this.cachePath = cacheDirName;
return this;
}
public WMultiThreadBreakpointDownloader build(){
return new WMultiThreadBreakpointDownloader(this);
}
}
public void setIsPause(boolean isPause){
WDownloadControl.pause();
}
public void download(IWDownLoadListener listener){
new Thread(new Runnable() {
@Override
public void run() {
WDownloadProgress.resetVal(); // 重置进度条
HttpURLConnection connection = null;
File file = null;
try {
URL url1 = new URL(url);
connection = (HttpURLConnection) url1.openConnection();
connection.setConnectTimeout(connectionTimeout);
connection.setRequestMethod(method);
connection.setRequestProperty("Charset", "UTF-8");
connection.setRequestProperty("accept", "*/*");
connection.connect();
// 获取文件总长度
totalLength = connection.getContentLength();
Log.e(TAG, "文件总长度: " + totalLength);
// todo 分为多个线程下载
long step = totalLength / maximumPoolSize;
Log.e(TAG, "每个线程下载的数据量大小为:" + step);
for (int i = 0; i < maximumPoolSize; i++) {
WDownLoadFileInfo info = null;
WDownloadThread downloadThread = null;
if(i != maximumPoolSize - 1) {
info = new WDownLoadFileInfo(context, url, suffix,
i * step, (i + 1) * step - 1, totalLength, i);
}else{
info = new WDownLoadFileInfo(context, url, suffix,
i * step, totalLength, totalLength, i);
}
// todo 更新进度条
WDownloadProgress.addProgressVal(info.getCurrentPosition(i));
if(null != listener) listener.onListener(WDownloadProgress.getProgressVal(), totalLength);
info.setCacheDir(cachePath);
downloadThread = new WDownloadThread(info, i);
downloadThread.setDownloadListener(listener);
executor.execute(downloadThread);
}
}catch (IOException e){
Log.e(TAG, "Download bitmap failed.", e);
if(listener != null) listener.onError(e.getLocalizedMessage());
e.printStackTrace();
}finally {
if(connection != null) connection.disconnect();
}
}
}).start();
}
}
4. 调用示例
布局文件:
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="10dp"
>
<ProgressBar
android:id="@+id/progressbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
style="@android:style/Widget.ProgressBar.Horizontal"
android:progress="0"
/>
<Button
android:id="@+id/start"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="下载"
/>
<Button
android:id="@+id/pause"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="暂停"
/>
</LinearLayout>
也就是一个进度条,两个按钮。
public class ThreeActivity extends AppCompatActivity implements View.OnClickListener {
private Button start, pause;
private ProgressBar progressbar;
private WMultiThreadBreakpointDownloader downloader;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_three);
start = findViewById(R.id.start);
pause = findViewById(R.id.pause);
progressbar = findViewById(R.id.progressbar);
progressbar.setMax(100);
downloader = new WMultiThreadBreakpointDownloader.Builder(this)
.url("http://vjs.zencdn.net/v/oceans.mp4")
.suffix(WFileSuffix.MP4)
.cacheDirName("MP4")
.build();
start.setOnClickListener(this);
pause.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
downloader.setIsPause(true);
}
});
}
@Override
public void onClick(View v) {
downloader.download(new WDownLoadListenerImpl() {
@Override
public void onSuccess(File file) {
runOnUiThread(new Runnable() {
@Override
public void run() {
Toast.makeText(ThreeActivity.this, "Successful!", Toast.LENGTH_SHORT).show();
}
});
}
@Override
public void onError(String msg) {
runOnUiThread(new Runnable() {
@Override
public void run() {
Toast.makeText(ThreeActivity.this, "Error!", Toast.LENGTH_SHORT).show();
}
});
}
@Override
public void onProgress(long currentPos, long totalLength) {
runOnUiThread(new Runnable() {
@Override
public void run() {
int val = (int) (currentPos * 1.0 / totalLength * 100);
progressbar.setProgress(val);
}
});
}
});
}
}
效果:
这里就不录制动态效果的了。点击暂停会暂停,下载则会继续下载。
5. 后记
刚开始使用临时文件来进行单个文件存储。虽然逻辑简单,但是最后需要等待所有线程下载完毕后,再进行这几个文件的合并。也就是需要再次读取一次文件,然后再写入到一个总的文件中。为了完成这个逻辑,使用了CountDownLatch
来实现线程等待,但是最后发现合并其实在我的案例中所用的时间更多,所以其实不适用。所以还是采用RandomAccessFile
文件的特性来实现,并结合使用SharedPreferences
文件来存储每个线程的下载位置即可。
References