Unity 里做网络请求很多人第一反应是去翻System.Net.Http或者干脆自己开线程写HttpWebRequest结果在移动端不是卡主线程就是被平台限制搞得莫名其妙。UnityWebRequest这个东西说白了就是 Unity 官方给你封装好的一层网络请求管线它把资源加载、下载、上传、纹理获取、AssetBundle 拉取这些场景统一到了一套 API 下面并且天然跟协程、异步、进度回调、超时、缓存这些机制打通。你要是还在用老掉牙的WWW那基本等于开着拖拉机上高速——能跑但迟早被淘汰。这篇内容我打算把UnityWebRequest从能跑通到跑得稳中间那一段讲透。适合两类人看一类是刚接触 Unity 网络请求、照着文档抄了个SendWebRequest就以为完事的新手另一类是已经用过但总在真机上遇到超时、内存暴涨、下载卡死、进度条乱跳这些问题的老手。我会把每个关键选择背后的理由讲清楚把参数怎么算、步骤为什么这么设计说明白再塞一些文档里不会写的实操坑。全程按从业者交流的口吻来不整那些虚的。1. 先搞清楚 UnityWebRequest 到底替你干了什么很多人把UnityWebRequest当成一个发请求的工具这个理解太浅了。它真正的价值在于把请求生命周期管理和平台差异屏蔽这两件脏活揽了过去。你写一句request.SendWebRequest()背后其实发生了一串事情请求对象被注册到 Unity 的内部调度器、底层根据平台走不同的传输实现、下载的数据被切块塞进一个 native 缓冲区、进度和状态通过主线程回调同步给你。理解这条链路你才知道为什么有些操作会卡、有些回调不触发。1.1 它和 WWW、HttpClient 的本质区别老的WWW类是 Unity 早期资源加载时代的产物它的问题在于把请求和结果绑得太死一个WWW对象既当请求又当数据容器复用性差而且错误处理极其粗糙——你只能靠isNetworkError和error两个字段猜发生了什么。UnityWebRequest把请求和数据处理拆开了请求负责传输DownloadHandler和UploadHandler负责数据的接收与发送。这个拆分是设计上的关键进步意味着你可以自定义下载处理器把数据直接写进磁盘而不经过托管堆也可以自定义上传处理器做流式上传。至于System.Net.Http.HttpClient它在纯 .NET 环境里确实强大但在 Unity 里用起来要小心。IL2CPP 后端下某些平台对HttpClient的支持并不完整而且它不参与 Unity 的协程调度你得自己管线程和回调切主线程。UnityWebRequest则是原生跟 Unity 的帧循环对齐的yield return request.SendWebRequest()这一句就能让协程在请求完成时恢复省掉大量线程同步代码。所以我的建议很直接只要你的请求跟 Unity 资源、场景、UI 有关优先用 UnityWebRequest只有纯后台的复杂 HTTP 逻辑且确认平台支持时才考虑 HttpClient。1.2 请求对象、处理器、协程三者的协作关系一个完整的UnityWebRequest请求由三部分组成请求本身URL、方法、头信息、一个下载处理器DownloadHandler、一个可选的上传处理器UploadHandler。这三者的关系可以类比成快递单 收件箱 寄件箱。请求是快递单告诉系统往哪送、用什么方式送下载处理器是收件箱决定收到的货怎么存放放内存、放磁盘、还是直接转成 Texture上传处理器是寄件箱决定你要寄的东西怎么打包。协程在这里扮演的是等待签收的角色。SendWebRequest()返回一个UnityWebRequestAsyncOperation你yield return它协程就会在每帧检查这个操作是否完成完成后恢复执行。这里有个容易忽略的点请求完成不等于成功。协程恢复后你必须检查request.result它可能是Success、ConnectionError、ProtocolError、DataProcessingError等等。我见过太多代码yield return完直接读downloadHandler.text结果在 404 的时候拿到空字符串还以为是服务器返回空。using (UnityWebRequest request UnityWebRequest.Get(url)) { yield return request.SendWebRequest(); if (request.result ! UnityWebRequest.Result.Success) { Debug.LogError($请求失败: {request.result}, {request.error}); yield break; } string json request.downloadHandler.text; // 继续处理 }注意这里用了using。UnityWebRequest实现了IDisposable它内部持有 native 资源缓冲区、连接句柄不释放就会泄漏。用using包起来是最省心的做法比手动Dispose()靠谱因为异常路径也能保证释放。1.3 为什么它天然适合协程而不适合直接同步调用UnityWebRequest的传输是异步的底层在后台线程推进但它的回调、进度更新、结果读取都发生在主线程。这个设计决定了它跟协程是绝配协程本身就在主线程上按帧推进yield return一个 AsyncOperation 正好把等待后台完成和主线程恢复处理衔接起来不需要任何锁。反过来如果你硬要同步等待比如写个while (!request.isDone) { }死循环那主线程就被你占死了请求的完成回调永远没机会执行直接死锁。这是新手最容易踩的坑之一。正确的做法永远是让出控制权协程里yield return或者用await配合AsyncOperation的 awaiterUnity 2021 之后对UnityWebRequestAsyncOperation支持await但要注意它恢复的线程上下文。2. 下载处理器选型内存、磁盘、纹理三条路怎么走DownloadHandler是UnityWebRequest里最值得细讲的部分因为选错处理器是内存问题和性能问题的主要来源。Unity 内置了几种DownloadHandlerBuffer存内存、DownloadHandlerFile写磁盘、DownloadHandlerTexture转纹理、DownloadHandlerAudioClip转音频、DownloadHandlerAssetBundle拉 AssetBundle、DownloadHandlerScript自定义流式处理。每种背后对应不同的数据流向选型错了轻则多一次内存拷贝重则整个包体下载时内存翻倍。2.1 DownloadHandlerBuffer 的适用边界与内存代价DownloadHandlerBuffer是最常用的它把响应体完整读进一块 native 缓冲区你通过.text或.data访问。方便是真方便但代价是整个响应体必须驻留内存。下载一个 50MB 的包峰值内存就会多出 50MB 左右native 缓冲区如果你再调.data拿一份byte[]拷贝托管堆上又多 50MB。移动端内存本来就紧张这种翻倍很容易触发 OOM。所以我的经验是小于 1MB 的文本类响应JSON、配置、小图标用 Buffer 没问题超过这个量级尤其是资源文件一律走 File 或自定义 Script。判断阈值不是拍脑袋你可以按目标设备的最低内存来算——比如低端机给应用 200MB 预算单个响应超过预算的 0.5% 就该考虑落盘。2.2 DownloadHandlerFile 落盘的正确姿势与临时文件陷阱DownloadHandlerFile直接把下载数据写进指定路径不经过托管堆这是大文件下载的正解。用法很简单string savePath Path.Combine(Application.persistentDataPath, bundle.dat); using (UnityWebRequest request UnityWebRequest.Get(url)) { request.downloadHandler new DownloadHandlerFile(savePath); yield return request.SendWebRequest(); if (request.result ! UnityWebRequest.Result.Success) { // 失败时记得清理可能残留的半截文件 if (File.Exists(savePath)) File.Delete(savePath); } }这里有个坑必须点出来下载失败或中途取消时目标路径可能留下一个不完整的文件。DownloadHandlerFile在构造时可以传第二个参数removeFileOnAbort设为true它会在中止时自动删文件。但即便如此我仍然建议在失败分支里手动检查并删除因为某些异常路径下自动清理不一定触发。另外写入路径必须是可写目录Application.streamingAssetsPath在移动端是只读的往那儿写会失败正确位置是persistentDataPath。还有一个细节DownloadHandlerFile在写入时是边下边写所以进度回调里的downloadProgress反映的是网络接收进度不是磁盘写入进度。如果你在进度条上做下载完成判断用request.downloadHandler.isDone比用进度值到 1 更可靠。2.3 DownloadHandlerTexture 与纹理下载的隐藏开销拉网络图片用DownloadHandlerTexture最省事它会在下载完成后自动把字节解码成Texture2D。但这里有两个隐藏开销一是解码发生在主线程大图解码会卡帧二是它内部仍然先把数据读进缓冲区再解码等于内存里同时存在原始字节和纹理两份数据。using (UnityWebRequest request UnityWebRequestTexture.GetTexture(imageUrl, true)) { yield return request.SendWebRequest(); if (request.result UnityWebRequest.Result.Success) { Texture2D tex DownloadHandlerTexture.GetContent(request); rawImage.texture tex; } }第二个参数nonReadable设为true能省一半内存因为不需要保留 CPU 可读的副本。但代价是你之后不能用GetPixels读像素。如果图片只是用来显示一律设true。另外纹理下载完记得在不用时Destroy(tex)否则纹理对象会一直挂在内存里这是 UI 列表滚动加载图片时最常见的泄漏点。2.4 自定义 DownloadHandlerScript 做流式处理当你需要边下边处理比如边下边解密、边下边解析大 JSON 流、边下边写自定义格式就得继承DownloadHandlerScript。它每收到一块数据就回调一次ReceiveData你在这块数据上做处理处理完就丢弃内存占用恒定。public class StreamingJsonHandler : DownloadHandlerScript { private readonly Listbyte _buffer new Listbyte(); public StreamingJsonHandler() : base(new byte[1024 * 16]) { } protected override bool ReceiveData(byte[] data, int dataLength) { // 在这里做增量解析注意 data 只有前 dataLength 字节有效 for (int i 0; i dataLength; i) _buffer.Add(data[i]); return true; } }构造时传的那个byte[]是接收缓冲区大小决定每次回调的数据块尺寸。太小回调频繁增加开销太大浪费内存16KB 到 64KB 是常见区间。ReceiveData返回false会中止下载可以用来做解析出错就停的逻辑。注意data数组是复用的你只能读前dataLength字节且不能持有这个数组的引用——要留数据必须自己拷贝出来。3. 上传、超时、重定向这些非主流配置怎么设大部分教程只讲 GET 下载但实际项目里 POST 上传、超时控制、重定向处理这些才是真机上出问题的高发区。这一节把这些配置项逐个拆开讲清楚每个参数背后的行为。3.1 POST 表单与原始字节上传的两种路径POST 上传分两种场景表单提交键值对和原始数据上传JSON、二进制。表单用WWWForm配合UnityWebRequest.Post原始数据用UploadHandlerRaw。// 表单 WWWForm form new WWWForm(); form.AddField(user, abc); form.AddBinaryData(file, fileBytes, a.png, image/png); UnityWebRequest req UnityWebRequest.Post(url, form); // 原始 JSON byte[] body Encoding.UTF8.GetBytes(jsonString); UnityWebRequest req2 new UnityWebRequest(url, POST); req2.uploadHandler new UploadHandlerRaw(body); req2.downloadHandler new DownloadHandlerBuffer(); req2.SetRequestHeader(Content-Type, application/json);WWWForm会自动设置Content-Type: multipart/form-data和边界字符串省心但会多一层封装开销。原始上传则要你自己设Content-Type忘了设服务器可能解析不了。UploadHandlerRaw会把整个byte[]拷进 native 缓冲区所以大文件上传时内存里会有两份数据超大文件建议用UploadHandlerFile较新版本支持做流式上传。3.2 timeout 的单位、默认值与真机上的实际表现request.timeout的单位是秒默认值 0 表示不超时。这个默认值在真机上是灾难——网络一断请求可能挂几分钟甚至永远不返回你的加载界面就卡死在那儿。生产环境必须显式设置 timeout我一般按业务给 10 到 30 秒资源下载按文件大小动态算。超时的行为要注意超时触发后request.result是ConnectionErrorerror里会有 timeout 字样。但超时是从请求发出开始算的总时长不是无数据时长所以大文件下载如果总时长超过 timeout 也会被掐断。大文件要么把 timeout 设大要么用分块下载Range 请求自己控制每块的时间。3.3 redirectLimit 与证书处理的取舍redirectLimit控制最多跟随几次重定向默认 32。一般不用动但如果你明确知道接口会 302 到 CDN可以适当调小避免无谓跳转。真正需要注意的是 HTTPS 证书校验UnityWebRequest默认会校验证书某些自签证书的内网环境会失败。certificateHandler可以自定义校验逻辑但生产环境绝对不要无脑返回 true 跳过校验那等于把中间人攻击的门敞开。内网测试环境临时用可以上线前必须去掉。4. 进度、并发与取消真机上最容易翻车的三件事前面讲的都是单个请求怎么发但真实项目里请求是一批一批来的进度条要动、并发要控、用户退出要能取消。这三件事处理不好轻则进度条乱跳重则请求泄漏、内存暴涨。4.1 downloadProgress 的取值逻辑与进度条平滑downloadProgress是 0 到 1 的浮点但它有个特点在响应头到达之前一直是 0之后才开始增长。所以如果你在请求刚发出时就把进度条从 0 开始画会有一段卡在 0的静止期用户以为卡死了。解决办法是给一个连接中的预备状态等downloadProgress 0再切到真实进度。另外downloadProgress的更新频率跟帧率相关不是每字节都更新。做进度条时建议做插值平滑避免数字跳变float displayed 0f; while (!request.isDone) { displayed Mathf.Lerp(displayed, request.downloadProgress, Time.deltaTime * 5f); progressBar.value displayed; yield return null; } progressBar.value 1f;注意这个循环里yield return null是每帧让出配合isDone判断比单纯yield return request.SendWebRequest()更灵活因为你能在等待期间持续更新 UI。但别忘了循环结束后仍然要检查request.result。4.2 并发请求的数量控制与连接池Unity 底层对同一域名的并发连接数有限制一次性发几十个请求并不会真的并行反而会排队导致整体变慢还容易触发服务器的限流。我的做法是用一个简单的信号量或队列控制并发数通常 4 到 6 个并发比较稳。SemaphoreSlim semaphore new SemaphoreSlim(4); async Task DownloadAsync(string url) { await semaphore.WaitAsync(); try { using var req UnityWebRequest.Get(url); var op req.SendWebRequest(); while (!op.isDone) await Task.Yield(); // 处理结果 } finally { semaphore.Release(); } }这里用Task.Yield()而不是Task.Delay是为了每帧让出而不引入额外延迟。并发控制的意义不只是别把服务器打挂更是让每个请求的进度和超时都可预期。4.3 Abort 的正确调用时机与资源回收顺序用户中途退出界面你得取消还在飞的请求。request.Abort()会立即中止传输之后result变成ConnectionError。但调用Abort之后仍然要 Dispose否则 native 资源不释放。顺序是先Abort再在using块结束或显式Dispose。一个常见错误是在OnDestroy里只Abort不Dispose或者反过来在请求还没完成时就Dispose导致回调访问已释放对象抛异常。稳妥的写法是把请求存在字段里OnDestroy时先判空、再Abort、再Dispose、最后置空。如果请求是在协程里using的取消时通过一个CancellationToken或布尔标志让协程自己退出让using正常走完释放流程比外部强行 Dispose 更安全。5. 踩过的坑与真机排查链路这一节不讲正确写法讲我实际遇到过的几个典型故障以及当时是怎么一步步定位的。这些排查思路比结论更有价值因为你的现场未必跟我一样。5.1 Android 上请求一直 pending 的排查过程现象编辑器里一切正常打包到 Android 后某个接口请求永远不完成进度停在 0超时也不触发因为当时没设 timeout。排查链路是这样的先确认 URL 在设备浏览器里能打开排除网络本身问题再在请求前后打日志发现SendWebRequest调用了但isDone永远 false接着怀疑是主线程被占检查发现请求是在一个while循环里同步等待的——这就是前面说的死锁。改成协程yield return后恢复。这个坑的教训是任何等待请求完成的代码只要它在主线程上忙等就一定会死锁。5.2 大文件下载内存翻倍的定位方法现象下载 80MB 资源包时Profiler 里 native 内存和托管内存同时暴涨低端机直接崩。定位方法是用 Profiler 的 Memory 模块分帧看发现下载过程中托管堆有一块持续增长的byte[]。追代码发现用了DownloadHandlerBuffer然后调.data拿字节数组再写文件等于数据在 native 缓冲、托管数组、文件三处各存一份。改成DownloadHandlerFile直接落盘后托管堆那块消失峰值内存降了一半多。这个案例说明选处理器之前先想清楚数据最终要去哪能直达就别中转。5.3 进度条到 99% 卡住的真相现象进度条走到 99% 停好几秒才完成。原因不是网络慢而是downloadProgress到 1 之后还有一段数据处理时间——比如纹理解码、AssetBundle 解压、文件 flush。这段不在下载进度里所以进度条看起来卡住。解决办法是在进度到 1 后切一个处理中的提示或者用request.downloadHandler.isDone作为真正的完成信号。这个细节文档里不会强调但用户体验上差别很大。故障现象最可能根因快速验证方式请求永不完成主线程忙等死锁检查是否有 while 同步等待内存翻倍用了 Buffer 又拷贝 byte[]Profiler 看托管堆 byte[] 增长进度卡 99%数据处理阶段未计入进度看 downloadHandler.isDone失败留半截文件未清理中止残留检查目标路径文件大小6. 把请求封装成可复用的工具层单次请求写起来简单但项目里请求散落各处、每个地方都重复写错误处理和超时设置维护起来就是灾难。我的做法是抽一个薄薄的工具层把发请求、判结果、超时、重试、日志这些横切逻辑收拢业务代码只关心我要什么数据。6.1 一个带重试和统一错误处理的请求封装封装的核心不是把 API 包一层而是把失败处理策略固化下来。比如网络类错误自动重试 2 次、协议错误不重试直接报错、每次请求统一打点日志。下面是一个简化版public static class HttpUtil { public static IEnumerator GetJson(string url, Actionstring onSuccess, Actionstring onError, int timeout 15, int retry 2) { for (int attempt 0; attempt retry; attempt) { using (UnityWebRequest req UnityWebRequest.Get(url)) { req.timeout timeout; yield return req.SendWebRequest(); if (req.result UnityWebRequest.Result.Success) { onSuccess?.Invoke(req.downloadHandler.text); yield break; } bool retryable req.result UnityWebRequest.Result.ConnectionError; if (!retryable || attempt retry) { onError?.Invoke(${req.result}:{req.error}); yield break; } yield return new WaitForSeconds(1f * (attempt 1)); } } } }这里重试只针对ConnectionError因为协议错误4xx、5xx重试也没用反而增加服务器压力。退避用递增秒数避免重试风暴。回调式接口虽然不如async/await优雅但在协程体系里最稳不涉及线程上下文切换。6.2 用 async/await 包装时的线程上下文注意点Unity 2021 之后UnityWebRequestAsyncOperation可以直接await写起来更顺public static async Taskstring GetJsonAsync(string url, int timeout 15) { using var req UnityWebRequest.Get(url); req.timeout timeout; await req.SendWebRequest(); if (req.result ! UnityWebRequest.Result.Success) throw new Exception(${req.result}:{req.error}); return req.downloadHandler.text; }关键点在于await之后恢复的上下文。Unity 的 SynchronizationContext 会把续体调度回主线程所以await之后访问 UI 是安全的。但如果你在Task.Run里调用这个异步方法续体可能不在主线程访问 Unity 对象就会抛异常。所以异步封装只在主线程发起、主线程消费的场景用别把它扔进线程池。6.3 日志与埋点让线上问题可回溯请求层是线上问题的高发区日志必须打全URL、方法、耗时、结果码、错误信息、响应体大小。但别把整个响应体打进日志大响应会撑爆日志文件。我的习惯是记录响应体前 200 字符加总长度既能看出内容类型又不失控。耗时统计用Time.realtimeSinceStartup在请求前后取差值比Stopwatch更贴合 Unity 的帧时间体系。这些日志在排查某个用户某个接口特别慢这类问题时是唯一线索。7. 版本差异与平台差异的实战备忘UnityWebRequest在不同 Unity 版本和不同平台上行为有细微差别这些差别不写在主文档里但会实实在在影响你。7.1 result 属性引入前后的兼容写法UnityWebRequest.Result枚举是 Unity 2020.2 之后才有的之前判断成功要靠!isNetworkError !isHttpError。如果你的项目要兼容老版本封装层里得做条件编译#if UNITY_2020_2_OR_NEWER bool ok req.result UnityWebRequest.Result.Success; #else bool ok !req.isNetworkError !req.isHttpError; #endif新项目直接用result就行语义清晰得多。老项目升级时注意把散落各处的isNetworkError判断统一替换否则新旧混用容易漏判。7.2 WebGL 平台的限制清单WebGL 平台下UnityWebRequest有几个硬限制不能直接用DownloadHandlerFile写本地文件系统浏览器沙箱跨域请求需要服务器配置 CORS 头timeout的支持也不完整。所以同一套下载代码在 WebGL 上要单独走分支用 Buffer 接收后交给浏览器的 IndexedDB 或内存缓存。这个差异必须在架构初期就考虑别等打包 WebGL 才发现下载全挂。7.3 iOS ATS 与明文 HTTP 的处理iOS 的 App Transport Security 默认禁止明文 HTTP 请求如果你的接口还是 http 开头请求会直接失败且错误信息很隐晦。解决办法要么全站上 HTTPS要么在 Info.plist 里配置例外域名。我的建议是能上 HTTPS 就上配例外只是临时手段而且审核时可能被问。这个坑在编辑器里完全复现不了只有真机才暴露所以联调阶段就要用真机验证网络层。8. 一些不那么显眼但很值钱的细节最后收几个零散但实用的点都是实际项目里攒下来的。UnityWebRequest对象不要复用。有人为了省 GC 想复用请求对象改 URL 再发这是不行的一个请求对象生命周期内只能发一次复用会抛异常或行为未定义。要复用就复用封装层不是复用请求实例。请求头设置要在SendWebRequest之前完成发送后再改无效。SetRequestHeader对同名头是覆盖不是追加需要多值头比如多个 Cookie得自己拼成一个字符串。DownloadHandlerBuffer.text每次访问都会从 native 缓冲解码一次字符串循环里反复读会重复解码。需要多次用就取一次存局部变量。对于需要认证的接口token 过期重登的逻辑不要塞进每个请求里放在封装层的统一错误处理里收到 401 就触发一次刷新 token 再重放原请求这样业务层完全无感。缓存方面UnityWebRequest本身不带 HTTP 缓存需要缓存就自己在封装层按 URL 加版本号做本地缓存或者用Cache-Control头配合自定义逻辑。别指望它像浏览器一样自动缓存。真机测试网络层一定要模拟弱网和断网。Unity 没有内置弱网模拟可以在路由器上限速或者在封装层人为加延迟。很多超时和重试的 bug 只有在弱网下才暴露编辑器里的流畅网络会骗你。这套东西讲下来核心其实就一句话UnityWebRequest的 API 表面很简单但它的价值全在那些你没注意到的默认行为和平台差异里。把处理器选型、超时、并发、取消这四件事按业务场景想清楚再配一层薄封装把横切逻辑收拢网络层基本就不会成为项目的短板了。