Unity大文件下载:基于UnityWebRequest实现高效断点续传方案

1. 项目概述

断点续传,这词儿听起来挺技术,但说白了就是“接着下”。想象一下,你正在用手机下载一个2个G的游戏更新包,进度到90%了,结果地铁进隧道,网络断了。要是没有断点续传,你只能从头再来,流量和时间都白费了。但有了它,网络恢复后,下载会从90%的地方继续,而不是0%。在Unity开发里,尤其是资源热更新、大文件下载(比如高清视频、音频包、AssetBundle)这些场景,断点续传几乎是刚需。

Unity官方提供的网络请求组件是UnityWebRequest,它功能强大,但原生并不直接支持断点续传。很多开发者遇到大文件下载需求时,要么自己从头造轮子,要么找一些不太稳定的第三方插件,踩坑无数。今天,我就结合自己趟过的雷,带你用最核心的UnityWebRequest配合一个自定义的DownloadHandler,在5分钟内实现一个稳定、高效且代码清晰的断点续传方案。这个方案不依赖任何外部插件,纯C#实现,你拿到代码稍作调整就能用在自己的项目里。

2. 核心原理与方案选型

2.1 断点续传是如何工作的?

要自己实现,首先得明白服务器和客户端是怎么“商量”着接着下载的。这背后依赖的是HTTP协议中的一个请求头:Range

  1. 首次下载:客户端发起一个普通的GET请求,服务器返回整个文件。
  2. 中断后续传
    • 客户端检查本地是否已存在一个部分下载的文件。如果存在,就获取这个文件当前的大小(例如,已经下载了1024字节)。
    • 客户端在发起新的UnityWebRequest时,在请求头中设置Range: bytes=1024-。这个头信息告诉服务器:“我不要从头开始,请从第1024个字节之后的数据开始发给我。”
    • 如果服务器支持断点续传(大部分静态文件服务器都支持),它会返回状态码206 Partial Content,并只发送从指定字节开始的数据流。
    • 客户端收到数据后,不是新建文件,而是以“追加写入”的模式打开已有的部分文件,将新数据接在后面。

所以,技术关键点就两个:正确设置Range请求头以追加模式处理收到的数据流

2.2 为什么选择自定义 DownloadHandlerScript?

UnityWebRequest的结果处理主要由DownloadHandler负责。官方提供了几个内置的:

  • DownloadHandlerBuffer: 将数据下载到内存中的一个字节数组。文件大了直接内存爆炸,不适合。
  • DownloadHandlerFile: 直接下载到文件。但它有个“坑”:它每次都会创建新文件,如果文件已存在会覆盖。这意味着它无法直接用于追加数据,实现续传需要更底层的控制。
  • DownloadHandlerScript: 这是一个抽象基类,它把接收数据的控制权完全交给了开发者。当数据从网络流中接收到时,Unity会回调它的ReceiveData方法,传入数据块。我们可以在这个回调里,自由决定如何处置这些数据——比如,写入一个已打开的文件流。

因此,要实现将数据追加到现有文件,DownloadHandlerScript是我们的不二之选。它给了我们所需的精细控制能力。

2.3 方案优势与潜在坑点

优势:

  • 轻量无依赖: 完全基于Unity官方API,兼容性好,无需管理第三方DLL。
  • 性能可控: 数据缓冲区大小自己定,避免频繁的IO操作。我们可以利用Unity在2017.2.1p1补丁中对大块数据传输的优化。
  • 功能可扩展: 在回调函数里,我们可以轻松加入下载速度计算、进度更新、暂停/继续等逻辑。

需要留意的坑:

  • 文件流管理: 文件流(FileStream)必须正确打开和关闭。如果下载中途异常退出,流没关闭,可能导致文件被锁定或数据损坏。一定要在CompleteContentDispose中确保流被关闭。
  • 进度计算: 续传时,总进度应该是(本地已有大小 + 本次下载大小) / 文件总大小。文件总大小需要从服务器的响应头Content-LengthContent-Range中正确获取。
  • 版本差异: 如搜索资料中提到的,在Unity 2017.3.0之前的版本,ReceiveData回调存在性能瓶颈,每次最多只传约64KB数据。如果你的项目受限于旧版本,需要留意下载速度上限。

3. 核心代码实现与逐行解析

接下来,我们一步步构建这个名为DownloadHandlerFileRange的自定义类。我会把完整代码贴出来,并逐段解释关键部分。

3.1 类定义与初始化

using UnityEngine.Networking; using System.IO; using System; public class DownloadHandlerFileRange : DownloadHandlerScript { private string _savePath; private FileStream _fileStream; private UnityWebRequest _request; private long _localFileSize = 0; private long _totalFileSize = 0; private long _currentDownloadedSize = 0; /// <summary> /// 构造函数 /// </summary> /// <param name="savePath">文件完整保存路径</param> /// <param name="request">关联的UnityWebRequest对象,用于设置请求头</param> public DownloadHandlerFileRange(string savePath, UnityWebRequest request) : base(new byte[1024 * 1024]) // 分配1MB的缓冲区 { _savePath = savePath; _request = request; // 1. 检查本地已存在文件的大小 if (File.Exists(_savePath)) { FileInfo fileInfo = new FileInfo(_savePath); _localFileSize = fileInfo.Length; _currentDownloadedSize = _localFileSize; // 当前大小初始化为本地已有大小 } // 2. 以追加模式打开文件流,准备写入 _fileStream = new FileStream(_savePath, FileMode.Append, FileAccess.Write); // 3. 关键步骤:设置Range请求头,告诉服务器从哪里开始传 _request.SetRequestHeader("Range", $"bytes={_localFileSize}-"); } }

关键点解析:

  1. 缓冲区大小base(new byte[1024 * 1024])我们传递了一个1MB(1024*1024字节)的缓冲区给父类。这个缓冲区是ReceiveData回调中data数组的预分配空间。1MB是一个经验值,在性能和数据块处理频率间取得平衡。太小会导致IO操作过于频繁,太大可能浪费内存。根据Unity的优化说明,在较新版本中,这个值基本是每次回调接收数据的上限。
  2. 文件模式FileMode.Append这个参数至关重要。它确保如果文件存在,写入操作将从文件末尾开始。这正是“续传”的核心。
  3. Range头bytes={_localFileSize}-这是实现断点续传的魔法指令。{_localFileSize}是本地已下载的字节数。-后面不写,表示直到文件结束。例如,已下载1024字节,则头信息为Range: bytes=1024-

3.2 接收数据与写入文件

这是整个下载过程的核心循环,数据在这里被写入磁盘。

protected override bool ReceiveData(byte[] data, int dataLength) { // 防御性编程:检查数据或请求是否异常 if (data == null || dataLength == 0 || _request.responseCode >= 400) { UnityEngine.Debug.LogError("接收数据失败或请求错误。"); return false; // 返回false可能使下载中止 } try { // 将接收到的数据块写入文件流 _fileStream.Write(data, 0, dataLength); // 更新当前已下载的总字节数(本地原有+本次下载) _currentDownloadedSize += dataLength; // 这里可以触发进度更新事件,供UI显示 // OnProgressUpdated?.Invoke(GetProgress()); } catch (System.Exception e) { UnityEngine.Debug.LogError($"写入文件时发生错误: {e.Message}"); return false; // 发生异常,中止下载 } return true; // 返回true表示继续接收下一个数据块 }

关键点解析:

  1. 数据有效性检查: 首先检查data是否为空或长度为0,同时检查HTTP响应码是否表示错误(4xx或5xx)。这是必要的健壮性处理。
  2. 写入操作_fileStream.Write(data, 0, dataLength)将缓冲区data中从0开始、长度为dataLength的有效数据写入文件。注意,data数组的长度可能大于dataLength,所以必须使用dataLength参数。
  3. 错误处理: 文件IO操作必须用try-catch包裹。磁盘空间不足、文件被占用等都可能导致写入失败,我们需要捕获异常并妥善处理(例如停止下载、通知用户),而不是让游戏崩溃。
  4. 返回值: 返回true告诉Unity继续下载;返回false会中止下载过程。我们在错误情况下返回false

3.3 获取文件总大小与进度计算

进度计算需要知道文件的总大小。这个信息来自服务器的响应头。

protected override void ReceiveContentLength(int contentLength) { // Unity传入的contentLength是int,对于大于2GB的文件会溢出。优先从响应头获取。 string contentLengthHeader = _request.GetResponseHeader("Content-Length"); // 对于断点续传,服务器可能用`Content-Range`头,格式如`bytes 1024-439857/439858` string contentRangeHeader = _request.GetResponseHeader("Content-Range"); long thisRequestFileSize = 0; // 本次请求需要下载的部分的大小 if (!string.IsNullOrEmpty(contentRangeHeader)) { // 解析Content-Range头,获取文件总大小 // 格式示例:bytes 1024-439857/439858 try { string totalSizePart = contentRangeHeader.Split('/')[1]; _totalFileSize = long.Parse(totalSizePart); // 本次请求的大小可以通过解析范围计算,也可以直接用contentLength(如果没溢出) thisRequestFileSize = contentLength > 0 ? contentLength : (_totalFileSize - _localFileSize); } catch { /* 解析失败处理 */ } } else if (!string.IsNullOrEmpty(contentLengthHeader)) { // 如果没有Content-Range,尝试用Content-Length try { thisRequestFileSize = long.Parse(contentLengthHeader); // 总大小 = 本地已有大小 + 本次请求的大小 _totalFileSize = _localFileSize + thisRequestFileSize; } catch { /* 解析失败处理 */ } } else { // 如果响应头都没有,只能使用Unity传过来的参数(可能有溢出风险) thisRequestFileSize = contentLength; _totalFileSize = _localFileSize + thisRequestFileSize; if (contentLength <= 0) { UnityEngine.Debug.LogWarning("无法从响应头获取文件大小,进度计算可能不准。"); } } // 进度更新事件可以在这里首次触发,因为此时知道了总大小 // OnDownloadStarted?.Invoke(_totalFileSize); } protected override float GetProgress() { if (_totalFileSize <= 0) return 0.0f; // 进度 = 当前总下载大小 / 文件总大小 return Mathf.Clamp01((float)_currentDownloadedSize / _totalFileSize); }

关键点解析:

  1. ReceiveContentLength回调: 当服务器返回响应头后,Unity会调用此方法。参数contentLength是Unity解析出的内容长度,但类型是int,对于超过2GB的文件,这个值会溢出变成负数。因此,绝对不能直接信任这个参数。
  2. 优先使用响应头: 我们使用_request.GetResponseHeader主动获取Content-LengthContent-Range头信息。对于断点续传,服务器更可能返回Content-Range,其中包含了文件的总大小,这是最可靠的信息源。
  3. 进度计算逻辑GetProgress方法会在查询unityWebRequest.downloadProgress时被调用。计算公式是当前总下载量 / 文件总大小。注意_currentDownloadedSize在构造函数中初始化为本地已有大小,并在ReceiveData中不断增加。
  4. 注意除零保护: 在GetProgress中,如果_totalFileSize为0(比如请求失败),直接返回0,避免运行时异常。

3.4 资源清理与事件

妥善管理资源,尤其是FileStream,防止内存泄漏和文件锁。

public new void Dispose() { Cleanup(); base.Dispose(); // 调用基类清理 } protected override void CompleteContent() { // 下载完成时调用 Cleanup(); UnityEngine.Debug.Log($"文件下载完成: {_savePath}"); // OnDownloadCompleted?.Invoke(); } private void Cleanup() { if (_fileStream != null) { _fileStream.Flush(); // 确保所有缓冲数据写入磁盘 _fileStream.Close(); _fileStream.Dispose(); _fileStream = null; } } ~DownloadHandlerFileRange() { // 析构函数作为最后保障 Cleanup(); }

关键点解析:

  1. CompleteContent: 当整个下载过程正常结束时,Unity会调用此方法。这是关闭文件流的理想位置。
  2. Dispose: 我们暴露一个新的Dispose方法供外部调用,用于主动中止下载并清理资源。注意里面也调用了base.Dispose()
  3. Cleanup方法: 集中处理文件流关闭的逻辑。先Flush()确保缓冲区数据不丢失,再关闭和释放。
  4. 析构函数: 作为最后的安全网,防止因为忘记调用Dispose而导致文件流泄漏。但不要依赖它,主动管理才是好习惯。

3.5 如何使用这个类

实现完核心类后,使用起来非常简单直观。

using UnityEngine; using System.Collections; public class FileDownloader : MonoBehaviour { public string url = "http://your-server.com/path/to/large-file.zip"; public string localFilePath = "D:/Downloads/myfile.zip"; IEnumerator StartDownloadWithResume() { // 1. 创建UnityWebRequest UnityWebRequest request = new UnityWebRequest(url); request.method = UnityWebRequest.kHttpVerbGET; // 默认为GET,可省略 // 2. 创建我们的自定义DownloadHandler,并关联request DownloadHandlerFileRange downloadHandler = new DownloadHandlerFileRange(localFilePath, request); request.downloadHandler = downloadHandler; // 3. (可选) 添加进度监控 // StartCoroutine(MonitorProgress(downloadHandler)); // 4. 发送请求 yield return request.SendWebRequest(); // 5. 处理结果 if (request.result == UnityWebRequest.Result.Success) { Debug.Log("下载成功!"); } else { Debug.LogError($"下载失败: {request.error}"); // 失败后,文件会保留已下载的部分。下次用相同路径创建DownloadHandlerFileRange时会自动续传。 } // 6. 清理请求(自定义的handler会在CompleteContent中自动清理流) request.Dispose(); } IEnumerator MonitorProgress(DownloadHandlerFileRange handler) { while(!handler.isDone) // 可以添加一个isDone属性到handler中 { Debug.Log($"下载进度: {handler.GetProgress():P2}"); yield return new WaitForSeconds(0.5f); // 每0.5秒更新一次 } } }

4. 高级功能与性能优化

基础功能完成后,我们可以让它更强大、更好用。

4.1 下载速度计算

ReceiveData回调中,我们可以加入简单的速度计算逻辑。

private float _lastUpdateTime; private long _lastDataSize; public float DownloadSpeedKBps { get; private set; } protected override bool ReceiveData(byte[] data, int dataLength) { // ... 原有的数据写入逻辑 ... _currentDownloadedSize += dataLength; // 计算下载速度(每秒) float currentTime = Time.time; if (currentTime - _lastUpdateTime >= 0.5f) // 每0.5秒计算一次,避免UI更新太频繁 { float deltaTime = currentTime - _lastUpdateTime; long deltaData = _currentDownloadedSize - _lastDataSize; DownloadSpeedKBps = (deltaData / 1024f) / deltaTime; // 计算KB/s _lastUpdateTime = currentTime; _lastDataSize = _currentDownloadedSize; // 触发速度更新事件 // OnSpeedUpdated?.Invoke(DownloadSpeedKBps); } // ... 返回true ... }

4.2 实现暂停与继续

UnityWebRequest本身没有暂停API,但我们可以通过Dispose来中止,利用断点续传机制来实现“暂停”。

public class PausableDownloader : MonoBehaviour { private UnityWebRequest _currentRequest; private DownloadHandlerFileRange _currentHandler; private string _url; private string _savePath; private Coroutine _downloadCoroutine; public void StartOrResumeDownload(string url, string path) { _url = url; _savePath = path; if (_downloadCoroutine != null) StopCoroutine(_downloadCoroutine); _downloadCoroutine = StartCoroutine(DownloadCoroutine()); } public void PauseDownload() { if (_currentRequest != null) { // 中止请求。DownloadHandlerFileRange的CompleteContent不会被调用, // 但它的析构函数或我们主动Dispose会关闭文件流,确保数据已写入。 _currentRequest.Dispose(); _currentRequest = null; _currentHandler = null; Debug.Log("下载已暂停。"); } } IEnumerator DownloadCoroutine() { // 每次启动协程,都会基于现有的_partial文件创建新的请求,实现续传 _currentRequest = new UnityWebRequest(_url); _currentHandler = new DownloadHandlerFileRange(_savePath, _currentRequest); _currentRequest.downloadHandler = _currentHandler; yield return _currentRequest.SendWebRequest(); // ... 处理完成 ... } }

注意: 暂停后,文件流必须被正确关闭,否则已下载的数据可能还在缓冲区,未写入磁盘。我们的Cleanup逻辑(在Dispose或析构中调用)保证了这一点。

4.3 多线程与主线程回调

ReceiveData回调可能在非主线程执行。如果我们需要在其中更新UI(如进度条、速度显示),必须将数据派发到主线程。

// 在自定义Handler中定义事件 public event Action<long, float> OnProgressAndSpeedUpdated; // 总大小,进度 protected override bool ReceiveData(byte[] data, int dataLength) { // ... 写入数据和计算速度 ... // 触发事件,传递数据 OnProgressAndSpeedUpdated?.Invoke(_totalFileSize, GetProgress()); return true; } // 在MonoBehaviour中使用时 void SetupHandler(DownloadHandlerFileRange handler) { handler.OnProgressAndSpeedUpdated += (totalSize, progress) => { // 使用MainThreadDispatcher或直接检查是否在主线程 #if UNITY_EDITOR || !UNITY_WEBGL // WebGL是单线程 if (!UnityEngine.Object.FindObjectOfType<UnityEngine.EventSystems.EventSystem>()) return; // 简单判断 #endif // 更新UI Text或Slider progressText.text = $"{progress:P2}"; speedText.text = $"{handler.DownloadSpeedKBps:F2} KB/s"; }; }

对于复杂的项目,建议使用一个专门的MainThreadDispatcher单例来安全地排队和执行主线程任务。

5. 常见问题排查与实战技巧

在实际项目中集成时,你可能会遇到下面这些问题。这里我整理了排查思路和解决方案。

5.1 服务器返回状态码416或200,而不是206

  • 问题现象: 设置了Range头,但服务器返回416 Range Not Satisfiable200 OK(返回了整个文件)。
  • 排查思路
    1. 检查URL和文件: 确保你请求的资源支持断点续传。不是所有服务器或所有类型的资源都支持。
    2. 检查Range头格式: 使用浏览器的开发者工具或Postman等工具,模拟你的请求,查看发送的Range头是否正确。格式必须是bytes=start-bytes=start-end
    3. 检查本地文件大小: 在设置Range头前,打印_localFileSize的值。如果文件不存在或大小为0,Range: bytes=0-是有效的,但有些服务器可能对0点开始的处理不同。
    4. 服务器配置: 对于自己搭建的服务器(如Nginx, Apache),需要确保配置了支持断点续传。例如Nginx默认是支持的,但某些自定义的CDN或后端服务可能需要额外配置。
  • 解决方案
    • 如果服务器返回416,通常意味着请求的范围无效(比如,请求的起始点超过了文件大小)。这时应该删除本地残破文件,重新开始下载。
    • 如果服务器总是返回200,说明它不支持或不理会Range头。你的代码依然能工作,但会变成覆盖式下载,无法实现续传。你需要为这类资源准备一个降级方案(比如提示用户,或使用普通下载方式)。

5.2 下载进度条“回退”或卡在某个点

  • 问题现象: 进度条在下载过程中突然跳回较低百分比,或者长时间不动。
  • 排查思路
    1. 进度计算逻辑: 检查GetProgress()方法。确保_totalFileSizeReceiveContentLength中被正确赋值,且_currentDownloadedSize是从_localFileSize开始累加的。
    2. 文件总大小获取错误: 如果_totalFileSize获取错误(例如,解析响应头失败,使用了溢出的contentLength),会导致进度分母错误。添加日志,打印出从响应头解析出的_totalFileSize值。
    3. 网络波动与重连: 网络中断后,UnityWebRequest可能会自动重试或进入错误状态。如果重连后创建了新的请求但进度回调没更新,可能导致UI“卡住”。确保每次创建新的下载器时,UI进度监听都绑定到新的实例。
  • 解决方案
    • ReceiveContentLength中加强错误处理,如果解析失败,可以尝试发起一个HEAD请求先获取文件总大小。
    • 进度更新事件传递_currentDownloadedSize_totalFileSize两个原始值,让UI层自己计算百分比,避免因_totalFileSize为0导致除零错误。

5.3 大文件下载(>2GB)异常

  • 问题现象: 下载大文件时,进度异常、文件损坏或直接崩溃。
  • 排查思路
    1. int32溢出: 这是最常见的问题。回顾ReceiveContentLength(int contentLength),Unity传入的contentLengthint类型,最大值约21亿(2.14GB)。超过这个值会溢出变成负数。我们的代码已经通过优先读取响应头Content-Length(long) 来规避此问题,这是关键。
    2. 文件流位置: 确保FileStream始终以FileMode.Append模式打开,这样Position会自动指向文件末尾,无需手动设置。
    3. 内存与缓冲区: 1MB的缓冲区对于大文件下载是合适的。避免在ReceiveData中分配新的字节数组或进行耗时的非IO操作。
  • 解决方案
    • 务必使用我们提供的、从响应头获取long类型文件大小的逻辑。
    • 在写入文件后,可以定期调用_fileStream.Flush()以确保数据落盘,但频繁刷新会影响性能,通常在下载完成或暂停时刷新一次即可。

5.4 文件下载完成后校验(MD5/SHA1)

为了保证下载文件的完整性,尤其是在不稳定的网络环境下,添加校验是很有必要的。

public IEnumerator VerifyFile(string filePath, string expectedHash) { // 注意:计算大文件哈希是CPU密集型操作,应在后台线程进行 yield return new WaitForBackgroundThread(); // 需要使用Unity的特定方式或线程池 string calculatedHash = CalculateFileHash(filePath, "MD5"); // 实现你的哈希计算函数 bool isValid = string.Equals(calculatedHash, expectedHash, StringComparison.OrdinalIgnoreCase); yield return new WaitForMainThread(); // 回到主线程更新结果 if (isValid) { Debug.Log("文件校验通过!"); } else { Debug.LogError("文件校验失败,可能已损坏。"); File.Delete(filePath); // 删除损坏文件 } }

技巧: 服务器端应在提供文件下载链接时,同时提供文件的哈希值(如MD5、SHA1)。客户端下载完成后进行比对。

5.5 实战技巧小结

  1. 日志是救星: 在ReceiveContentLengthReceiveData开始和结束、CompleteContentDispose等关键节点添加详细的日志(Debug.Log),记录文件大小、进度、速度、响应码。出问题时,这些日志是第一时间定位的关键。
  2. 异常处理要周全: 网络请求、文件IO处处是坑。用try-catch包裹所有可能出错的操作,并在catch块中提供有意义的错误信息和恢复逻辑(如清理资源、通知用户)。
  3. 提供取消接口: 除了暂停,还应该提供一个彻底的取消接口,用于删除未完成的残破文件。
  4. 考虑电量与性能: 在移动设备上,长时间大文件下载是耗电大户。可以考虑在应用进入后台或锁屏时暂停下载,回到前台时再续传。
  5. 测试,测试,再测试: 在各种网络条件下测试(Wi-Fi、4G、弱网模拟)。测试正常中断(用户暂停)、异常中断(杀进程、断网)后的恢复情况。测试不同大小文件(小文件、大文件、超过2G文件)的下载。

这个基于UnityWebRequest和自定义DownloadHandlerScript的断点续传方案,核心代码不到200行,但涵盖了从原理到实践,从基础功能到异常处理的大部分要点。它足够轻量,可以方便地集成到任何Unity项目中;也足够健壮,能够应对真实环境中的各种挑战。希望这份详细的拆解和代码,能帮你彻底搞定Unity中的大文件下载难题。