Android断点续传实践

所谓的断点续传,就是下载/上传过程中被中断,然后下次下载/上传的时候接着上次中断的地方继续下载/上传,直到下载/上传完成。

Range

HTTP头部用于在HTTP请求或响应中传递额外信息。range是HTTP请求头,可用于从服务器获取文档的一部分。如果服务器返回文档的一部分,则响应状态码为206(部分内容)。 如果给定的范围无效,则响应状态码为416(范围无法满足),并且服务器在忽略范围请求的情况下响应状态码为200(OK)。

  • 从给定range开始获取整个文档

      Range: <unit>=<range-start>-
    
  • 请求多部分

      Range: <unit>=<range-start>-<range-end>, <range-start>-<range-end>
    
  • 请求文档的特定结尾部分

      Range: <unit>=-<suffix-length>
    

RandomAccessFile

RandomAccessFile支持读取和写入随机访问文件。随机访问文件的行为类似于存储在文件系统中的字节数组。隐式数组中有游标或索引,称为文件指针;输入操作从文件指针开始读取字节,然后使文件指针前进经过读取的字节。如果随机访问文件是在读/写模式下创建的,则输出操作也可用;输出操作从文件指针处开始写入字节,然后使文件指针前进经过写入的字节。如果写入操作超过隐含数组尾部就会导致数组扩展。文件指针可以通过getFilePointer方法获取,并通过seek方法设置。
如果在读取所需字节数之前已到达文件末尾,则将引发EOFException

单线程下载

单线程下载只需要每次读取已下载文件长度,并作为range的起始位置传到服务端请求数据。

多线程下载

将一个文件分成多个片段,同时下载。需要先分片段,确定每个片段的起始位置和结束位置,然后存放到数据库或者文件中。然后每次下载都需要读取对应片段的起始位置,将起始位置和结束位置传到服务端请求数据。

数据库设计

单线程下载不需要数据库,每次下载只需要读取已下载文件即可。多线程下载需要文件或者数据库来存储片段信息。

  • 断点信息表
列名类型约束描述
idINTEGERPRIMARY KEY序号
urlVARCHARNOT NULL下载链接
etagVARCHAR请求条件,请求头If-Match的值,传到服务器作验证,匹配则允许下载 ,没有值则不需要匹配,直接下载
parent_pathVARCHARNOT NULL文件存储目录
file_nameVARCHARNOT NULL文件名
signatureVARCHAR签名,用于下载完成后验证文件是否合法,如md5校验
  • 片段信息表
列名类型约束描述
idINTEGERPRIMARY KEY AUTOINCREMENT序号
breakpoint_idINTEGERNOT NULL断点信息表中的序号
block_indexINTEGER片段索引,文件分片以后记录第几片
start_positionINTEGER片段在文件中的开始位置
block_lengthINTEGER片段长度
current_positionINTEGER该片段已下载长度

IO优化

可以采用NIO的方式来替代RandomAccessFile读写。首先根据文件File生成一个FileOutputStream对象,然后调用FileOutputStream#getChannel方法获取到FileChannel,FileChannel#position方法用于定位数据追加位置,类似于RandomAccessFile#seek方法,最后用FileOutputStream对象生成BufferedOutputStream来进行写入。

实践

DownloadManager
public class DownloadManager {

    private static volatile DownloadManager sInstance;

    private Context context;

    final Dispatcher dispatcher;

    final OkHttpClient client;

    private DownloadStore<DownloadInfo> store;

    public static DownloadManager getInstance() {
        if (sInstance == null) {
            synchronized (DownloadManager.class) {
                if (sInstance == null) {
                    sInstance = new DownloadManager();
                }
            }
        }
        return sInstance;
    }

    public void init(Context context) {
        this.context = context.getApplicationContext();
    }

    public void init(Context context, DownloadStore store) {
        this.context = context.getApplicationContext();
        this.store = store;
    }

    private DownloadManager() {
        dispatcher = new Dispatcher();
        client = new OkHttpClient.Builder()
                .connectTimeout(2, TimeUnit.MINUTES)
                .writeTimeout(20, TimeUnit.SECONDS)
                .readTimeout(20, TimeUnit.SECONDS)
                .build();
        client.dispatcher().setMaxRequests(20);
        client.dispatcher().setMaxRequestsPerHost(20);
    }

    public void start(DownloadTask task) {
        dispatcher.enqueue(task);
    }

    public void cancel(@NonNull DownloadTask task) {
        dispatcher.cancel(task);
    }

    public void cancelAll() {
        dispatcher.cancelAll();
    }

    Context getContext() {
        return context;
    }

    String getDownloadFileDir() {
        return Environment.getExternalStorageDirectory() + File.separator + "download";
    }
}
DownloadTask
public class DownloadTask implements Runnable {

    public static final String RANGE = "Range";

    public static final int DEFAULT_READ_BUFFER_SIZE = 4096;
    public static final int DEFAULT_FLUSH_BUFFER_SIZE = 16384;

    private volatile String taskId;
    private String url;
    private String fileName;
    private String signature;
    private DownloadListener listener;
    private String range;
    private long existFileLength;

    private boolean executed = false;
    private boolean canceled = false;

    private DownloadOutputStream output;

    private DownloadTask(String taskId, String url, String fileName, String range, String signature, DownloadListener listener) {
        this.taskId = taskId;
        this.url = url;
        this.fileName = fileName;
        this.range = range;
        this.signature = signature;
        this.listener = listener;
    }

    @Override
    public void run() {
        synchronized (this) {
            if (executed) throw new IllegalStateException("The task already executed");
            executed = true;
        }

        if (listener != null) {
            listener.onDownloadStart(this);
        }

        if (TextUtils.isEmpty(url)) {
            downloadError(new IllegalArgumentException("The url is empty"));
            DownloadManager.getInstance().dispatcher.finished(this, true);
            return;
        }

        if (canceled) {
            if (listener != null) {
                listener.onDownloadCanceled(this);
            }
            DownloadManager.getInstance().dispatcher.finished(this, true);
            return;
        }

        DownloadConnection connection = null;
        try {
            connection = callAndSave();
        } catch (Exception e) {
            if (listener != null) {
                listener.onDownloadError(this, e);
            }
        } finally {
            if (output != null) {
                try {
                    output.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
            if (connection != null) {
                connection.release();
            }

            DownloadManager.getInstance().dispatcher.finished(this, true);
        }
    }

    private DownloadConnection callAndSave() throws Exception {
        File dir = new File(DownloadManager.getInstance().getDownloadFileDir());
        if (!dir.exists()) {
            dir.mkdirs();
        }

        File file = new File(dir, fileName);
        if (!file.exists()) {
            //不存在则创建
            file.createNewFile();
        } else if (!TextUtils.isEmpty(signature) && checkFileValid(file)) {
            //已经下载过
            downloadComplete(file);
            return null;
        } else {
            existFileLength = file.length();
        }

        Log.d("DownloadTask", "The exist file length is " + existFileLength);

        DownloadConnection connection = new DownloadConnection(DownloadManager.getInstance().client, url);
        if (!TextUtils.isEmpty(range)) {
            connection.addHeader(RANGE, "bytes=" + this.range);
        } else {
            connection.addHeader(RANGE, "bytes=" + this.existFileLength + "-");
        }
        connection.execute();

        final long contentLength = connection.contentLength();
        final long totalSize = contentLength + existFileLength;

        if (contentLength == 0) {
            downloadComplete(file);
            return connection;
        }

        output = new DownloadOutputStream(file, DEFAULT_FLUSH_BUFFER_SIZE);
        output.seek(existFileLength);

        int readLength = 0;
        int progress = 0, lastProgress = 0;
        byte[] buf = new byte[DEFAULT_READ_BUFFER_SIZE];
        InputStream is = connection.inputStream();
        while (!canceled && (readLength = is.read(buf)) != -1) {
            output.write(buf, 0, readLength);
            if (listener != null) {
                existFileLength += readLength;
                lastProgress = progress;
                progress = (int) (existFileLength * 100 / totalSize);
                if (progress > 0 && progress >= lastProgress) {
                    listener.onDownloading(this, progress);
                }
            }
        }

        if (!canceled) {
            output.flushAndSync();
            output.setLength(existFileLength);
            if (!TextUtils.isEmpty(signature)) {
                if (!checkFileValid(file)) {
                    file.delete();
                    downloadError(new Exception("File md5 check failed"));
                } else {
                    downloadComplete(file);
                }
            } else {
                downloadComplete(file);
            }
        } else if (listener != null) {
            listener.onDownloadCanceled(this);
        }

        return connection;
    }

    public void cancel() {
        canceled = true;
    }

    public void delete() {
        try {
            File dir = new File(DownloadManager.getInstance().getDownloadFileDir());
            if (!dir.exists()) return;

            File file = new File(dir, fileName);
            if (file.exists()) {
                file.delete();
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    /**
     * MD5校验
     *
     * @param file
     * @return
     */
    private boolean checkFileValid(File file) {
        final String downloadFileMd5 = Utils.getMD5(file, 32);
        return !TextUtils.isEmpty(downloadFileMd5) && downloadFileMd5.equalsIgnoreCase(signature.toLowerCase());
    }

    public String getId() {
        return taskId;
    }

    private void downloadComplete(File file) {
        if (listener != null) {
            listener.onDownloadComplete(this, file.getPath());
        }
    }

    private void downloadError(Throwable e) {
        if (listener != null) {
            listener.onDownloadError(this, e);
        }
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        DownloadTask task = (DownloadTask) o;
        return ObjectsCompat.equals(taskId, task.taskId) &&
                ObjectsCompat.equals(url, task.url);
    }

    @Override
    public int hashCode() {
        return ObjectsCompat.hash(taskId, url);
    }

    public static class Builder {
        private String url;
        private String fileName;
        private String signature;
        private DownloadListener listener;
        private String range;

        public Builder url(@NonNull String url) {
            this.url = url;
            return this;
        }

        public Builder fileName(String fileName) {
            this.fileName = fileName;
            return this;
        }

        public Builder listen(DownloadListener listener) {
            this.listener = listener;
            return this;
        }

        public Builder setRange(String range) {
            this.range = range;
            return this;
        }

        public Builder setSignature(String signature) {
            this.signature = signature;
            return this;
        }

        public DownloadTask build() {
            final String taskId = url != null && url.length() > 0 ? String.valueOf(url.hashCode()) : null;
            return new DownloadTask(taskId, url, fileName, range, signature, listener);
        }
    }
}

感谢大家的支持,如有错误请指正,如需转载请标明原文出处!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值