RxDownload2 源码解析(三)

922 阅读5分钟

源码解析,如需转载,请注明作者:Yuloran (t.cn/EGU6c76)

前言

造轮子者:Season_zlc

本文主要讲述 RxDownload2 的多线程断点下载技术

断点下载技术前提

服务器必须支持按 byte-range 下载,也就是支持 Range: bytes=xxx-xxx 请求头。详见 Http 协议 rfc2616 - Range

下载范围分割

很简单,先读取 Content-Length 响应头,获取文件大小,然后用文件大小除以线程数就可计算出每条线程的下载范围。

比如,假设文件大小是 100 bytes,下载线程数为 3。因为 100 / 3 = 33,所以:

  • 线程 0 的下载范围是 0 ~32[0 * 33 ~ (0 + 1) * 33 - 1]
  • 线程 1 的下载范围是 33~65[1 * 33 ~ (1 + 1) * 33 - 1]
  • 线程 2 的下载范围是 66~99[2 * 33 ~ 100 - 1]

上代码:

  1. prepareDownload() [-> FileHelper.java]
    public void prepareDownload(File lastModifyFile, File tempFile, File saveFile,
                                long fileLength, String lastModify)
            throws IOException, ParseException {
        // 将响应头中的上次修改时间转为 long 类型的 unix 时间戳,然后保存到文件中
        writeLastModify(lastModifyFile, lastModify);
        // 设置下载文件的大小、计算每条线程的下载范围并保存到 tempFile 中
        prepareFile(tempFile, saveFile, fileLength);
    }
  1. prepareFile() [-> FileHelper.java]
    private void prepareFile(File tempFile, File saveFile, long fileLength)
            throws IOException {
        RandomAccessFile rFile = null;
        RandomAccessFile rRecord = null;
        FileChannel channel = null;
        try {
            rFile = new RandomAccessFile(saveFile, ACCESS);
            rFile.setLength(fileLength);//设置下载文件的长度 

            rRecord = new RandomAccessFile(tempFile, ACCESS);
            // 下载范围在文件中的记录方式:|start|end|start|end|start|end|...
            // 数据类型是 long,long类型在 java 中占 8 个字节,所以每个线程的下载范围都占 16 字节
            // 所以 tempFile 的长度 RECORD_FILE_TOTAL_SIZE = 16 * 线程数
            rRecord.setLength(RECORD_FILE_TOTAL_SIZE); //设置指针记录文件的大小

            // NIO 内存映射文件的方式读写二进制文件,速度更快
            channel = rRecord.getChannel();
            // 注意映射方式为读写
            MappedByteBuffer buffer = channel.map(READ_WRITE, 0, RECORD_FILE_TOTAL_SIZE);

            long start;
            long end;
            // 计算并保存每条线程的下载范围,计算方法同上面举的例子
            int eachSize = (int) (fileLength / maxThreads);
            for (int i = 0; i < maxThreads; i++) {
                if (i == maxThreads - 1) {
                    start = i * eachSize;
                    end = fileLength - 1;
                } else {
                    start = i * eachSize;
                    end = (i + 1) * eachSize - 1;
                }
                buffer.putLong(start);
                buffer.putLong(end);
            }
        } finally {
            closeQuietly(channel);
            closeQuietly(rRecord);
            closeQuietly(rFile);
        }
    }

读取下载范围

很简单,上面已经将每条线程的下载范围保存到了 tempFile 中,只要再从 tempFile 中按位置读出来就行了。

  1. readDownloadRange() [-> FileHelper.java]
    public DownloadRange readDownloadRange(File tempFile, int i) throws IOException {
        RandomAccessFile record = null;
        FileChannel channel = null;
        try {
            // 入参 i 表示线程序号
            record = new RandomAccessFile(tempFile, ACCESS);
            channel = record.getChannel();
            MappedByteBuffer buffer = channel
                    .map(READ_WRITE, i * EACH_RECORD_SIZE, (i + 1) * EACH_RECORD_SIZE);
            long startByte = buffer.getLong();
            long endByte = buffer.getLong();
            return new DownloadRange(startByte, endByte);
        } finally {
            closeQuietly(channel);
            closeQuietly(record);
        }
    }

注意 MappedByteBuffer buffer = channel.map(READ_WRITE, i * EACH_RECORD_SIZE, (i + 1) * EACH_RECORD_SIZE); 这句代码是有坑的,但是表现不出来,因为这里的文件打开方式为 READ_WRITE。要是改成 READ_ONLY 就有导致读取最后一条线程的下载范围时抛出IllegalArgumentException(代码静态检查工具 Fortify 提示要以合适的权限打开文件,我将其改为了 READ_ONLY ,发现了这一问题)。

错误原因:map() 方法的最后一个参数表示要映射的字节数,以只读方式打开时,若参数大小超过了文件剩余可读字节数,就会抛出 IllegalArgumentException。而以读写方式打开文件时,会自动扩展文件长度,所以不会抛出异常。

因为每段下载范围的长度都是 EACH_RECORD_SIZE = 16 bytes,所以,上述代码应修改为: MappedByteBuffer buffer = channel.map(READ_WRITE, i * EACH_RECORD_SIZE, EACH_RECORD_SIZE);

Intellij IDEA 示例代码

自己写了个示例代码,测试了一下:

        RandomAccessFile file = new RandomAccessFile("temp.txt", "rw");
        file.setLength(48);
        FileChannel channel = file.getChannel();
        MappedByteBuffer buffer = channel.map(FileChannel.MapMode.READ_WRITE, 0, 48);
        for (int i = 0; i < 3; i++) {
            if (i == 2) {
                buffer.putLong(i * 33).putLong(99);
            } else {
                buffer.putLong(i * 33).putLong((i + 1) * 33 - 1);
            }
        }
        channel.close();

        RandomAccessFile file1 = new RandomAccessFile("temp.txt", "r");
        FileChannel channel1 = file1.getChannel();
        for (int i = 0; i < 3; i++) {
            MappedByteBuffer buffer1 = channel1.map(FileChannel.MapMode.READ_ONLY, i * 16, 16);
            System.out.println(String.format("long1: %d", buffer1.getLong()));
            System.out.println(String.format("long2: %d", buffer1.getLong()));
        }
        channel1.close();

Notepad++ 装个十六进制查看器,查看生成的 temp.txt 中的内容是否和我们代码写的一样:

temp.txt view in HEX

上面是十六进制,换算成十进制就是上面示例代码写的内容。

写下载文件

很简单,利用 RandomAccessFile 可从任意位置读写的属性,分别将每条线程下载的数据写到同一个文件的不同位置。

  1. saveFile() [-> FileHelper.java]
    public void saveFile(FlowableEmitter<DownloadStatus> emitter, int i, File tempFile,
                         File saveFile, ResponseBody response) {

        RandomAccessFile record = null;
        FileChannel recordChannel = null;
        RandomAccessFile save = null;
        FileChannel saveChannel = null;
        InputStream inStream = null;
        try {
            try {
                // 1.映射 tempFile 到内存中
                record = new RandomAccessFile(tempFile, ACCESS);
                recordChannel = record.getChannel();
                MappedByteBuffer recordBuffer = recordChannel
                        .map(READ_WRITE, 0, RECORD_FILE_TOTAL_SIZE);

                // i 代表线程序号,startIndex 代表该线程下载范围的 start 字段在文件中的指针位置
                int startIndex = i * EACH_RECORD_SIZE;
                // start 表示该线程的起始下载位置
                long start = recordBuffer.getLong(startIndex);

                 // 新建一个下载状态对象,用于发射下载进度
                DownloadStatus status = new DownloadStatus();
                // totalSize 代表文件总大小,也可以从 saveFile 中读出
                long totalSize = recordBuffer.getLong(RECORD_FILE_TOTAL_SIZE - 8) + 1;
                status.setTotalSize(totalSize);

                int readLen;
                byte[] buffer = new byte[2048];
                inStream = response.byteStream();

                save = new RandomAccessFile(saveFile, ACCESS);
                saveChannel = save.getChannel();

                while ((readLen = inStream.read(buffer)) != -1 && !emitter.isCancelled()) {
                    MappedByteBuffer saveBuffer = saveChannel.map(READ_WRITE, start, readLen);
                    saveBuffer.put(buffer, 0, readLen);

                    // 成功下载一段数据后,将已下载位置写回 start 字段
                    start += readLen;
                    recordBuffer.putLong(startIndex, start);

                    // 计算已下载字节数 = 文件长度 - 每条线程剩余未下载字节数
                    status.setDownloadSize(totalSize - getResidue(recordBuffer));
                    // 发射下载进度
                    emitter.onNext(status);
                }
                // 发射下载完成
                emitter.onComplete();
            } finally {
                closeQuietly(record);
                closeQuietly(recordChannel);
                closeQuietly(save);
                closeQuietly(saveChannel);
                closeQuietly(inStream);
                closeQuietly(response);
            }
        } catch (IOException e) {
            emitter.onError(e);
        }
    }

总结

  • 下载流程就不分析了,只要熟练使用下图所示两个快捷键,什么源码分析都是手到擒来:

  • RxDownload2 源码解析系列至此结束,虽然框架比较简单,但是还是有很多值得学习的东西。尤其是作者对 RxJava2 的使用,可以说非常之六了。他写的十篇 Rxjava2 教程也非常的通俗易懂,感兴趣的可以看一看。

RxDownload2 系列文章: