☰
Unity热更资源并行下载优化:并发数调优与提速一倍实践
2026/9/29 18:17:45 网站建设 项目流程

1. 从一次真实的下载卡顿说起

去年做一款休闲游戏的时候,资源包体控制得还算克制,首包压到了 80MB 出头,热更资源大概 200MB 左右。测试机上跑得挺欢,结果一到真机、尤其是中低端安卓机上,玩家反馈最多的不是玩法问题,而是“进游戏转圈太久”“更新条卡在 60% 不动”。我一开始以为是 CDN 的问题,换了节点、加了带宽,改善有限。后来抓包一看,问题出在我自己的下载逻辑上——用的是最朴素的UnityWebRequest一个一个串行下载,一个文件下完才发起下一个。200MB 拆成几十个小文件,每个文件都要经历一次完整的请求往返,光握手和等待就吃掉了一大半时间。

这就是我做“并行下载实验”的起点。标题里说的“下载提速一倍”,不是拍脑袋喊的口号,而是我把串行改成受控并行之后,在同样的网络环境下实测出来的结果。这篇内容适合两类人看:一类是正在用 Unity 做热更、资源加载,被下载速度折磨过的开发者;另一类是想搞清楚“并发数到底设多少合适”“为什么并发不是越高越好”的同行。我会把实验过程、参数选择、踩过的坑都摊开讲,代码可以直接抄。

先说结论性的东西,方便你判断要不要往下读:并行下载的核心不是“开越多线程越快”,而是找到请求往返延迟和带宽占用之间的平衡点。Unity 里做并行下载,主流方案是UnityWebRequest配合协程或者async/await,控制同时进行的请求数量。我实测下来,在普通家宽和 4G 环境下,并发数设在 4 到 6 之间收益最高,再往上加,速度提升微乎其微,反而容易触发各种超时和内存问题。

2. 并行下载到底在解决什么问题

2.1 串行下载的时间都花在哪了

要理解并行为什么快,得先搞清楚串行下载慢在哪。假设你要下载 20 个文件,每个文件平均 500KB,网络往返延迟(RTT)是 50ms,带宽是 10Mbps。串行的情况下,每个文件的耗时大致是:建立连接 + 发送请求 + 等待服务器响应 + 传输数据 + 关闭连接。其中“等待服务器响应”这一段,也就是首字节时间(TTFB),往往在 100ms 到 300ms 之间,跟文件大小没关系,纯粹是网络往返和服务器处理的开销。

20 个文件,每个光 TTFB 就按 150ms 算,那就是 3 秒纯等待。再加上实际传输时间,20 个文件 × 500KB = 10MB,10Mbps 带宽下理论传输 8 秒左右。串行总耗时轻松超过 11 秒。而如果同时发起 5 个请求,那 20 个文件分成 4 批,TTFB 的等待被重叠掉了,总耗时能压到 8 秒多一点,接近带宽的理论上限。这就是“提速一倍”的数学来源——省下来的主要是那些被浪费掉的等待时间。

提示:这里的计算是理想化的,实际还会受服务器并发限制、DNS 解析、TLS 握手等因素影响,但方向是对的。文件越小、数量越多,并行的收益越明显。

2.2 并发数不是越高越好

很多人第一反应是“那我开 20 个并发,不就 20 个文件一起下了”。我一开始也这么想,结果被打脸。并发数拉高之后出现了几个问题:一是内存占用飙升,每个UnityWebRequest都会持有自己的缓冲区和连接资源,20 个并发在低端机上直接触发 GC 卡顿;二是很多服务器和 CDN 对单 IP 的并发连接数有限制,超过之后新的请求会被排队甚至拒绝,反而更慢;三是移动网络下,过多的并发连接会加剧丢包和重传,实际吞吐不升反降。

我做过一组对比实验,同一批 30 个文件、总计约 15MB,在同一个 WiFi 环境下,只改并发数,记录总耗时:

并发数总耗时(秒)内存峰值(MB)备注
1(串行)14.218基线
29.822提升明显
47.128收益最佳区间
66.934与 4 接近
107.552开始劣化
209.388明显劣化,偶发超时

数据很直观:4 到 6 是甜点区。再往上,耗时不再下降,内存却线性增长。这个结论在不同网络环境下会有浮动,但大方向一致。所以我在代码里把并发数做成了可配置项,默认给 5,特殊场景再调。

2.3 什么场景适合并行,什么场景别碰

并行下载不是万能药。它适合的是“大量小文件”的场景,比如热更资源、图集、配置表、音频片段。这类文件单个不大,但数量多,串行时 TTFB 的浪费占比极高。

反过来,如果是单个大文件,比如一个 500MB 的安装包,并行就没意义了,因为你没法把一个文件切成多段去下(除非服务端支持 Range 请求做分片下载,那是另一套逻辑)。另外,如果服务器本身并发能力弱,或者你的资源有严格的下载顺序依赖(比如必须先下 A 才能下 B),那也不适合无脑并行,得做依赖调度。

3. 用 UnityWebRequest 搭一套受控并行下载器

3.1 核心思路:任务队列加并发窗口

我的实现思路很朴素:把所有待下载的 URL 放进一个队列,维护一个“当前进行中的请求”计数器,只要计数器没到上限,就从队列里取下一个发起请求,请求完成后再补一个进来。这样既保证了并发数受控,又不会一次性把所有请求都发出去。

用协程实现的话,核心结构大概是这样:

using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.Networking; public class ParallelDownloader : MonoBehaviour { public int maxConcurrency = 5; private Queue<string> pendingUrls = new Queue<string>(); private int runningCount = 0; private int finishedCount = 0; private int totalCount = 0; public void StartDownload(List<string> urls) { pendingUrls.Clear(); foreach (var url in urls) pendingUrls.Enqueue(url); totalCount = urls.Count; finishedCount = 0; runningCount = 0; StartCoroutine(PumpQueue()); } private IEnumerator PumpQueue() { while (finishedCount < totalCount) { while (runningCount < maxConcurrency && pendingUrls.Count > 0) { string url = pendingUrls.Dequeue(); runningCount++; StartCoroutine(DownloadOne(url)); } yield return null; } Debug.Log("全部下载完成"); } private IEnumerator DownloadOne(string url) { using (UnityWebRequest req = UnityWebRequest.Get(url)) { req.timeout = 30; yield return req.SendWebRequest(); if (req.result == UnityWebRequest.Result.Success) { // 这里处理下载到的数据,比如写入本地缓存 byte[] data = req.downloadHandler.data; // SaveToCache(url, data); } else { Debug.LogWarning($"下载失败: {url}, 原因: {req.error}"); // 失败重试逻辑可以加在这里 } } runningCount--; finishedCount++; } }

这段代码的关键点在于PumpQueue这个“泵”:它每帧检查一次,只要还有空位就补请求进去。yield return null让它每帧跑一次,不会阻塞主线程。DownloadOne里用using确保UnityWebRequest被正确释放,这一点很重要,否则连接资源不会及时回收。

3.2 为什么用协程而不是 async/await

Unity 里其实也能用async/await配合UnityWebRequest,但有几个坑。一是async/await在 Unity 里的异常处理跟协程不太一样,SendWebRequest的await在某些版本上行为不稳定;二是协程跟 Unity 的生命周期绑定得更自然,StopAllCoroutines就能一键停掉所有下载,做场景切换时的清理很方便。所以我个人在下载这种跟游戏生命周期强相关的逻辑上,更倾向协程。

当然,如果你用的是较新的 Unity 版本,Awaitable也是个不错的选择,它比传统协程更轻量。但核心的“队列 + 并发窗口”思路是一样的,换汤不换药。

3.3 并发窗口的动态调整

固定并发数在大多数情况下够用,但如果你想再精细一点,可以根据当前网络状况动态调整。比如检测到连续几个请求的耗时都很短,说明网络状况好,可以适当提高并发;如果出现超时或失败,就降低并发。这个逻辑我做过一版,但说实话,收益没有想象中大,反而增加了复杂度。对于大多数项目,固定并发数 + 合理的超时重试已经能覆盖 90% 的场景。

4. 提速一倍的几个关键细节

4.1 超时设置与失败重试

并行下载最怕的不是慢,而是“卡住”。串行时一个请求卡住,后面的都下不了;并行时一个请求卡住,虽然不影响其他请求,但它会一直占着并发窗口的名额,导致整体吞吐下降。所以超时设置是必须的。我一般把单个请求的超时设在 15 到 30 秒之间,具体看文件大小和网络环境。超过这个时间还没完成,直接判定失败,释放窗口,让后面的请求补上来。

重试逻辑也要有,但不能无脑重试。我的做法是每个 URL 最多重试 2 次,且重试之间加一个递增的等待时间(比如第一次等 1 秒,第二次等 3 秒),避免在服务器压力大时雪上加霜。

private IEnumerator DownloadWithRetry(string url, int maxRetry = 2) { int attempt = 0; while (attempt <= maxRetry) { using (UnityWebRequest req = UnityWebRequest.Get(url)) { req.timeout = 20; yield return req.SendWebRequest(); if (req.result == UnityWebRequest.Result.Success) { // 处理数据 yield break; } } attempt++; if (attempt <= maxRetry) yield return new WaitForSeconds(attempt * 2); } Debug.LogError($"重试后仍失败: {url}"); }

4.2 内存与 GC 的控制

并行下载会带来额外的内存开销,主要是每个请求的下载缓冲区和UnityWebRequest对象本身。如果下载的是大文件,downloadHandler.data会把整个文件读进内存,几个并发叠加起来很容易触发 GC。我的经验是:对于超过 1MB 的文件,尽量用DownloadHandlerFile直接写磁盘,而不是先读到内存再写。

using (UnityWebRequest req = UnityWebRequest.Get(url)) { string savePath = Path.Combine(Application.persistentDataPath, fileName); req.downloadHandler = new DownloadHandlerFile(savePath); yield return req.SendWebRequest(); // 文件已经直接落盘,不需要再手动写 }

DownloadHandlerFile的好处是数据流式写入磁盘,内存占用恒定,不会因为文件大小而波动。这个改动在我那个项目里直接把内存峰值从 80 多 MB 压到了 30MB 以内,低端机上的卡顿明显减少。

4.3 请求头的复用与连接保持

HTTP 协议里有个Keep-Alive机制,可以让多个请求复用同一个 TCP 连接,省去反复握手的时间。UnityWebRequest默认是开启连接复用的,但前提是请求发往同一个域名。如果你的资源分散在多个 CDN 域名下,那连接复用就无从谈起了。所以尽量把资源收敛到少数几个域名,这对并行下载的提速帮助很大。

另外,如果你的资源有版本号或者哈希值,记得在 URL 上带上,这样可以配合 CDN 的缓存策略,避免每次都回源。这个属于服务端配置的范畴,但对下载速度的影响不亚于客户端代码。

5. 实测数据与常见问题排查

5.1 不同网络环境下的表现

我在三种网络环境下跑了同一批资源(30 个文件,总计 15MB),并发数固定为 5,对比串行和并行:

网络环境串行耗时并行耗时提速比例
办公室 WiFi14.2s7.1s约 2.0 倍
4G 移动网络22.5s12.8s约 1.76 倍
模拟弱网(限速 2Mbps)48.3s31.5s约 1.53 倍

可以看到,网络越好,并行的收益越接近理论值;弱网环境下,因为带宽本身是瓶颈,并行的收益会打折扣,但依然有 1.5 倍左右的提升。这也印证了前面的结论:并行省的是等待时间,不是传输时间。

5.2 常见问题速查

问题现象可能原因解决方向
下载速度没提升并发数设太低,或文件太大提高并发到 4-6,检查文件大小分布
内存飙升、GC 卡顿大文件读到内存改用 DownloadHandlerFile
部分请求一直不返回没设超时设置 req.timeout
并发高了反而变慢服务器限流或网络丢包降低并发,加失败重试
下载完成后文件损坏没等请求完全结束就写盘确认 result 为 Success 再处理
切换场景后下载报错协程被销毁但请求还在场景切换时 StopAllCoroutines 并释放请求

5.3 几个我踩过的坑

第一个坑是在SendWebRequest之后立刻访问downloadHandler.data。有些情况下请求还没真正完成,数据是空的。正确做法是等yield return req.SendWebRequest()返回后,先判断req.result,再取数据。

第二个坑是并发数在编辑器里调好了,打包到真机就变慢。原因是编辑器的网络环境和真机差异很大,尤其是 Android 上的网络权限和后台限制。我的建议是并发数做成配置项,真机实测后再定,别拿编辑器的数据当准。

第三个坑是忘记释放UnityWebRequest。虽然 C# 有 GC,但UnityWebRequest持有的网络连接资源不会自动回收,必须显式Dispose或者用using。我有一版代码漏了using,跑久了之后连接数耗尽,新请求全部失败,排查了半天才发现。

6. 还能怎么继续优化

并行下载做到这里,基本能满足大部分项目的需求了。如果还想再压榨一点性能,有几个方向可以尝试。一是分片下载,对于单个大文件,利用 HTTP Range 请求把它切成几段并行下,最后合并,这个对超大资源包很有效,但实现复杂度高不少。二是预连接,在真正下载之前先对目标域名发起一个轻量请求,把 TCP 和 TLS 握手提前做掉,等真正下载时就能省掉这部分时间。三是本地缓存校验,下载前先检查本地是否已有相同版本的文件,避免重复下载,这个在热更场景下能省掉大量无效流量。

我自己在实际项目里的体会是,先把并发窗口和超时重试做扎实,再考虑这些进阶优化。很多团队一上来就追求花哨的分片下载,结果基础的并发控制都没做好,反而问题一堆。下载这件事,稳定比极致速度更重要,毕竟玩家最不能忍的是卡住不动,而不是慢那么一两秒。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询