1. 为什么 Unity 项目里断点续传值得单独做一层Unity 断点续传这件事第一次听到的人往往会觉得没什么技术含量——不就是加个Range请求头然后接着上次的位置往下写文件吗我最早也是这么想的直到一个数字孪生项目在客户现场演示前半小时400MB 的资源包在弱网会议室里下到 87% 断了而下载器只会从 0 开始重来。那一次之后我把断点续传从顺手加的功能升级成了资源层的一个独立模块前后在 PC、Android、iOS、WebGL、小游戏几个平台上都跑过一轮踩的坑比想象中多得多。这篇东西写给三类人看正在做游戏或应用热更、需要下载几十上百 MB 资源包的 Unity 开发者做工业仿真、数字孪生、VR/AR 培训这类离线或半离线部署场景需要把大模型和大贴图下发到终端设备的人以及单纯想搞清楚 HTTP 下载底层链路、想知道为什么自己写的下载器总是看起来能用但总是出问题的同学。读完之后你应该能拿到一套可以落地改的代码骨架、一份平台差异清单以及一张能直接对着排查的故障表。1.1 一次 400MB 资源包下载失败带来的连锁反应先把场景说清楚不然很多人会觉得断点续传是锦上添花。假设一个 Unity 客户端需要下载一份 400MB 的资源包用户网络是 4G 或者不太稳定的宽带平均下载速度 3MB/s理论耗时 130 秒左右。这 130 秒里只要出现一次抖动——地铁进隧道、Wi-Fi 切 4G、家里路由器被微波炉干扰——TCP 连接断开整包资源作废重来一次又要 130 秒而且第二次同样可能失败。用户看到的体验是进度条走到 60%归零再走到 40%再归零反复几次之后直接卸载。这还只是第一层损失。真正的连锁反应在于资源包下载是所有后续流程的前置条件它卡住热更就走不完玩家进不了游戏运营活动上不了线版本节奏被拖乱。在工业项目里更明显现场设备要么是内网慢速链路要么是把 U 盘拷贝和网络下载混着用一个 2GB 的三维场景包从头下第二遍客户那边的耐心基本就耗尽了。所以断点续传解决的从来不是下载速度问题它解决的是下载这件事的可恢复性是让失败这个必然会发生的事件代价从重来一次变成接着往下走。1.2 断点续传和失败重试是两件完全不同的事很多团队在第一版下载器里加了重试逻辑失败了 sleep 两秒再来一次最多重试五次。这看起来已经挺健壮了但和断点续传的本质差别很大。重试是把同一个动作整个重做已经消耗的流量、时间和电量全部归零断点续传是把动作切成可累积的小段每一段完成后就产生一个持久的、可验证的成果失败只影响当前这一段。前者在稳定网络下够用后者在任何网络下都够用。从成本角度算一笔账会更直观。假设 400MB 资源网络成功率 80%这是个很乐观的估计移动端弱网下经常低于 60%纯重试模式下成功下完的概率是 0.8期望尝试次数 1.25 次期望流量 500MB。如果改成按 8MB 分片续传单片成功率 99%那么需要重下的只是失败的那几片期望流量几乎就是 400MB 加上极少的重传量。这中间的差距不是优化是量级上的区别。更关键的是重试模式在网络特别差的时候会陷入永远下不完的死循环而续传模式最坏情况也只是慢它会一直往前走。1.3 哪些场景必须做哪些场景其实可以偷懒虽然我上面把断点续传说得很重要但也不是所有 Unity 项目都需要。判断标准很简单单个可下载资源的大小乘以网络失败的期望次数是否超过了你愿意承担的重复成本。如果项目里的资源都是几百 KB 的配置表和小图标总量几 MB那老老实实用UnityWebRequest整包下载失败就重试代码简单维护成本低没必要上续传。真正需要上续传的场景大概有这么几类。第一类是热更新资源包超过 50MB 的项目尤其是手游和 VR 应用。第二类是工业数字孪生和仿真培训单个场景动辄几百 MB 到几 GB而且是内网或者专网下发链路质量不可控。第三类是离线部署包的首次初始化用户可能在地铁上用手机预热数据。第四类是 AI 模型、大视频、点云数据这类非游戏资源本质是 GB 级文件下发和 HuggingFace 那种模型下载器面对的是同一类问题。反过来有几类场景我建议先别急着上多线程分片资源在 32MB 以内的、下载目标平台是 WebGL 或小游戏的、服务端是那种老旧的、不支持 Range 的静态服务器的。这些场景要么收益不明显要么实现路径完全不同硬套一套代码只会给自己找麻烦。2. 看懂 HTTP 断点续传Range 请求的完整协议链路写代码之前得先把协议搞清楚。断点续传在 HTTP 层面的实现其实非常朴素靠的就是Range请求头加几个响应头。但我见过太多人只记住了Range: bytesx-这一行结果在服务端返回 200 而不是 206 的时候完全不知道发生了什么日志里一片茫然。把链路吃透后面所有排查都会变得很轻松。2.1 三个响应头和一个请求头撑起整条链路客户端要发起续传只需要在 GET 请求里带上Range: bytes10485760-意思是从第 10485760 字节开始一直到文件末尾。也可以指定结束位置写成bytes0-1048575表示前 1MB这就是分片下载的基础。服务端如果支持这个语义会返回状态码206 Partial Content并带上Content-Range: bytes 10485760-419430399/419430400格式是本次返回的起始-结束/文件总长度。同时Content-Length是本次返回的字节数而不是文件总大小这点特别容易搞错。服务端如果不支持这个语义通常会忽略Range头直接返回200 OK然后把整个文件吐给你Content-Length是文件全量。这个行为不会报错客户端如果不检查状态码就会把整个文件追加到已有内容后面最终得到一个体积翻倍、完全损坏的文件。这是我见过最高频的一类 bug。还有一个状态码是416 Range Not Satisfiable出现在请求的起始位置大于等于文件总长度的时候。它的含义是你要的范围不存在通常意味着本地记录的长度已经超过或者等于远端文件或者远端文件被替换成了更小的版本。遇到 416 不要慌重新探测一次远端信息然后重置断点就行。2.2 服务端不配合客户端写再多代码也白搭这是我要重点强调的一条断点续传能不能用决定权在服务端。客户端这边代码写得再漂亮只要服务端不返回Accept-Ranges: bytes一切归零。所以项目启动阶段第一件该做的事是拿 curl 或者浏览器开发者工具确认一下你的资源服务器到底支不支持。curl -I -H Range: bytes0-1023 https://your-cdn.com/patch/1.0.3/main.bundle看返回的HTTP/1.1那一行是 206 还是 200看响应头里有没有Accept-Ranges: bytes。如果只有 200就得去找运维或者服务商了。常见的几种服务端情况和处理方式Nginx 托管静态文件默认是支持 Range 的但如果对静态资源开了 gzipNginx 在压缩响应时会忽略 Range 头直接返回压缩后的全量 200。这是个巨坑因为 gzip 通常是一开始就配好的谁也不会想到它和 Range 冲突。解决办法有两个一是在下载目录上关掉 gzip二是客户端请求时加Accept-Encoding: identity明确告诉服务端别压缩。对象存储类服务S3、MinIO、各类兼容 S3 协议的私有存储基本都支持 Range它们本身就是为分片场景设计的。但要注意一个细节如果一个对象是通过分片上传multipart upload写进去的它的ETag形如d41d8cd98f00b204e9800998ecf8427e-3后面的数字是分片数量这个值不是文件的 MD5不能拿来当内容校验用。这点在 MinIO 和 S3 上都成立很多人第一次看到 ETag 拿去和本地算出来的 MD5 比对怎么都对不上白白排查半天。CDN 是另一个变量。CDN 边缘节点在回源的时候Range 请求有可能被合并成一次全量回源或者被边缘节点改写。大多数主流 CDN 都做了正确的 Range 透传但如果你发现某些区域总是从 0 开始下载可以先怀疑 CDN 配置做一个多地区拨测确认一下。2.3 一致性校验ETag、Last-Modified 和 If-Range 的配合续传有一个隐蔽的风险断点期间远端文件变了。比如你昨天下了 200MB今天服务器更新了资源包前 200MB 的字节内容已经完全不同。这时候如果直接接着下拼出来的文件是两个版本杂交的怪物校验能过才有鬼。HTTP 用If-Range头来解决这个问题。它的值可以填ETag或者Last-Modified。客户端在续传请求里带上If-Range: 5d8f3a2b-1f4e服务端会拿这个值和当前文件的对应字段比对如果一致就正常返回 206 的续传内容如果不一致就返回 200 和完整文件明确告诉你文件变了从头来吧。注意这里返回 200 是符合 RFC 规定的正确行为不是错误客户端看到 200 就应该主动放弃已有断点清空临时文件重下。所以客户端的处理逻辑应该是断点记录里必须存 ETag或者 Last-Modified续传时带上If-Range然后根据返回码决定是接着写还是重来。没做这一步的续传在资源更新频繁的项目里迟早出事。2.4 上传方向的断点续传是另一套逻辑下载和上传的续传看着像其实是两套完全不同的机制别混在一起想。下载用的是 HTTP 的 Range是标准协议客户端说了算。上传的断点续传没有统一的 HTTP 标准常见做法是靠服务端提供的多段上传接口比如 S3 协议里的CreateMultipartUpload/UploadPart/CompleteMultipartUpload三个接口。流程是客户端先调CreateMultipartUpload拿到一个uploadId然后把文件切成若干段每段单独调UploadPart每段上传成功后会返回一个ETag客户端需要把这些uploadId和每段的ETag持久化下来断点恢复时通过ListParts查询哪些段已经上传成功跳过它们最后所有段都完成后调CompleteMultipartUpload合并。MinIO、AWS S3、阿里云 OSS 都是这个套路接口名字大同小异。如果你做的是客户端上传日志和用户数据到服务器这类需求用的就是这套逻辑核心难点不在传输本身而在uploadId和分片 ETag 的持久化管理——它们丢了之前上传的所有分片就找不回来了只能等生命周期策略自动清理。这一点和下载的断点记录管理思路是一致的都是把中间状态当成一等公民来对待。3. Unity 端下载器的完整实现协议清楚之后落到 Unity 里就是工程问题。我把实现拆成四块目录和记录文件设计、单连接续传主体、分片并发、校验与提交。这套结构是改了好几版之后稳定下来的中间换过两次核心实现原因我下面会讲。3.1 目录结构设计和断点记录文件先把目录定清楚后面所有逻辑都基于它{persistentDataPath}/DownloadCache/ ├─ 1.0.3_main.bundle.tmp # 正在下载的临时文件 ├─ 1.0.3_main.bundle.meta # 断点记录JSON ├─ 1.0.3_main.bundle.parts/ # 分片模式下的分片目录 │ ├─ part_0 │ ├─ part_1 │ └─ ... └─ 1.0.3_main.bundle # 下载完成、校验通过的最终文件关键约定有三个。第一正在下载的目标永远写.tmp后缀只有校验通过才改名到最终名字这样任何时刻程序读到最终文件名都能确信这是一份完整可用的资源。第二断点记录独立成文件不塞进 PlayerPrefs 或者自己写的二进制存档里因为它需要频繁写入而且必须能容忍损坏。第三分片模式下每个分片一个独立文件各自独立可恢复合并一次完成不做原地拼接。断点记录的字段设计{ url: https://cdn.example.com/patch/1.0.3/main.bundle, totalSize: 419430400, downloaded: 123456789, etag: \5d8f3a2b-1f4e\, lastModified: Wed, 21 Oct 2025 07:28:00 GMT, sliceSize: 8388608, finishedSlices: [0, 1, 2, 4, 5], version: 1.0.3, updatedAt: 1761118080 }totalSize和etag来自服务端探测downloaded是单连接模式下的进度finishedSlices是分片模式下已完成的片索引。updatedAt用 Unix 时间戳方便做过期清理——比如超过 7 天没动过的断点直接删掉避免缓存目录无限膨胀。3.2 单文件续传UnityWebRequest 加 Range 头先做远端探测。我习惯先用 HEAD 探一次因为探测成本最低而且能明确拿到Accept-Ranges。public struct RemoteFileInfo { public bool SupportRange; public long TotalSize; public string ETag; public string LastModified; } static string FindHeader(UnityWebRequest req, string name) { var dict req.GetResponseHeaders(); if (dict null) return null; foreach (var kv in dict) { if (string.Equals(kv.Key, name, StringComparison.OrdinalIgnoreCase)) return kv.Value; } return null; } IEnumerator ProbeRemote(string url, ActionRemoteFileInfo onResult) { var info new RemoteFileInfo(); using (var head UnityWebRequest.Head(url)) { head.timeout 15; yield return head.SendWebRequest(); if (head.result ! UnityWebRequest.Result.Success) { Debug.LogWarning($[DL] HEAD 失败 {head.responseCode} {head.error}); onResult(info); yield break; } string acceptRanges FindHeader(head, Accept-Ranges); info.SupportRange !string.IsNullOrEmpty(acceptRanges) acceptRanges.IndexOf(bytes, StringComparison.OrdinalIgnoreCase) 0; long.TryParse(FindHeader(head, Content-Length), out long len); info.TotalSize len; info.ETag FindHeader(head, ETag); info.LastModified FindHeader(head, Last-Modified); } onResult(info); }这里有个细节值得单独说一下GetResponseHeaders()返回的字典在有些平台上是大小写敏感的resp.GetResponseHeader(content-length)和Content-Length可能查不到同一个值。我吃过这个亏所以统一用FindHeader做忽略大小写的遍历查找代码多几行省下的是半夜排查的时间。然后是核心的下载器。这里我要交代一个重要选择我最终没有用DownloadHandlerFile而是自己写了一个继承DownloadHandlerScript的追加写入处理器。原因是这样的。DownloadHandlerFile(string path, bool append)这个重载从 Unity 2020.1 开始才有用起来确实方便它内部是流式写盘的不会把整个文件读进内存。但在续传场景下它有几个让我不放心的地方一是它内部对失败文件的清理策略我不完全确定在 append 模式下如果请求失败文件最终处于什么状态需要实测二是我在不同小版本上见过 append 行为不一致的反馈三是我需要在写入过程中实时拿到已经写了多少字节用来更新进度和做停滞检测DownloadHandlerFile不暴露这个能力。自己写 handler 之后这三个问题一次解决。public class FileAppendHandler : DownloadHandlerScript { private FileStream _fs; private long _written; public long ReceivedBytes _written; public long ExpectedTotal { get; private set; } public FileAppendHandler(string path, long startOffset, long expectedTotal) : base(new byte[256 * 1024]) // 预分配接收缓冲避免内部再分配 { ExpectedTotal expectedTotal; string dir Path.GetDirectoryName(path); if (!string.IsNullOrEmpty(dir) !Directory.Exists(dir)) Directory.CreateDirectory(dir); _fs new FileStream(path, FileMode.OpenOrCreate, FileAccess.Write, FileShare.None, 256 * 1024, FileOptions.None); // 以真实文件长度为基准做一次截断防止文件比记录长 _fs.SetLength(startOffset); _fs.Seek(startOffset, SeekOrigin.Begin); } protected override bool ReceiveData(byte[] data, int dataLength) { if (data null || dataLength 0) return true; _fs.Write(data, 0, dataLength); _written dataLength; return true; // 返回 false 会中断请求正常情况一定返回 true } protected override void CompleteContent() { if (_fs ! null) { _fs.Flush(true); // 只在结束时做一次 fsync _fs.Dispose(); _fs null; } } protected override void Dispose(bool disposing) { if (_fs ! null) { try { _fs.Flush(); _fs.Dispose(); } catch { } _fs null; } base.Dispose(disposing); } }SetLength(startOffset)这一行很重要。如果盘上的文件长度和断点记录里的数字不一致断电、写入未落盘、记录文件写了一半必须以真实文件长度为准做一次截断而不是相信记录文件。记录文件只是加速恢复的提示磁盘上的字节才是事实。主体续传流程IEnumerator DownloadWithResume(string url, string finalPath, string metaPath) { var meta DownloadMeta.LoadOrCreate(metaPath, url); RemoteFileInfo remote default; yield return ProbeRemote(url, r remote r); if (!remote.SupportRange) { Debug.Log([DL] 服务端不支持 Range清空断点整包重下); meta.Reset(); } string tmpPath finalPath .tmp; long localLen File.Exists(tmpPath) ? new FileInfo(tmpPath).Length : 0; // 本地文件长度是唯一事实来源 meta.Downloaded localLen; if (remote.TotalSize 0 localLen remote.TotalSize) { Debug.Log([DL] 本地文件已达总长度直接进入校验); yield return VerifyAndCommit(tmpPath, finalPath, meta); yield break; } // 文件版本变了或者服务端不支持续传从头来 bool versionChanged !string.IsNullOrEmpty(meta.ETag) !string.IsNullOrEmpty(remote.ETag) meta.ETag ! remote.ETag; if (versionChanged || !remote.SupportRange) { if (File.Exists(tmpPath)) File.Delete(tmpPath); localLen 0; meta.Downloaded 0; } meta.TotalSize remote.TotalSize; meta.ETag remote.ETag; meta.LastModified remote.LastModified; meta.Save(metaPath); var handler new FileAppendHandler(tmpPath, localLen, remote.TotalSize); using (var req new UnityWebRequest(url, UnityWebRequest.kHttpVerbGET)) { req.downloadHandler handler; if (localLen 0) { req.SetRequestHeader(Range, $bytes{localLen}-); if (!string.IsNullOrEmpty(remote.ETag)) req.SetRequestHeader(If-Range, remote.ETag); } // 不压缩避免服务端因为 gzip 丢掉 Range 支持 req.SetRequestHeader(Accept-Encoding, identity); req.timeout 0; // 自己控制停滞检测不用内置超时 var op req.SendWebRequest(); float lastReport Time.realtimeSinceStartup; long lastBytes 0; int stallCount 0; while (!op.isDone) { long now localLen handler.ReceivedBytes; if (Time.realtimeSinceStartup - lastReport 5f) { // 停滞检测30 秒没有任何新字节就认为连接死了 if (handler.ReceivedBytes lastBytes) stallCount; else stallCount 0; if (stallCount 6) { req.Abort(); break; } lastBytes handler.ReceivedBytes; meta.Downloaded now; meta.Save(metaPath); // 5 秒落一次盘频率不能再高了 lastReport Time.realtimeSinceStartup; OnProgress?.Invoke(now, remote.TotalSize); } yield return null; } int code req.responseCode; if (code 416) { Debug.Log([DL] 416本地长度超出远端重置断点); meta.Reset(); meta.Save(metaPath); yield break; } if (code 200 localLen 0) { // 服务端忽略了 Range 或者文件变了全量返回 Debug.Log([DL] 收到 200服务端放弃了 Range截断重下); handler.Dispose(); using (var fs new FileStream(tmpPath, FileMode.Create, FileAccess.Write)) fs.SetLength(0); meta.Downloaded 0; meta.Save(metaPath); yield return DownloadWithResume(url, finalPath, metaPath); yield break; } if (req.result ! UnityWebRequest.Result.Success) { Debug.LogWarning($[DL] 中断 code{code} err{req.error}断点已保存); meta.Downloaded localLen handler.ReceivedBytes; meta.Save(metaPath); yield break; } } meta.Downloaded remote.TotalSize; meta.Save(metaPath); yield return VerifyAndCommit(tmpPath, finalPath, meta); }这段代码里有几处是踩坑换来的。req.timeout 0关掉内置超时是因为 UnityWebRequest 的 timeout 语义是整个请求从开始到完成的总时长对一个大文件续传来说设小了会误杀设大了没意义。真正能反映连接健康度的是字节增长速度所以我用 5 秒一次采样、连续 6 次无增长就 Abort 的方式做停滞检测这个策略在各种网络环境下都比固定超时靠谱。断点记录的落盘频率也值得说。每次收到数据都写 JSON 会导致每秒几十次文件 IO在手机上直接拖慢下载5 秒写一次是折中值配合以真实文件长度为准的恢复策略最坏情况也就丢 5 秒的数据重传成本可以忽略。3.3 断点记录的原子写入与损坏自愈断点记录文件本身也必须防损坏因为它恰恰是在最危险的时刻网络抖动、应用被杀被写入的。做法是标准的写临时文件 原子替换public void Save(string path) { string tmp path .writing; try { File.WriteAllText(tmp, JsonUtility.ToJson(this)); if (File.Exists(path)) File.Delete(path); File.Move(tmp, path); } catch (Exception e) { Debug.LogWarning($[DL] 断点写入失败{e.Message}); } } public static DownloadMeta LoadOrCreate(string path, string url) { try { if (File.Exists(path)) { var meta JsonUtility.FromJsonDownloadMeta(File.ReadAllText(path)); if (meta ! null meta.url url) return meta; } } catch (Exception e) { Debug.LogWarning($[DL] 断点解析失败丢弃重建{e.Message}); } return new DownloadMeta { url url }; }JsonUtility在 Unity 里性能不错但对损坏内容会抛异常所以一定要 try/catch。解析失败就当没有记录用磁盘上真实文件长度重新开始——这就是我说的自愈任何中间状态坏掉都不会导致整个下载流程卡死最多损失一点已经下好的数据。顺带一提清理策略。缓存目录要定期扫一遍.tmp文件如果对应的最终文件已经存在删掉.meta文件如果updatedAt超过 7 天连同.tmp一起删掉.writing这种中间态文件只要存在就删。这个清理放在应用启动时做一次就行成本很低能防止用户手机上堆出几个 G 的垃圾。3.4 分片并发下载与合并单连接续传能解决 90% 的问题但如果单个连接速度被限制在 1MB/s而你的带宽其实有 10MB/s那就需要分片并发。分片的思路是先探测拿到总长度按固定大小切成 N 片每片发一个独立的 Range 请求写进独立的.part文件全部完成后按序号合并。IEnumerator DownloadSlices(string url, string partsDir, long totalSize, int sliceSize, int concurrency) { Directory.CreateDirectory(partsDir); int count (int)((totalSize sliceSize - 1) / sliceSize); var done new bool[count]; var running new ListCoroutine(); int nextIndex 0; int finishedCount 0; // 先扫描已有分片跳过已完成的 for (int i 0; i count; i) { string p Path.Combine(partsDir, $part_{i}); long expect Math.Min(sliceSize, totalSize - (long)i * sliceSize); if (File.Exists(p) new FileInfo(p).Length expect) { done[i] true; finishedCount; } } while (finishedCount count) { while (running.Count concurrency nextIndex count) { if (done[nextIndex]) { nextIndex; continue; } int idx nextIndex; running.Add(StartCoroutine(DownloadOneSlice( url, partsDir, idx, sliceSize, totalSize, ok { done[idx] ok; if (ok) finishedCount; }))); } running.RemoveAll(c c null); yield return new WaitForSeconds(0.2f); // 实际项目里用回调计数替代轮询这里为了逻辑清晰用轮询示意 } } IEnumerator DownloadOneSlice(string url, string partsDir, int index, int sliceSize, long totalSize, Actionbool onDone) { long start (long)index * sliceSize; long end Math.Min(start sliceSize, totalSize) - 1; string path Path.Combine(partsDir, $part_{index}); for (int attempt 0; attempt 5; attempt) { var handler new FileAppendHandler(path, 0, end - start 1); using (var req new UnityWebRequest(url, UnityWebRequest.kHttpVerbGET)) { req.downloadHandler handler; req.SetRequestHeader(Range, $bytes{start}-{end}); req.SetRequestHeader(Accept-Encoding, identity); req.timeout 0; yield return req.SendWebRequest(); if (req.responseCode 206 req.result UnityWebRequest.Result.Success) { onDone?.Invoke(true); yield break; } } float backoff 0.5f * Mathf.Pow(2, attempt) Random.Range(0f, 0.3f); Debug.LogWarning($[DL] 分片 {index} 第 {attempt 1} 次失败{backoff:F1}s 后重试); yield return new WaitForSeconds(backoff); } onDone?.Invoke(false); }注意那几个判断分片请求的期望返回码是206不是 200。如果某个分片收到 200说明服务端根本没理 Range这个分片的.part文件内容就是整个文件长度对不上必须丢弃重来。我在代码里用req.responseCode 206做严格判断宁可重试也不接受可疑数据。合并本身很简单顺序流式复制static void MergeParts(string[] partPaths, string targetPath) { using (var output new FileStream(targetPath, FileMode.Create, FileAccess.Write, FileShare.None, 1 20)) { byte[] buffer new byte[1 20]; foreach (var p in partPaths) { using (var input new FileStream(p, FileMode.Open, FileAccess.Read, FileShare.Read, 1 20)) { int read; while ((read input.Read(buffer, 0, buffer.Length)) 0) output.Write(buffer, 0, read); } } output.Flush(true); } }1MB 的缓冲区在 PC 和移动端都够用合并 1GB 大概几秒到十几秒取决于闪存速度。合并期间要给出明确的 UI 提示因为这段时间进度条是不动的用户容易以为卡死了。合并完成后把.parts目录整体删掉再进入校验。3.5 校验与提交改名之前的最后一道关校验要不要做、做到什么程度是个需要权衡的问题。完整的 MD5 校验在 400MB 文件上中端手机大概要 1 到 2 秒完全可以接受我建议必做。但如果是 2GB 以上的文件整包 MD5 可能超过 5 秒这时候可以退化成文件长度 首尾各 64KB 哈希的组合校验成本几乎为零能挡住绝大多数截断和拼接错误。IEnumerator VerifyAndCommit(string tmpPath, string finalPath, DownloadMeta meta) { long len new FileInfo(tmpPath).Length; if (meta.TotalSize 0 len ! meta.TotalSize) { Debug.LogError($[DL] 长度不符{len} ! {meta.TotalSize}丢弃重下); File.Delete(tmpPath); meta.Reset(); meta.Save(finalPath .meta); yield break; } // 大文件可以按需跳过完整 MD5 if (meta.TotalSize 0 meta.TotalSize 512L * 1024 * 1024 !string.IsNullOrEmpty(meta.md5)) { Taskstring task Task.Run(() { using (var md5 System.Security.Cryptography.MD5.Create()) using (var fs File.OpenRead(tmpPath)) { var hash md5.ComputeHash(fs); return BitConverter.ToString(hash).Replace(-, ).ToLowerInvariant(); } }); while (!task.IsCompleted) yield return null; if (task.Result ! meta.md5) { Debug.LogError([DL] 哈希校验失败丢弃重下); File.Delete(tmpPath); meta.Reset(); meta.Save(finalPath .meta); yield break; } } if (File.Exists(finalPath)) File.Delete(finalPath); File.Move(tmpPath, finalPath); if (File.Exists(finalPath .meta)) File.Delete(finalPath .meta); Debug.Log($[DL] 下载完成{finalPath}); }File.Move在同一卷内是原子操作很快。但要留意跨卷会失败比如临时目录在 C 盘、目标在 D 盘在persistentDataPath下同一容器内不存在这个问题。移动端上Application.persistentDataPath都是一个目录安全。MD5 计算放在Task.Run里跑线程池避免阻塞主线程卡住渲染。用while (!task.IsCompleted) yield return null协程等待这样主线程还是一帧一帧地跑。哈希值从哪来最稳的是随资源包一起下发的清单文件里面列出每个资源的路径、大小和 MD5客户端拿到清单后按条目校验。清单本身也要有签名或者哈希保护否则篡改清单等于绕过所有校验当然这已经属于安全范畴一般项目做到清单和资源走同一条可信链路下发就够了。4. 参数怎么定分片、并发、超时与重试的计算思路参数这件事没有标准答案但每一类参数背后都有可以推导的逻辑。我把自己项目里用的取值和推导过程写下来你可以按实际情况套。4.1 分片大小在握手开销和重试成本之间找平衡分片太小的代价是请求数爆炸。每个 HTTPS 请求至少包含 TCP 三次握手和 TLS 握手在 100ms 的 RTT 下大概吃掉 3 到 4 个 RTT也就是 300 到 400ms 的纯等待时间完全没传数据。如果分片是 256KB每片实际传输 256KB / 1MB/s 256ms那么握手开销接近总耗时的 60%非常划不来。分片太大的代价是重试粒度变粗。8MB 的分片在 1MB/s 的链路上要传 8 秒传了 7.5 秒断了这 7.5 秒白费。分片越大单次失败的损失越大。我用的经验值是这样的文件总大小建议分片大小说明小于 32MB不分片单连接续传分片收益不抵复杂度32MB 到 256MB4MB分片数 8 到 64状态管理轻256MB 到 1GB8MB分片数 32 到 128大于 1GB16MB分片数控制在 200 以内200 个分片是个心理上限超过这个数量断点记录的 JSON 会变大扫描已存在分片文件的耗时也会变得明显而且并发调度本身的复杂度上升。如果你有 10GB 的文件要下与其把分片做到 64MB不如考虑分两层先按资源包级别切每个包内部再分片这样还能做到包级别的按需下载。4.2 并发数4 到 6 是个甜点区并发数不是越大越好原因有三个。第一TCP 拥塞控制会让多个连接互相竞争总吞吐反而可能下降。第二很多 CDN 对单 IP 的并发连接数有限制超了会被限速甚至拒绝表现是某些分片一直超时。第三手机端的网络协议栈和应用层调度能力有限8 个以上并发容易引发额外的 CPU 消耗和电量消耗。我实测下来4 到 6 个并发基本能把 100Mbps 的链路跑满再多收益就很小了。如果发现某个并发配置下总耗时反而变长先别怀疑代码直接用 curl 在不同并发数下测一下同一台服务器很多时候是服务端或者 CDN 侧的限流策略在起作用。内网环境可以适当放开到 8因为内网的握手延迟极低多连接的收益更明显。4.3 超时、重试与退避别让重试变成雪崩前面说了请求级别的 timeout 在续传场景下意义不大真正有用的是停滞检测。我给的经验值是连续 30 秒没有新增字节就判定连接失效Abort 当前请求进入重试流程。这个阈值在 4G 弱网和 Wi-Fi 之间都能覆盖如果链路特别差比如卫星链路、远洋链路可以放宽到 60 秒。重试退避用指数加抖动第 n 次重试等待0.5 * 2^n random(0, 0.3)秒。抖动这一项很关键如果所有分片同时失败同时重试会在服务端形成瞬时冲击反而更容易再次失败。加上随机抖动之后重试请求会自然错开成功率明显提高。重试次数我一般设 5 次。5 次之后还没成功说明问题不在偶发抖动而是链路质量或者服务端状态出了问题这时候应该把错误上报给业务层弹出提示让用户切换网络而不是继续无声地重试。用户看到一个一直卡在 60% 的进度条比看到一个网络异常请检查网络后重试的提示要难受得多。4.4 磁盘写入与主线程阻塞两个容易被忽视的隐形瓶颈写盘这件事在 PC 上几乎无感但在手机和低端设备上会实实在在拖慢下载。FileStream如果每收到一小块数据就Flush会触发大量系统调用如果用Flush(true)强制落盘每次都要等闪存控制器响应慢得更多。所以我的策略是接收缓冲区给到 256KB让FileStream自己攒批只在下载完成的时候做一次Flush(true)断点记录 5 秒写一次且用普通的WriteAllText不加 fsync接受极小的数据丢失风险。主线程阻塞则是协程写法带来的问题。所有UnityWebRequest的轮询、进度回调、JSON 序列化都在主线程上跑如果这些操作很重帧率会掉。解决办法是把耗时的纯计算任务挪到Task.Run里比如合并分片、算哈希、解析大 JSON。Unity 的大部分 API 不能在线程池里调但System.IO和System.Security.Cryptography是可以的这两个正好覆盖了最重的部分。一个实用的判断标准是如果某个操作在 400MB 文件上要跑超过 100ms就别放在主线程。5. 平台差异与踩坑排查实录同一套下载代码在不同平台上表现完全不同。这部分是我踩出来的清单按平台分。5.1 PC 和移动端路径、权限与生命周期路径统一用Application.persistentDataPath别自己拼/sdcard/或者Application.dataPath。前者在 Android 10 之后的存储模型下基本不可写后者在打包后指向的是只读目录写入会抛异常。权限方面persistentDataPath是应用私有目录读写不需要任何权限声明这一点很多人不知道白白申请了存储权限还引起用户反感。如果你真的需要写到用户可见的外部存储那才需要权限但资源缓存这种内部数据完全没必要。移动端真正需要注意的是应用生命周期。Android 上应用切到后台网络请求可能被系统冻结iOS 更激进后台几分钟后应用会被挂起网络连接直接被掐。这时候如果不主动处理UnityWebRequest会以一种不明确的状态结束可能既不报错也不返回。我的做法是在OnApplicationPause(true)的时候主动Abort所有进行中的下载请求把当前进度写进断点记录回到前台时再重新读取记录继续下。这样用户的感觉是切出去一会儿回来继续下而不是卡住了要重启。iOS 上还有一个细节persistentDataPath默认会被纳入 iCloud 备份一个几百 MB 的缓存目录会让用户的云空间被无声占用。对于纯缓存性质的文件可以设置NSURLIsExcludedFromBackupKey属性排除掉避免不必要的空间消耗。这个属于产品体验层面的优化不是必须但做出来会显得很专业。5.2 WebGL 和小游戏没有文件系统玩法完全不同WebGL 平台不支持DownloadHandlerFile没有真正的文件系统可写FileStream也不能用。数据要么放内存受浏览器标签页内存上限限制要么写进 IndexedDB有配额通常几十 MB 到几百 MB用户不清缓存就一直存在。所以 WebGL 上的断点续传其实是缓存续用把已下载的数据手动分块写进 IndexedDB下次启动时读出来接着用。还有一个很容易踩的坑是 CORS。浏览器里Range头属于受管控的请求头服务端必须在Access-Control-Allow-Headers里明确允许Range并且在Access-Control-Expose-Headers里暴露Content-Range、Accept-Ranges、ETag否则前端代码连响应头都读不到更别说判断 206 了。如果你的项目要发布到 WebGL务必让后端把这两个头配好并且准备一个跨域的实际测试别等上线才发现。小游戏平台的 Unity 适配层对文件系统的实现是重定向过的Application.persistentDataPath背后对应的可能不是真实的目录DownloadHandlerFile和DownloadHandlerScript的行为需要以官方适配文档为准。我的建议是小游戏平台的资源缓存优先使用平台自身的缓存机制管理把续传逻辑做成如果平台不提供能力就退化为整包下载 平台缓存而不是强行套用 PC 端的实现。这类平台变化快一定以真机实测结果为准文档都有滞后。5.3 服务器返回 200 而不是 206 的完整排查路径这是最高频的问题我整理了一条排查链路按顺序走基本能定位。第一步用 curl 直连源站绕开 CDN 和任何中间层确认源站本身是否支持 Range。如果源站返回 206 而通过 CDN 返回 200问题在 CDN。第二步检查响应头里有没有Content-Encoding: gzip。如果有说明服务端在压缩响应Nginx 这类服务器在压缩时会自动放弃 Range 支持解决办法是客户端加Accept-Encoding: identity或者让运维在下载目录关掉 gzip。第三步检查请求有没有被重定向。302 跳转后如果新地址的请求丢了Range头也会导致全量返回需要在代码里跟随重定向并保留请求头。第四步确认资源本身是不是动态生成的。如果是后端脚本实时拼出来的响应那它默认就不支持 Range需要后端专门实现 206 的返回逻辑。5.4 常见问题速查表现象可能原因排查手段处理方式进度每次从 0 开始服务端未返回Accept-Rangescurl 看响应头让运维给下载目录开 Range关 gzip收到 200 且文件体积翻倍服务端忽略 Range客户端仍追加写打印 responseCode 和 Content-Encoding检测到 200 时截断文件从 0 重下收到 416本地长度已达或超过远端长度对比Content-Range里的总长度重置断点重新 HEAD 探测下载完资源打不开拼接错误或文件被截断对比文件长度与Content-Length做长度和哈希校验不一致就丢弃重下断点记录解析报错JSON 写入过程中被中断捕获异常并打印内容解析失败即重建以文件真实长度兜底断网后进度条不动也不报错timeout 0且服务端不断开连接监控已接收字节是否增长加停滞检测30 秒无增长就 Abort应用切后台后下载中断系统冻结网络请求看设备日志OnApplicationPause主动保存断点并 Abort并发数提高反而变慢CDN 限流或服务端串行处理不同并发数测速对比降到 1 到 2 个并发WebGL 上 Range 请求失败CORS 未允许Range头浏览器控制台看 CORS 报错服务端补Access-Control-Allow-HeadersETag 和本地 MD5 对不上对象是分片上传的ETag 不是 MD5看 ETag 是否带-数字后缀不要用 ETag 做内容校验只用于If-Range6. 我在项目里沉淀下来的几条经验先说一个我改了三次才定下来的判断断点记录文件永远不要被当成可信数据。它只是一个提示真正的状态在磁盘上的字节里。每次恢复都先读一遍文件的真实长度和记录里的数字对不上就以文件为准。这一个原则能自动修掉一大类问题——断电、进程被杀、写入未落盘、记录文件半截损坏全都不需要特殊处理用统一的恢复路径就解决了。第二条是关于看起来成功的请求。UnityWebRequest 的result Success只代表 HTTP 层面没出错不代表数据是对的。200 和 206 都是成功但前者在续传场景下是灾难。我现在的习惯是凡是和下载相关的判断一律用responseCode做精确匹配不依赖result。多写几行判断换来的是一整类静默损坏问题的消失。第三条是关于测试。断点续传这个功能正常网络下永远测不出问题必须在异常条件下测。我常用的几个手段用 Charles 或者 mitmproxy 模拟丢包和随机断连在下载到任意进度时直接kill掉应用进程再启动把 CDN 域名改到一个会返回 200 的测试地址验证降级路径把断点记录文件手动改成乱码验证损坏自愈。这几组用例跑下来基本能把实现里的坑都逼出来。尤其是中途杀进程这一条我见过太多实现在这里翻车因为开发时都是在编辑器里按停止按钮那种退出方式和用户强杀应用完全不是一回事。第四条是关于进度条的体验。断点续传有个容易忽略的副作用用户会看到进度条停在某个位置不动然后又从那个位置继续。如果不做提示用户会以为卡死了。我的做法是在进度条下面加一行小字显示已缓存 XXX MB可在下次启动时继续同时在续传开始时给一个短暂的提示。这个改动成本很低但对用户心理的影响很大——他知道自己之前的等待没有白费就愿意继续等下去。最后说一个后续可以扩展的方向。资源下发的下一步是增量更新也就是只下载变化的文件而不是整个资源包。这需要在服务端维护每个版本的资源清单客户端对比本地清单和服务端清单算出差异集然后针对差异集做断点续传下载。这一层的难点不在传输而在清单的版本管理和一致性保证——尤其是当客户端跨越多个版本更新时需要明确是走逐版本增量还是直接走最新全量。我个人的取舍是小版本差异控制在全量的 30% 以内时走增量超过就走全量因为多个差异集的下载和校验开销加起来有时候真的不如直接下一份完整的来得快。