一次基础库设计引发的反思:从断点续传的功能实现说起

412 阅读21分钟

开局尬聊奠定气氛

        小弟毕业后至今一直在做安卓开发,除了第一年是参与应用开发写写界面,做做业务之外,后面这两年半的时间,一直在参与所在公司基础库开发,说好听点叫负责基础sdk的维护和设计,中间就包括应用的基础功能,例如登录支付和自升级等.
其实做久了以后,都会想拥有自己的一个库,加上这次刚好碰上有断点续传的功能需求.索性把这次功能当成自己的一个小目标来实现.但我强调,不是给轮子出来,只是想分享一下思路.结尾附上效果跟github地址,顺便总结一下.希望对大家有所启发.

需求说明

这天,我喝着养生茶水,产品经理笑盈盈走了过来."啪!"把手机摆面前. 

对话如下: 

"你看,人家的网页为什么能打开那么快,我们就加载半天"
"人家缓存在本地呀,打开本地页面肯定快" 
"那我们为什么不做"
"我刚来的时候问你们,不是说这样就行,别改,就是要改了马上看到效果???我就只能干别的先了" 
"想想有没什么方案呀" 
"肯定有呀,你后台配置个新的网页包,我下载了用不就行了,没下载好先用本地的嘛"
"真棒,今天能做完吗?" 
"..."

场景分析

        到这里我们需求算是定了,从指定url进行一次文件下载.理想中的情况,是直接下载完成,保存到本地然后进行操作.但实际过程可能是出现.客户端网络波动,有无网络状态切换,用户应用退出等情况.这些都要考虑进去.这是从客户端的使用场景去考虑.

        看到这里有的人就不耐烦了,不就是断点下载么,说那么多来引入.但,这就够了么?我强调这是一个库,所以,不光要考虑生产环境中的情况,还要考虑开发者的使用.我给大家列举一下.

如何确保下载任务正常入列

  • 创建下载任务是否重复->带唯一标识队列

  • 多线程提交任务如何保证队列同步->锁(其实和第一点可以一起使用带同步的队列一起解决,如CopyOnWriteArraySet等)

  • 保存位置和文件名是否合法->参数检测,并对不可用参数进行补齐

  • 创建任务是否已经下载完成->数据库记录

  • 所提供url出现重定向怎么办->对该链接发起请求检测是否有302出现,并获取重定向后地址

  • 如何管理任务监听对象->带锁的能去重的增删查改

如何获取文件流及本地存储

  • 怎么获取到文件流->对请求得到的响应体body进行流获取

  • 怎么从文件流缓存到本地->IO流操作,核心类RandomAccessFile

  • 怎样确保下载未完成情况下,不完整的文件不被拿来使用->文件占位,下载好了再换回原先文件格式

  • 怎样实现多线程下载且不混乱各自进度->确定文件长度后分段,请求头RANGE携带起始点,开启线程池下载,数据库记录各线程已下载长度

  • 分段任务如何确保重复下载->数据库记录

  • 多线程下载如何异步变同步->核心类CountDownLatch

  • 怎么个断点下载法->api被调用,或者初始化时候从本地取缓存任务,获取各线程已下载长度

  • 数据库字段如何设计合理->如分段任务进度记录应该共用字段,还是使用分开字段

往大了说,还可以考虑是否方便开发者调用,版本更新时候怎样减少调用者的改动.这些都是从这次设计中我们需要包含进去的.

大致流程

任务入列和预加载阶段

需要解决任务重复,已完成,或者参数错误,不可用的情况.更重要的是,确定任务的下载url是否为最终所下载的文件的来源.

文件流获取落地阶段

开始目标文件的下载,包括文件多线程下载的起始确定和本地记录.

关键类具体实现

相关类介绍

BreakPointHelper:断点下载对外暴露类,客户端调用该类方法对任务进行提交
BreakPointDownloader:实际业务处理和下载类(如果后面类过于冗余,可以考虑拆解该类)
Task.Builder和Task:前者为客户端创建后者所需要依赖的类,通过该类进行任务对象的参数设置
TaskRecordEntity:和Task相对应,作为本地记录实体类存在
TaskPostListener:任务执行监听,对任务下载过程进行监听并回调给客户端

        在确定好大概流程以后,我们可以先有个雏形,确定对应的类里最起码需要的成员或者方法,再一步步完成该功能的开发和实现.开始上代码配合.

Helper类

        全局功能我们考虑单例模式来进行对外暴露类的实例化,内部持有一个实际下载类引用.对客户端任务进行响应下载;持有任务队列引用完成任务检测和任务队列管理;并提供对应方法接收移除来自客户端的任务请求.

public void postTask(@NonNull Context context, @NonNull Task task) {
    synchronized (mTaskLock) {
        AtomicReference<Task> t = new AtomicReference<>();

        if (addTask(context, task)) {
            LogUtils.i("新任务添加成功");
            task.postNewTaskSuccess();
            t.set(task);
        } else {
            LogUtils.i("关联已有任务");
            DataCheck.checkNoNullWithCallback(mTaskMap.get(task.getTaskId()), taskBeListen -> {
                t.set(taskBeListen);
            });
        }

        LogUtils.i("最终执行的任务" + t.get());
        mBreakPointDownloader.executeTask(context, t.get());

    }
}

        同时提供初始化等方法便于从本地恢复之前的任务队列

public void attachApplication(@NonNull Context context) {...}

Task类

        从对象的创建来看,采用建造者模式对任务对象进行创建.在而不是使用直接传参的方式,避免后续维护过程中出现新增字段参数的时候,要修改Helper的post方法参数列表的情况.如

Task task = new Task.Builder()
        .setTaskUrl("https://qd.myapp.com/myapp/qqteam/AndroidQQ/mobileqq_android.apk")
        //.setTaskFileName("downtask.apk")
        .setTaskFileDir(getContext().getFilesDir().getAbsolutePath())
        .build();

        这样一来,后续维护需要修改字段等操作,交给Task.Builder进行处理,和Task具体交互.客户端只需要修改Builder类即可.为了能够配合初始化阶段从本地记录恢复任务列表,我们考虑将本地记录和内存中的任务对象相互转化的功能也放在Builder类内,这样来确保该类只负责一件事,就是创建任务.

        从成员变量来看,我们要保证客户端对该任务的监听,就需要维护一个可去重的集合去保存任务的执行监听,并在实现接口的基础上对集合进行管理.同时根据任务的下载url跟文件保存全路径来生成一个唯一标识.如果后续客户端有重复提交的行为,那么我们可以根据实际需求跟任务是否重复,来确定是否要废弃重复任务,或者将任务的监听列表添加到已有任务的监听列表上实现关联.任务对象在Helper类内完成检测入列后交给Downloader类开始执行任务.

Downloader类

        作为具体下载的执行类,我们从前面分析能确定大概的成员变量,需要线程池支持多线程下载,故此处定义线程池大小及核心线程数等配置,一开始我们先考虑使用手机设备的cpu核数,作为线程池核心线程数.但是这里会带来一个问题,我们后面再拓展开.

/**
 * 处理下载任务的线程池
 *
 * 线程池核心线程数取决于当前核数
 *
 * @author wsb
 * */
public class DownloadExecutor extends ThreadPoolExecutor {

    public static final String TAG = "DownloadExecutor";

    private static final int CPU_COUNT = Runtime.getRuntime().availableProcessors();
    private static final int CORE_POOL_SIZE = Math.max(3, CPU_COUNT / 2);
    //    private static final int CORE_POOL_SIZE = 8;
    private static final int MAX_POOL_SIZE = CORE_POOL_SIZE * 2;
    private static final long KEEP_ALIVE_TIME = 0L;

    public DownloadExecutor() {
        super(CORE_POOL_SIZE, MAX_POOL_SIZE, KEEP_ALIVE_TIME, TimeUnit.MILLISECONDS, new LinkedBlockingDeque<>());
    }

}

        还需要数据库记录当前下载进度和状态,由于数据库操作离不开对本地记录的增删查改,故定义一个数据库操作对象DataBaseRepository.在该类具体实现之前,我们定义行为接口DatabaseOperate来规范数据库的操作.具体如下

/**
 * 数据库操作定义
 * <p>
 * 无论采用sqlite,还是room的方式,都需要对数据库操作
 *
 * @author wsb
 */
public interface DatabaseOperate {
    /**
     * 添加任务到本地记录中
     *
     * @param recordEntity 准备保存的任务条目
     */
    void addTaskRecord(TaskRecordEntity recordEntity);

    /**
     * 更新任务记录到本地记录中
     *
     * @param recordEntity 更新条目
     */
    void updateTaskRecord(TaskRecordEntity recordEntity);

    /**
     * 更新任务记录到本地记录中
     *
     * @param taskId      更新的id
     * @param currentSize 当前下载量
     */
    void updateTaskRecord(int taskId, String currentSize);

    /**
     * 从本地记录中删除任务条目
     *
     * @param recordEntity 删除条目
     */
    void deleteTaskRecord(TaskRecordEntity recordEntity);

    /**
     * 清除所有本地记录的任务条目
     */
    void cleanTaskRecord();

    /**
     * 获取本地记录的所有任务
     *
     * @return 返回任务条目列表
     */
    @Nullable
    List<TaskRecordEntity> loadAllTaskRecord();

    /**
     * 根据id获取对应的任务记录
     *
     * @param id 查找id
     * @return 从数据库中查找到的任务条目
     */
    @Nullable
    TaskRecordEntity obtainTaskRecordById(String id);
}

        完成类定义以后,可以使用静态代理的方式将数据库操作类及其代理对象关联起来.当下载类需要进行操作数据库的时候操作DataBaseRepository即可,具体实现交给内部的mDatabaseStrategy去实现,在第一个版本里面,我使用了传统的sqlite来实现,由于后面开始接触jetpack,尝试使用room来实现,也就在原来的基础上增加多一个DatabaseOperate的具体实现类.在新增策略类以后,只需要改动原来一行代码即可.

public class DataBaseRepository implements DatabaseOperate {
    private DatabaseOperate mDatabaseStrategy;

    public DataBaseRepository(Context context) {
//        mDatabaseStrategy = new SqliteStrategy(context);
        mDatabaseStrategy = new RoomStrategy(context);
    }

    ...
}
public class SqliteStrategy implements DatabaseOperate {...}
public class RoomStrategy implements DatabaseOperate {...}

        此外,流处理获取对象,同样道理,网络请求可以是OkHttp,可以是Retrofit,完全是根据自家的网络框架来决定,如果你喜欢,UrlConnection都行,能获取到文件的InputStream流就行了.既然说完了抽象操作,我们就照着上面数据库操作的思路,再定义一个流处理的行为接口.

/**
 * 流处理对象标准操作
 *
 * @author wsb
 */
public interface StreamProcessor {

    /**
     * 获取下载文件完整的流
     * <p>
     * 确保该方法被调用在子线程中,主线程获取将导致阻塞
     *
     * @param url            下载链接
     * @param streamListener 文件流获取监听对象
     */
    @WorkerThread
    void getCompleteFileStream(String url, FileStreamListener streamListener);


    /**
     * 下载指定起终点的分段文件
     *
     * @param url              下载链接
     * @param tmpAccessFile    所写入的文件
     * @param startIndex       实际写入起点
     * @param endIndex         实际写入终点
     * @param downloadListener 分段下载监听
     * @throws Exception 流处理过程中可能出现异常需要被捕获处理
     */
    @WorkerThread
    void downloadRangeFile(String url, File tmpAccessFile, long startIndex, long endIndex, RangeDownloadListener downloadListener) throws Exception;
}
/**
 * 文件流获取监听
 *
 * @author wsb
 */
public interface FileStreamListener {
    /**
     * 流获取失败
     *
     * @param msg 失败信息
     */
    @WorkerThread
    void getFileStreamFail(String msg);

    /**
     * 流获取成功回调
     *
     * @param contentLength 文件流长度
     * @param byteStream    文件流对象
     */
    @WorkerThread
    void getFileStreamSuccess(long contentLength, InputStream byteStream);
}

        由于我偷懒,这次就是纯粹实现,这里我用了OkHttp实现.剩下的就是需要线程切换的工具Handler实现的类,关于Handler引发的内存泄露问题在此我们不做更多的讨论.在这几个成员变量基础上,定义相关方法如主线程子线程执行Runnable,数据库更新记录等来完成文件下载.

执行阶段

预加载阶段

        在Downloader对象拿到一个能被执行的任务之后,就可以直接下载吗?不,你需要确定这个链接是否可用.所以我们这里需要先将任务对象的链接进行预加载操作,通过访问该url,确定是否返回302来决定是否进一步获取重定向以后的地址.注意,明确方法是要在什么线程中执行,什么线程中回调给客户端.而不是随意去依赖当前被调用的线程,我先提个醒

BreakPointDownloader#executeTask

public void executeTask(Context context, Task task) {
    final PreloadListener preloadListener = new PreloadListener() {
        @Override
        public void preloadFail(String message) {
            mainThreadExecute(() -> task.onTaskPreloadFail("预加载失败," + message));
        }

        @Override
        public void preloadSuccess(String realDownloadUrl) {
            mainThreadExecute(() -> task.onTaskPreloadSuccess(realDownloadUrl));
// 进入获取文件流阶段
            getFileStream(task, realDownloadUrl);
        }
    };
    asyncExecute(task.preload(preloadListener));
}

Task#preload

@WorkerThread
public Runnable preload(PreloadListener preloadListener) {
    return () -> {
        try {
            String redirectionUrl = DataUtils.getRedirectionUrl(getTaskUrl());
            preloadListener.preloadSuccess(TextUtils.isEmpty(redirectionUrl) ? getTaskUrl() : redirectionUrl);
        } catch (Exception e) {
            e.printStackTrace();
            preloadListener.preloadFail(e.getMessage());
        }
    };
}

DataUtils#getRedirectionUrl

/**
 * 获取重定向处理后的url
 *
 * @param sourceUrl 源url
 * @return 重定向后的url
 */
public static String getRedirectionUrl(String sourceUrl) throws Exception {

    String redirectUrl = null;

    URL url = new URL(sourceUrl);
    HttpURLConnection conn = (HttpURLConnection) url.openConnection();
    conn.setRequestMethod("GET");

    conn.connect();
    if (conn.getResponseCode() == BreakPointConst.REQ_REFLECT) {
        redirectUrl = conn.getHeaderField("Location");
        LogUtils.i(" 该链接出现重定向,下载地址重定向为 " + redirectUrl);
    } else {
        LogUtils.i(" 下载地址请求码 = " + conn.getResponseCode());

    }
    conn.disconnect();

    return redirectUrl;
}

文件流获取阶段

        文件流获取阶段严格意义上不应该算是获取文件流,应该是确定文件长度多些.在这个步骤,我们交给流处理对象来执行

BreakPointDownloader#getFileStream

private void getFileStream(Task task, String downloadUrl) {
        try {
            final FileStreamListener streamListener = new FileStreamListener() {

                @Override
                public void getFileStreamFail(String msg) {
                    mainThreadExecute(() -> task.onTaskDownloadError("文件流获取出错: " + msg));
                }

                @Override
                public void getFileStreamSuccess(long contentLength, InputStream byteStream) {
                    mainThreadExecute(() -> task.onTaskDownloadStart(downloadUrl));

//                    文件分段下载方案
                    task.parseSegment(contentLength, getSegmentTaskEvaluator(task, downloadUrl));
                }
            };

            mStreamProcessor.getCompleteFileStream(downloadUrl, streamListener);
        } catch (Exception e) {
            e.printStackTrace();
            mainThreadExecute(() -> task.onTaskDownloadError(e.getMessage()));
        }
    }

OkHttpSteamProcessor#getCompleteFileStream

public class OkHttpSteamProcessor implements StreamProcessor {

    ...

    @Override
    @WorkerThread
    public void getCompleteFileStream(String url, FileStreamListener streamListener) {
        Request request = new Request.Builder()
                .url(url)
                .build();

        getHttpClient().newCall(request).enqueue(new Callback() {
            @Override
            public void onFailure(@NotNull Call call, @NotNull IOException e) {
                streamListener.getFileStreamFail(e.getMessage());
            }

            @Override
            public void onResponse(@NotNull Call call, @NotNull Response response) {
                final ResponseBody body = response.body();
                if (response.code() == BreakPointConst.REQ_SUCCESS && null != body) {
                    final long contentLength = body.contentLength();
                    final InputStream byteStream = body.byteStream();
                    body.close();
                    streamListener.getFileStreamSuccess(contentLength, byteStream);
                } else {
                    streamListener.getFileStreamFail("该链接请求码为" + response.code() + "或者response.body()为空");
                }
            }
        });
    }
    ...
}

        文件长度一旦被确定我们就可以精准地知道各分段任务的下载起始跟终点位置.这里有的人开始发问,为什么是线程池放在了下载类里面,既然你要搞分段任务下载,干嘛不一个任务就创建一个线程池,这样各个任务都是拥有一个独立的线程池.唔,其实也行,只是线程池这玩意,咱们考虑一下性能损耗,假设我是个应用市场呢,用户一点我就下载,十几个应用下载任务,对应十几个线程池来处理,不过真要这么搞也没问题.就是我个人比较杠.就这么设计了,大佬们千万别坐不住吊锤我.

分段下载阶段

        在确定了文件长度后,我们可以根据当前支持的线程数确定分段数量,分段进行文件下载.但是在这之前,我们需要引入断点下载的核心类RandomAccessFile.这里附上类的简介说明

Instances of this class support both reading and writing to a
random access file. A random access file behaves like a large
array of bytes stored in the file system. There is a kind of cursor,
or index into the implied array, called the <em>file pointer</em>;
input operations read bytes starting at the file pointer and advance
the file pointer past the bytes read. If the random access file is
created in read/write mode, then output operations are also available;
output operations write bytes starting at the file pointer and advance
the file pointer past the bytes written. Output operations that write
past the current end of the implied array cause the array to be
extended. The file pointer can be read by the
{@code getFilePointer} method and set by the {@code seek} method.

        什么玩意,就是告诉我们这东西可以支持随机访问读写,依靠什么呢,依靠里面的seek方法来指定文件指针下标.我们确定分段下载,就是要将一个文件指定位置开始写入指定长度.

  • 怎样确定起点?

        终点好说,文件总长度除以分段后得到平均值乘以段数,甚至最后一个任务,直接指定终点即可.可是起点呢,哦,简单,不是说数据库记录嘛,那就提供下载进度更新回调,在回调里做数据库保存.就算是退出了,下次开启下载,先取缓存,加上即可.好的,在任务入列的时候,在客户端的监听对象基础上手动增加一个内部的任务下载监听,再回调里写上数据库的操作.心想就美滋滋了.来,上手.

BreakPointHelper

/**
 * 初始化断点下载(必调)
 * <p>
 * 加载原来保存的下载任务到队列内并对未完成任务添加数据库监听
 *
 * @param context 上下文
 */
public void attachApplication(@NonNull Context context) {
    mBreakPointDownloader.loadTaskRecord(context, taskMap -> {

        for (Map.Entry<Integer, Task> entry : taskMap.entrySet()) {
            final Task task = entry.getValue();
            if (task.incompleteState()) {
                task.addTaskListener(getDatabaseTaskListener(task));
            }
            mTaskMap.put(entry.getKey(), task);
        }
    });
}
/**
 * 提交任务
 * <p>
 * 如果列表中未存在重复任务(判断标准查看{@link #taskDuplicate(Task)}),则任务添加到列表中.列表中已存在,说明当前或之前应用生命周期内出现过该任务的提交,
 * 任务监听将会挂载到之前已创建的任务上
 *
 * @param context 上下文
 * @param task    任务对象
 */
public void postTask(@NonNull Context context, @NonNull Task task) {
    synchronized (mTaskLock) {
        AtomicReference<Task> t = new AtomicReference<>();

        if (addTask(context, task)) {
            LogUtils.i("新任务添加成功");
            task.addTaskListener(getDatabaseTaskListener(task));
            task.postNewTaskSuccess();
            t.set(task);
        } else {
            LogUtils.i("关联已有任务");
            DataCheck.checkNoNullWithCallback(mTaskMap.get(task.getTaskId()), taskBeListen -> {
                taskBeListen.addTaskListeners(task.getTaskListener());
                t.set(taskBeListen);
            });
        }

        LogUtils.i("最终执行的任务" + t.get());
        mBreakPointDownloader.executeTask(context, t.get());

    }
}

但是下一步呢?

  • 如何设计数据库字段实现新旧版本兼容?

        数据库字段记录是吧,分任务我知道,分几个任务就创建几个字段.细想真的这里没问题么,第一个版本是开启4个线程下载,所以我数据库内有4个字段来记录.下次,我老板告诉产品经理说太慢了太慢了,好,产品经理告诉我要开到6个线程,8个线程,我以前那些下载到一半的任务呢,告诉用户说,"更新版本后把缓存清掉,不要问,再问就是新功能需要这样搞",那我觉得再来几次这样情况,自己就要被老板清掉了,不要问,再问就是菜.

        有什么稍微好看点的方式?我的做法是用json,一个记录各任务下载情况的字段,保存内容是json形式.优势在于当任务重建的时候,根据json长度来确定,而不是当前版本所用的线程数,对于这个任务,要开多少个线程取决于原来执行该任务时候的线程数.

  • 如何避免大量的数据库更新操作,确保性能?

        性能方面呢?我们需要减少对数据库的写入.要知道,我们分段任务是有好几个,不可能说各个分段任务没完成之前的所有进度更新,都不算吧?就直接按照下载了几分之一来算?万一客户端说要让用户看到进度条呢,一次蹦一大段?所以我们分段任务出现进度更新时候都会触发一次总下载任务的进度更新.

        这字段设计勉强能理解,但是性能上减少写入这咋整,更新几个字段一次,跟更新一个字段几次没区别.这样说有点牵强,但是我们可以在任务对象里维护一个成员变量记录各个线程的已下载长度,当进度更新的时候,更新的是内存中的成员变量内容.隔个一两秒再进行一次数据库的进度写入,在文件下载完成的时候标记分段任务已完成,也是为了防止下次恢复任务后对已完成的分段任务重复执行.

确定了以后就可以开始下载,这块逻辑交OkHttpSteamProcessor#getCompleteFileStream

    @Override
    @WorkerThread
    public void downloadRangeFile(String url, File file, long startIndex, long endIndex, RangeDownloadListener downloadListener) throws IOException {
        Request request = new Request.Builder()
                .header("RANGE", "bytes=" + startIndex + "-" + endIndex)
                .url(url)
                .build();

        final Response response = getHttpClient().newCall(request).execute();
        final ResponseBody body = response.body();
        if (response.code() == BreakPointConst.REQ_RANGE_SUCCESS && null != body) {
            final InputStream is = body.byteStream();
            // 获取前面已创建的文件.
            RandomAccessFile tmpAccessFile = new RandomAccessFile(file, "rw");
            // 文件写入的开始位置.
            tmpAccessFile.seek(startIndex);

            byte[] buffer = new byte[1024 << 2];
            int length = -1;
            // 记录本次请求所下载文件的长度
            int currentDownloadLength = 0;
//            当前分段文件的写入的位置
            long currentRangeFileIndex = 0;

            while ((length = is.read(buffer)) > 0) {
                tmpAccessFile.write(buffer, 0, length);
//                更新本次已下载长度
                currentDownloadLength += length;
//                更新当前分段文件的已下载的长度,即文件实际下载起点加已下载长度
                currentRangeFileIndex = startIndex + currentDownloadLength;
                downloadListener.updateRangeProgress(currentDownloadLength,currentRangeFileIndex);
            }

//            分段任务下载完成
            downloadListener.rangeDownloadFinish(currentDownloadLength, currentRangeFileIndex);

        } else {
            downloadListener.rangeDownloadFail("该链接请求码为" + response.code() + "或者response.body()为空");
        }
  • 怎么优雅地异步操作转同步?

按照前面的流程来走,我们目的是开启了分段下载以后等待各分段下载完成通知整体的任务,那怎样实现?给个状态?每个分段任务下载完成以后都检查一遍?又要有个来专门记录的.好烦.我先给大家看下CountDownLatch使用以后的代码长什么样

public void parseSegment(long contentLength, SegmentTaskEvaluator creator) {
    try {
        LogUtils.i("已获取文件长度" + contentLength);
        createTaskTmpFile(contentLength);

        for (int threadIndex = 0; threadIndex < getDownloadThreadCount(); threadIndex++) {
// 具体的分段任务下载
            calculateSegmentPoint(threadIndex, creator);
        }

        LogUtils.d("开始执行分段下载,等待分段下载结束");
        getCountDownLatch().await();
        LogUtils.d("该下载任务所有分段任务结束");

        requestDownloadSuccess();

    } catch (Exception e) {
        e.printStackTrace();
        onTaskDownloadError(e.getMessage());
    }
}

        等等?这就完事了?没错,还真的就是这样,CountDownLatch提供了一个线程阻塞的方案,可以所在线程阻塞并等待其他线程的唤醒.调用很简单,就是await跟countDown方法.在我们这个项目中,子线程中开启分线程去下载,子线程内用CountDownLatch对象使用await方法进入阻塞(反正是子线程嘛,不影响UI线程),在各个分线程下载完成时候,调用该对象的countDown方法唤醒即可.这样依赖就减少了其他多余的变量在记录状态跟下载完成的检测行为.

遭遇问题

        总算快结束了.这时候心想等待下载进度更新或者完成,回调更新数据库内容,告知客户端,就完成本次下载了.好家伙,结果一看,又出问题了.咱们一一描述.        

线程池核心线程数过少带来的问题

  • 场景:任务都下载了,结果线程池腾不出空间来执行我原来准备记录进度的Runnable.任务有下载,结果数据库没记录,你说可不可怕.别急,我们冷静一下.        

  • 分析:大家还记得我前面定义时候是怎么定义下载线程池的核心线程数的吧,我定义了根据设备的核数来确定,更确切说是影响最终的数量.我现在核心线程数,为3.但是分段下载Runnable数量是4,甚至更多,这还不包括进度更新时候所需要额外执行的本地数据库记录Runnable.导致什么问题?如果说我现在分段任务数量小于线程池核心线程数,一切正常.但是,万一等于或者多余核心线程数呢,根据我们对线程池的理解,未能得到执行的Runnable对象将被闲置等待,等线程池空出来的时候再进行调用.哦豁,这回找到问题了.

  • 调整:可以尝试调整数据库记录和分段Runnable的执行顺序,或者,调整核心线程数大小并约定分段任务数不能太多,放弃借助设备核数这个方案.做下微调即可.

子线程CountDownLatch未countDown带来的问题

  • 场景:暂停任务后恢复任务收到多次回调,暂停越多次,收到越多次
  • 分析:一开始以为是回调对象问题,经确认,已经做了回调对象去重.但是线程中止以后,第二次发起,已经是走新的逻辑,不可能跟第一次的有关.走读捋了逻辑发现,自己子线程发起了await后阻塞,但是由于自己当前的设计是线程池整体公用以及分线程被中止后没有对应的收尾操作,最要命是一个task对象公用了一个CountDownLatch对象,所以我怀疑是不是没有在分线程里对应执行CountDownLatch的countDown导致
  • 调整:有调用await,就要有countDown对应.或者在任务被停止的时候,应该从线程池里移除该任务所产生的执行线程,包括分段下载线程等线程进行管理回收行为.而不是把线程只开启不进行回收.

效果预览及链接地址

暂停任务后恢复从断点处继续下载

取消任务后重新下载 

github.com/SiuBun/inst…

总结

功能实现方面

        明确需求后再去做分析,像下载文件,必须考虑的除了前面所提到的下载来源,支持断点和分段下载之外,也需要考虑生产环境和版本迭代带来的问题,兼容旧版本

库设计方面

        毕竟库设计出来就是要被人使用的,减少对调用者的麻烦是很重要的一点,例如最现实的代码改动.而且,把工作上的事情作为自己的事情来做好确实会让自己用心许多,因为写了最起码就是自己在用,那还要写一堆bug给以后的自己挖坑加班?毕竟遇到需求只会找第三方或者上github的搬运工模式大家都不喜欢.

        更重要的一点就是自己去打磨自己的项目,会把学到的东西去落地使用,回归生产,这也是我之前的文章 juejin.im/post/684490…"

未考虑场景和尝试优化点

  • 任务执行遭遇网络状态切换将导致文件下载失败,尽管下次启动后会将任务恢复
  • 执行阶段三个步骤用了三次回调,切换成kotlin协程是否更合适
  • 如果是小文件,或者小于某个指定大小的,可以直接单线程下载,不需要分段执行
  • 分段下载所需线程池除了交给下载类或者任务对象,还有没更好的方式
  • io流处理考虑使用OKio简化逻辑,避免传统io的读写操作