ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Unity安全高效异步压缩解压:告别主线程卡顿

Unity安全高效异步压缩解压:告别主线程卡顿 做Unity项目但凡碰到文件压缩解压十有八九逃不过“卡顿”这个坎。热更包、存档、日志上报、资源导入随便一个几百MB的zip包在主线程上同步操作轻则掉帧几秒重则直接白屏、被系统判定无响应。这个问题的根源不是压缩算法本身而是“同步阻塞主线程”这个老毛病。只要把压缩解压改成异步配合流式读写和平台细节处理就能把耗时操作挪到后台线程同时还能做出实时进度条整个体验完全不一样。这篇文章要聊的就是这个主题在Unity里如何安全、高效地把文件压缩和解压做成异步。适合做热更下载、存档管理、编辑器工具、资源包导入的开发者参考尤其是被大文件卡到怀疑人生的朋友。1. 为什么文件压缩解压必须异步化1.1 同步操作的卡顿根因Unity的主线程承担了太多职责渲染指令、Update逻辑、输入事件、物理模拟、UI布局全在这一条线程上串行执行。任何耗时操作插进来整个游戏循环都得停下等它。文件压缩解压恰好就是那种“不显眼但非常耗时间”的操作。我实测过一组数据一台中端Android手机上用同步方式解压一个50MB的zip包大约需要2到8秒。这期间如果包内有大量小文件耗时还会指数级上升因为每个文件的打开、写入、关闭都有系统调用开销。而压缩通常更慢因为要计算压缩率CPU占用会被拉满。如果这段逻辑放在Update或者按钮回调里UI线程直接冻住玩家点任何按钮都没反应等到解压完成才突然跳一下。在iOS上超过一定时长系统还会触发看门狗误杀进程表现为“秒退”。这是我在早期项目里踩过的坑后来所有压缩解压逻辑都被我强制挪到后台线程。1.2 三种异步方案怎么选技术上来讲Unity里实现异步压缩解压主要有三条路我分别整理一下优缺点。方案实现方式优点缺点协程 子线程用Thread跑压缩逻辑主线程用WaitForSeconds轮询完成标志兼容老版本Unity5.x也能用不依赖C#新特性需要自己管理线程生命周期代码结构容易乱Task.Run async/await基于.NET 4.x的Task线程池配合async/await语法代码清晰异常处理方便支持CancellationToken取消操作需要Unity 2018.3并且开启.NET 4.x Equivalent部分老安卓机型线程池调度有一定开销UniTask第三方异步库改造了Task在Unity里的调度性能好能直接切换回主线程配合UI更新非常顺手需要引入第三方依赖对团队新手有一定门槛选型建议很直接老项目、团队不熟悉异步语法用协程加Thread现代Unity项目直接用Task.Run加async/await追求极致性能并且已经在工程里用了UniTask那就全面拥抱UniTask。我个人现在的标准做法是独立工具类用Task.Run实现业务层调用时封装成UniTask做主线程回调两者兼容起来也很容易。1.3 异步不等于万事大吉异步只是把耗时操作挪到后台线程但文件系统的问题一点都不会少。最典型的是文件占用解压过程中如果用户切到后台或者另一个逻辑在读取同一个文件就可能抛IOException。还有Android的Application.persistentDataPath在不同机型上指向不同路径甚至部分老设备对目录访问权限有额外限制。所以异步方案里一定要做异常捕获、日志记录、重试机制这个我放到后面的排查章节细说。2. 压缩解压方案选型标准库到底够不够用2.1 .NET标准库在Unity的可用性边界Unity从2018.3开始默认支持.NET 4.x Equivalent这意味着System.IO.Compression里的ZipArchive、ZipFile、GZipStream这些类型都能直接用。这是我最喜欢的方式因为它不需要引入任何第三方插件纯托管代码跨平台一致性最好。但有几个边界必须清楚ZipFile这个静态类依赖System.IO.Compression.FileSystem程序集在某些IL2CPP裁剪配置下方法可能被剪掉运行时抛FileNotFound或者MethodAccessException。打包时如果不小心把API Compatibility Level设成了.NET 2.0连async/await都编不过。Unity 2020以下的版本在Android上对ZipArchive的支持也有过几个bug偶发解压中途流关闭异常。所以我的做法是不用ZipFile.CreateFromDirectory这种“一把梭”的高层方法而是用ZipArchive加一个一个Entry自己写。这样既能控制进度又能规避裁剪问题还能顺手处理路径和编码细节。2.2 什么时候用第三方库第三方库并不是不能用但要搞清楚为什么用。SharpZipLib是老牌生态Unity适配良好有完整的压缩解压API在zip包内容加密、压缩级别细分这些场景比标准库灵活。DotNetZip虽然还在一些老项目里躺着但已经停止维护新项目别碰。如果遇到非zip格式的需求比如7z、tar.gz、LZ4标准库就完全不够用了。LZ4解压算法在内存带宽上有天然优势适合超大文件的快速打包7z则胜在压缩率。这些情况通常需要引入原生库或者用C#封装库。引入第三方库有个常见坑如果引入的是原生动态库比如C写的解压库Android上必须处理各个ABI架构arm64-v8a、armeabi-v7a、x86_64少一个库就闪退。纯C#实现的库虽然省心但要留意它在IL2CPP下的裁剪问题同样需要link.xml配合。2.3 自研分块方案的场景如果只是普通zip包标准库完全够用。但有两个场景我会选择自研分块压缩一是超大文件超过2GBzip格式的64位扩展在部分平台支持不完整压到一半报错很难受二是需要断点续传或者流式传输比如日志打包上传时希望边写边传不希望等全量压缩完再读内存。自研方案一般用DeflateStream或者GZipStream对每个分块做流式压缩再把分块按自定义格式拼接。这个方案灵活度高但要自己设计文件头、校验值、长度记录代码量相当可观。普通项目如果不是被逼到这份上没必要自己造轮子。3. 核心实现异步压缩与解压的完整落地3.1 目录压缩写一个可感知进度的ZipHelper标准库的ZipFile.CreateFromDirectory虽然一行代码就能压缩目录但它不支持进度回调、不支持取消、对路径分隔符的处理也很“Windows式”。所以我自己写了一个ZipHelper核心逻辑是遍历文件列表逐个写入ZipArchive的Entry。using System; using System.IO; using System.IO.Compression; using System.Threading; using System.Threading.Tasks; public static class ZipHelper { public static async Task CompressDirectoryAsync( string sourceDir, string zipFilePath, IProgressfloat progress null, CancellationToken token default) { await Task.Run(() { if (File.Exists(zipFilePath)) { File.Delete(zipFilePath); } using (var zipStream new FileStream(zipFilePath, FileMode.Create, FileAccess.Write, FileShare.None, 1 20)) using (var archive new ZipArchive(zipStream, ZipArchiveMode.Create)) { var files Directory.GetFiles(sourceDir, *, SearchOption.AllDirectories); long totalBytes 0; foreach (var file in files) { totalBytes new FileInfo(file).Length; } long doneBytes 0; foreach (var file in files) { token.ThrowIfCancellationRequested(); var relativePath Path.GetRelativePath(sourceDir, file).Replace(\\, /); var entry archive.CreateEntry(relativePath, CompressionLevel.Fastest); using (var entryStream entry.Open()) using (var fileStream new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.Read, 1 20)) { var buffer new byte[1 20]; int read; while ((read fileStream.Read(buffer, 0, buffer.Length)) 0) { token.ThrowIfCancellationRequested(); entryStream.Write(buffer, 0, read); doneBytes read; progress?.Report((float)doneBytes / totalBytes); } } } } }, token); } }注意关键细节Path.GetRelativePath在Unity 2020可用之前版本需要自己写相对路径计算逻辑。Replace(\\, /)是重点zip包内部标准就是正斜杠如果直接拿Windows下的Path.Combine结果去压缩Android上解压时很可能得到错误路径。缓冲区选了1MB太小会导致系统调用频繁太大又浪费内存实测1MB在大多数机型上平衡得不错。CompressionLevel.Fastest是为了速度考虑文件I/O任务里压缩率反而不是第一目标。3.2 带安全校验的异步解压解压比压缩更需要注意安全。zip包本质上是用户可以控制的文件集合如果压缩包内的EntryName写的是../../shell.sh之类的路径盲目录拼接之后就可能写出目标目录之外的文件这就是俗称的“Zip Slip”漏洞。热更包虽然通常是自己生成、自己下载但万一CDN被篡改或者用户手动替换本地包风险就真实存在。public static async Task ExtractZipAsync( string zipFilePath, string destDir, IProgressfloat progress null, CancellationToken token default) { await Task.Run(() { Directory.CreateDirectory(destDir); using (var archive new ZipArchive(File.OpenRead(zipFilePath), ZipArchiveMode.Read)) { long totalBytes 0; foreach (var entry in archive.Entries) { totalBytes entry.Length; } long doneBytes 0; foreach (var entry in archive.Entries) { token.ThrowIfCancellationRequested(); // 路径穿越校验 var fullDestDir Path.GetFullPath(destDir); var fullEntryPath Path.GetFullPath(Path.Combine(destDir, entry.FullName)); if (!fullEntryPath.StartsWith(fullDestDir, StringComparison.OrdinalIgnoreCase)) { throw new Exception($非法路径被拦截: {entry.FullName}); } if (entry.Name string.Empty) { Directory.CreateDirectory(fullEntryPath); continue; } Directory.CreateDirectory(Path.GetDirectoryName(fullEntryPath)); using (var entryStream entry.Open()) using (var fileStream new FileStream(fullEntryPath, FileMode.Create, FileAccess.Write, FileShare.None, 1 20)) { var buffer new byte[1 20]; int read; while ((read entryStream.Read(buffer, 0, buffer.Length)) 0) { token.ThrowIfCancellationRequested(); fileStream.Write(buffer, 0, read); doneBytes read; progress?.Report((float)doneBytes / totalBytes); } } } } }, token); }这段代码里有几个值得展开的点Path.GetFullPath会帮我们规范化路径..这种相对路径会被正确解析所以先用它计算出完整路径再判断是否以目标目录前缀开头就能拦截穿越攻击。这里一定要用StartsWith加分隔符边界判断否则理论上可能出现destDir2这种前缀误判保险起见加一行字符判断。entry.FullName本身是zip内部路径但如果你用Windows自带工具压缩出来的包可能混进反斜杠。Path.GetFullPath在Windows下能处理反斜杠在Android下只会把反斜杠当普通字符。这点我习惯在解压前先把entry.FullName里的\\全部替换成/再处理。空目录在zip里表现为entry.Name string.Empty这时候需要直接创建目录而不是写文件。3.3 主线程回调和进度UI怎么接进度回调最容易出错的地方在于线程上下文。ProgressT在构造时会捕获当前SynchronizationContext如果构成时有主线程上下文回调会回到主线程但在Unity的某些平台比如Android的纯Mono环境下主线程同步上下文可能缺失回调就跑到线程池去了UI更新就会抛“不能在工作线程操作”的异常。保险做法是自己维护一个主线程调度器把UI更新扔回主线程执行。简单方案是用UniTask的MoveToUnityThread或者用协程等待Task完成后再更新UI。var progress new Progressfloat(value { // 如果同步上下文可靠这会在主线程执行 // 不可靠的话把更新动作post到主线程 UnityMainThreadDispatcher.Enqueue(() { slider.value value; }); }); await ZipHelper.CompressDirectoryAsync(srcDir, zipPath, progress, token);UnityMainThreadDispatcher是一个经典组件思路是维护一个线程安全的队列在Update里把队列里的Action取出来执行。这个不复杂网上有很多实现如果你不想引入UniTask自己写20行代码就搞定。另外进度模型上我推荐“字节累计”而不是“文件数累计”。文件数进度在压缩一个大文件时会瞬间跳几次看起来非常假字节累计配合totalBytes的计算误差小、自然平滑。代价是先要遍历一遍文件列表统计大小几万个小文件时花费的时间约几十毫秒完全可接受。3.4 取消操作CancellationToken 在 Unity 中落地异步不做取消等于少了一条腿。热更包解压到一半玩家切换场景如果不能让任务停下来后台线程还在继续写文件主线程已经在加载新场景资源磁盘I/O互相打架卡顿感会很明显。CancellationToken是.NET标准库的取消机制上面的代码里已经用了token.ThrowIfCancellationRequested()。这套机制在Unity里完全可行唯一要注意的是取消后释放资源Task.Run里用了using块包裹流抛异常时会自动释放文件句柄所以不用担心文件被长期占用。var cts new CancellationTokenSource(); // 在某个按钮回调中触发取消 public void OnCancelButtonClicked() { cts.Cancel(); }但要注意ThrowIfCancellationRequested只会在循环体里被检查如果你正在写一个超大文件最中间的几十毫秒内其实没法立即中断。对于Unity大多数场景这个粒度已经够了。假如需要更精细的取消就得在写入循环里增加字节级别的检查比如每写入64KB检查一次但会牺牲一部分吞吐量。4. 性能优化与内存控制4.1 压缩到底耗什么CPU还是磁盘很多人下意识觉得压缩是CPU密集型任务因为要算压缩算法其实在Unity的移动端真机上磁盘I/O往往是更大的瓶颈。尤其是写大量小文件时文件系统元数据操作比压缩本身慢得多。压缩级别的影响要分开看CompressionLevel.Optimal比Fastest多消耗大量CPU计算但体积减少并不总是那么明显。文本、JSON这类冗余度高的数据压缩率差异显著而纹理、音频、视频这类已经被压缩过的数据再压一遍几乎不省空间只会白白耗CPU。我做过一次实际测试一个包含500个文件、总共约300MB的资源目录Fastest级别压缩耗时约4秒Optimal级别耗时约9秒压缩包大小分别约为182MB和174MB。只省了8MB却多花了5秒这买卖不划算。所以现在的项目里默认全是Fastest除非是日志、配置这种纯文本数据需要极致压缩才偶尔用Optimal。4.2 流式处理禁止一次性读入内存同步代码最容易犯的错是byte[] fileBytes File.ReadAllBytes(filePath);如果文件是500MB这行代码直接把内存打爆Android上强杀是分分钟的事。异步代码也一样很多人开了Task.Run里面依然是ReadAllBytes结果把压力从主线程转移到内存依然崩。正确姿势是流式读写用固定大小的缓冲区循环这在上面的代码里已经呈现。这个模式叫“流式传输”核心逻辑就是读一块、写一块、推进进度、循环往复。内存占用恒定为缓冲区大小1MB左右不管源文件是10MB还是10GB内存都不会膨胀。如果是要在内存里操作层级的小文件比如单个压缩包内需要频繁随机访问所有Entry那可以试情况读取到MemoryStream但要提醒一句这种情况只适合小包千万别把游戏资源包全量塞内存。4.3 并行压缩到底有没有用参与过几个项目后我对“多线程并行压缩”的结论是收益有限坑却不少。文件压缩是多线程可以加速但瓶颈往往在磁盘写入。手机闪存并发写入同一目录时性能并不会线性提升反而会因为文件系统锁和缓存抖动变得更慢。我实测过同时开4个线程压缩不同文件总耗时和单线程串行相比几乎没有优势在部分低端机上还更慢了。所以我的建议是文件级压缩只用单线程串行不要并行。如果是纯CPU计算场景比如把一张超大贴图拆成多个分块做LZ4压缩那并行才有意义。如果你确实要并行并行度建议控制在min(CPU核心数 - 1, 4)并做统一的进度汇总避免每个线程各报各的进度导致UI混乱。4.4 大量小文件的处理细节小文件场景是最容易出性能事故的。几千个几KB的小文件逐个创建Entry、逐个打开文件流一次系统调用往返就要几十微秒累计起来非常可观。处理大量小文件有几个技巧把多个小文件合并成一个大文件再压缩解压后重新切分这个方案能大幅减少Entry数量但需要自定义索引结构。在ZipArchive层面不要频繁调用CreateEntry后再展开操作可以先收集文件列表按顺序一次性创建Entry。压缩时保留文件修改时间这在热更校验时很重要。ZipArchiveEntry.LastWriteTime如果不设置会取当时时间导致每次打包解压后文件时间戳都不同。我在热更项目里压缩AssetBundle目录时就是先收集所有文件信息遍历两次第一次统计大小第二次写入Data。这样进度和顺序都稳定内存也基本恒定。5. 常见问题与排查技巧5.1 IL2CPP下ZipFile方法缺失或异常这是我在iOS真机上踩过最惨的坑编辑器里跑得好好的打包到iPhone上运行一调用压缩方法就直接抛FileNotFoundException。原因是IL2CPP的代码裁剪把没被“显式发现”的API剪掉了ZipFile这种反射调用比较多的类型尤其容易中招。解决方案是配置link.xml在Assets/目录下创建这个文件内容类似linker assembly fullnameSystem.IO.Compression preserveall / assembly fullnameSystem.IO.Compression.FileSystem preserveall / /linker这样告诉IL2CPP这几块代码必须保留。另外检查一下Player Settings里的Managed Stripping Level我建议设置为Low虽然包体会大一点但省心。如果压缩解压不是核心业务也可以试试完全关掉剥离基础包增量大概几MB可接受。5.2 Android解压中文乱码zip压缩包里存的文件名编码一直是个老大难。ZipArchive读EntryName时默认按UTF-8解码但Windows下很多打包工具特别是老版本WinRAR写的文件名是GBK编码而且没有正确设置UTF-8标志位Unity里读出来就是乱码。我在一个项目里接到的热更包是Windows服务器上的Go程序生成的zip用户下载后解压进度条是好的但entry.FullName全显示成乱码文件路径完全对不上。排查后确认是编码问题。解决思路有两种一是生成端强制使用UTF-8编码并设置相应标志位二是读取端做兼容处理检测到乱码时尝试用GBK再解码一遍。检测逻辑可以看ZipArchiveEntry的ExternalAttributes或者直接对名字做一次TryGetBytes分析但最省事的方法还是让服务端生成包的人规范编码。这里分享一个判断技巧如果EntryName里出现锟斤拷这种典型乱码字符基本能确认是UTF-8解码GBK导致的。5.3 async void的错误会直接崩async void是C#异步编程里最危险的写法因为它的异常无法被调用方捕获会直接抛到当前同步上下文。在Unity里一个async void方法里如果没包try-catch解压中途抛异常可能导致整个游戏直接崩溃而且日志里经常看不到完整堆栈。我的规矩是所有对外暴露的异步方法都返回Task不用async void在调用入口处统一捕获异常记录日志回调错误事件然后由业务层决定是弹Toast还是静默重试。public async Taskbool ExportAndReportErrorAsync(string src, string dest) { try { await ZipHelper.CompressDirectoryAsync(src, dest); return true; } catch (Exception e) { Debug.LogError($压缩失败: {e}); return false; } }5.4 文件占用导致写入失败解压时最烦的就是文件被另一个进程占用这在Windows编辑器和真机后台任务里都有可能出现。FileStream构造时可以传FileShare.None确保独占但如果对方已经打开了文件你这边会直接报IOException。为了兼容我通常会在打开输出流时做三次重试每次间隔100msFileStream OpenStreamWithRetry(string path, int maxRetry 3) { for (int i 0; i maxRetry; i) { try { return new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None, 1 20); } catch (IOException) { Thread.Sleep(100); } } throw new IOException($文件写入失败: {path}); }5.5 磁盘空间不足和临时目录策略解压大包时如果没提前检查磁盘空间写一半磁盘满了所有文件都得废弃。更严重的是如果直接写最终目录失败后留下半截文件下次启动时可能会被当成可用文件加载导致版本错乱。我习惯的流程是先检查目标盘符的剩余空间是否大于压缩包大小的2倍因为解压后的实际大小通常远大于压缩包然后解压到临时目录如persistentDataPath/tmp_unzip_xxx全部完成后做一次目录切换。目录切换如果涉及删除旧目录再移动新目录要确保使用绝对路径避免Directory.Delete的路径遍历风险。临时目录在Application退出时自动清理。5.6 文件时间戳与热更校验很多项目做热更时用文件长度或CRC做校验但如果你在解压过程中改了文件的LastWriteTime会导致基于时间戳的增量更新逻辑失效。ZipArchiveEntry有LastWriteTime属性但从zip读出来后是保留原时间还是重新写入API行为不太直观。我踩过这个坑服务器打包时用的统一固定时间本地解压后文件时间全部相同但下次做差量更新的算法却因为“文件时间大于本地时间”判断为需要更新导致每次都重复下载。最后在压缩时显式设置LastWriteTime并在解压后不修改文件时间问题才解决。6. 实际场景扩展热更包解压、存档压缩与编辑器工具6.1 热更包下载后解压的正确流程热更新下载的包基本都是zip下载完成后的解压流程可以串成一条清晰的流水线用UnityWebRequest下载zip到临时目录校验MD5或CRC。创建CancellationTokenSource把取消按钮和切场景事件绑定到Cancel()。调用ZipHelper.ExtractZipAsync把zip解压到临时解压目录解压过程中更新进度条。解压完成后先做完整性校验文件数、总字节数、关键文件存在性通过后再执行目录切换。删除临时目录更新本地版本号。目录切换这里强调一点绝对不能直接把新文件解压到现有业务目录一旦解压失败旧版本已经被破坏玩家连回滚的机会都没有。正确做法是解压到version_xxx/这类独立目录校验通过后把文件指针切过去。如果你的项目用AssetBundle文件路径不一定能随便切换那至少先备份原版本。6.2 存档压缩与云上传存档目录往往不大但包含的碎片文件很多玩家每个关卡都生成几个JSON和截图。如果每次手动点击上传打包这个动作放在主线程会卡一下如果放在异步玩家体验就顺畅得多。存档压缩还有一个细节存档按文件名排序压缩保证哈希稳定这样云服务端可以按哈希去重节省存储和流量。排序要在压缩前用OrderBy排序文件列表而不是依赖Directory.GetFiles的返回值——它在不同平台上的顺序未必一致。6.3 编辑器扩展工具也可以受益Unity编辑器里我经常在Asset目录上右键打包zip发给美术伙伴这个工具表面上和运行时压缩没什么关系但其实也可以用同样的ZipHelper因为编辑器的Mono环境完全支持Task。唯一的区别是编辑器里调用异步方法时如果没有手动等待Unity会提示“任务未完成时就退出了”的潜在风险。所以在编辑器菜单里我反而推荐直接用同步版把Task.Run换成直接调用核心逻辑反正编辑器卡一下没人有意见。顺带一提编辑器工具如果需要对Asset目录中大量资源做处理AssetDatabase.GetDependencies这种API本身带缓存但直接I/O读取文件时要排除.meta文件否则压缩包里会带一堆Unity自动生成的meta数据美术伙伴们看到会一头雾水。最后的小建议异步压缩解压这件事代码本身并不难难在处理边界情况。我做了几年Unity项目前后踩过至少三代坑第一代是协程加Thread能跑但代码很乱第二代换成Task.Run加async/await清爽很多但CancellationToken用得不够熟第三代结合UniTask做主线程调度才算真正顺手。如果你现在准备在项目里做类似功能我的建议是先把ZipHelper封装成一个独立静态类不要和业务逻辑混在一起然后给压缩解压加统一的进度回调和错误事件最后一定要写单元测试覆盖空目录、只读文件、非法压缩包、超大zip这几个用例把最离谱的输入都试一遍再上线不迟。之前遇到过好几个团队把解压逻辑直接写在热更管理器里代码几百行改个需求都困难。其实只要把压缩解压这个基础能力做好后续接下载、接存档、接编辑器工具都是顺手的事。希望这篇文章能帮你省下几个晚上的排查时间。
返回列表