
做Unity项目尤其是涉及热更、AB包资源、或者需要从服务器拉取配置和音视频素材的时候Http下载是个绕不过去的坎。我早期做的一个上线项目就是因为下载模块处理得糙上线第一周就爆出部分机型下载失败、进度卡住、内存暴涨的问题那段时间天天对着日志排查到半夜。后来把整个下载流程重新设计了一遍才真正把这块稳住。这篇内容我就围绕Unity里Http下载这条线把自己踩过的坑和最终沉淀下来的方案完整梳理一遍。不是那种贴个UnityWebRequest就完事的入门文章而是从选型、实现、工程化到多平台适配把下载模块在真实项目中会遇到的问题和解决思路都讲清楚。1. 先搞清楚Unity里发起Http下载到底有几条路很多刚接触这块的开发者上来就直接搜Unity Http下载然后抄一段WWW的旧代码或者直接拿WebRequest写结果跑起来各种问题。实际上Unity环境下的Http下载有好几种实现路径选错了后面全是坑。1.1 UnityWebRequest、HttpWebRequest、HttpClient选谁很关键先梳理一下主流的三个方案UnityWebRequestUnity官方封装是现在最推荐的方案。底层在原生平台有Unity自己实现的网络层在WebGL平台会走浏览器的XMLHttpRequest。API设计上对Unity生命周期友好支持协程。HttpWebRequest.NET标准库的Http实现Unity里也能用。但是它有硬伤WebGL平台不支持而且用起来特别啰嗦各种回调嵌套。在Mono和IL2CPP下偶尔还会碰到平台差异导致的诡异问题。HttpClient.NET 4.x时代的产物支持async/await代码写起来最舒服。但同样WebGL不支持而且如果线程调度处理不好回调回到主线程时会炸。这里有个很核心的平台兼容性问题方案Windows/MacAndroid/iOSWebGLUnityWebRequest支持支持支持HttpWebRequest支持支持不支持HttpClient支持支持不支持WebGL是个特殊的“异类”它没有完整的.NET网络栈只有浏览器提供的网络能力。所以只要你的项目有可能发WebGL版本就老老实实用UnityWebRequest别折腾另外两个。1.2 为什么最终只推荐UnityWebRequest除了平台兼容性UnityWebRequest还有一个关键优势内存和生命周期由Unity引擎托管。下载过程中产生的网络缓冲、异步操作对象都能跟随Unity的更新循环自动管理。用HttpWebRequest和HttpClient你还要自己操心线程是否被Unity主线程正确同步处理不好就出现“回调了半天UI不动”的灵异事件。打个比方HttpClient就像你自己开了一辆手动挡车操控感很强但每个档位都得自己挂UnityWebRequest更像自动挡虽然有些操作没有手动挡那么精细但至少不会让你在半坡起步时熄火。这个选型决定了后续所有代码的写法所以一定要先想清楚。如果你是个新项目不用考虑老代码兼容直接UnityWebRequest起步就对了。2. 用UnityWebRequest写一个真正能用的下载模块确定了方案之后就要把代码落到实处。这里的重点不是“能跑”而是“在真实项目里能扛住”。我会把从最小实现到工程化落地的完整链路都过一遍。2.1 最小可用实现下载文件并显示进度先看一段最基础的代码用协程下载一个文件到本地并把进度输出出来using System.Collections; using UnityEngine; using UnityEngine.Networking; using UnityEngine.UI; public class SimpleDownloader : MonoBehaviour { public Text progressText; public IEnumerator DownloadFile(string url, string savePath) { UnityWebRequest request UnityWebRequest.Get(url); request.timeout 30; // 超时时间单位秒 var operation request.SendWebRequest(); while (!operation.isDone) { // downloadProgress是0~1的浮点数 progressText.text $下载中: {Mathf.RoundToInt(operation.progress * 100)}%; yield return null; } if (request.result ! UnityWebRequest.Result.Success) { Debug.LogError($下载失败: {request.error}); request.Dispose(); yield break; } try { System.IO.File.WriteAllBytes(savePath, request.downloadHandler.data); } catch (System.Exception e) { Debug.LogError($保存文件失败: {e.Message}); } request.Dispose(); } }这段代码里有几个细节值得留意SendWebRequest()返回的是UnityWebRequestAsyncOperation它会跟随Unity的帧循环自动推进所以才能用协程等。operation.progress和request.downloadProgress不是一个东西。前者是“请求整体进度”包括连接、响应头等阶段后者是“下载内容的进度”。显示下载百分比用downloadProgress更准确但要注意它在响应头没返回之前可能一直是0。request.Dispose()千万别漏。这是一个容易被忽略但很重要的释放操作。UnityWebRequest不是纯托管对象底层有原生内存不释放长时间频繁下载内存就会缓慢上涨最终触发GC压力甚至卡死。2.2 下载到内存还是落盘DownloadHandler的选择决定了内存上限上面代码用的是默认的DownloadHandlerBuffer它会把完整文件内容加载到内存里然后通过request.downloadHandler.data取字节数组。这在下载小配置文件、图片时没问题但下载AB包、视频这种几十上百MB的文件时会让你内存直接爆炸。正确做法是使用DownloadHandlerFile把数据边下边写磁盘内存占用几乎恒定public IEnumerator DownloadFileToDisk(string url, string savePath) { var request new UnityWebRequest(url, UnityWebRequest.kHttpVerbGET); var downloadHandler new DownloadHandlerFile(savePath); downloadHandler.removeFileOnAbort true; // 下载失败时自动清除半截文件 request.downloadHandler downloadHandler; request.timeout 60; yield return request.SendWebRequest(); if (request.result ! UnityWebRequest.Result.Success) { Debug.LogError($下载失败: {request.responseCode} {request.error}); request.Dispose(); yield break; } request.Dispose(); }DownloadHandlerFile是Unity 5.3.3之后才提供的API。它的内部实现就是开了一个文件流接收到数据直接写入磁盘省掉了Byte[]中转这一步。对应到WWW时代的老代码里这是没有的很多人还在用File.WriteAllBytes把大文件一次性写进来这是内存飙升最常见的源头。注意DownloadHandlerFile在WebGL平台不可用。WebGL平台的文件系统不是真实的本地文件系统后面第4部分我会专门讲WebGL下的处理。2.3 协程和异步两种写法的取舍与坑协程写法看起来方便但有个天然缺陷它必须依托一个MonoBehaviour来启动。如果下载逻辑写在纯C#类里你得想办法拿到MonoBehaviour的引用或者搞一个常驻的单例Runner否则协程跑不起来。所以我在实际项目中更常用async/await写法。Unity 2018版本之后配合.NET 4.x运行时async/await在Unity里已经相当成熟了。把UnityWebRequest封装成Task可以这样写using System.Runtime.CompilerServices; using System.Threading.Tasks; using UnityEngine.Networking; public static class UnityWebRequestExtensions { public static TaskAwaiterUnityWebRequest GetAwaiter(this UnityWebRequestAsyncOperation operation) { var taskCompletionSource new TaskCompletionSourceUnityWebRequest(); operation.completed _ taskCompletionSource.TrySetResult(operation.webRequest); // 恢复主线程上下文 return taskCompletionSource.Task.GetAwaiter(); } }有了这个扩展之后就可以这样写下载逻辑public async Taskbool DownloadAsync(string url, string savePath) { var request UnityWebRequest.Get(url); request.downloadHandler new DownloadHandlerFile(savePath); request.timeout 60; await request.SendWebRequest(); bool success request.result UnityWebRequest.Result.Success; request.Dispose(); return success; }这里有个关键点UnityWebRequestAsyncOperation.completed事件是在主线程回调的所以await之后的代码会自动回到主线程不需要额外用SynchronizationContext切线程。如果你自己开线程用HttpClient下载回调到主线程就得做线程切换这块的实际体验差距很大。我自己写下载管理模块时统一用async/await这套再包一层Task缓存做了并发控制代码比协程嵌套清晰太多。3. 下载管理器里必须处理的工程问题写一个能下载文件的方法不难但放到真实项目里你要面对的是几十上百个文件要排队下载、某个文件下载失败不能卡死整个流程、弱网环境下需要断点续传、并发下载时不能把带宽和内存占满。这些问题是下载管理器的核心工程点。3.1 并发数与队列控制很多人一开始就直接开协程一个文件一个协程同时跑十几二十个下载。这是最容易出问题的写法。问题主要有三个文件句柄数量过多移动端对文件句柄有限制下载中断导致大量半截文件残留。带宽被无意义争抢小文件还好大文件并发下载时相互竞争带宽都是龟速。内存峰值不可控虽然用DownloadHandlerFile能降低内存但如果并发数量太高UnityWebRequest自身的缓冲也会成倍堆积。合理的做法是任务队列 固定并发数。维护一个下载任务队列同一时刻只跑N个下载任务其余排队等待。这个N一般建议2~4按项目的实际使用场景调节。一个简化版的调度核心逻辑public class DownloadManager { private readonly int _maxConcurrent 3; private readonly QueueDownloadTask _pendingQueue new QueueDownloadTask(); private int _activeCount 0; public void Enqueue(DownloadTask task) { _pendingQueue.Enqueue(task); TryStartNext(); } private void TryStartNext() { while (_activeCount _maxConcurrent _pendingQueue.Count 0) { var task _pendingQueue.Dequeue(); _activeCount; _ ExecuteTaskAsync(task); } } private async Task ExecuteTaskAsync(DownloadTask task) { try { bool result await DownloadAsync(task.Url, task.SavePath); task.OnComplete?.Invoke(result); } catch (System.Exception e) { task.OnError?.Invoke(e.Message); } finally { _activeCount--; TryStartNext(); // 一个任务结束立刻补位 } } }这个模块有几个细节_ ExecuteTaskAsync(task)是典型的“fire and forget”写法让异步任务跑起来然后立刻返回用finally里的逻辑维护并发计数。TryStartNext()在入队和任务结束时都会被调用保证调度不会“卡死”。任务里需要带OnComplete、OnError回调而不是让每个下载逻辑自己处理成败这样队列调度层和业务层可以解耦。3.2 失败重试必须考虑状态码网络请求有各种失败可能直接用“失败就重试”的粗暴策略在很多情况下是帮倒忙。需要重试的情况超时、网络抖动、服务端临时返回5xx错误如502、503。不能重试的情况服务端明确返回404文件不存在、401鉴权失败、400请求参数错误。这类错误重试一百次也是同样的结果白白浪费流量和时间。所以重试策略一定要结合responseCode判断。一个相对通用的做法private async Taskbool DownloadWithRetryAsync(string url, string savePath, int maxRetryCount 3) { int retryCount 0; while (retryCount maxRetryCount) { var result await DownloadAsync(url, savePath); if (result) return true; // 通过结果判断是否可以重试 if (_canRetryOnResponseCode(responseCode)) { retryCount; // 指数退避1s 2s 4s await Task.Delay(1000 * (int)Mathf.Pow(2, retryCount - 1)); } else { break; } } return false; }指数退避重试是这里的关键经验。首次失败立刻重试和等待几秒再重试成功率差距很大。就像你拨一个忙线电话拨不通立刻再拨大概率还是忙线等一会儿再拨反而能通。3.3 断点续传Range请求的实现思路下载大文件、或者弱网环境下载总中断的时候断点续传是刚需。原理并不复杂HTTP协议的Range头。客户端在发起请求时带上Range: bytes文件已下载长度-服务端判断文件支持断点续传时会返回206 Partial Content然后从指定字节位置继续发送数据。UnityWebRequest里设置Range头只需要一行request.SetRequestHeader(Range, $bytes{downloadedSize}-);但要注意几个前置条件服务端必须支持Range请求。判断方法第一次请求时看响应里有没有Accept-Ranges: bytes或者用HEAD请求测试。本地已下载的文件大小不能超过服务端文件大小。如果服务端文件更新了比本地小Range请求会返回416错误这时要删掉本地文件重新下载。下载过程中断时要保留已下载的半截文件而不是删除。DownloadHandlerFile会直接把数据写入指定路径所以只要不触发removeFileOnAbort断掉后文件会保留半截下次从半截处继续。完整的断点续传下载逻辑可以这样写public async Taskbool DownloadResumableAsync(string url, string savePath) { long existingSize 0; if (System.IO.File.Exists(savePath)) { existingSize new System.IO.FileInfo(savePath).Length; } var request new UnityWebRequest(url, UnityWebRequest.kHttpVerbGET); if (existingSize 0) { request.SetRequestHeader(Range, $bytes{existingSize}-); } request.downloadHandler new DownloadHandlerFile(savePath, true); // appendtrue追加写入 request.timeout 60; await request.SendWebRequest(); // 状态码处理 if (request.result UnityWebRequest.Result.Success) { request.Dispose(); return true; } else if (request.responseCode 416) // Range不可满足文件可能已变化 { System.IO.File.Delete(savePath); request.Dispose(); return await DownloadResumableAsync(url, savePath); // 删除后重新走全量下载 } request.Dispose(); return false; }这里new DownloadHandlerFile(savePath, true)中的第二个参数append很关键它决定是覆盖写还是追加写。断点续传场景下必须为true否则每次都会从文件头开始覆盖之前下载的数据就白费了。3.4 超时、连接复用与心跳保活UnityWebRequest的timeout属性默认是0也就是永不超时。这在真实项目里非常危险。一旦服务端连接建立后长时间不返回数据请求永远挂在那边既占资源又拖累后续任务。一个合理建议小文件下载30秒超时。大文件下载60秒~120秒或者直接用50 * 1024 * 1024 / 预期最低速度这种动态计算方式。还有一个容易被忽略的问题是连接复用。HTTP/1.1默认支持Keep-Alive同一个域名下的请求会复用TCP连接避免每次下载都重新建立连接、经历TCP握手和TLS握手。但如果你的下载模块反复创建销毁UnityWebRequest或者服务端对短连接做了特殊配置连接复用就形同虚设。注意避免超高并发因为并发数超过连接池上限会触发大量连接重建。关于“心跳保活”长连接场景下会用到。对于普通Http下载来说服务器长时间不返回数据时可以自己做一个超时监控比如通过SendWebRequest之后同时跑一个TimeSpan计时器到了阈值就主动Abort()。(UnityWebRequest这个API真的会发Awake但要注意网络抖动的特殊性也就是它不会抛出异常但是可能请求并没有被服务端收到所以要靠主动超时来抓这种半死连接。)4. 多平台适配中那些容易翻车的地方同一个下载模块Windows上跑得飞快打包到Android、iOS、WebGL后表现可能完全不同。这些平台差异只有真机测试过才会暴露。4.1 WebGLIDBFS写入失败与浏览器沙箱限制WebGL平台是最特殊的。Unity WebGL的代码运行在浏览器沙箱里没有传统意义上的本地文件系统。Application.persistentDataPath在WebGL下映射到浏览器的IndexedDBUnity通过IDBFS机制模拟文件系统。这里有很多人踩过一个坑在WebGL下用DownloadHandlerFile日志直接报错。原因就是DownloadHandlerFile在WebGL平台不可用因为底层没有真实的文件描述符可以用。WebGL平台的推荐做法是小文件用DownloadHandlerBuffer下载到内存再通过PlayerPrefs或直接走内存使用。需要持久化的文件写入IndexedDBUnity的persistentDataPath但要做好写入失败的兜底。另外浏览器隐私模式或某些浏览器安全策略下IndexedDB的写入可能失败。用户看到的场景就是热更新下载“成功”了但重启游戏后文件丢失或者下载过程中直接报写入错误。这种问题只能在代码里做一层try-catch失败后给出降级提示。WebGL还有一个特殊场景如果你的业务是下载一个文件并让用户保存到本地磁盘UnityWebRequest在WebGL下走的是浏览器网络栈默认不会弹浏览器的下载行为因为这不是一个简单HTTP请求能完成的事情。这时候就得用Application.ExternalCall或[DllImport(__Internal)]去调用浏览器原生的Blob a标签下载方式。4.2 Android与iOS路径、权限与后台下载Android端的典型问题是存储权限。Android 6.0以上API 23动态权限机制Android 11API 30以后分区存储限制都会影响你写文件的路径选择。建议是统一走Application.persistentDataPath。这个路径在Android上对应/storage/emulated/0/Android/data/包名/files/在iOS上对应Documents或Library目录都是应用私有目录不需要额外申请存储权限也不会被用户手动清理iOS的Documents会被iCloud备份如果文件很大要注意配置跳过备份。iOS端的坑主要是ATSApp Transport Security。如果你的下载地址是HTTP明文而不是HTTPSiOS会默认拦截。需要在Info.plist里配置NSAppTransportSecurity例外或者干脆让服务端升级HTTPS。这个坑在本地调试时经常遇到Android下HTTP能下载iOS下请求直接被拒。还有一个iOS特性后台下载受限。App切后台后正在进行的下载任务很快会被系统暂停。如果是大文件下载尽量在用户活跃期间引导完成或者用BGTaskScheduler申请后台处理时间但那是另一个复杂话题了。4.3 本地存储路径规划在多平台情况下路径规划也是一个工程细节。建议按照文件类型分目录persistentDataPath/ ├── config/ # 配置文件 ├── bundles/ # AB包、热更资源 ├── cache/ # 临时缓存文件可清理 └── user/ # 用户数据缓存目录可以用Application.temporaryCachePath系统在存储空间不足时会自动清理这个目录下的内容。需要长期保留的资源比如版本资源包放persistentDataPath临时素材比如活动海报放temporaryCachePath这个习惯能避免很多空间管理上的麻烦。5. 线上问题的快速排查与性能调优最后这部分聊几个真实项目里高频出现的线上问题定位方法以及我做性能调优时总结的一些指标。5.1 内存、流量与速度三个容易忽视的指标内存下载模块最容易拖垮内存的元凶就是DownloadHandlerBuffer。线上监控时要特别注意下载过程中的Mono堆内存曲线。如果发现堆内存在下载几十MB文件时直接跳了对应大小基本可以断定有地方把整个文件内容load进内存了。改用DownloadHandlerFile后这个曲线会非常平坦。流量下载失败后的无脑重试是流量的隐形杀手。一个50MB的AB包下载到90%失败如果代码每次都从头下载两次失败就是100MB流量用户立刻就能感知到流量异常。断点续传在这里的价值不只是缩短下载时间更是帮用户省流量。速度如果发现下载速度远低于带宽上限先排查是不是并发下载数太高导致TCP窗口争抢。在限速场景比如下载A文件时希望B文件暂停下载可以手动调用request.Abort()而不是等待超时。5.2 常见报错快查与处理建议我在真实项目中遇到过一些高频报错整理成一张速查表方便线上快速定位现象常见原因处理方式一直无进度最终超时服务端未响应、DNS解析慢缩短超时时间到30s增加重试下载到50%左右总是失败服务端连接被断开、代理中断开启断点续传保存进度返回404文件不存在、路径拼错检查URL不要重试返回502网关层错误、服务端暂时不可用指数退避重试3次WebGL下写入文件失败IDBFS写入受限降级处理提示用户清理浏览器空间iOS下HTTP请求失败ATS拦截了明文HTTP服务端换HTTPS下载速度、内存异常并发数过高、DownloadHandler选错限制并发数2~4改用文件落地方式这里有一个容易被忽视的点UnityWebRequest的result字段在不同Unity版本中取值时机和含义略有差异。Unity 2020之前的版本判断成功用的是request.isHttpError和request.isNetworkError两个boolUnity 2020之后的版本统一推荐用request.result枚举。如果你的项目升级了Unity主版本这个改动会被编译器漏掉运行逻辑就可能悄悄改变。排查线上问题时我的习惯是给每个下载任务打一条结构化日志包括URL、文件大小、当前进度、重试次数、最终状态码。没有日志就排查网络问题是盲人摸象有了日志一个成绩单就能还原完整下载链路。特别是502这类网关错误服务端和客户端看到的错误信息往往是对不上的必须靠双方日志联查。另外一个性能优化经验下载完成后的request.Dispose()时机很重要。有些开发者习惯在回调里先把downloaded bytes取出来再Dispose这种做法在极大数据量时短暂时间会保留完整文件的内存副本。正确顺序是把文件写入工作交给下载回调完成后的独立环节下载回调里只保存路径和结果标记然后立刻Dispose不等不靠避免内存快照堆叠。最后再分享几个实操中沉淀的小技巧关于Unity Http下载代码之外的经验其实比代码本身更值钱。我根据个人项目体验再多说几句第一下载模块的日志要留足。我建议至少保留最近50条下载任务的记录包含URL、文件大小、耗时、状态码、重试次数。线上问题发生时这份日志能让你十分钟内定位问题而不是花几个小时猜。第二服务端配合很重要。下载模块做得好不好一半看客户端一半看服务端。比如断点续传需要服务端支持Range重试策略需要服务端对临时性错误和永久性错误有清晰的语义区分。和服务器同事约定好状态码语义比任何客户端代码都管用。第三下载完成后的校验不能省。下载下来就完事了吗对重要资源我强烈建议加一个文件大小校验甚至MD5或CRC校验。下载过程中即使显示成功也可能因为网络问题或代理篡改导致文件内容不完整。校验不通过的直接重新下载并清理损坏文件。第四考虑弱网场景的降级方案。在2G/3G网络或者高延迟环境下大文件下载体验会很糟糕。这时可以根据当前网络状态用Application.internetReachability判断决定下载策略弱网下只下载关键资源非关键资源延迟到WiFi环境再下。这个逻辑虽然简单但能显著提升低端机用户的体验。Unity的Http下载说到底是“网络请求 文件读写 状态管理”的组合题难点不在某一项技术而在把这些基础能力拼装成一个工程级模块时的各种细节。希望这些从项目里踩坑踩出来的经验能帮你少走一段弯路。