☰
Unity异步压缩解压实战:告别主线程卡顿与内存飙升
2026/10/6 13:52:39 网站建设 项目流程

做Unity项目久了,你会发现很多痛点特别相似:存档要上传服务器、日志包要发给运营、更新资源要先下载到本地,结果一压缩就卡帧,一解压就白屏。我自己在多个项目里被这个事儿折磨过,后来才沉淀出一套稳定的Unity异步压缩和解压文件方案,今天拿出来聊聊。这篇东西不装高深,就从实际需求出发,把为什么必须异步、算法和库怎么选、代码怎么写、坑在哪里一次说清楚。适合游戏客户端、工具链开发、甚至做Unity编辑器扩展的朋友参考,新手也能照着抄。

1. 为什么要在Unity里做异步压缩解压

1.1 你会遇到这些真实场景

先说场景,不然很多人不明白这个东西到底用来干嘛。我接触过的项目中,压缩解压需求集中在四类。

第一类是存档和配置的打包上传。单机游戏要上传云端存档,玩家本地会有几十个文件、几百兆的存档目录,总不能把原始目录一个个传上去,压缩成一个包是标准做法。

第二类是日志收集。游戏线上运行时,崩溃日志、网络日志、性能采样数据非常多,一天能攒几个GB。运营和分析团队要的是"你打包好发我",而不是自己一个个拉文件。

第三类是资源更新和热更。客户端下载补丁包,下载完要解压替换到沙盒目录,这里对速度和内存的要求比存档更高,因为解压动作直接发生在玩家设备上,体验不好就是卸载。

第四类是编辑器和开发工具。比如美术要批量导出一批模型、策划要导出配置表,用工具直接打成压缩包。别小看编辑器场景,Unity主线程一卡,整个IDE都能卡死,异步在这里同样需要。

1.2 同步压缩的灾难现场

新人最容易犯的错,是把压缩解压当成一个普通的I/O操作,直接在Update或常规调用里同步执行。等文件一上来,灾难就出现了。

我踩过一次很深刻的坑。项目里有个功能,每次进关卡前要把上一关的日志和统计文件压缩成一个zip,文件量不大,总共也就50MB左右。结果同步压缩完,帧率从60直接掉到十几,持续了大概三四秒,玩家在关卡切换动画里明显看到界面一顿一顿的,还有用户反馈iOS设备甚至直接杀进程。后来一查,问题不是压缩算法本身慢,而是整个压缩过程吃满了主线程,UI、动画、渲染全在等它。

更隐蔽的问题是内存。很多人图省事,先是File.ReadAllBytes把整个文件读进内存,再压缩,压缩完再写。一个100MB的文件,读入内存100MB,压缩流内部还要再开一块缓冲,峰值可能冲到300MB以上。在PC上行,在Android低端机上那就是闪退、OOM的前兆。

所以异步不是锦上添花,而是必须。压缩解压是阻塞型I/O加CPU密集计算,这两个特性叠加,天生就不能留在主线程。

1.3 异步方案三选一

Unity里做异步,有三条主流路线,我全试过,说说我的结论。

协程(Coroutine)是最多人第一反应想到的方案。写法简单,yield return一下就能控制节奏。但协程本质上还是跑在主线程上的,只是把执行切成了很多帧分片。如果你的压缩逻辑是一整块同步代码,协程并不能让它不卡,最多是卡顿被拆成一段一段。除非你把压缩拆成"每次压缩一部分数据、yield一帧处理下一部分",否则解决不了根本问题。而且这种拆法的代码复杂度高得离谱,不推荐。

C#的async/await配Task.Run是更正统的解法。Task.Run把真正耗时的压缩工作丢给线程池,await返回主线程上下文,既能不卡UI,又能自然拿到结果。Unity 2018以后对async/await支持已经比较好了,到了Unity 2021+配合.NET Standard 2.1,甚至可以用ValueTask、Span这些更现代的东西。这是我现在的主力方案。

另外还有一个装了UniTask之后的选择。UniTask是对Unity量身定制的异步库,零GC、可以await MonoBehaviour生命周期、性能更好。本质上它和原生async/await的套路差不多,但如果你项目里已经引入了UniTask,用它写压缩解压会更顺手,因为可以直接规避一些Unity同步上下文的问题。后面我给的代码默认用原生C#,想换UniTask的也只需要把Task换成UniTask,结构不变。

还有人会问,是不是可以直接用Unity的WWW/UnityWebRequest来下载解压。这里要分清楚,UnityWebRequest管的是网络层,它本身不提供压缩解压功能。你拿到的还是压缩包,解压这一步还是要自己做。所以不要指望引擎帮你搞定一切。

2. 压缩算法与库怎么选

2.1 先看算法:GZip、LZ4还是Brotli

压缩解压方案的底层,绕不开算法选型。Unity里能用的压缩算法,主流就那几种,我直接把关键参数列出来。

算法压缩率压缩速度解压速度内存占用Unity生态支持
GZip / Deflate中等中等中等中等内置
LZ4低极快极快低需第三方
Brotli高慢较快高支持有限
LZMA / 7z最高很慢较慢很高需第三方

挑算法要看场景,不是一味选压缩率最高的。

存档上传、日志上报这类一次性动作,对压缩率敏感,因为会影响上传流量和存储成本。GZip是性价比最高的选择,压缩率尚可,速度和内存都能接受。如果你追求极致压缩率,可以把压缩级别拉满,或者升级到Brotli。Brotli的压缩率确实比GZip高不少,尤其对JSON、文本这类日志数据,有时候能再省20%到30%,但代价是压缩非常吃CPU,压缩速度慢很多。日志包生成往往在后台执行,慢点其实无所谓。

资源热更、运行时加载这类场景,优先级完全不同。玩家手机正在跑游戏,压缩解压太快才重要,压缩率反而是次要矛盾。这时候LZ4是更好的选择,它的解压速度异常快,内存占用小,几乎是准实时的。市面上很多资源系统选用LZ4不是没道理的。

LZMA和7z是另一个极端,压缩率天花板,但速度太慢,内存也吃得多,一般只在离线工具链里用,运行时基本不考虑。

2.2 库的选型:内置还是SharpZipLib

确定了算法,还得选库。Unity的托管运行时有一个很尴尬的点:System.IO.Compression的完整度在不同后端下不一样。用Mono时GZipStream基本没问题,切换到IL2CPP后,部分平台对zlib底层的支持会出现裁剪或缺失,实测在某些Android构建里会报找不到方法的错误。还有,ZipFile、ZipArchive这些API在IL2CPP下表现也不稳定,很多人一用就翻车。

所以我的建议分两种。

如果你只是压缩单个文件,用GZipStream足够,它简单可靠,配合FileStream就能搞定。代码量小,没有额外依赖。

如果你要打包多个文件成zip,或者要解压zip,直接用SharpZipLib(ICSharpCode.SharpZipLib)或SharpCompress。这两个库在Unity社区里用了很多年,兼容性和可控性比内置的ZipArchive好得多。SharpZipLib更老牌,接口粗糙但稳定;SharpCompress接口更现代,功能也更全,比如原生支持7z和RAR的解压(RAR解压在商业使用上有授权问题,一般不建议碰)。我个人在打包zip时习惯用SharpZipLib,它足够完成90%的需求,而且踩坑的人多,网上参考资料多。

2.3 格式设计:单文件还是打包

接下来要设计的是"压缩包里到底装什么"。

最简单的是单个文件直接GZip压缩,比如"save.dat.gz"。这种方式实现最简单,但如果你要打包整个目录,就会遇到连锁问题:目录结构怎么保存、多个文件要不要合并、解压之后怎么还原路径。

我常用的做法是:先打成zip,再把整个目录结构映射成zip里的条目。zip天然保存了相对路径和目录层级,解压的时候按照entry.Name逐级创建目录就行。这个方案无论从实现成本还是兼容性角度都是最稳的。前面表格里说的GZip单文件适合内部缓存、临时数据,面向用户和跨端传输的场景,还是zip更合适。

还有一个细节:打包的时候,在zip里塞一个manifest.json清单文件,记录文件总数、总大小、各文件CRC32校验、版本号。这个清单的作用后面展开说,能解决进度计算、完整性校验、版本比对三个大问题。

2.4 移动端与WebGL的兼容性

选库之前,一定先确认目标平台。Android/iOS用IL2CPP,前面说的裁剪问题可能让你在真机上才崩。解决手段是加link.xml,把需要的System.IO.Compression程序集排除裁剪,或者干脆放弃内置压缩,全部走SharpZipLib。iOS还有一个特殊点,文件系统是沙盒机制,压缩包解压出来的缓存路径统一放Application.persistentDataPath,这个倒是小问题。

WebGL是最大的坑。WebGL构建只能用单线程脚本,而且System.IO.Compression在WebGL里经常直接抛出PlatformNotSupportedException。如果你要发布WebGL版本,压缩解压方案要么全部改成主线程的极轻量操作、要么使用Web端的原生API或者第三方JS库来配合,这个必须提前和客户端团队对齐,不要等到发版前再改。

补充一个经验:Android的压缩包路径如果放在外部存储,需要提前检查权限和空间。解压前算一下目标文件的总大小,对比剩余空间,避免解压到一半报磁盘满,不然包损坏之后你只能让用户删缓存重下。

3. 异步压缩解压的完整实战

3.1 项目准备与工具类骨架

理论部分差不多了,现在上代码。这一节我按"单文件压缩 -> 多文件打包 -> 解压 -> 进度与取消"的顺序来,你直接Ctrl+C改改就能用。

项目准备阶段,先引入SharpZipLib。在Unity里装它有两种方式:一是Package Manager里搜索"SharpZipLib",二是从asset store里导入旧版文件。注意版本差异,新版API的命名空间是ICSharpCode.SharpZipLib,旧版有些是Ionic.CSharp。我自己用的是新版,下面的代码都基于新版。

工具类我建议组织成一个静态类,命名AsyncCompressionTool,内部不依赖任何MonoBehaviour,方便在游戏逻辑、编辑器扩展、后台线程里直接调用。

using System; using System.IO; using System.Threading; using System.Threading.Tasks; namespace GameTools { public static class AsyncCompressionTool { // 缓冲区大小:81920字节是IO流公认比较合理的经验值 private const int BufferSize = 81920; } }

BufferSize这里先说清楚,81920字节不是随便写的。它等于64KB再加上一些余量,在FileStream和网络流的混合读写场景下,能减少系统调用次数,同时不会像1MB大缓冲那样造成明显内存压力。你可以按自己平台调,但80KB左右是个经过多次实测的好值。

3.2 异步压缩单个文件

先写GZip方式的单文件压缩。这个方法用来处理日志、存档等单个文件的快速压缩。

public static async Task CompressFileToGzipAsync( string sourceFilePath, string targetFilePath, IProgress<float> progress = null, CancellationToken cancellationToken = default) { using FileStream sourceStream = new FileStream(sourceFilePath, FileMode.Open, FileAccess.Read); long totalLength = sourceStream.Length; using FileStream targetStream = new FileStream(targetFilePath, FileMode.Create, FileAccess.Write); using System.IO.Compression.GZipStream gzipStream = new System.IO.Compression.GZipStream( targetStream, System.IO.Compression.CompressionLevel.Optimal); long processedLength = 0; byte[] buffer = new byte[BufferSize]; // 真正耗时的工作丢到线程池 await Task.Run(() => { int bytesRead; while ((bytesRead = sourceStream.Read(buffer, 0, buffer.Length)) > 0) { // 每个循环里检查一次取消,保证响应及时 cancellationToken.ThrowIfCancellationRequested(); gzipStream.Write(buffer, 0, bytesRead); processedLength += bytesRead; if (progress != null) { progress.Report((float)processedLength / totalLength); } } }, cancellationToken); }

这段代码有几个关键点。

第一,FileStream的打开等在主线程完成,但真正的读、压缩、写逻辑都在Task.Run的线程池里。主线程await之后可以继续干别的,不会卡UI。

第二,为什么不在Task.Run里也new FileStream?因为FileStream的构造函数本身涉及系统调用和文件锁,耗时不可控,但在Unity里new FileStream并不会阻塞太久,所以放在外面没问题,代码也更清晰。当然,如果你遇到极端慢的磁盘,也可以把Stream的创建整体放进Task.Run。

第三,关于progress.Report,这里要特别提醒:IProgress的Report不会自动回到主线程。在Unity里,progress回调可能在任意线程池线程触发。如果你在回调里直接更新UGUI,会引发线程不安全问题。正确做法是,利用Unity的SynchronizationContext,或者用一个主线程派发器把更新动作Post回主线程。后面3.5节我再给一个完整方案。

3.3 异步压缩整个文件夹为Zip

单个文件好搞,目录打包才是真正的生产力场景。这里使用SharpZipLib实现。

using ICSharpCode.SharpZipLib.Zip; public static async Task CompressDirectoryToZipAsync( string sourceDirectory, string zipFilePath, IProgress<float> progress = null, CancellationToken cancellationToken = default) { // 预热:统计所有文件和总大小,用于计算进度 string[] files = Directory.GetFiles(sourceDirectory, "*", SearchOption.AllDirectories); long totalBytes = 0; foreach (string file in files) { totalBytes += new FileInfo(file).Length; } long processedBytes = 0; using FileStream zipFileStream = new FileStream(zipFilePath, FileMode.Create, FileAccess.Write); using ZipOutputStream zipStream = new ZipOutputStream(zipFileStream); // 压缩级别:0-9,5是速度与压缩率的均衡点 zipStream.SetLevel(5); await Task.Run(() => { foreach (string file in files) { cancellationToken.ThrowIfCancellationRequested(); string relativePath = Path.GetRelativePath(sourceDirectory, file); // zip内部统一使用正斜杠,避免Windows路径分隔符导致的乱码或层级错误 string entryName = relativePath.Replace('\\', '/'); ZipEntry entry = new ZipEntry(entryName); zipStream.PutNextEntry(entry); using FileStream fileStream = new FileStream(file, FileMode.Open, FileAccess.Read); byte[] buffer = new byte[BufferSize]; int bytesRead; while ((bytesRead = fileStream.Read(buffer, 0, buffer.Length)) > 0) { zipStream.Write(buffer, 0, bytesRead); processedBytes += bytesRead; if (progress != null) { progress.Report((float)processedBytes / totalBytes); } } zipStream.CloseEntry(); } }, cancellationToken); }

写这个方法的几个注意点。

一是Path.GetRelativePath,这是.NET Standard 2.0就有的API,Unity 2020以上都能用。如果你还在老版本,手写一个基于URI的替代方案也行,但没必要,升级Unity比改代码划算。

二是ZipEntry的Name里禁止出现盘符前缀和绝对路径。如果直接把"E:\data\save.txt"传进去,生成的zip在解压时可能无法还原目录,甚至在某些平台上被认为是非法路径。所以统一用相对路径并且Replace反斜杠为正斜杠,这是zip规范要求的,也是跨平台解压不出乱子的前提。

三是stream写完后一定要CloseEntry,否则最后一条entry可能没写全。我见过很多新人写完zip发现文件损坏,就是漏了这一步。

如果你要打包的文件数量特别多,比如几千个小文件,文件的预热统计那一步也会花时间,也可以往后放。真实项目里几千个文件的扫描大概是几十毫秒级,通常不必优化。

3.4 异步解压Zip与进度反馈

解压是反向过程,同样要异步和带进度。

public static async Task DecompressZipAsync( string zipFilePath, string targetDirectory, IProgress<float> progress = null, CancellationToken cancellationToken = default) { using FileStream zipFileStream = new FileStream(zipFilePath, FileMode.Open, FileAccess.Read); using ZipFile zipFile = new ZipFile(zipFileStream); // 预统计所有文件条目的总大小 long totalSize = 0; foreach (ZipEntry entry in zipFile) { if (entry.IsFile) { totalSize += entry.Size; } } long processedSize = 0; await Task.Run(() => { foreach (ZipEntry entry in zipFile) { cancellationToken.ThrowIfCancellationRequested(); if (!entry.IsFile) { continue; } string entryPath = Path.Combine(targetDirectory, entry.Name.Replace('/', Path.DirectorySeparatorChar)); string fullPath = Path.GetFullPath(entryPath); // 防止 zip 路径穿越漏洞:确保解压路径都在目标目录内 string rootPath = Path.GetFullPath(targetDirectory); if (!fullPath.StartsWith(rootPath, StringComparison.OrdinalIgnoreCase)) { throw new IOException("Illegal entry path: " + entry.Name); } string directoryPath = Path.GetDirectoryName(fullPath); if (!string.IsNullOrEmpty(directoryPath)) { Directory.CreateDirectory(directoryPath); } using Stream entryStream = zipFile.GetInputStream(entry); using FileStream outputFileStream = new FileStream(fullPath, FileMode.Create, FileAccess.Write); byte[] buffer = new byte[BufferSize]; int bytesRead; while ((bytesRead = entryStream.Read(buffer, 0, buffer.Length)) > 0) { outputFileStream.Write(buffer, 0, bytesRead); processedSize += bytesRead; if (progress != null) { progress.Report((float)processedSize / totalSize); } } } }, cancellationToken); }

这里有一个非常关键的细节:路径穿越校验。zip是一种不设防的格式,恶意或手误生成的zip,entry.Name可能是"../../evil.exe"。如果直接Path.Combine往外写,解压就写到了目标目录之外。我在代码里用Path.GetFullPath和StartsWith做了白名单校验,任何越界的entry直接抛异常。做日志包、存档包时你自己是生成方,不会出现这个问题;但如果你解压的是玩家上传或网络下载的包,这步校验必须保留。

进度计算方面,我用的是每个entry的Size总和。注意ZipEntry.Size在某些压缩流里可能是-1(表示未知),如果遇到这种包,进度会失灵。更稳的方案是读取所有entry的CompressedSize总和,或者干脆基于已解压文件数量/总文件数来计算。我自己在正式项目里用了"文件数进度 + 单文件流内字节进度"的混合方案,保证即使size信息缺失也不会卡在0%或100%。

另外,解压时需要注意:targetDirectory最好先用Directory.CreateDirectory预先创建,不然首次写入可能报目录不存在。我在代码里对每个文件的directoryPath做了CreateDirectory,所以这个问题被兜住了,但为了逻辑清晰,还是在调用解压前先显式建一下根目录。

3.5 进度回调与取消机制的完整实现

前面几次提到进度和取消,这里给一个真正可以落地的组合方案。

先解决主线程回调问题。Unity的SynchronizationContext默认会捕获Unity主线程,在await之后代码默认回到主线程。但IProgress的Report可以在任何线程调用,所以不能直接在回调里碰UI。最简单的做法是,在进度回调内部不做UI操作,仅仅收集进度值;UI层通过每帧轮询或通过主线程派发器来处理。

我在项目里更常用的是一个轻量派发器:

public static class MainThreadDispatcher { private static SynchronizationContext _syncContext; [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] private static void Initialize() { _syncContext = SynchronizationContext.Current; } public static void Post(Action action) { if (_syncContext == null || _syncContext == SynchronizationContext.Current) { action(); } else { _syncContext.Post(_ => action(), null); } } }

有了这个,进度更新就可以这样用:

var progress = new Progress<float>(value => { MainThreadDispatcher.Post(() => { progressBar.value = value; progressText.text = $"{value:P0}"; }); }); await AsyncCompressionTool.CompressDirectoryToZipAsync( dataFolder, zipPath, progress, cancellationToken);

Progress 本身是线程安全的,它的Report会异步地触发回调。注意,Progress 的回调默认跑在创建时的上下文,但底层依然有跨线程风险,所以还是套了一层派发器,双保险。

取消这一块,CancellationTokenSource支持超时取消和手动取消。如果你想让玩家在解压时能点"取消",在UI按钮的监听里调用cancellationTokenSource.Cancel()即可。

using CancellationTokenSource cts = new CancellationTokenSource(); cancelButton.onClick.AddListener(() => cts.Cancel()); try { await AsyncCompressionTool.DecompressZipAsync(zipPath, outPath, progress, cts.Token); // 解压完成 } catch (OperationCanceledException) { // 用户取消,清理半成品文件 if (Directory.Exists(outPath)) { Directory.Delete(outPath, true); } }

重点说一下取消之后的清理。压缩解压这种写文件流程,取消的场景下很容易留下半成品文件。如果是一个zip,解压到一半取消,目标目录里可能已经有几十个先写好的文件。我习惯把解压目标设成一个临时目录,比如outPath + ".tmp",全部完成后重命名为正式目录。这样即使取消,直接删临时目录即可,不会污染历史数据。

4. 内存与性能优化关键细节

4.1 流式处理:缓冲区与分块读写

字符串拼接式的"先全读进来再写出去"是压缩解压里最坏的做法。一个500MB的存档目录,如果你在代码里写了File.ReadAllBytes然后压,内存就是实打实多出500MB,加上压缩流内部缓冲和文件系统缓存,低端机不崩才怪。

我所有压缩代码都是流式循环读写,缓冲区固定为BufferSize(81920字节),单个文件不论多大,内存占用保持在一个稳定区间。具体模型是这样的:FileStream读一块 -> 压缩流写一块,循环往复,整个过程中堆内存峰值主要来自byte[] buffer这一个对象和其它几个小对象。

这个模型不仅省内存,还让进度计算变得顺理成章——你已经读了processedBytes,总长是totalBytes,进度就是两者的比值,不需要额外的分析阶段。

有人会问,是不是缓冲区越大越快?实测结果不是。缓冲区从8KB升到64KB,性能提升可观;但再往上升,收益就趋平了。81920字节正好是.NET网络流/文件流做异步读写时的常见最优块大小,用这个值就行,不用自己调来调去。

4.2 压缩级别怎么调

压缩级别是另一个容易被忽略却影响巨大的参数。

GZipStream里CompressionLevel有三个选项:Fastest、Optimal、NoCompression。SharpZipLib的SetLevel是0到9的数字。零级就是"只打包不压缩",速度最快,体积不变;九级最压缩但最慢,且速度下降远大于体积下降。

我实测过一个包含大量JSON日志的包:level 5压缩耗时约level 1的1.8倍,但体积只小个位数;level 9比level 5慢一倍多,体积再小3%。所以纯文本占主时,级别5左右就是甜蜜点;如果包里有大量纹理、音频等本身已压缩过的文件,级别设为1到3就够,因为二次压缩收益极低,纯浪费CPU。

如果你的包主要存的是JSON/XML/文本,用级别6到7;主要存二进制资源文件,用级别3到4;实时性要求高,用级别1甚至NoCompression。这个映射关系是我多个项目验证过的一般规律,不是拍脑袋,你可以拿自己的包跑一轮对比再定。

4.3 主线程延续与UI进度条

前面的代码都在强调"耗时工作放线程池",但还有一个容易翻车的点:await之后的延续代码默认回到主线程。这本来是好事,UI安全了,但如果你的调用方写在了一个非主线程的Task里,await之后的延续会回到那个Task的执行线程,而Unity的很多API只能在主线程调用。这时候代码可能时好时坏,表现为"用主线程调用没问题,从线程池调用就随机报错"。

解决方式是流出清晰的上下文约定:所有对压缩工具的公开调用,统一从主线程入口发起。如果某个调用链可能跨线程,就在内部用前面写的MainThreadDispatcher.Post强制切回主线程。

进度条的更新频率也要控制。IProgress的Report如果每次读满一个Buffer就调一次,压缩100MB文件可能要回调成千上万次,GC压力全在这了。我通常对进度做降频,比如只有进度变化超过0.5%才向UI派发,或者在Post里判断上一次派发时间,超过100毫秒才更新一次。

4.4 移动端实测与参数调优

最后说说真机实测。我在一个手游项目里验证过一套参数:Android中端机(骁龙中端型号),压缩300MB的存档目录,使用GZip level 5,异步方案整体耗时约8秒,过程中帧率稳定在55到60,内存增幅约30MB,波动很小。而此前同步方案在同样设备上,压缩期间游戏直接掉到20帧以下,内存峰值超过200MB,差别非常明显。

参数调优我建议按这个顺序来:先确定格式(zip还是gzip),再选压缩级别,最后调缓冲区。如果发现压缩太慢,先考虑降级别,而不是无限加大缓冲区;如果发现内存偏高,先检查是否有代码ReadAllBytes这种"一把梭"的坏习惯,再考虑缩小缓冲区。移动端的发热问题也值得关注,压缩是CPU密集任务,长时间高负载会让手机发热降频。大文件任务建议加一个"电量充足时才执行"的策略,或用队列在后台串行执行,而不是让玩家在游戏内直接触发一个巨大压缩任务。

5. 常见问题与排查技巧实录

5.1 主线程卡顿:怎么定位真凶

很多人写完异步版本后反馈"还是卡",这时候别急着甩锅给库,先按下面三步查。

第一步,确认耗时任务是不是真的进了线程池。检查代码里是否出现了await之后仍然有耗时的同步循环,或者干脆忘了包Task.Run。最常见的错误是把await Task.Run写在了一个已经被主线程占用的循环里,本质还是同步。

第二步,用Unity Profiler的CPU模块,看主线程时间线里那段长任务的名字。如果是我们自己的方法,里面有FileStream操作,说明代码路径不对;如果是某个原生dll的callbcak,可能是平台相关的压缩库在内部回调主线程。

第三步,检查编辑器本身的"出栈"行为。编辑器里FileStream性能远低于真机,有些卡顿是编辑器虚拟文件系统导致的,不代表真机体验。判断依据是看真机Profile数据,别只信编辑器里的表现。

5.2 中文文件名的编码坑

中文文件名在Unity压缩解压里是个高频坑,表现是:压缩端正常,解压端文件名变成乱码,或者直接抛"找不到文件"。

根因是zip规范里entry name的编码没有明确指定,Windows传统的zip工具默认用系统代码页(GBK/ANSI),而.NET和Linux下默认认为是UTF-8。SharpZipLib在较新的版本里默认使用UTF-8,但如果你用老版本,或者和系统自带压缩工具交互,编码不一致就会乱。

我的处理是统一在生成zip时强制设置UTF-8标记,并在解压时也指定:

zipStream.SetEntryTime(DateTime.Now); ICSharpCode.SharpZipLib.Zip.ZipStrings.CodePage = 65001;
using ZipFile zipFile = new ZipFile(zipFileStream); zipFile.UseZip64 = UseZip64.Dynamic; ICSharpCode.SharpZipLib.Zip.ZipStrings.CodePage = 65001;

注意CodePage是静态属性,全局生效,所以如果同一进程里既有旧格式zip又有新格式zip,你会遇到编码冲突。稳妥的做法是解压时先读取entry.Name,自己用Encoding识别是否可以正常转为UTF-8,不行就退回系统GBK,但这属于高阶玩法,一般项目不需要。

还有一个附带坑:zip里entry的名字如果包含Unity编辑器不认的字符(某些特殊符号),在PC上能解压,但在Android上文件系统会拒绝创建。治本方案是生成zip时规范命名,只允许字母、数字、下划线、中划线、点、中文字符,当然中文也要经过上面UTF-8的处理。

5.3 压缩包损坏与文件句柄

我见过太多"压缩完的zip打不开"的问题,80%出在Stream没有正确关闭。

具体来说,ZipOutputStream写完所有entry后,必须调用Finish或内部Close,GZipStream写完数据后必须Dispose而不是只Flush。为什么?因为压缩流内部的压缩数据,最后一段可能还留在内部缓冲里没有刷到FileStream。不关闭就结束,末尾数据丢失,zip结构损坏。用using块或者在finally里Dispose能保证这点。

另一个隐蔽问题是文件句柄占用。Windows上,如果你压缩的目标zip和源文件在同一目录,且命名为同一个文件(比如把"save.txt"压成"save.txt.zip"),一般没问题。但如果你把zip写到了源目录且文件名与源文件冲突,文件流互斥会造成IOException。我习惯把临时压缩包写到Application.temporaryCachePath,成功后再Move到最终位置,顺便还能避免写了一半的程序崩溃把线上文件弄脏。

5.4 IL2CPP裁剪与WebGL限制

IL2CPP裁剪导致压缩库报错,这个坑在Android构建里特别常见。现象是编辑器里一切正常,构建到真机后调用压缩方法直接抛NotSupportedException或MethodMissing。原因简单:IL2CPP的裁剪组件把System.IO.Compression内部依赖的某些方法当作未引用而裁掉了。

解法有三招,按优先级来。

第一,在Assets目录下添加link.xml文件,把压缩库相关的程序集排除裁剪。这是最标准的做法,Unity官方文档有写。

<linker> <assembly fullname="System.IO.Compression" preserve="all"/> <assembly fullname="ICSharpCode.SharpZipLib" preserve="all"/> </linker>

第二,直接用SharpZipLib替换所有System.IO.Compression调用。SharpZipLib不依赖zlib原生库,纯托管实现,裁剪问题少很多。我现在的默认做法就是这个。

第三,避免在WebGL平台上调用任何压缩解压API。WebGL没有线程池概念,Task.Run在WebGL下也不会真正开线程,所有代码都跑在同一个循环里。如果你一定要在WebGL解压小文件,可以试试在主线程小批量处理,或者走JS互操作。最省事的方案是:WebGL端全部用预压缩资源,不在运行时做动态压缩解压。

5.5 进度计算与ZipEntry Size为负

刚才提到ZipEntry.Size可能是-1,这个在实际项目中真的会碰到。某些压缩工具生成的zip,entry size字段写的是0或者-1,SharpZipLib读出来就是-1。如果你用totalSize做进度分母,计算结果是负数,进度条直接倒着走。

对策有两个:一是改用文件数作为进度主指标,二是读取entry时同时读取uncompressedSize并做降级处理。如果你不给进度条,只是"转圈等待",这个无所谓,但给玩家看的进度条一旦倒走会非常出戏,务必处理。

我最后在项目里落地的是一个混合策略:预处理阶段尽量从zip Central Directory里读出所有entry的Size,读不到的就临时置为文件数计数,把整体进度切分成"按文件数"的50%和"按字节数"的50%,既平滑又不会倒走。代码量不大,但体验提升明显。

6. 写在最后的几点体会

这一路踩下来,最大的体会是:Unity里的压缩解压从来不是一个"调个API"的事情,而是一个要考虑主线程模型、内存模型、平台兼容性的系统工程。我在项目里用这套异步方案之后,玩家反馈的卡顿和闪退明显减少,运营拿日志包也顺利很多,整体收益非常直接。

最后再分享一个小技巧:如果你时常要对同一个目录做多轮压缩,可以在第一次压缩时把文件清单和CRC32校验值一起写进zip里的manifest文件,后面再压缩时先比对清单,只更新有变化的文件。这样不仅压缩速度更快,解压端做增量更新也顺手多了。这个扩展在我的资源更新系统里帮了大忙,推荐你试试。希望这篇东西能帮你少走点弯路,项目顺利上线。

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

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

立即咨询