4. OkDownload 源码分析————DownloadChain 流程分析

259 阅读14分钟

Author: istyras
Date: 2024-10-11
Update: 2024-10-11


本文是对 OkDownload源码分析 的第三篇。

上一篇分析,我们知道了 DownloadCall 最后会转到 DownloadChain 调用执行。


1. 真正开始下载任务的下载执行链管理器,针对单个block的下载; 2. 这里组装执行链:连接的执行链,获取数据的执行链; 3. 然后通过run方法启动。


DownloadChain 流程分析

`DownloanChain` 是一个 `Runnable` ,所以它启动的方法为 `run` 。


    /**
     * 执行当前链的任务
     * 如果链已经完成,则抛出异常
     * 设置当前线程为正在执行的线程
     * 尝试启动任务,如果发生IOException,则中断执行并忽略异常
     * 无论是否发生异常,都标记链为已完成,并异步释放连接
     */
    @Override
    public void run() {
        // 检查链是否已经完成,如果已完成,则抛出异常
        if (isFinished()) {
            throw new IllegalAccessError("The chain has been finished!");
        }
        // 设置当前执行线程
        this.currentThread = Thread.currentThread();

        try {
            // 尝试启动任务
            start();
        } catch (IOException ignored) {
            // 如果发生IOException,中断执行并忽略异常
        } finally {
            // 标记链为已完成
            finished.set(true);
            // 异步释放连接
            releaseConnectionAsync();
        }
    }
run 方法内调用了 start 方法。

    /**
     * 开始下载任务
     * 该方法负责初始化下载过程中的各个拦截器,并处理连接和获取数据的逻辑
     * 它通过CallbackDispatcher来通知下载过程中的各种事件
     *
     * @throws IOException 如果在下载过程中发生I/O错误
     * @throws InterruptException 如果下载过程中收到中断信号
     */
    void start() throws IOException, InterruptException {
        // 获取回调调度器实例
        final CallbackDispatcher dispatcher = OkDownload.with().callbackDispatcher();

        // 创建并添加重试拦截器和断点拦截器到连接拦截器列表
        final RetryInterceptor retryInterceptor = new RetryInterceptor();
        final BreakpointInterceptor breakpointInterceptor = new BreakpointInterceptor();
        connectInterceptorList.add(retryInterceptor);
        connectInterceptorList.add(breakpointInterceptor);
        // 添加头信息拦截器和调用服务器拦截器到连接拦截器列表
        connectInterceptorList.add(new HeaderInterceptor());
        connectInterceptorList.add(new CallServerInterceptor());

        // 重置连接索引
        connectIndex = 0;
        // 处理连接逻辑并获取连接状态
        final DownloadConnection.Connected connected = processConnect();
        // 如果缓存中断标志为真,则抛出中断异常
        if (cache.isInterrupt()) {
            throw InterruptException.SIGNAL;
        }

        // 通知调度器开始获取数据,包括任务信息、块索引和响应内容长度
        dispatcher.dispatch().fetchStart(task, blockIndex, getResponseContentLength());

        // 创建并添加数据获取拦截器到获取拦截器列表
        final FetchDataInterceptor fetchDataInterceptor =
                new FetchDataInterceptor(blockIndex, connected.getInputStream(),
                        getOutputStream(), task);
        fetchInterceptorList.add(retryInterceptor);
        fetchInterceptorList.add(breakpointInterceptor);
        fetchInterceptorList.add(fetchDataInterceptor);

        // 重置获取索引
        fetchIndex = 0;
        // 处理获取数据逻辑并获取总共获取的字节数
        final long totalFetchedBytes = processFetch();
        // 通知调度器获取数据结束,包括任务信息、块索引和总共获取的字节数
        dispatcher.dispatch().fetchEnd(task, blockIndex, totalFetchedBytes);
    }

对于 start 方法,我们分两步来分析:连接部分 和 获取数据部分。


1.1、start 方法的连接部分

这里的 connectInterceptorList 维护了一个 Interceptor.Connect 的列表,从 start 接口的方法代码实现中可以看到,其添加了4个连接拦截器,分别为:RetryInterceptorBreakpointInterceptorHeaderInterceptorCallServerInterceptor。现在先分析一下这四个连接拦截器的工作流程。



1.1.1、首先,连接拦截器的启动是在 processConnect() 方法中开始的,代码如下:

image.png

这里我们先不考虑中断的情况,按照正常的下载流程,需要一直到完成的流程来分析。

start 方法中,connectIndex = 0,所以,这里从 connectInterceptorList 从第一个连接拦截器开始。


1.1.2、RetryInterceptor 分析

    /**
     * 拦截下载连接,处理连接过程中的重试和异常捕获
     * 此方法主要用于在进行下载连接时,通过缓存机制和异常处理,确保连接的稳定性和可靠性
     *
     * @param chain 下载链,包含下载过程中的所有步骤和数据
     * @return 返回处理连接的结果
     * @throws IOException 如果连接过程中发生无法重试的IO异常,则抛出此异常
     */
    @NonNull @Override
    public DownloadConnection.Connected interceptConnect(DownloadChain chain) throws IOException {
        // 获取下载链中的缓存对象
        final DownloadCache cache = chain.getCache();

        while (true) {
            try {
                // 检查是否中断下载操作
                if (cache.isInterrupt()) {
                    throw InterruptException.SIGNAL;
                }
                // 成功处理连接并返回连接结果,这里下载链的下一个任务是 BreakpointInterceptor ,
                // 在其 interceptConnect 内中处理断点续传,并返回结果,同时会抛出一个 RetryException 异常
                return chain.processConnect();
            } catch (IOException e) {
                // 处理IO异常,对于重试异常进行特殊处理
                if (e instanceof RetryException) {
                    // 重置连接状态,准备重新尝试连接
                    chain.resetConnectForRetry();
                    continue;
                }

                // 捕获其他异常,进行记录或处理
                chain.getCache().catchException(e);
                // 如果输出流在连接时遇到阻塞异常,进行处理
                chain.getOutputStream().catchBlockConnectException(chain.getBlockIndex());
                // 抛出异常,终止循环
                throw e;
            }
        }
    }

这里,不考虑异常的情况下,就直接执行下一个拦截器 return chain.processConnect();


1.1.3、BreakpointInterceptor 分析

因为内部要支持断点续传功能,所以需要对请求的头部信息进行加工,加上相关的头部信息,用来诊断指定的资源能否支持断点续传以及分块下载的能力。所以 BreakpointInterceptor 处理的就是这个逻辑。

    /**
     * 拦截连接下载过程
     *
     * 此方法主要用于处理连接下载过程中的拦截操作,检查是否需要从中断处继续下载,或者重试下载任务
     * 它通过检查资源的块数和完整性来决定如何处理当前的下载任务
     *
     * @param chain 下载链表,包含下载所需的所有信息和当前状态
     * @return 返回连接下载的状态信息
     * @throws IOException 如果存储更新失败或出现其他I/O错误,抛出此异常
     */
    @NonNull @Override
    public DownloadConnection.Connected interceptConnect(DownloadChain chain) throws IOException {
        // 处理连接下载,下一个拦截器是 HeaderInterceptor ,
        // 而这个拦截器没有多余的处理
        final DownloadConnection.Connected connected = chain.processConnect();
        // 获取下载信息
        final BreakpointInfo info = chain.getInfo();

        // 如果缓存被中断,抛出中断异常
        if (chain.getCache().isInterrupt()) {
            throw InterruptException.SIGNAL;
        }

        // 如果资源只有一个块且不是分块下载,则进行特殊处理
        if (info.getBlockCount() == 1 && !info.isChunked()) {
            // 获取准确的内容长度范围,因为是只有一个下载块,所以从连接返回的响应头信息中获取的是全内容长度
            final long blockInstanceLength = getExactContentLengthRangeFrom0(connected);
            // 获取总的实例长度,这个长度是前面ConnectTrail中获取到的,所以是准确的
            final long infoInstanceLength = info.getTotalLength();
            // 如果长度不一致,则进行重置block信息,因为 ConnectTrail 的获取与真正开始请求得到的响应信息不一致
            // 这里的长度不一致表示后端的资源发生了变化
            if (blockInstanceLength > 0 && blockInstanceLength != infoInstanceLength) {
                Util.d(TAG, "SingleBlock special check: the response instance-length["
                        + blockInstanceLength + "] isn't equal to the instance length from trial-"
                        + "connection[" + infoInstanceLength + "]");
                // 获取唯一的块信息
                final BlockInfo blockInfo = info.getBlock(0);
                // 判断是否从断点开始下载
                boolean isFromBreakpoint = blockInfo.getRangeLeft() != 0;

                // 创建新的块信息
                final BlockInfo newBlockInfo = new BlockInfo(0, blockInstanceLength);
                // 重置并添加新的块信息
                info.resetBlockInfos();
                info.addBlock(newBlockInfo);

                // 如果是断点下载,抛出重试异常,因为没有办法继续下载,只能抛出重试异常,从头开始下载
                if (isFromBreakpoint) {
                    final String msg = "Discard breakpoint because of on this special case, we have"
                            + " to download from beginning";
                    Util.w(TAG, msg);
                    throw new RetryException(msg);
                }
                // 通知从头开始下载
                OkDownload.with().callbackDispatcher().dispatch()
                        .downloadFromBeginning(chain.getTask(), info, CONTENT_LENGTH_CHANGED);
            }
        }

        // 更新存储信息
        final DownloadStore store = chain.getDownloadStore();
        try {
            if (!store.update(info)) {
                throw new IOException("Update store failed!");
            }
        } catch (Exception e) {
            throw new IOException("Update store failed!", e);
        }

        // 返回连接状态
        return connected;
    }

这个断点续传的拦截器中,连接方法开始的地方就执行 chain.processConnect(); ,所以这里继续调用下一个拦截器了。

这里,在等到 chain.processConnect(); 返回结果后,会解析连接请求的响应头不信息,然后


1.1.4、HeaderInterceptor 分析

    /**
     * 对连接头部进行处理的拦截器,用于给下载请求连接的header信息做处理
     *
     * @param chain 下载链,包含下载任务的相关信息和当前的下载连接
     * @return 返回一个DownloadConnection.Connected对象,表示已连接到服务器
     * @throws IOException 如果在连接过程中发生错误,例如未找到区块信息
     */
    @NonNull
    @Override
    public DownloadConnection.Connected interceptConnect(DownloadChain chain) throws IOException {
        // 获取下载链中的基本信息
        final BreakpointInfo info = chain.getInfo();
        // 在这里开始创建一个下载连接对象,如果连接对象为空,则创建一个连接对象
        final DownloadConnection connection = chain.getConnectionOrCreate();
        final DownloadTask task = chain.getTask();

        // 添加用户自定义的请求头
        final Map<String, List<String>> userHeader = task.getHeaderMapFields();
        // 添加用户自定义的请求头字段,内部会校验用户是否有添加 If-Match 和 Range 的头部信息,
        // 这两个信息,只能有内部添加,外部不允许用户自定义添加
        if (userHeader != null) Util.addUserRequestHeaderField(userHeader, connection);
        // 如果没有自定义用户代理或用户代理不包含USER_AGENT,则添加默认的用户代理
        if (userHeader == null || !userHeader.containsKey(USER_AGENT)) {
            Util.addDefaultUserAgent(connection);
        }

        // 添加范围请求头,用于指定下载的字节范围
        final int blockIndex = chain.getBlockIndex();
        final BlockInfo blockInfo = info.getBlock(blockIndex);
        if (blockInfo == null) {
            throw new IOException("No block-info found on " + blockIndex);
        }

        String range = "bytes=" + blockInfo.getRangeLeft() + "-";
        range += blockInfo.getRangeRight();

        connection.addHeader(RANGE, range);
        // 记录调试信息,关于范围请求的详细信息
        Util.d(TAG, "AssembleHeaderRange (" + task.getId() + ") block(" + blockIndex + ") "
                + "downloadFrom(" + blockInfo.getRangeLeft() + ") currentOffset("
                + blockInfo.getCurrentOffset() + ")");

        // 如果存在ETag,则添加到请求头中
        final String etag = info.getEtag();
        if (!Util.isEmpty(etag)) {
            connection.addHeader(IF_MATCH, etag);
        }

        // 检查是否中断下载
        if (chain.getCache().isInterrupt()) {
            throw InterruptException.SIGNAL;
        }

        // 通知监听器连接开始
        OkDownload.with().callbackDispatcher().dispatch()
                .connectStart(task, blockIndex, connection.getRequestProperties());

        // 处理连接过程,传递到下一个连接拦截器上,这里会等,等到所有的拦截器都处理完毕了才会回来
        // 也就是连接完成了,下面可以拿到连接的返回
        DownloadConnection.Connected connected = chain.processConnect();

        // 再次检查是否中断下载
        if (chain.getCache().isInterrupt()) {
            throw InterruptException.SIGNAL;
        }

        // 获取响应头字段
        Map<String, List<String>> responseHeaderFields = connected.getResponseHeaderFields();
        if (responseHeaderFields == null) responseHeaderFields = new HashMap<>();

        // 连接返回后,通知监听器连接结束
        OkDownload.with().callbackDispatcher().dispatch().connectEnd(task, blockIndex,
                connected.getResponseCode(), responseHeaderFields);

        // 检查预设条件是否失败
        final DownloadStrategy strategy = OkDownload.with().downloadStrategy();
        final DownloadStrategy.ResumeAvailableResponseCheck responseCheck =
                strategy.resumeAvailableResponseCheck(connected, blockIndex, info);
        // 这里会throw ResumeFailedException 和 ServerCanceledException 异常
        // 当连接请求的header信息服务端不能满足预设条件,抛出异常 ResumeFailedException
        responseCheck.inspect();

        // 计算内容长度
        final long contentLength;
        final String contentLengthField = connected.getResponseHeaderField(CONTENT_LENGTH);
        if (contentLengthField == null || contentLengthField.length() == 0) {
            final String contentRangeField = connected.getResponseHeaderField(CONTENT_RANGE);
            contentLength = Util.parseContentLengthFromContentRange(contentRangeField);
        } else {
            contentLength = Util.parseContentLength(contentLengthField);
        }

        // 设置响应内容长度并返回连接结果
        chain.setResponseContentLength(contentLength);
        return connected;
    }

HeaderInterceptor 处理的工作为:

  1. 开始创建真正的连接对象 chain.getConnectionOrCreate();
  2. 给连接请求的header进行外部添加额外头部信息的校验;
  3. 添加断点续传需要的特定 Range 信息;
  4. 继续传递给下一个连接拦截器 chain.processConnect();,然后等待返回;
  5. 得到连接返回后,对连接返回的响应头信息做提取与分析判断,判断该资源能否正常访问、能否支持分开请求;
  6. 可以先看一下 CallServerInterceptor 的处理逻辑;
  7. CallServerInterceptor 的处理逻辑看,请求完成后的返回相应数据就return回来了;
  8. 收到连接返回后,这里就继续下面的逻辑,对返回的响应信息进行相关的校验;
  9. 然后return后给上层。

1.1.5、CallServerInterceptor 分析

    @NonNull @Override
    public DownloadConnection.Connected interceptConnect(DownloadChain chain) throws IOException {
        OkDownload.with().downloadStrategy().inspectNetworkOnWifi(chain.getTask());
        OkDownload.with().downloadStrategy().inspectNetworkAvailable();

        return chain.getConnectionOrCreate().execute();
    }

CallServerInterceptor 这里处理网络可用性判断,然后就执行实际的网络请求,将请求的返回给上层。


1.1.6、DownloadChain#getConnectionOrCreate 分析

    /**
     * 获取下载连接,如果不存在则创建新的连接
     * 此方法是线程安全的,确保在多线程环境下不会创建多个连接
     * 
     * @return 返回现有的或新创建的下载连接
     * @throws IOException 如果创建连接过程中发生I/O错误
     */
    @NonNull public synchronized DownloadConnection getConnectionOrCreate() throws IOException {
        // 检查是否中断,如果是,抛出中断异常
        if (cache.isInterrupt()) throw InterruptException.SIGNAL;

        // 如果连接为空,表示需要创建新的连接
        if (connection == null) {
            // 定义URL变量,用于创建连接
            final String url;
            // 检查是否存在重定向位置,如果有,使用重定向后的URL
            final String redirectLocation = cache.getRedirectLocation();
            if (redirectLocation != null) {
                url = redirectLocation;
            } else {
                // 如果没有重定向,使用原始的URL
                url = info.getUrl();
            }

            // 打印日志,记录创建连接的URL
            Util.d(TAG, "create connection on url: " + url);

            // 创建新的下载连接
            connection = OkDownload.with().connectionFactory().create(url);
        }
        // 返回现有的或新创建的连接
        return connection;
    }


到此为止,start 方法的连接部分就分析完成了,下面我们分析 start 方法的获取数据部分。



1.2、start 方法的获取数据部分

获取数据部分中 fetchInterceptorList 维护了一个 Interceptor.Fetch 拉取数据拦截器的列表,从 start 方法看到,该列表添加了三个拦截器:RetryInterceptorBreakpointInterceptorFetchDataInterceptor

image.png

现在分析一下这3个连接拦截器的工作流程。



1.2.1、首先,获取数据拦截器的启动在 processFetch(); 方法中开始的,代码如下:

image.png

这里我们先不考虑中断的情况,按照正常的下载流程,需要一直到完成的流程来分析。

start 方法中,fetchIndex = 0,所以,这里从 fetchInterceptorList 从第一个获取数据拦截器开始。



1.2.2、RetryInterceptor 分析

RetryInterceptorinterceptFech 没有对数据获取做多余的处理,只是负责catch异常,然后直接就传递到下一个拦截器上。

image.png



1.2.2、BreakpointInterceptor 分析

    /**
     * 重写拦截下载方法,用于处理下载过程中的数据拦截和校验
     *
     * @param chain 下载链式调用对象,包含下载所需的所有信息和控制方法
     * @return 返回本次下载拦截过程中已获取的数据长度
     * @throws IOException 当下载数据长度与响应内容长度不一致时抛出此异常
     */
    @Override
    public long interceptFetch(DownloadChain chain) throws IOException {
        // 获取响应内容的总长度
        // 在 HeaderInterceptor 的 interceptConnect 中进行设置的
        // 设置响应内容长度并返回连接结果
        // chain.setResponseContentLength(contentLength);
        final long contentLength = chain.getResponseContentLength();
        // 获取当前处理的数据块索引
        final int blockIndex = chain.getBlockIndex();
        // 判断内容长度是否为非分块传输编码,即内容长度是否确定
        final boolean isNotChunked = contentLength != CHUNKED_CONTENT_LENGTH;

        // 初始化已获取的数据长度,对下面的while中得到的processFetchLength进行累加
        long fetchLength = 0;
        // 初始化本次处理获取的数据长度
        long processFetchLength;

        // 获取多点输出流对象,用于写入下载的数据块
        final MultiPointOutputStream outputStream = chain.getOutputStream();

        try {
            // 循环获取数据,直到没有更多数据可获取
            while (true) {
                // 执行一次数据获取操作,
                // 这个地方需要特别注意,loopFetch方法内部的操作以及当前这个方法是怎么进来的,
                // 以及进来的时候对fetchIndex做了++的操作,即已经指向了FetchDataInterceptor,
                // 通过loopFetch代码的分析,可以知道,loopFetch的方法,始终触发的是第3个连接拦截器,
                // 也就是 FetchDataInterceptor 的interceptFetch方法,所有循环获取数据的逻辑都在这个方法中。
                processFetchLength = chain.loopFetch();
                // 如果本次获取的数据长度为-1,表示没有更多数据,结束循环
                if (processFetchLength == -1) {
                    break;
                }

                // 累加本次获取的数据长度到总长度中
                fetchLength += processFetchLength;
            }
        } finally {
            // 完成下载,刷新数据并增加字节计数,但不回调
            chain.flushNoCallbackIncreaseBytes();
            // 如果用户没有取消下载,则标记数据块写入完成
            if (!chain.getCache().isUserCanceled()) outputStream.done(blockIndex);
        }

        // 如果内容长度是确定的(非分块传输编码)
        if (isNotChunked) {
            // 检查并处理本地持久化数据
            outputStream.inspectComplete(blockIndex);

            // 校验获取的数据长度是否与响应内容长度一致
            if (fetchLength != contentLength) {
                // 如果不一致,抛出异常
                throw new IOException("Fetch-length isn't equal to the response content-length, "
                        + fetchLength + "!= " + contentLength);
            }
        }

        // 返回本次下载拦截过程中已获取的数据长度
        return fetchLength;
    }

image.png



1.2.3、FetchInterceptor 分析

这个拦截器是真正对下载数据进行获取与存储的地方,inputStream.read(readBuffer);connected 的返回数据流中一段一段的读取内容,然后写入到对应文件的相应位置。

    /**
     * 拦截并处理下载过程中的数据获取
     *
     * @param chain 下载链式调用对象,包含下载任务的相关信息和方法
     * @return 返回本次获取的数据长度,如果返回-1,表示输入流已结束
     * @throws IOException 如果在数据获取或写入过程中发生I/O错误,或者缓存被中断,将抛出此异常
     */
    @Override
    public long interceptFetch(DownloadChain chain) throws IOException {
        // 检查缓存是否被中断,如果被中断,则抛出中断异常
        if (chain.getCache().isInterrupt()) {
            throw InterruptException.SIGNAL;
        }

        // 根据下载策略检查网络连接(仅在Wi-Fi环境下进行下载的任务会进行此检查)
        OkDownload.with().downloadStrategy().inspectNetworkOnWifi(chain.getTask());

        // 开始获取数据,从 connected.inputStream 中获取内容数据到readBuffer中,并返回获取到的长度
        int fetchLength = inputStream.read(readBuffer);
        // 如果数据获取结束,则返回-1
        if (fetchLength == -1) {
            return fetchLength;
        }

        // 将获取的数据写入文件
        outputStream.write(blockIndex, readBuffer, fetchLength);

        // 增加已回调的数据量
        chain.increaseCallbackBytes(fetchLength);
        // 如果满足进度回调的最小时间间隔,则进行一次进度回调,将这个时间间隔内累计获取到的数据进度一起回调出去
        // 这里就正好对应了OkDownload在初始化是配置的最小回调时间间隔的设置,控制下载进度回调的频率
        if (this.dispatcher.isFetchProcessMoment(task)) {
            chain.flushNoCallbackIncreaseBytes();
        }

        return fetchLength;
    }

这里每执行一次,会回到 BreakpointInterceptor.interceptFetch 方法的循环体内,所以最终的全部都下载完成后,最后回到 BreakpointInterceptor.interceptFetch 方法体中,跳出循环体,继续下方的代码执行。在这里最终对 outputStream 进行是否完成的标记处理。



到此,对 `DownloadChain` 就分析完成了。


单纯的下载流程,大部分就都分析完成了。

当然,我们在分析的过程中,有意的忽略了下载过程中持久化各种状态数据的过程,因为我打算将这部分的实现单独出来分析。

至于,对Http网络框架的使用上,应该比较简单就不进行特别的分析,这部分代码看一下就明白。

所以,下一篇,我们就开始分析下载信息存储相关的功能实现。