首页 / 资讯中心 / 文章详情

Android多线程断点续传实战:解决大文件下载卡顿与损坏

Android多线程断点续传实战:解决大文件下载卡顿与损坏 ★ FEATURED ARTICLE
简介这是一份面向Android中高级开发者的实战型下载技术资料聚焦多线程、断点续传与多文件并发下载三大核心场景帮助解决大文件下载中UI阻塞、重复下载和并发失控等常见痛点。资源包共1142个文件以png图片、xml布局与配置、json数据、class与java源码为主另含jar、aidl、apk、dex等编译与运行产物整体约8.63MB结构完整可直接对照源码理解线程池调度、进度持久化与任务恢复的实现细节。内容覆盖ThreadPoolExecutor参数配置、下载任务Runnable设计、本地进度检查与错误恢复、主线程UI同步以及OkHttp拦截器实现断点续传等关键知识点并附带可运行的示例工程与测试APK便于读者边调试边验证。目前已有804人学习下载适合希望深入掌握Android下载框架、提升应用性能与用户体验的开发者参考。1. 多线程断点续传为什么你的下载器总在 99% 卡住做过移动端下载功能的人多半遇到过这种场景用户下载一个 2GB 的视频进度条走到 99% 突然停住或者切到后台再回来进度直接归零重来。更糟的是同时下载三四个大文件时内存飙升、界面卡死、部分文件损坏。这类问题的根源往往不在网络而在于下载器本身没有把「多线程」「断点续传」「并发调度」这三件事拆开处理。Android 多线程断点续传同时下载多个大文件核心要解决的是三件事把单个大文件切成多段并行拉取以跑满带宽把每段的进度持久化到本地以便中断后从断点继续以及用一个统一的调度器管理多个下载任务避免线程和 IO 互相踩踏。它适合需要离线缓存、批量素材拉取、OTA 包分发的场景也适合任何不想依赖第三方下载库、想自己掌控重试和校验逻辑的团队。下面按「先跑通单文件多线程再扩展到多任务并发最后处理校验和异常」的顺序讲。2. 单文件多线程断点续传的最小实现2.1 用 HTTP Range 把文件切成 N 段断点续传的前提是服务端支持Accept-Ranges: bytes。客户端在发起请求前先发一个 HEAD 请求拿到Content-Length然后按固定大小切片每片带Range: bytesstart-end头去拉。常见做法是每段 1MB 到 4MB太小会导致请求数暴涨太大则单段失败重试成本高。我一般按文件大小动态算小于 10MB 用 1MB 段10MB 到 100MB 用 2MB超过 100MB 用 4MB。// 计算分段返回每段的起止字节 public Listlong[] splitSegments(long totalLength, long segmentSize) { Listlong[] segments new ArrayList(); long start 0; while (start totalLength) { long end Math.min(start segmentSize - 1, totalLength - 1); segments.add(new long[]{start, end}); start end 1; } return segments; }这段逻辑的关键是end的边界处理最后一段的end必须等于totalLength - 1否则会多请求一个字节导致 416 错误。segmentSize就是上面说的动态值实际项目中建议把它做成可配置项方便针对不同 CDN 调优。2.2 每段独立写文件RandomAccessFile 的定位写入多线程下载最容易翻车的地方是多个线程同时写同一个文件。正确做法是用RandomAccessFile打开同一个文件句柄每个线程按自己的start偏移量seek后写入写完立即flush。不要用FileOutputStream追加模式因为追加模式无法指定偏移多线程下会互相覆盖。// 每个分段线程的写入逻辑 RandomAccessFile raf new RandomAccessFile(targetFile, rw); raf.seek(segment[0]); // 定位到本段起始位置 byte[] buffer new byte[8192]; int len; while ((len inputStream.read(buffer)) ! -1) { raf.write(buffer, 0, len); downloaded len; // 每写 64KB 更新一次进度避免频繁写数据库 if (downloaded % (64 * 1024) 0) { progressDao.update(taskId, segmentIndex, downloaded); } } raf.close();seek是这里最关键的调用它决定了每个线程写文件的位置。buffer用 8KB 是 IO 吞吐和内存占用的折中实测 4KB 到 16KB 差异不大。进度更新频率要控制每 64KB 写一次数据库足够太频繁会导致 SQLite 锁竞争。2.3 断点信息持久化一张表存清楚每段进度断点续传的本质是把「每个分段下载了多少字节」持久化。用一张download_segment表字段包括task_id、segment_index、start_pos、end_pos、downloaded、status。任务启动时先查这张表如果某段downloaded等于end_pos - start_pos 1就跳过否则从start_pos downloaded继续请求。CREATE TABLE download_segment ( id INTEGER PRIMARY KEY AUTOINCREMENT, task_id TEXT NOT NULL, segment_index INTEGER NOT NULL, start_pos INTEGER NOT NULL, end_pos INTEGER NOT NULL, downloaded INTEGER DEFAULT 0, status INTEGER DEFAULT 0, -- 0待下载 1下载中 2已完成 3失败 UNIQUE(task_id, segment_index) );UNIQUE(task_id, segment_index)这个约束很重要它保证同一个任务的同一段不会重复插入。status字段用于区分「下载中」和「已完成」进程被杀后重启时所有status1的段都要重置为0重新下载因为无法确认上次写入是否完整。3. 多任务并发调度线程池和任务队列怎么配3.1 全局线程池 vs 每任务线程池同时下载多个大文件时如果每个任务都开自己的线程池线程数会迅速膨胀。假设同时下 5 个文件、每个文件 4 段就是 20 个线程同时跑再加上 IO 等待CPU 上下文切换开销会很明显。常见做法是用一个全局线程池核心线程数设为CPU 核数 * 2 1最大线程数设为 8 到 16队列用有界队列防止任务无限堆积。// 全局下载线程池配置 int coreSize Runtime.getRuntime().availableProcessors() * 2 1; ThreadPoolExecutor downloadPool new ThreadPoolExecutor( coreSize, Math.max(coreSize, 12), 60L, TimeUnit.SECONDS, new LinkedBlockingQueue(64), new ThreadFactory() { private final AtomicInteger count new AtomicInteger(0); Override public Thread newThread(Runnable r) { Thread t new Thread(r, download- count.incrementAndGet()); t.setPriority(Thread.NORM_PRIORITY - 1); // 降低优先级避免抢占 UI 线程 return t; } }, new ThreadPoolExecutor.CallerRunsPolicy() // 队列满时由调用线程执行形成背压 );CallerRunsPolicy是这里的关键选择当队列满时提交任务的线程自己执行这样能自然形成背压避免任务被静默丢弃。线程优先级降低一级是为了在 UI 线程繁忙时让出 CPU。3.2 任务级并发控制同时最多下几个文件全局线程池管的是「段」级别的并发但业务上还需要控制「同时下载几个文件」。比如用户设置了「同时下载 3 个」就需要一个任务级信号量。用Semaphore实现最简单每个任务开始前acquire结束后release。// 任务级并发控制 Semaphore taskSemaphore new Semaphore(3); // 同时最多 3 个任务 public void startTask(DownloadTask task) { taskSemaphore.acquire(); try { // 提交该任务的所有分段到全局线程池 for (long[] seg : task.getSegments()) { downloadPool.execute(new SegmentRunnable(task, seg)); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }注意acquire和release必须配对且release要放在任务所有分段都完成之后而不是提交完就释放。实际项目中建议用CountDownLatch配合等所有分段线程结束后再释放信号量。3.3 分段任务的合并与回调每个分段下载完成后需要通知任务管理器检查是否所有段都完成。用AtomicInteger计数每完成一段减一减到零时触发合并回调。合并本身不需要真的拼接文件因为RandomAccessFile已经按偏移写好了只需要校验文件长度和 MD5。// 分段完成计数 AtomicInteger remaining new AtomicInteger(segments.size()); // 每个分段线程结束时 if (remaining.decrementAndGet() 0) { // 所有段完成校验文件 if (verifyFile(targetFile, expectedMd5)) { callback.onSuccess(taskId); } else { callback.onFailed(taskId, MD5 mismatch); } }decrementAndGet返回的是减一后的值等于零说明是最后一个完成的段。校验 MD5 时要注意大文件读取要用流式不要一次性读进内存。4. 避坑与排查那些让下载器翻车的细节4.1 服务端不支持 Range 导致 416 或全量返回现象请求带了Range头但服务端返回 200 而不是 206或者返回 416。原因是服务端没开Accept-Ranges或者 CDN 回源时丢了 Range 头。解决发请求前先 HEAD 一次检查响应头里有没有Accept-Ranges: bytes没有就降级为单线程下载。另外 416 通常是因为请求的start超过了文件长度检查Content-Length是否在下载过程中变了。4.2 多线程写同一文件导致数据错乱现象下载完成的文件能打开但内容损坏或者 MD5 对不上。原因多半是多个线程用了各自的FileOutputStream追加写偏移量没对齐。解决全局只开一个RandomAccessFile实例或者每个线程开自己的RandomAccessFile但必须seek到正确位置。我一般用后者因为RandomAccessFile不是线程安全的共享实例需要加锁反而降低并发。4.3 进度更新太频繁导致数据库锁竞争现象下载速度慢日志里大量database is locked。原因是每个分段每读几 KB 就写一次数据库。解决进度更新做批量或节流每 64KB 或每 500ms 更新一次。另外 SQLite 开启 WAL 模式能显著减少读写冲突。4.4 进程被杀后断点信息不一致现象重启后从断点继续但文件损坏。原因是进度写入了数据库但文件数据还在缓冲区没落盘。解决写入文件后先flush再更新数据库或者接受少量重复下载——把断点位置往前回退一个缓冲区大小比如 8KB牺牲一点流量换一致性。4.5 并发任务过多导致内存和文件句柄耗尽现象同时下载 10 个文件后 OOM 或Too many open files。原因是每个分段线程都持有 buffer 和文件句柄。解决限制全局并发段数不超过 16每个任务的段数不超过 8文件句柄用完立即关闭。用try-with-resources确保异常时也能释放。5. 进阶用校验和重试策略把下载成功率拉到 99%5.1 分段级重试与指数退避单段下载失败是常态尤其是移动网络切换时。不要整个任务重试而是只重试失败的那一段。重试策略用指数退避第一次等 1 秒第二次 2 秒第三次 4 秒最多重试 3 次。超过 3 次标记该段为失败任务整体暂停并通知用户。// 分段重试逻辑 int retry 0; while (retry MAX_RETRY) { try { downloadSegment(segment); break; } catch (IOException e) { retry; if (retry MAX_RETRY) { markSegmentFailed(segment); throw e; } Thread.sleep((long) Math.pow(2, retry) * 1000); } }Math.pow(2, retry)产生 2、4、8 秒的退避间隔实际项目中可以加随机抖动避免多个段同时重试打爆服务端。5.2 全文件 MD5 校验与分块校验的取舍全文件 MD5 需要读一遍完整文件2GB 文件大概多花 2 到 3 秒。如果对完整性要求极高这一步不能省。如果只是缓存素材可以用分块校验每段下载完算一次 CRC32记录在数据库里合并后抽查几个段。我一般对 OTA 包和安装包做全量 MD5对图片视频做分块 CRC32。校验方式耗时2GB适用场景实现复杂度全文件 MD52-3 秒安装包、OTA低分块 CRC320.5 秒图片、视频中仅校验长度忽略不计临时缓存低5.3 用 WorkManager 做后台下载的兜底Android 后台限制越来越严前台服务 WorkManager 组合是常见做法。前台服务负责展示通知和保持进程优先级WorkManager 负责在进程被杀后重新拉起任务。注意 WorkManager 的setBackoffCriteria要和自己的重试策略协调避免双重退避导致等待过久。// WorkManager 配置 Constraints constraints new Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) .build(); OneTimeWorkRequest work new OneTimeWorkRequest.Builder(DownloadWorker.class) .setConstraints(constraints) .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10, TimeUnit.SECONDS) .addTag(download_ taskId) .build(); WorkManager.getInstance(context).enqueueUniqueWork( download_ taskId, ExistingWorkPolicy.KEEP, work);ExistingWorkPolicy.KEEP保证同一个任务不会重复入队。setBackoffCriteria的初始延迟设 10 秒和自己的分段重试错开避免同时触发。5.4 一个我踩过的坑Content-Length 在下载中变化有一次下载一个动态生成的报表文件服务端每次请求返回的Content-Length都不一样导致分段边界对不上文件直接损坏。后来改成先 HEAD 拿长度如果后续任何一段返回的Content-Range里的总长度和初始值不一致立即终止任务并重新开始。这个检查加在每段请求的响应处理里成本很低但能避免脏数据。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站