ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Android多线程下载工具类实战:分段并发、断点续传与进度回调

Android多线程下载工具类实战:分段并发、断点续传与进度回调 直接说结论多线程下载工具类是Android开发里听着简单、写起来全是坑的典型模块。网上教程一搜一大把但大多数只教你用HttpURLConnection开几个线程读流代码能跑就行完全不考虑断点续传、进度回调卡UI、暂停恢复状态错乱这些实际问题。真要在项目里落地这些细节才是决定用户骂不骂娘的关键。我最初写这个工具类是因为项目要下载一个几百MB的资源包单线程下用户等得不耐烦后台切几次网络断了进度直接归零被用户差评轰炸。后来花了两个晚上重构做成支持进度回调、任务暂停恢复、多段并发下载的工具类上线之后下载成功率从78%提到了95%以上。本文就把完整思路和核心代码拆开讲清楚适合正在做下载功能、或者面试准备里想深挖多线程下载实现细节的同学参考。1. 内容整体设计与思路拆解1.1 多线程下载的核心思路Chunk机制多线程下载的核心原理说起来一句话就能讲透把一个大文件切成多个分段每个线程负责下载其中一段最后拼接成完整文件。关键在于分段这个动作怎么做得合理。我常拿搬家来类比多线程下载。单线程下载就像一个搬家工人一趟一趟搬一次搬一件多线程下载则是叫了三四个工人每人负责搬客厅、卧室、厨房的不同区域最后汇合到新房。问题来了工人怎么分配区域怎么保证不搬重了也不漏件搬完之后怎么确认东西都齐了这一套逻辑迁移到代码上就是分段的计算、Range请求头的构建、写文件的边界控制、最终完整性的校验。真正的工程实现里多线程下载还需要解决几个核心问题拿到文件总大小并动态计算分段区间处理服务器不支持Range请求的降级方案多个线程并发写同一文件时的原子性控制以及进度回调从子线程安全切回主线程。这些问题不提前设计好代码一旦跑起来就是各种偶发崩溃和文件损坏。1.2 架构设计的取舍从Task到Dispatcher整个工具类我用两层结构来设计思路是任务调度层和传输执行层分离。任务调度层负责管理任务状态、维护队列、暴露回调接口。传输执行层负责实际的分段下载、文件写入。为什么这么分因为Android的主线程需要保持流畅所有的网络操作必须在子线程完成状态回调要切回主线程调度逻辑和传输逻辑混在一起会非常难维护。实际开发中我看到很多同学把下载逻辑和界面刷新写在一个类里一个任务还好两个任务同时下就开始出现状态错乱。调度层我用了一个DownloadManager的抽象维护一个任务集合每个任务有唯一ID。任务有几种状态等待中、下载中、暂停、完成、失败。状态转换必须串行化处理否则会出现竞争条件。这里我没有用锁去同步而是用了一个串行的HandlerThread来处理调度指令这样天然避免了多线程下状态错乱的问题代码也简单很多。传输层才是干重活的地方。每个下载任务对应一个线程组线程组里的线程数量可以在创建任务时指定。我一般推荐3个线程下载一个大文件线程太少速度上不来线程太多分段过多增加调度开销纯属浪费资源。3个线程配合合理的分段策略在大部分真实网络场景下效果已经非常可观。从架构层面看整个工具流的运行逻辑调用方通过DownloadManager创建任务指定URL、目标路径、线程数DownloadManager把任务放入队列返回一个任务ID任务调度线程启动下载流程先做一次HEAD或GET请求获取文件总长度根据总长度和线程数进行分段动态计算每个线程的下载区间每个线程使用独立的HTTP连接分别下载自己的区间边下边写文件每下载一段数据通过进度回调上报字节数所有分段完成后调度层合并校验文件完整性标记任务完成。这个流程看起来简单但每一步都有细节坑下面几个小节逐一拆解。2. 核心细节解析与实操要点2.1 获取文件长度与分段的正确姿势拿到一个下载地址第一件事就是获取文件的总大小。这一步不能用普通的GET请求然后读body那样你根本拿不到Content-Length而且在Android上直接读流是顺序下载跟多线程没关系了。正确做法是单独发一个请求只看响应头。我用HttpURLConnection举例因为它在Android原生环境里不需要额外引库而且足够用了。请求方式用GET就行注意setRequestProperty(Range, bytes0-)这段要加。为什么因为有些服务器对带Range头的请求会返回206 Partial Content响应头里会带Content-Range里面包含了文件总长度。如果服务器不支持断点续传它返回200这时Content-Length就是整个文件的大小。有一个细节很多CDN服务器如果不带Range头响应里压根没有Content-Length或者返回的是chunked编码。这时候你得靠Content-Range来拿总长度所以无论哪种情况先把Range头带上拿到状态码再分情况处理返回206说明服务器支持分段下载总长度从Content-Range头解析格式是bytes 0-1023/10240斜杠后面的数字就是总长度返回200服务器不支持分段请求只能降级为单线程顺序下载工具类里要做好这个兼容分支返回302或301要做重定向跟随HttpURLConnection默认会跟随但如果你自己设置了重定向处理需要小心Header丢失的问题。拿到总长度后计算分段区间。假设总长度是totalSize线程数为threadCount那么每个线程负责大小为blockSize totalSize / threadCount。第i个线程的区间是start i * blockSize end (i 1) * blockSize - 1最后一个线程的end要改成totalSize - 1因为有可能totalSize不能整除threadCount直接按照整除公式算会漏掉最后的尾巴。很多初学实现就把文件下少了几个字节最后发现文件损坏其实原因就在这。我还建议对每个分段做一次10KB左右的小探测确认服务器返回的是206而不是200。为什么要探测因为有些服务器在浏览器里支持Range但API层面对带Range的请求直接忽略仍然返回整个body。如果所有线程同时请求同一个完整文件每个线程都拿到完整数据写文件时互相覆盖文件必坏。这个事情我在对接一些老旧的服务器接口时真实遇到过所以工具类里默认加一道探测保险成本很低但能提前发现异常。2.2 进度回调线程切换的两种实现进度回调是面试里问烂了的问题但实际写的时候还是有很多坑。核心矛盾是下载线程是后台线程直接在回调里更新UI会崩溃必须切回主线程。第一种方式是使用Handler。在工具类初始化时用Looper.getMainLooper()创建一个主线程Handler。所有进度回调都通过handler.post()或者handler.sendMessage()发给主线程执行。这种方式实现简单性能开销小适合进度回调比较频繁的场景。缺点是在回调里做耗时操作会卡UI所以回调里只做界面刷新不做文件操作。第二种方式是使用runOnUiThread但这个方法只能在Activity内部用工具类里不适用。还有一种是EventBus或者LiveData把进度事件发到总线由订阅方自行决定线程。这种方式适合复杂的多页面监听场景比如同时有几个页面都在监听同一个下载任务的进度。缺点是需要引入额外的库工具类本身的独立性会被破坏。我个人在实际项目中用的是Handler方式并且做了一个节流处理。为什么需要节流因为多线程下载时每个线程每读取一次缓冲区就会回调一次3个线程同时工作假设缓冲区8KB一个100MB的文件就要回调一万多次。高频回调会让主线程卡成PPT。我的做法是限定200毫秒内最多回调一次进度中间的状态通过postDelayed攒到一起上报。这样UI每秒刷新5次视觉上依然流畅主线程压力却降了一个数量级。节流逻辑代码大致是这样private void notifyProgress(String taskId, long downloaded, long total) { long now System.currentTimeMillis(); if (now - lastNotifyTime.get(taskId) 200) { pendingProgress.put(taskId, new long[]{downloaded, total}); return; } lastNotifyTime.put(taskId, now); handler.post(() - callback.onProgress(taskId, downloaded, total)); }在节流窗口内先记录最新的进度到达时间点后统一发送最新值。如果只是简单丢弃中间回调最后显示的进度会有跳变视觉效果不好。攒住最新值再补发进度条看起来就是连续上升的。还有一个细节onProgress回调的参数传递的是taskId、downloaded、total而不是直接传percent。为什么因为percent是浮点数在回调里做整除和格式化太容易出错而且不同页面可能有不同的展示需求有的要显示百分比有的要显示已下载大小。回调里给原始数据展示层自己处理职责更清晰。2.3 文件写入的原子性与并发控制多线程下载时每个线程负责自己区间内的字节。这里常见的实现方式是RandomAccessFile它可以通过seek跳转到指定位置写入非常适合分段下载场景。RandomAccessFile raf new RandomAccessFile(file, rw); raf.seek(startPosition); raf.write(buffer, 0, len);每个线程持有自己独立的RandomAccessFile实例还是共享同一个实例加锁我一般推荐每个线程持有独立实例。因为RandomAccessFile内部有文件指针共享实例需要频繁seek和加锁反而拖慢速度。每个线程只需要在开始下载前做一次seek到自己的起始位置之后顺序写入就行没有任何锁竞争。这里有一个隐藏问题RandomAccessFile写的是物理文件的逻辑位置多个实例同时写不同区域不会互相覆盖但底层依赖系统的文件锁机制。对大多数Linux内核的Android设备来说这种并发写是安全的。不过要留意一点在分段边界处不要留缝隙或重叠前面说的end计算不准写出来的文件就会多字节或少字节。我遇到过一个极端案例某同事写工具类时区间计算用了闭区间两个线程在交界处都写了同一块字节文件长度多了一截视频播放到最后几秒花屏排查了整整一天才发现是这个低级错误。写入缓冲建议用8KB到32KB之间我实测下来16KB是比较平衡的。太小会导致磁盘写入次数过多太大占用内存而且收益不再提高。缓冲大小不要超过网络读取时设置的缓冲区否则会有一次额外的复制。文件写完之后还有一个校验步骤每个线程写完自己的区间后把实际写入的字节数上报给调度层。调度层累加所有线程的写入量与Content-Length对比。如果一致说明文件没问题可以对外发布完成事件。如果不一致说明中途断了或者区间算错直接标记失败触发重试机制。这个校验看似多余但对多线程下载这种信任但验证的逻辑来说是防止脏数据落地的最后一道防线。2.4 暂停、恢复与取消任务的状态流转任务管理最容易被忽视但也最容易被用户吐槽。用户常用的操作就是暂停、恢复、取消。如果设计不好点暂停没反应或者恢复之后进度从零开始体验就会很差。这里的关键是状态机和断点位置的保存。先讲状态机。我定义了五个状态INIT、DOWNLOADING、PAUSED、FINISHED、FAILED。状态之间的合法流向是INIT可以进入DOWNLOADINGDOWNLOADING可以进入PAUSED、FINISHED、FAILEDPAUSED只能回到DOWNLOADINGFINISHED和FAILED是终态。工具类里用一个状态字段加synchronized方法做状态切换每次切换都打印日志方便排查问题。暂停的核心问题是如何让正在下载的线程停下来。直接使用Thread.interrupt()是行不通的因为网络阻塞在socket read上时interrupt并不能打断。我的做法是引入一个volatile布尔变量cancelled在每个线程的循环条件里检查。循环条件写成while (!cancelled downloaded endPosition) { // 读取数据写入文件 }每个线程在读取下一块之前先检查标志位一旦发现被暂停就退出循环。socket read最多会阻塞到下一个缓冲区读完所以暂停指令会有最长一个缓冲区时长的延迟这个延迟在可接受范围内。停下来的线程需要记录自己已经完成了多少。这个信息必须持久化不然恢复下载时不知道怎么接着下。我做的方案是单独生成一个.taskinfo后缀的文件里面记录任务总长度、线程数、每个线程的起始位置和已完成位置。暂停时把线程状态写入这个文件恢复时读取从上次位置继续。这里要注意写taskinfo文件时的原子性直接覆盖写有小概率导致文件损坏我用先写临时文件再改名的策略确保任何时候这个文件都是完整的。取消任务就简单一些设置cancelledtrue等待线程退出然后删除下载的临时文件和taskinfo文件。如果下载的是最终文件本身取消时要恢复到下载前的状态即文件不存在。这里建议下载时先写一个.part后缀的临时文件全部完成后rename成最终文件名。这样下载失败或取消时用户看到目录里不会残留一个看似完整但实际损坏的文件。3. 实操过程与核心环节实现3.1 工具类完整代码结构与关键实现下面给出一个完整的实现骨架你可以直接抄走根据业务需求裁剪。核心类分为DownloadTask、DownloadRunnable、DownloadManager、DownloadCallback。先看DownloadTask它承载的是任务实体的数据和状态public class DownloadTask { public static final int STATE_INIT 0; public static final int STATE_DOWNLOADING 1; public static final int STATE_PAUSED 2; public static final int STATE_FINISHED 3; public static final int STATE_FAILED 4; private String taskId; private String url; private String destPath; private int threadCount; private volatile int state; private long totalSize; private long downloadedSize; private ListDownloadSegment segments; // getter/setter 省略线程安全通过 DownloadManager 串行调用保证 }DownloadSegment是分段信息包含start、end、current三个字段。current是当前线程已经写到的位置暂停恢复时靠它续传。DownloadRunnable是每个线程的执行体核心逻辑在run方法里public class DownloadRunnable implements Runnable { private DownloadTask task; private DownloadSegment segment; private volatile boolean cancelled; private RandomAccessFile raf; private InputStream inputStream; Override public void run() { try { HttpURLConnection connection openConnection(task.getUrl()); connection.setRequestProperty(Range, bytes segment.getCurrent() - segment.getEnd()); connection.setConnectTimeout(15000); connection.setReadTimeout(30000); long code connection.getResponseCode(); if (code ! 206 code ! 200) { task.markSegmentFailed(segment); return; } inputStream connection.getInputStream(); raf new RandomAccessFile(task.getDestPath(), rw); raf.seek(segment.getCurrent()); byte[] buffer new byte[16 * 1024]; int len; while (!cancelled (len inputStream.read(buffer)) ! -1) { raf.write(buffer, 0, len); segment.setCurrent(segment.getCurrent() len); task.addDownloaded(len); } } catch (IOException e) { task.markSegmentFailed(segment); } finally { closeQuietly(inputStream); closeQuietly(raf); } } }这个循环里需要注意如果服务器返回的是200而不是206说明服务器忽略了Range头此时当前线程读到的是从文件头开始的完整body。如果这里不处理多线程同时读同一份数据会造成内容错乱。前面提到要探测就是为了避免这个分支进入错误状态。当发现返回200时最优策略是放弃多线程整个任务降级为单线程从0下载。代码里做个判断即可。3.2 调度器与暂停恢复的实现细节DownloadManager是暴露给业务方的门面它维护了一个任务仓库和一个串行调度线程。任务仓库用HashMap存储key是taskIdvalue是DownloadTask。调度线程用HandlerThread实现所有任务的创建、暂停、恢复、取消操作都投递到这个线程上执行这样保证了任务状态的一致性。创建任务的流程public String startDownload(String url, String destPath, int threadCount) { String taskId UUID.randomUUID().toString(); DownloadTask task new DownloadTask(taskId, url, destPath, threadCount); task.setState(DownloadTask.STATE_INIT); taskMap.put(taskId, task); scheduleHandler.post(() - { prepareTask(task); startSegments(task); }); return taskId; }prepareTask做的事情就是获取文件长度、计算分段区间、初始化segment列表、创建临时文件。startSegments则根据segment数量创建对应数量的DownloadRunnable丢到线程池执行。线程池我用的不是newFixedThreadPool而是自己拼了一个ThreadPoolExecutor。原因在于固定线程池会一直占着线程资源而且队列的拒绝策略不好控制。自定义参数如下ThreadPoolExecutor executor new ThreadPoolExecutor( 4, 8, 60, TimeUnit.SECONDS, new LinkedBlockingQueue(64), new ThreadFactory() { private AtomicInteger count new AtomicInteger(0); Override public Thread newThread(Runnable r) { return new Thread(r, download-worker- count.incrementAndGet()); } }, new ThreadPoolExecutor.CallerRunsPolicy() );核心线程数设为4可以同时跑多个下载任务。CallerRunsPolicy的拒绝策略是当线程池满了由调用方线程去执行任务。这里调用方是调度线程这样提交任务不会因为队列满而丢失最多是让调度线程自己干一会儿。暂停任务的实现是每个task设一个cancelled标志并保存每个segment的位置。恢复时重新计算每个segment的下载区间从current到end继续请求。因为Range头支持指定起始位置所以续传不需要额外逻辑只要改变Range头的参数即可。保存进度状态我单独抽了一个方法private void saveTaskInfo(DownloadTask task) { String infoPath task.getDestPath() .taskinfo; File tmp new File(infoPath .tmp); try (BufferedWriter writer new BufferedWriter(new FileWriter(tmp))) { writer.write(total task.getTotalSize() \n); writer.write(threads task.getThreadCount() \n); for (DownloadSegment segment : task.getSegments()) { writer.write(segment.getStart() - segment.getEnd() - segment.getCurrent() \n); } writer.flush(); } catch (IOException e) { return; } if (tmp.renameTo(new File(infoPath))) { return; } new File(infoPath).delete(); tmp.renameTo(new File(infoPath)); }这里先写临时文件再改名的原因前面讲过这里再强调一下renameTo在Linux上是原子操作比直接写原文件遭遇中途崩溃更安全。有些文件系统对renameTo的支持不完善所以我在rename失败后做一个fallback删除重建保证逻辑不出错。3.3 网络层适配与异常分支处理到这里多线程下载的主要逻辑已经完成。但在实际对接真实服务器时各种异常分支才是真正的重头戏。一个成熟的工具类异常分支处理可能占总代码量的40%以上。下面列几个我实际踩过的坑。一个是URL的编码问题。下载链接里如果带中文文件名或带空格直接new URL会解析失败或者请求404。写工具类时要先把URL拆分然后对每个部分做编码处理。用Uri.parse(url)解析然后手动对path部分做URLEncoder.encode注意不要对整个URL编码否则会把问号和冒号也一起转义导致请求行错误。另一个是证书和重定向问题。某些服务商用了自签HTTPS证书默认的HttpURLConnection校验会失败。公司内部测试环境经常遇到这种情况解决方案是在工具类里提供设置自定义HostnameVerifier和信任所有证书的测试开关。但是注意生产环境此开关必须关闭否则会有中间人攻击风险。代码里用注释标清楚仅供测试。重定向的处理要重点说说。HttpURLConnection默认自动跟随重定向但这里有个坑跟随重定时自定义的Header会丢失。你设置了Range头服务器返回302自动跟随后新请求没有带Range下载就从头开始了而且多线程各自从0开始下载文件内容完全错乱。规避方法一是把setInstanceFollowRedirects设为false自己处理重定向二是跟随重定向后重新设置必要的Header。我建议用前者因为重定向URL可能需要用到不同的Header策略手动处理更可控。还有一个是断网恢复的问题。移动网络环境下下载中断是常态。多线程下载的韧性就体现在这里某个线程读到一半socket超时不能整个任务标记失败应该让这个线程重新建立连接从自己当前position继续下载。重试次数可以配置我一般默认3次。重试之间加一个递增的delay比如第一次500毫秒第二次2秒第三次5秒避免短时间大量重连给服务器造成压力。睡眠等待的代码private void waitForRetry(int retryCount) { long delay Math.min(5000, 500L * (retryCount 1) * (retryCount 1)); try { TimeUnit.MILLISECONDS.sleep(delay); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }指数退避的规律这里体现出来了。重试3次以后还是失败就标记该segment失败。当某个segment失败时是否让整个任务失败我实现里的策略是看失败比例如果只有一个segment失败而其他都完成了可以让成功的那些segment保持不变单独重试失败的segment。如果一半以上都失败了说明网络或服务器有严重问题直接整体失败。4. 常见问题与排查技巧实录4.1 最典型的几种下载异常速查表这个部分是实战中积累下来的排查经验放在一起作为速查表遇到同类问题先对照一下现象可能原因排查与解决下载文件比服务器上的小end区间算错漏掉了尾部字节检查分段计算公式最后一个线程必须覆盖到totalSize-1文件播放到最后几秒花屏分段边界重叠多线程写到了同一字节检查区间表达式是否正确左闭右闭用hash校验文件大小进度条跳变不连贯回调里做了耗时操作把回调内容精简只做UI更新进度上报加节流暂停后恢复从0开始恢复时Range头带的起始位置错误检查taskinfo文件是否被删除恢复时读取segment.current下载文件大小为0但提示完成服务器返回200但body为空或写文件用了错误的输出流下载完成后先校验已写入字节数和Content-Length再发完成事件偶发整块数据写错位置多个线程共享同一个RandomAccessFile实例改为每个线程独立实例在启动时seek到自己的位置HTTPS连接报SSLHandshakeException服务器证书链不完整或自签名生产环境安装正确证书测试环境可临时开启信任所有证书的开关后台下载几分钟后线程被系统杀死任务没有绑定前台服务或没有使用WorkManager长任务建议做成前台Service加上通知栏常驻通知这张表里比较容易被忽略的是最后一条。Android系统对长时间后台运行有严格限制纯粹在Activity里开的线程用户按Home或者锁屏后进程很快会被系统回收。你要做一个下载工具的话必须考虑用前台Service提升进程优先级。这不是工具类本身的问题但工具类跑在哪个载体上直接决定下载能不能最后完成。4.2 判断服务器是否支持多线程下载的实用技巧我在最初做这个工具类时发现有一半的多线程下载变慢问题其实不是代码问题而是服务器压根就不支持并发分段请求。掌握快速判断方法能省很多排查时间。最直接的办法是用curl命令。命令行执行curl -I -H Range: bytes0-1023 http://你的下载地址看响应头里的状态码。如果是206并且有Content-Range字段说明服务器支持分段下载。如果返回200且Content-Length等于文件总大小说明服务器忽略了Range头这种地址用多线程下载不但没增益反而会互相干扰。另外留意响应头里的Server字段。Nginx、Apache、S3、OSS、CDN这些主流服务默认都支持Range。但有些动态生成的下载接口比如后端直接循环读数据库拼文件返回的Servlet很可能没有处理Range头。如果你要对接的服务器是你们自己团队开发的建议在接口层直接加上Range支持这对下载体验的提升非常明显。4.3 适配Android不同版本与存储权限的经验把这个工具类真正放进工程里跑还有一道绕不过去的大关卡存储权限适配。Android 10及之前的版本往公共目录写文件需要WRITE_EXTERNAL_STORAGE权限。在AndroidManifest里声明后还要在运行时分情况动态申请。Android 9及以下用户拒了权限就退出Android 10可以用分区存储的兼容模式但如果你targetSdkVersion已经升到30就不能再用requestLegacyExternalStorage这套老办法了。Android 11开始分区存储变成了强制规则。直接使用getExternalStorageDirectory()往公共存储根目录写文件是行不通的即使申请了权限也不行。这时候有两条路一条是往app自己的外部私有目录写比如getExternalFilesDir()不需要额外权限应用卸载时数据自动清理。适合那些下载文件只给本app用的场景。另一条是走MediaStore接口写公共目录可以配合DownloadManager系统服务或者自己用ContentResolver写入。适合做一个类似文件管理器的下载应用。用户从下载列表点击打开文件时也要注意fileProvider的配置。很多开发者在这个步骤遇到FileUriExposedException崩溃就是因为Android 7.0开始禁止直接暴露file://格式的Uri。你的下载工具类保存完文件回调返回给业务方时最好直接返回一个content://的Uri或者返回绝对路径再由业务方转换不要在工具类里生成Intent去打开文件破坏工具类的通用性。同步知识还是要补一句多线程下载涉及的文件锁、网络连接、状态回调在Android的严格模式下都有对应的lint检查。如果出现StrictModeViolation先看看是在哪个线程触发的大概率是你在主线程调用了startDownload而没有走调度线程。4.4 实测数据3线程并发下载效果参考最后放一组我在开发机上实测的数据供你调整参数时参考。测试条件模拟器环境网络带宽约30Mbps下载一个120MB的安装包文件服务器走Nginx支持Range。下载方案耗时秒平均速度MB/s备注单线程28.64.19基线对比2线程19.16.28提升约50%3线程15.27.89提升明显综合最优4线程14.58.27提升有限6线程14.68.21无提升反而略降结论很清晰3线程是性价比最高的点。2线程到3线程的提升明显3线程到4线程大概只有1.5%的收益堆6线程已经不涨反降。所以前面说默认threadCount设3不是拍脑袋是实测数据支撑的。当然不同网络条件和不同服务器连接数限制下最佳线程数会略有浮动但这个数据在大多数场景下都足够有参考价值。多线程下载工具类用好了整个下载体验会有质的提升。我个人的体会是这个类最容易被低估的地方其实是任务管理那一层进度回调好写难的是各种边界状态的处理。如果你也在做一个下载功能的app强烈建议把暂停恢复的能力和状态持久化做扎实这两块搞定了无论用户是在地铁里信号忽好忽坏还是切后台锁屏下载任务都能稳稳推进到完成。毕竟用户感知到的不是底层多线程的细节而是这个app下载东西到底稳不稳。
返回列表