总有一些场景做出来之后回头看特别简单但踩坑的过程能让人想砸电脑。我这次要分享的是一个在自研App里模拟朋友圈九宫格图集场景时用Java实现的一套图片下载优化组件。核心就两件事分块请求HTTP Range和断点续传。前者解决带宽利用效率后者解决弱网下的重复下载成本。做这套东西的起因很简单用户点开一个九宫格图集一次性要拉9张图每张1到3MB理论上就是十几到二十几MB的流量。在弱网环境里任一张图失败就得整体重试流量和时间的浪费都成倍放大。如果你也在做图片列表、图集加载、大文件下载相关的功能或者单纯想搞清楚Range请求和断点续传在Java里到底怎么落地这篇文章应该能给你省下不少弯路的成本。我这里聊的“微信朋友圈”不是逆向某个客户端而是指“同类型九宫格图集”的产品场景我们的实现完全是基于常规HTTP协议来做服务端和客户端都在自己可控范围内。我需要先说明这套方案的出发点不是“把下载速度拉满”而是“把每一KB流量的价值榨干”。在移动网络里真正的敌人往往不是带宽上限而是请求失败导致的重复传输、串行等待导致的链路空闲以及大文件整体下到一半断掉之后的归零重来。分块请求加断点续传恰好就是冲着这三个问题去的。1. 朋友圈图集下载问题出在带宽而不只是速度1.1 九宫格场景的带宽账单你可以先算一笔账。假设一张图压缩后是2MB一个九宫格页面9张图就是18MB。如果用户在4G网络下看到缩略图后决定点开原图这18MB要走完按运营商常见的下行带宽计算理论上需要几十秒。这还只是单次传输的成功成本。真正让人头痛的是失败成本。移动网络的信号是动态的地铁里、电梯里、地下车库任何一个信号波动的瞬间都可能导致某一张图下载中断。如果客户端采用“整图下载失败重下”的策略那么240KB、180KB甚至2.1MB的数据可能已经下载了80%但你只能丢掉重来。一次失败不只是浪费一张图的流量而是浪费掉它已经消耗的所有流量同时用户还看到了一张永远转圈的图片。这背后其实暴露了两个维度的问题。第一个是“带宽利用率”网络连接建立之后数据在传输过程中是存在空档的尤其是串行下载9张图每张图之间的TLS握手、DNS解析、请求排队都会让链路闲置。第二个是“有效流量占比”真正有用的数据应该是一次下载就落盘成功而不是反复重传。分块请求解决的是第一个问题——让多个分块可以并行下载把链路空档填满断点续传解决的是第二个问题——让已经成功下载的部分永久生效即使中断也只是补齐剩余部分。1.2 优化思路对比转码、CDN与分块请求当时摆在我面前的有三个方向。第一个方向是服务端做图片转码比如对超过一定尺寸的图生成WebP或者压缩到更低分辨率让客户端按需拉取“较小版本”。这个思路本身没错但它有一个前提你得能控制服务端能在图片上传时生成多套规格。如果图片来自第三方接口或者历史数据已经堆积了很久做一次全量转码的代价并不小。第二个方向是CDN加速分发。把图片资源放到CDN上让用户从最近的边缘节点拿数据。这个方案对首屏速度帮助很大但对“带宽消耗总量”没有本质优化——该下载2MB还是2MBCDN只是让这2MB来得更快不会让它变得更小。而且如果原图本身在某个存储桶或老服务器上接入CDN也需要配置和迁移成本。第三个方向就是我最终采用的分块请求加断点续传。它的优势在于完全不需要服务端做任何改造只要HTTP服务器支持Range请求头即可而这个支持几乎已经成为所有静态资源服务器的默认能力。即便某些边缘场景不支持我们还可以降级到全量下载。更重要的是它同时押中了“带宽利用率”和“有效流量”两个痛点并行分块填补链路空档断点续传让每一次成功的传输都被记录。所以最后的选择逻辑很简单在改动成本最低的前提下优先解决用户流量浪费和加载失败率同时不牺牲加载速度。分块请求加断点续传是这三个方案里性价比最高的那个。2. HTTP Range协议与分块下载的核心机制2.1 206 Partial Content服务端是怎么“按需发货”的分块请求不是一个客户端自定义的“玩法”它有非常标准的协议支撑就是HTTP/1.1里定义的Range请求头。客户端发起请求时在头部带上Range: bytes0-262143含义是说我只需要这个资源从第0个字节到第262143个字节即前256KB。服务器如果支持就返回状态码206 Partial Content并且在响应头里带上Content-Range告诉客户端本次返回的是整个资源的哪一段以及资源总大小Content-Range: bytes 0-262143/2048576其中2048576是文件的总字节数。如果服务器不支持Range就会忽略这个头部直接返回200和完整内容。这一点特别重要因为客户端代码必须同时处理206和200两种响应不能一看到206默认服务器支持也不能一看到200就认为下载失败。这个机制用一个生活化的例子来解释就是你去书店买一本书可以要求店员只复印第1页到第100页给你。店员如果愿意就告诉你“我复印了第1到第100页全书总共500页”。如果不愿意就直接把整本书塞给你。分块下载就是客户端把这份“复印请求”并发地发出去每份都指定不同的页码范围最后把收到的片段按页码拼回一本书。因为每一块都走独立的HTTP连接并行下载时链路空档就被填满了理论上多个分块同时传输总吞吐量可以接近带宽上限。2.2 分块大小怎么定经验值与方法分块大小是个需要认真权衡的参数。切得太小比如每块16KB请求数量会非常多。一个2MB的文件会被切成128块每块都要经历一次完整的HTTP往返TCP握手、TLS握手、请求头、响应头这些固定开销摊到每块头上会变得很亏。切得太大比如整张图就是一个块那又回到了串行下载的状态关键时刻一个块失败整个文件依然要重下重来。我实践下来的经验值是这样1MB以下的小图直接用整块下载不切分。因为这类图本身下载时间短失败重试的成本可控切分带来的收益抵不过请求开销。1MB到3MB的图切成4个分块。3MB以上的大图切成8个分块。每一块的尺寸大约落在256KB到512KB之间。如果按“固定分块数”来计算每次下载前需要先拿到文件总大小然后动态计算每个分块的边界。伪逻辑是分块数 根据文件大小确定例如 4 或 8 每块大小 ceil(文件总大小 / 分块数) 第 i 块的起始位置 i * 每块大小 第 i 块的结束位置 min((i 1) * 每块大小 - 1, 文件总大小 - 1)在弱网环境下我建议把分块数适当下调也就是让每一块更大一点。原因是弱网下每个HTTP请求的成功率都降低请求数越多整体失败的概率就越大。把图片切成2到4块配合断点续传通常比切成8块的成功率更高。这个取舍用一句话概括网络越差越要减少并行请求数用续传兜底网络越好越可以增加并行度用带宽换时间。2.3 断点续传的三个关键条件范围、偏移量与源文件校验断点续传看似只是一个“记录位置”的小功能实际上牵扯到三个必须同时满足的条件。第一个条件是“范围正确”。续传时你要重新发起Range请求但这个Range必须和原来没下完的某个分块范围完全一致不能多也不能少。否则要么数据重叠要么文件中间出现空洞。我见过一种低级bug下载到一半的块续传时把start重新计算成了已写入的字节偏移导致数据错位。第二个条件是“写入偏移正确”。分块写入文件时RandomAccessFile的seek位置必须是这一块的起点。并发下载多个分块时每块负责一段互不重合的字节区间。如果两个块写到同一个位置后写入的会把先写入的覆盖掉文件看起来大小是对的内容却已经坏了。第三个条件是“源文件一致性”。断点续传最大的隐患是服务器上的文件已经更新了但客户端还在续传旧的任务。如果Content-Range里的总大小变了或者ETag、Last-Modified变了就必须放弃所有续传状态从零开始重新下载。我一般是先记录初始请求时服务器返回的ETag和Last-Modified续传前用Range: bytes0-0探一下只要发现这两个值变了直接返回“文件已更新需要重新下载”。3. Java端实现从分块请求到断点续传的完整落地3.1 环境与依赖准备这套组件的开发环境是Java 11HTTP客户端用的OkHttp 4.12.0JSON序列化用的Gson没有引入其他复杂的重型依赖。选择OkHttp而不是原生HttpURLConnection主要是因为它对连接池、超时、重试和响应体读取的控制更细尤其是读取超时可以精确到每个单独的读操作这对弱网环境下的分块请求至关重要。Maven依赖如下dependency groupIdcom.squareup.okhttp3/groupId artifactIdokhttp/artifactId version4.12.0/version /dependency dependency groupIdcom.google.code.gson/groupId artifactIdgson/artifactId version2.10.1/version /dependency如果你用的是Android端同样适用加依赖时注意网络权限即可。3.2 服务端支持性探测Range探测请求任何一次分块下载第一步都不是直接切分而是先探测服务器支不支持Range同时拿到文件总大小。我喜欢用Range: bytes0-0这个“最小段请求”来探测。为什么用0-0因为这一段的请求对服务器压力最小却足以让我们从响应头里读出关键信息。public class RangeProbeResult { private boolean rangeSupported; private long totalSize; private String etag; private String lastModified; } public RangeProbeResult probe(String url) throws IOException { Request request new Request.Builder() .url(url) .header(Range, bytes0-0) .build(); try (Response response client.newCall(request).execute()) { RangeProbeResult result new RangeProbeResult(); if (response.code() 206) { result.rangeSupported true; String contentRange response.header(Content-Range); // Content-Range 形如: bytes 0-0/2048576 result.totalSize Long.parseLong( contentRange.substring(contentRange.lastIndexOf(/) 1)); } else if (response.code() 200) { result.rangeSupported false; result.totalSize response.body() ! null ? response.body().contentLength() : -1L; } else { throw new IOException(探测请求失败HTTP状态码: response.code()); } result.etag response.header(ETag); result.lastModified response.header(Last-Modified); return result; } }注意解析Content-Range时要格外小心。格式是bytes 起始位置-结束位置/总大小我见过有些老服务器返回的总大小写的是*表示未知。这种场景基本可以判定为不支持分块直接走全量下载降级路径。3.3 分块下载核心代码与RandomAccessFile写入拿到总大小并且确认支持Range之后就可以计算分块边界并发起分块请求。分块的写入必须用RandomAccessFile因为它允许你指定写入的字节偏移位置这样多个分块之间互不干扰。先看分块边界计算public ListChunk splitChunks(long totalSize, int chunkCount) { ListChunk chunks new ArrayList(); long chunkSize (long) Math.ceil(totalSize * 1.0 / chunkCount); for (int i 0; i chunkCount; i) { long start i * chunkSize; if (start totalSize) { break; } long end Math.min(start chunkSize - 1, totalSize - 1); chunks.add(new Chunk(i, start, end)); } return chunks; }这里有一个特别容易踩的边界问题最后一个分块的结束位置必须用min封顶到totalSize - 1。如果不做这个处理最后一个分块的Range会超出文件末尾服务器会返回416 Range Not Satisfiable整个任务直接失败。然后是单个分块的下载逻辑public void downloadChunk(String url, File localFile, Chunk chunk) throws IOException { Request request new Request.Builder() .url(url) .header(Range, bytes chunk.start - chunk.end) .build(); try (Response response client.newCall(request).execute()) { int code response.code(); if (code 206) { writeChunkToFile(localFile, response, chunk.start); } else if (code 200) { // 服务器忽略 Range 请求返回全量数据只能按全量方式处理 writeChunkToFile(localFile, response, 0); } else { throw new IOException(分块下载失败HTTP状态码: code); } } } private void writeChunkToFile(File localFile, Response response, long seekPosition) throws IOException { try (RandomAccessFile raf new RandomAccessFile(localFile, rw); InputStream in response.body().byteStream()) { raf.seek(seekPosition); byte[] buffer new byte[8192]; int len; long totalWritten 0; while ((len in.read(buffer)) ! -1) { raf.write(buffer, 0, len); totalWritten len; if (totalWritten response.body().contentLength()) { break; } } } }这个写入逻辑有两点值得说。第一点每次写入分块前都必须seek到块的起始偏移。如果多个分块在多个线程里同时写同一个文件RandomAccessFile的写位置是各自的实例状态互不干扰。第二点读取循环里的contentLength判断是双保险防止某些服务器给的Content-Length与实际流长度不一致导致写入超出分块边界。3.4 断点续传元数据设计断点续传要落地必须把“哪些分块已经完成”这个信息持久化。内存里放一份只能管一次进程运行进程一退出全丢了。我用的方案很简单在目标文件的同目录下生成一个同名加.meta后缀的JSON文件。{ url: https://example.com/images/2MB.jpg, totalSize: 2048576, etag: \abc123\, chunkSize: 256102, chunks: [ {index: 0, start: 0, end: 256101, done: true}, {index: 1, start: 256102, end: 512203, done: false} ] }对应的Java实体类很简单public class ChunkMeta { private int index; private long start; private long end; private boolean done; } public class DownloadMeta { private String url; private long totalSize; private String etag; private ListChunkMeta chunks; }下载前先读取meta文件如果本地文件已经存在且长度等于totalSize直接认为下载完成。如果meta存在但etag和服务器不一致说明源文件已更新删除meta重新下载。如果meta存在且一致遍历chunks列表只提交done为false的分块。每个分块下载成功后立即把meta里对应的done置为true并写回磁盘。这个过程有几个设计上的细节。一是每个分块完成就立刻更新meta而不是全部完成后一次性写回否则中途退出会因为meta一直显示旧进度而丢失已完成的块。二是并发写meta文件需要同步简单做法是给meta的读写方法加synchronized避免多个线程同时做读改写导致状态错乱。三是在整个流程开始前先创建一个空的本地文件并设置长度等于totalSize这样即使RandomAccessFile写入某个分块时还没下载其他分块也不会因为文件不存在而报错。设置长度的方法try (RandomAccessFile raf new RandomAccessFile(localFile, rw)) { raf.setLength(totalSize); }这一步不是必须的但加上之后配合文件长度等于totalSize就可以作为“下载完成”的快速判断条件。3.5 并发控制与全流程拼装分块下载的并发控制我用的是线程池加CountDownLatch。线程池大小一般等于分块数但不能盲目放大建议不要超过4到8个。因为移动端的带宽和内存都有限线程数太多TCP连接数猛增链路拥塞反而会让每个分块都变慢。public boolean download(String url, File localFile) throws IOException, InterruptedException { RangeProbeResult probe probe(url); if (probe.totalSize 0) { throw new IOException(无法获取文件大小); } // 如果本地文件已存在且大小一致可能需要额外校验 if (localFile.exists() localFile.length() probe.totalSize) { return true; } int chunkCount decideChunkCount(probe.totalSize); ListChunk chunks splitChunks(probe.totalSize, chunkCount); // 读取续传元数据清理已完成分块 DownloadMeta meta loadOrCreateMeta(localFile, url, probe, chunks); ListChunk pending meta.getChunks().stream() .filter(m - !m.isDone()) .map(m - new Chunk(m.getIndex(), m.getStart(), m.getEnd())) .collect(Collectors.toList()); ExecutorService pool Executors.newFixedThreadPool(Math.min(4, chunkCount)); CountDownLatch latch new CountDownLatch(pending.size()); for (Chunk chunk : pending) { pool.submit(() - { try { int retryTimes 3; while (retryTimes 0) { try { downloadChunk(url, localFile, chunk); markChunkDone(meta, chunk.index); break; } catch (IOException e) { retryTimes--; if (retryTimes 0) { throw new IllegalStateException(分块下载失败: chunk.index, e); } Thread.sleep(1000); } } } catch (Exception e) { // 记录失败具体由上层决定如何处理 } finally { latch.countDown(); } }); } latch.await(); pool.shutdownNow(); return localFile.length() probe.totalSize; }decideChunkCount的逻辑可以是总大小小于1MB返回11MB到3MB返回4大于3MB返回8。这个可以根据实际网络情况调。任务执行完之后如果文件大小等于totalSize但是某些分块失败了代码里返回的false会让上层决定是重新调度还是提示用户。千万不能根据文件大小判断成功后就对坏文件置之不理这点在问题排查里会专门说。3.6 关键参数配置参考分块下载器的行为很大程度由几个参数决定。我给出自己用过的配置供参考参数推荐值说明connectTimeout10s连接建立的超时弱网下太短容易误判失败太长会拖住整体进度readTimeout15s单次读取操作的超时适合大部分图片服务器bufferSize8192写入缓冲8KB是性能和内存占用比较均衡的点分块大小256KB-512KB根据网络质量动态调整弱网用大块分块线程数4最多8超过之后收益下降明显单分块重试次数3超过3次该分块直接上报失败重试间隔1s-3s避免高频重试把服务器打挂同时要记得给OkHttp客户端配置一个合理的连接池参数。默认的连接池对单次下载没什么问题但如果你同时要下载多张图连接池的maxIdleConnections太少会导致频繁复用连接失败重新建连。4. 真实环境踩坑记录与排查技巧4.1 服务端不认Range头200与206的处理我最早只按206做了实现上线后测试发现有一张图总下载失败。排查日志后发现请求发出去了Range也带了服务器却返回200并且直接吐出了整个文件。问题在于这个图片资源所在的那个静态服务器没有将Range头透传到后端文件服务而是当成普通资源请求返回了全量数据。处理方式其实很简单在downloadChunk方法里同时处理206和200。如果是200意味着服务器不认Range这时候就只能按全量下载走。但要注意多线程同时发起多个200全量下载时每个线程都会写出完整文件RandomAccessFile在多线程下同时seek再写数据就是乱的一团。所以一旦发现200响应我会立即中止其他分块任务改回单线程全量下载。这里日志变得很关键。我在探测阶段就会记录rangeSupported字段如果返回false就不再发起分块请求直接全量下载。这样既不浪费时间也能让日志清晰地告诉你哪些地址不支持分块。4.2 下载完成却打不开图片偏移量边界计算有个测试反馈说一张图“下载完成”了文件大小和服务器给的Content-Length完全一致但手机相册里打不开。用命令查看二进制发现问题出在每两个分块的交界处前一块的末尾和后一块的开头有重叠或空洞。查下来发现根因是分块划分方法和写入逻辑之间有偏差。splitChunks里第i块的start被算成了i乘以chunkSize但chunkSize是向上取整得到的而后一块又被算成从(start chunkSize)开始本身逻辑没有问题。问题出现在从META文件读取progress时有些块的start/end和原始splitChunks算出的不一致原因是对已完成块和未完成块的处理逻辑里有一个字段在写回去时被重复修改了。这个坑给我的教训是分块的边界信息应该以第一次划分后存入meta的数据为准每次续传时不做重新计算只读取并禁用本地重新推导。否则一旦修改了分块大小策略新旧meta里的边界就会冲突。还有一个常见错误是最后一个分块的end没有用Math.min拉住导致Range请求的结束位置超过了文件总长减1服务器返回416。416的日志特别好认一旦看到这个状态码不要犹豫直接去看分块边界计算。4.3 弱网线程长时间假死超时与重试策略弱网环境下最让人崩溃的不是下载失败而是线程“不失败也不同意”。某个分块请求发出后服务器迟迟不返回数据OkHttp的readTimeout设成30秒这段时间整个线程就像死掉一样其他分块下载完之后等它用户又盯着转圈。后来我把readTimeout从30秒改为15秒一开始还担心超时太短会让弱网用户大量重试实际数据显示重试次数只增加了一点但整体的“最终完成时间”反而缩短了。原因很简单假死的时间被压缩了早期失败能被更早发现更早进入重试。重试也不是马上重试我用1秒起步按指数退避到3秒封顶。这能有效避免弱网抖动时所有分块同时重试造成瞬时请求风暴。另外不要在主线程里await。CountDownLatch的await务必放到子线程主线程只通过回调感知进度变化。Android上如果在主线程做这个操作直接就是ANR的节奏。4.4 “伪完成”的断点续传文件大小对不上怎么办有一种情况很迷惑本地文件的长度已经等于totalSize但文件内容其实是坏的。比如多个分块并发写入同一个文件seek位置算错数据互相覆盖文件大小恰好不变。再比如服务器文件在下载过程中被重新上传ETag变化了但客户端还在用旧的meta续传旧的偏移量下载出来的文件新旧内容交错。对这类“伪完成”我采用两层校验。第一层是文件长度等于totalSize这是快速通过条件。第二层是续传前用新请求探测ETag和Last-Modified发现和meta记录不一样立刻删除本地文件和meta重新开始整个下载流程。如果内容对完整性要求更高比如要校验最终文件哈希可以在下载完成后对文件计算MD5或CRC32和服务器提供的值比对。但很多图片服务器不提供文件哈希所以这个字段是可选配置没有值时就只依赖长度和ETag校验。4.5 常见问题速查表问题现象可能原因解决办法请求返回416分块边界超出文件总长检查最后一块end是否做min限制返回200而不是206服务器不支持Range探测阶段识别并降级为全量下载文件大小对但打不开多个分块写入偏移重叠确认每个分块独立seekmeta边界不重算续传总是从零开始meta文件未及时落盘或未读取每个分块完成后立即写回meta某分块卡住不动网络假死、readTimeout过长缩短读取超时采用指数退避重试大图下载慢并发分块数不足3MB以上切成8块4线程并发下载中途崩溃后文件图标损坏meta丢失或未创建空文件下载前先setLength(totalSize)同一张图反复走全量下载ETag校验失败检查服务器是否返回稳定ETag这套排查方式不一定适用范围最广但对于图片分块下载这个场景我目前遇到的九成问题都能在表里找到对应项。后面如果要做进一步优化我还会考虑把分块大小做成动态自适应的根据历史下载速度自动调整弱网自动降低分块数好网自动提高并发度。另外一个思路是按需优先加载九宫格页面只下载首屏可见的几张滚动到哪张再触发哪张的下载配合现有的断点续传用户体验还能再上一层。不过这些都是后续的扩展方向当前版本这套分块请求加断点续传的方案已经足够把重复下载的流量压到可以忽略的程度。