ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

UnityWebRequest从入门到精通:避坑指南与性能优化

UnityWebRequest从入门到精通:避坑指南与性能优化

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的 awaiter(Unity 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 List<byte> _buffer = new List<byte>(); 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是ConnectionError,error里会有 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, Action<string> onSuccess, Action<string> 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 Task<string> 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 表面很简单,但它的价值全在那些"你没注意到的默认行为"和"平台差异"里。把处理器选型、超时、并发、取消这四件事按业务场景想清楚,再配一层薄封装把横切逻辑收拢,网络层基本就不会成为项目的短板了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进