Android文件下载——多线程断点下载

1. 前言

    在前面的博客中简单实现了Android单线程断点下载以及Android文件多线程下载,这篇将实现多线程断点下载。对于断点下载,我们知道主要是为了实现不重复下载上次下载过的数据文件内容,而和前面实践的区别在于我们将从单线程环境拓展到多线程环境中。下面简单整理下思路。

项目代码链接:https://github.com/baiyazi/AndroidDownloadUtils,可自取。

2. 设计思路

  • 确定多线程环境下的线程数量;
  • 确定每个线程自己所需要下载的数据范围;
  • 每个线程下载的数据,使用临时文件进行存储,最终进行文件合并。 (测试结果发现合并太耗时了,故而不考虑使用临时文件的方式。)
  • 记录每个线程下载了多少数据,即:记录下载进度;
  • 设计回调接口;

3. 实现逻辑

这一次不再像之前的一样使用一个Java文件来实现,因为这样代码耦合度太高了。这里就拆分为如下一些文件:
在这里插入图片描述

所有W开头的均是本次的文件。至于SingleThreadBreakpointDownloaderMultiThreadDownLoader也就是单线程断点下载和多线程文件下载的代码。

对于文件多线程断点下载,这里还是使用线程池来实现,并将它封装到了一个类中,即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;
    }
}

然后,定义一个回调接口和对应的一个抽象类的实现,即IWDownLoadListenerWDownLoadListenerImpl

扫描二维码关注公众号,回复: 13186003 查看本文章
// 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();
        }
    }
}

最后就是WDownloadControlWDownloadProgress

// 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

猜你喜欢

转载自blog.csdn.net/qq_26460841/article/details/120658272