Unity跨平台Zip压缩解压实战:SharpZipLib集成与性能优化 1. 项目概述为什么Unity开发者需要关注Zip压缩在Unity项目的日常开发中处理文件压缩与解压是一个高频且绕不开的需求。无论是从服务器下载资源包、打包用户生成内容上传、还是管理本地缓存文件Zip格式因其高普及率和良好的压缩比成为了事实上的标准。然而Unity引擎本身并没有提供原生的、功能完整且跨平台的Zip处理API。System.IO.Compression命名空间下的ZipFile类在部分平台如部分移动端、WebGL存在兼容性问题或功能缺失这让许多开发者尤其是需要发布到多平台的团队感到头疼。我接手过不少项目都曾在这个环节踩过坑。比如一个移动端项目在Android平台上使用System.IO.Compression解压从CDN下载的资源包时偶尔会抛出“invalid zip archive: could not find EOCD”的异常导致玩家无法进入游戏。又或者在WebGL平台由于线程和文件系统的限制标准的压缩解压流程根本无法运行。这些问题迫使我们必须寻找一个更可靠、更跨平台的解决方案。这就是SharpZipLib (ICSharpCode.SharpZipLib)登场的时候。它是一个成熟、稳定、完全托管的.NET库专门用于处理Zip、GZip、Tar和BZip2等格式。其最大的优势在于纯C#实现不依赖任何平台原生API因此在Unity支持的所有平台上包括Windows、macOS、Linux、iOS、Android、WebGL都能保持行为一致。本次实战我将带你从零开始在Unity中集成SharpZipLib实现一个健壮的、支持中文路径、并经过性能优化的Zip压缩与解压工具。无论你是需要处理资源热更还是管理存档文件这套方案都能直接拿来用。2. 核心需求与方案选型解析2.1 明确我们的核心需求在动手之前我们必须明确工具需要满足哪些具体需求这决定了我们如何设计和使用SharpZipLib。全平台兼容性这是首要需求。工具必须在Unity Editor、PC Standalone、iOS、Android以及棘手的WebGL平台上稳定运行。任何平台相关的代码或依赖都必须被排除。正确处理中文路径这是一个非常经典的坑。很多压缩工具在遇到包含中文文件或文件夹名的Zip包时解压出来会是乱码。我们的工具必须能正确识别和保存中文名称。异步操作支持压缩和解压特别是处理大文件时是典型的耗时操作。如果在主线程同步执行会导致游戏卡顿甚至假死。工具必须支持异步操作不阻塞游戏主循环。进度反馈用户或程序需要知道当前操作的进度。无论是显示一个进度条还是在后台日志中记录进度反馈机制都必不可少。性能与内存优化在移动设备或WebGL环境下内存是珍贵资源。我们需要避免在压缩/解压大文件时产生巨大的内存峰值导致应用崩溃。同时操作速度也应尽可能快。错误处理与日志网络下载的Zip包可能损坏磁盘空间可能不足。工具需要有完善的异常捕获和错误信息反馈机制方便排查类似“导入资源包失败 caused by: invalid zip archive”这类问题。2.2 为什么选择SharpZipLib面对这些需求我们来看看几个常见选项Unity 内置System.IO.Compression优点无需额外集成开箱即用。缺点在部分非Windows平台如旧版本Mono、部分移动端运行时实现不完整或行为不一致对中文路径支持需要额外处理设置编码在WebGL上基本不可用。对于追求稳定跨平台的项目风险较高。第三方原生插件如7z、minizip的C封装优点通常性能极佳。缺点需要为每个目标平台编译原生库增加项目复杂度和包体大小可能存在平台兼容性问题和许可证风险与托管代码交互需要额外封装。SharpZipLib优点100%纯C#实现真正的“一次编写处处运行”开源MIT许可证可自由使用和修改功能全面支持Zip、加密、流式处理等社区成熟问题容易找到解决方案。缺点纯托管代码的性能在极端场景下可能略逊于优化到极致的高性能原生库API相对底层需要一些封装才能好用。综合来看对于绝大多数Unity项目SharpZipLib在功能、稳定性、易用性和跨平台性上取得了最佳平衡。它完美契合了我们列出的所有核心需求特别是全平台兼容和中文支持。注意网络上有些教程会教你用Process.Start调用系统命令行工具如mac的unzipWindows的tar。这在Editor下或许可行但在移动端或WebGL是绝对行不通的务必避免。3. 环境准备与SharpZipLib集成3.1 获取与导入SharpZipLibSharpZipLib可以通过多种方式导入Unity项目。最推荐使用Unity的Package Manager从NuGet导入或者直接下载DLL。方法一通过Unity Package Manager (UPM) 从NuGet导入推荐这是最干净、最便于版本管理的方式。打开Unity进入Window - Package Manager。点击左上角的号选择Add package from git URL...。输入SharpZipLib的GitHub仓库URL适用于其NuGet包https://github.com/icsharpcode/SharpZipLib.git?pathsrc/ICSharpCode.SharpZipLib#v1.4.2注意URL末尾的#v1.4.2指定了版本号请根据需要替换为最新稳定版。点击Add。Unity会自动克隆仓库并编译导入相应的程序集。方法二直接下载DLL文件前往SharpZipLib的 GitHub Releases 页面。下载ICSharpCode.SharpZipLib.dll文件通常位于发布的压缩包内。在Unity项目的Assets文件夹下创建一个Plugins文件夹如果不存在。将下载的ICSharpCode.SharpZipLib.dll文件拖入Plugins文件夹。导入后你可以在脚本中使用using ICSharpCode.SharpZipLib.Zip;等命名空间来调用其功能。3.2 基础工具类框架设计我们不建议在业务代码中直接调用SharpZipLib原始的、较为底层的API。更好的做法是封装一个专门的工具类统一处理编码、异常、进度和异步逻辑。首先我们创建一个ZipUtility.cs脚本。这个类将提供静态方法供全局调用。using System; using System.IO; using System.Text; using System.Threading.Tasks; using ICSharpCode.SharpZipLib.Zip; using UnityEngine; namespace YourNamespace.Tools { /// summary /// 基于SharpZipLib的跨平台Zip压缩解压工具 /// /summary public static class ZipUtility { // 关键设置用于处理中文及其他非ASCII字符的编码 // GBK常用于处理简体中文但UTF-8是更通用的选择。SharpZipLib内部会处理。 // 使用ZipConstants.DefaultCodePage 可以让库自动处理但显式指定UTF-8更稳妥。 private static Encoding _encoding Encoding.UTF8; // 压缩/解压的缓冲区大小影响IO性能和内存占用后续优化会讲 private const int BUFFER_SIZE 4096 * 1024; // 4MB 缓冲区 // 进度回调委托 public delegate void ProgressCallback(string currentFile, long processedBytes, long totalBytes); } }这里我们首先定义了几个核心要素编码、缓冲区大小和进度回调委托。编码_encoding设置为UTF-8是确保中文路径正确的关键第一步。缓冲区大小BUFFER_SIZE设置为4MB这是一个在大多数场景下兼顾性能和内存的折中值我们会在性能优化章节详细讨论。4. 核心实现压缩功能详解4.1 创建压缩文件的核心逻辑压缩功能的本质是遍历目标目录或文件列表将每个文件以特定的压缩算法如Deflate添加到Zip输出流中。SharpZipLib的ZipOutputStream是这个过程的核心。我们先实现一个基础的同步压缩方法理解其流程public static bool CreateZip(string sourcePath, string outputZipPath, string password null, CompressionLevel level CompressionLevel.DEFAULT) { if (!Directory.Exists(sourcePath) !File.Exists(sourcePath)) { Debug.LogError($[ZipUtility] 源路径不存在: {sourcePath}); return false; } try { using (FileStream fsOut File.Create(outputZipPath)) using (ZipOutputStream zipStream new ZipOutputStream(fsOut)) { // 1. 设置Zip包全局参数 zipStream.SetLevel((int)level); // 压缩等级 if (!string.IsNullOrEmpty(password)) { zipStream.Password password; // 设置密码AES加密需要额外处理 } // 2. 判断源路径是文件还是目录 FileAttributes attr File.GetAttributes(sourcePath); bool isDirectory (attr FileAttributes.Directory) FileAttributes.Directory; string baseFolder isDirectory ? sourcePath : Path.GetDirectoryName(sourcePath); if (isDirectory) { // 压缩整个目录 CompressDirectory(sourcePath, zipStream, baseFolder); } else { // 压缩单个文件 CompressFile(sourcePath, zipStream, baseFolder); } zipStream.Finish(); zipStream.Close(); } Debug.Log($[ZipUtility] 压缩成功: {outputZipPath}); return true; } catch (Exception ex) { Debug.LogError($[ZipUtility] 压缩失败: {ex.Message}\n{ex.StackTrace}); // 清理可能已部分创建的错误文件 if (File.Exists(outputZipPath)) File.Delete(outputZipPath); return false; } }这个方法处理了输入路径是文件还是目录的情况并设置了压缩等级和密码。核心的压缩逻辑在CompressDirectory和CompressFile中。4.2 处理目录与文件条目CompressDirectory需要递归遍历所有子目录和文件private static void CompressDirectory(string folder, ZipOutputStream zipStream, string baseFolder) { string[] files Directory.GetFiles(folder); foreach (string file in files) { CompressFile(file, zipStream, baseFolder); } string[] subFolders Directory.GetDirectories(folder); foreach (string subFolder in subFolders) { // 为目录本身创建一个条目确保解压时目录存在 string relativePath GetRelativePath(subFolder, baseFolder) /; ZipEntry dirEntry new ZipEntry(relativePath); dirEntry.DateTime DateTime.Now; zipStream.PutNextEntry(dirEntry); zipStream.CloseEntry(); // 递归压缩子目录 CompressDirectory(subFolder, zipStream, baseFolder); } } private static void CompressFile(string file, ZipOutputStream zipStream, string baseFolder) { try { using (FileStream fsInput File.OpenRead(file)) { string relativePath GetRelativePath(file, baseFolder); ZipEntry entry new ZipEntry(relativePath); // 关键设置外部文件属性确保在Unix/Linux系统上也能保留可执行权限等信息 entry.ExternalFileAttributes (int)File.GetAttributes(file) 16; entry.DateTime File.GetLastWriteTime(file); zipStream.PutNextEntry(entry); byte[] buffer new byte[BUFFER_SIZE]; int bytesRead; while ((bytesRead fsInput.Read(buffer, 0, buffer.Length)) 0) { zipStream.Write(buffer, 0, bytesRead); } zipStream.CloseEntry(); } } catch (Exception ex) { Debug.LogWarning($[ZipUtility] 压缩文件时跳过 {file}: {ex.Message}); } } // 辅助方法计算相对于基目录的相对路径 private static string GetRelativePath(string fullPath, string basePath) { // 确保basePath以目录分隔符结尾 if (!basePath.EndsWith(Path.DirectorySeparatorChar.ToString())) basePath Path.DirectorySeparatorChar; Uri pathUri new Uri(fullPath); Uri folderUri new Uri(basePath); string relativePath Uri.UnescapeDataString(folderUri.MakeRelativeUri(pathUri).ToString()); // Uri生成的路径使用‘/’Zip标准也使用‘/’这里保持即可 return relativePath.Replace(Path.DirectorySeparatorChar, /); }这里有几个关键点目录条目在CompressDirectory中我们为子目录本身也创建了一个ZipEntry其名称以“/”结尾。这确保了即使一个目录为空在解压时也能被创建出来。相对路径计算GetRelativePath方法使用Uri类来安全地计算相对路径并统一将Windows的反斜杠\替换为Zip标准规定的正斜杠/。外部文件属性entry.ExternalFileAttributes (int)File.GetAttributes(file) 16;这行代码非常重要。它将文件的系统属性如只读、隐藏在Unix系统上还包括可执行权限编码到Zip条目中。这样在Linux或macOS上解压时可执行脚本文件能保持其可执行权限。错误处理在CompressFile中我们对单个文件的压缩错误进行了捕获和警告而不是让整个压缩过程失败。这提高了工具的健壮性。4.3 支持异步压缩与进度反馈同步压缩会阻塞主线程。在Unity中我们可以利用Task.Run将耗时的IO操作放到后台线程池中执行并通过回调在主线程通知进度和完成状态。public static async Taskbool CreateZipAsync(string sourcePath, string outputZipPath, string password null, CompressionLevel level CompressionLevel.DEFAULT, ProgressCallback progressCallback null, System.Threading.CancellationToken cancellationToken default) { return await Task.Run(() { try { long totalBytes CalculateTotalSize(sourcePath); long processedBytes 0; using (FileStream fsOut File.Create(outputZipPath)) using (ZipOutputStream zipStream new ZipOutputStream(fsOut)) { zipStream.SetLevel((int)level); if (!string.IsNullOrEmpty(password)) zipStream.Password password; FileAttributes attr File.GetAttributes(sourcePath); bool isDirectory (attr FileAttributes.Directory) FileAttributes.Directory; string baseFolder isDirectory ? sourcePath : Path.GetDirectoryName(sourcePath); if (isDirectory) { CompressDirectoryAsync(sourcePath, zipStream, baseFolder, ref processedBytes, totalBytes, progressCallback, cancellationToken); } else { CompressSingleFileAsync(sourcePath, zipStream, baseFolder, ref processedBytes, totalBytes, progressCallback, cancellationToken); } if (cancellationToken.IsCancellationRequested) { zipStream.Close(); fsOut.Close(); File.Delete(outputZipPath); // 取消时清理 throw new OperationCanceledException(cancellationToken); } zipStream.Finish(); } Debug.Log($[ZipUtility] 异步压缩成功: {outputZipPath}); return true; } catch (OperationCanceledException) { Debug.Log($[ZipUtility] 压缩操作被取消。); return false; } catch (Exception ex) { Debug.LogError($[ZipUtility] 异步压缩失败: {ex.Message}); if (File.Exists(outputZipPath)) File.Delete(outputZipPath); return false; } }); } // 计算总大小用于进度这是一个粗略估计因为压缩后大小会变 private static long CalculateTotalSize(string path) { if (File.Exists(path)) return new FileInfo(path).Length; if (Directory.Exists(path)) { long size 0; foreach (string file in Directory.GetFiles(path, *, SearchOption.AllDirectories)) { size new FileInfo(file).Length; } return size; } return 0; } // 支持进度和取消的目录压缩方法 private static void CompressDirectoryAsync(string folder, ZipOutputStream zipStream, string baseFolder, ref long processedBytes, long totalBytes, ProgressCallback progressCallback, System.Threading.CancellationToken cancellationToken) { // ... 类似同步版本但在CompressSingleFileAsync调用前后更新processedBytes并触发回调 string[] files Directory.GetFiles(folder); foreach (string file in files) { cancellationToken.ThrowIfCancellationRequested(); long fileSize new FileInfo(file).Length; CompressSingleFileAsync(file, zipStream, baseFolder, ref processedBytes, totalBytes, progressCallback, cancellationToken); processedBytes fileSize; progressCallback?.Invoke(file, processedBytes, totalBytes); } // ... 处理子目录 }CreateZipAsync方法返回一个Taskbool可以在async函数中用await调用。它通过Task.Run在后台线程执行压缩并通过progressCallback报告进度cancellationToken支持取消操作。CalculateTotalSize预先计算总字节数虽然压缩后大小不同但用原始大小计算进度对用户来说更直观。实操心得在移动端尤其是iOS进行后台文件操作时需要注意应用状态切换。如果应用被切到后台系统可能会暂停或终止线程。对于长时间操作建议在CompressSingleFileAsync中频繁检查cancellationToken并在应用获得焦点后重新开始或恢复任务。5. 核心实现解压功能详解5.1 读取与解压Zip文件解压是压缩的逆过程。我们使用ZipFile类来读取Zip包并遍历其中的每一个ZipEntry将其提取到文件系统中。public static bool ExtractZip(string zipPath, string outputFolder, string password null) { if (!File.Exists(zipPath)) { Debug.LogError($[ZipUtility] Zip文件不存在: {zipPath}); return false; } if (!Directory.Exists(outputFolder)) { Directory.CreateDirectory(outputFolder); } try { using (ZipFile zipFile new ZipFile(zipPath)) { // 设置编码解决中文乱码问题 zipFile.EntryFactory new ZipEntryFactory(); zipFile.EntryFactory.NameTransform new CustomNameTransform(_encoding); if (!string.IsNullOrEmpty(password)) { zipFile.Password password; } foreach (ZipEntry entry in zipFile) { ExtractZipEntry(zipFile, entry, outputFolder); } } Debug.Log($[ZipUtility] 解压成功至: {outputFolder}); return true; } catch (ZipException zipEx) { // 专门处理Zip格式错误如“invalid zip archive: could not find EOCD” Debug.LogError($[ZipUtility] Zip文件格式错误或已损坏: {zipEx.Message}); return false; } catch (Exception ex) { Debug.LogError($[ZipUtility] 解压失败: {ex.Message}\n{ex.StackTrace}); return false; } }这里最关键的一行是zipFile.EntryFactory.NameTransform new CustomNameTransform(_encoding);。SharpZipLib默认可能使用系统当前编码读取文件名这会导致中文乱码。我们需要一个自定义的INameTransform来强制使用UTF-8编码。5.2 解决中文路径乱码问题创建CustomNameTransform类// 自定义名称转换器确保使用指定编码 private class CustomNameTransform : INameTransform { private readonly Encoding _encoding; public CustomNameTransform(Encoding encoding) { _encoding encoding; } public string TransformFile(string name) { return DecodeEntryName(name); } public string TransformDirectory(string name) { return DecodeEntryName(name); } private string DecodeEntryName(string name) { // SharpZipLib内部存储的名称可能是字节数组这里根据编码转换 // 实际上如果Zip文件是用UTF-8编码创建的SharpZipLib能正确读取。 // 这个转换器主要是一个保障处理一些编码不明确的旧Zip包。 // 对于明确是UTF-8的包也可以直接返回 name。 try { // 假设传入的name字符串已经是SharpZipLib根据Zip文件头信息解码过的。 // 更彻底的方案是直接操作字节但较为复杂。以下是一种通用处理 byte[] data _encoding.GetBytes(name); return _encoding.GetString(data); // 简单情况下直接返回 name 通常也能工作因为库已处理。 // return name; } catch { return name; // 解码失败返回原名称 } } }实际上对于新创建的、明确使用UTF-8编码的Zip文件SharpZipLib通常能正确读取。这个自定义转换器主要用来处理那些编码信息缺失或不标准的“历史遗留”Zip包提供一个兜底的解码策略。更严谨的做法是在创建Zip文件时就设置ZipOutputStream.UseUnicode true;这样库会在文件头写入UTF-8标志所有现代解压工具都能正确识别。5.3 提取条目与目录结构重建ExtractZipEntry方法负责将单个ZipEntry写入磁盘private static void ExtractZipEntry(ZipFile zipFile, ZipEntry entry, string outputFolder) { string entryName entry.Name; // 处理目录条目 if (entryName.EndsWith(/) || entry.IsDirectory) { string dirPath Path.Combine(outputFolder, entryName); Directory.CreateDirectory(dirPath); // 设置目录时间戳可选 // Directory.SetLastWriteTime(dirPath, entry.DateTime); return; } // 处理文件条目 string filePath Path.Combine(outputFolder, entryName); string directoryName Path.GetDirectoryName(filePath); if (!string.IsNullOrEmpty(directoryName) !Directory.Exists(directoryName)) { Directory.CreateDirectory(directoryName); } using (Stream zipStream zipFile.GetInputStream(entry)) using (FileStream fsOutput File.Create(filePath)) { byte[] buffer new byte[BUFFER_SIZE]; int bytesRead; while ((bytesRead zipStream.Read(buffer, 0, buffer.Length)) 0) { fsOutput.Write(buffer, 0, bytesRead); } } // 恢复文件属性如只读、隐藏和时间戳 File.SetLastWriteTime(filePath, entry.DateTime); // 注意ExternalFileAttributes 的高16位存储了宿主系统属性需要处理 if (entry.ExternalFileAttributes ! -1) { // 这是一个简化处理实际应根据HostSystem判断 File.SetAttributes(filePath, (FileAttributes)(entry.ExternalFileAttributes 16)); } }这个方法清晰地处理了文件和目录条目。对于文件它确保目标目录存在然后通过流将数据写入磁盘。最后它还尝试恢复文件的原始时间戳和属性这对于一些需要保持元数据的场景如资源热更很有用。5.4 异步解压与进度实现与压缩类似我们也需要异步解压public static async Taskbool ExtractZipAsync(string zipPath, string outputFolder, string password null, ProgressCallback progressCallback null, System.Threading.CancellationToken cancellationToken default) { return await Task.Run(() { if (!File.Exists(zipPath)) { /* 错误处理 */ return false; } try { using (ZipFile zipFile new ZipFile(zipPath)) { zipFile.EntryFactory new ZipEntryFactory(); zipFile.EntryFactory.NameTransform new CustomNameTransform(_encoding); if (!string.IsNullOrEmpty(password)) zipFile.Password password; long totalBytes zipFile.Count; // 条目总数用于进度 long processedEntries 0; foreach (ZipEntry entry in zipFile) { cancellationToken.ThrowIfCancellationRequested(); ExtractZipEntry(zipFile, entry, outputFolder); processedEntries; progressCallback?.Invoke(entry.Name, processedEntries, totalBytes); } } Debug.Log($[ZipUtility] 异步解压成功至: {outputFolder}); return true; } catch (OperationCanceledException) { /* 处理取消 */ return false; } catch (Exception ex) { /* 错误处理 */ return false; } }); }这里进度计算以条目数量为单位因为解压前我们无法准确知道解压后的数据总量。对于非常大的Zip包也可以考虑用entry.CompressedSize来估算数据量进度。6. 性能优化与内存管理实战SharpZipLib是纯托管代码在性能上已经做了不少优化。但在Unity环境下特别是资源受限的移动端和WebGL我们仍需注意以下几点。6.1 缓冲区大小的权衡我们在工具类开头定义了BUFFER_SIZE 4096 * 1024(4MB)。这个值直接影响IO性能和内存占用。值太小如4KB会导致频繁的磁盘读写和GC内存分配增加CPU开销降低吞吐量。值太大如100MB单次操作占用内存高在解压大文件时如果文件本身很大可能瞬间申请大量内存在移动端容易引发OOM内存溢出崩溃。4MB-8MB对于大多数游戏资源文件纹理、音频、预制体等这是一个比较安全的范围。它在IO效率和内存占用之间取得了较好的平衡。你可以根据项目典型文件大小调整这个值。// 可以根据目标平台动态调整缓冲区大小 private static int GetOptimalBufferSize() { #if UNITY_WEBGL // WebGL内存限制严格使用较小的缓冲区 return 1024 * 1024; // 1MB #elif UNITY_IOS || UNITY_ANDROID // 移动端使用中等缓冲区 return 4096 * 1024; // 4MB #else // PC/主机端可以使用更大的缓冲区 return 8192 * 1024; // 8MB #endif }6.2 流式处理与避免内存峰值SharpZipLib的核心优势之一是支持流式处理Streaming。我们上面的代码已经体现了这一点无论是压缩还是解压我们都是通过FileStream和ZipInputStream/ZipOutputStream以固定大小的缓冲区Buffer进行数据搬运而不是将整个文件读入内存。这一点至关重要。假设你要解压一个500MB的Zip包如果一次性将500MB数据读入内存在移动设备上几乎是致命的。而流式处理只需要维持一个几MB的缓冲区内存大大降低了内存峰值。确保你的代码始终使用using语句包裹流对象或者确保在finally块中关闭流避免资源泄漏。6.3 针对WebGL平台的特别优化WebGL平台有其特殊性它没有真正的多线程Thread所有代码都在主线程执行文件系统访问是异步且受限制的。虽然我们的异步方法使用了Task.Run但在WebGL上Task.Run并不会创建新线程而是将工作抛回给主线程的下一个更新周期。在WebGL上使用SharpZipLib的注意事项避免同步操作绝对不要在WebGL的主线程上调用同步的CreateZip或ExtractZip方法这会导致浏览器页面“卡死”。务必使用Async版本。减小缓冲区如上面GetOptimalBufferSize所示在WebGL上使用更小的缓冲区如1MB以减少单次内存分配压力。分帧处理对于超大的Zip包即使异步也可能导致长时间的主线程占用。一个更高级的优化是将解压过程“分帧”。你可以修改异步解压循环每解压完一个或几个文件后使用await Task.Yield()让出一帧保证游戏渲染不卡顿。但这会显著增加总解压时间需要权衡。// 简化的分帧解压思路在ExtractZipAsync的循环内 foreach (ZipEntry entry in zipFile) { cancellationToken.ThrowIfCancellationRequested(); ExtractZipEntry(zipFile, entry, outputFolder); processedEntries; progressCallback?.Invoke(entry.Name, processedEntries, totalBytes); // 每解压10个文件让出一帧仅在WebGL或需要时启用 #if UNITY_WEBGL if (processedEntries % 10 0) { await Task.Yield(); // 注意这需要将方法签名改为 async Task并在循环内使用await } #endif }注意UnityWebRequest下载如果你从网络下载Zip包在WebGL上使用UnityWebRequest下载后得到的是DownloadHandlerBuffer.data字节数组。对于大文件这同样会导致内存峰值。理想的做法是使用DownloadHandlerFile直接下载到持久化路径然后再用我们的工具解压。6.4 压缩等级的选择SharpZipLib允许设置压缩等级SetLevel从0不压缩到9最大压缩。等级0 (Store)仅打包不压缩。速度最快适用于已经压缩过的格式如jpg、png、mp3。等级1-3较快压缩压缩率较低。适合对速度要求高、对包体大小不敏感的场景。等级6 (DEFAULT)默认等级在速度和压缩率之间取得平衡。适用于大多数情况。等级9 (BestCompression)最高压缩率但速度最慢CPU消耗高。适合网络传输带宽极其宝贵且压缩操作在后台进行的场景如制作资源更新包。在游戏运行时动态压缩用户数据如存档建议使用等级1-3。在Editor工具中打包资源可以使用等级6或9。7. 常见问题排查与实战技巧即使有了完善的工具在实际项目中还是会遇到各种问题。这里记录一些我踩过的坑和解决方案。7.1 典型错误与解决方案问题现象可能原因解决方案解压时抛出ZipException: invalid zip archive: could not find EOCD1. Zip文件下载不完整或已损坏。2. 文件路径错误读取的不是Zip文件。3. 文件正在被其他进程占用。1. 检查文件MD5/SHA1重新下载。2. 使用File.Exists确认路径并用二进制查看器检查文件头是否为PK。3. 确保没有其他程序如杀毒软件、编辑器锁定了该文件。解压后中文文件名乱码1. Zip文件创建时未使用UTF-8编码。2. 解压时未指定正确的编码。1. 创建Zip时设置zipStream.UseUnicode true;。2. 解压时使用我们提供的CustomNameTransform并指定Encoding.UTF8。尝试Encoding.GetEncoding(GBK)处理一些旧的中文Windows压缩包。在Android/iOS上解压失败权限被拒绝1. 尝试写入应用没有权限的目录如根目录。2. 目标文件夹路径不存在。1. 使用Application.persistentDataPath作为解压目标目录这是移动端可写的安全路径。2. 解压前用Directory.CreateDirectory创建目录。在WebGL上操作非常慢或卡死1. 使用了同步压缩/解压方法。2. 缓冲区设置过大单次操作阻塞主线程过久。3. 解压的文件数量极多。1. 务必使用Async方法。2. 减小BUFFER_SIZE如1MB。3. 考虑分帧处理Task.Yield或提示用户等待。解压后的文件丢失了可执行权限Linux/macOS压缩时未保存文件属性。压缩时设置entry.ExternalFileAttributes (int)File.GetAttributes(file) 16;。内存使用量飙升移动端崩溃1. 同时处理多个超大Zip文件。2. 缓冲区设置过大。3. 没有使用流式处理试图一次性加载整个文件。1. 串行处理压缩/解压任务。2. 调整缓冲区至合理大小如4MB。3. 检查代码确保使用FileStream分段读写而非File.ReadAllBytes。7.2 加密Zip包的处理SharpZipLib支持传统的Zip加密ZipCrypto和AES加密。但需要注意的是传统的ZipCrypto存在安全漏洞不建议用于敏感数据。如果必须加密建议使用AES。// 设置AES加密需要SharpZipLib版本支持 zipStream.Password yourPassword; // 默认使用ZipCrypto。要使用AES256需要更复杂的设置通常通过ZipEntry的AESKeySize属性。 // 注意AES加密在解压时也需要对应版本的SharpZipLib和正确的密码。一个重要的实践是游戏资源包通常不加密因为密码会硬编码在客户端没有实质安全意义。加密主要用于保护本地用户存档等场景且需权衡性能开销。7.3 与Unity AssetBundle的协同AssetBundleAB包是Unity主要的资源分发格式。有时你可能需要将多个AB包或配置文件打包成一个Zip进行下载。压缩AB包注意AB包本身通常已经使用LZMA或LZ4压缩过。对其再进行Zip压缩压缩率会很低甚至可能变大。对于AB包建议使用Zip的“存储”等级0模式仅起到打包作用避免浪费CPU时间做无效压缩。WebGL与LZ4Unity官方强烈建议WebGL平台下的AssetBundle必须使用LZ4压缩而不是LZMA。因为LZMA解压需要大量连续内存而LZ4是流式解压内存友好。我们的Zip工具处理的是外部的Zip容器不影响AB内部的压缩格式。但务必在Unity的AssetBundle打包设置中为WebGL选择LZ4。7.4 日志与调试建议在工具类中集成了详细的Debug.Log日志。在实际项目中建议将这些日志输出到你的游戏日志系统并区分信息、警告和错误等级。在发布版本中可以关闭Debug.Log以减少性能开销但错误日志 (Debug.LogError) 应保留或上报到服务器便于排查线上问题。你可以通过编译指令来控制日志#if DEVELOPMENT_BUILD || UNITY_EDITOR Debug.Log($[ZipUtility] 操作详情...); #endif Debug.LogError($[ZipUtility] 操作失败必须记录的错误...); // 错误始终记录8. 完整工具类封装与使用示例将以上所有代码整合我们就得到了一个功能完整、鲁棒性强的ZipUtility类。这里再提供一个简单的MonoBehaviour脚本来演示如何使用它。使用示例TestZipBehaviour.csusing System.IO; using System.Threading.Tasks; using UnityEngine; public class TestZipBehaviour : MonoBehaviour { public string sourceFolderPath Assets/TestResources; public string outputZipPath TestArchive.zip; public string extractFolderPath ExtractedResources; async void Start() { // 示例1异步压缩文件夹 bool compressSuccess await ZipUtility.CreateZipAsync( sourceFolderPath, Path.Combine(Application.persistentDataPath, outputZipPath), progressCallback: (file, processed, total) { float progress (float)processed / total; Debug.Log($压缩进度: {progress:P2} - 当前文件: {file}); } ); if (compressSuccess) { Debug.Log(压缩完成开始解压...); // 示例2异步解压到持久化数据路径 bool extractSuccess await ZipUtility.ExtractZipAsync( Path.Combine(Application.persistentDataPath, outputZipPath), Path.Combine(Application.persistentDataPath, extractFolderPath), progressCallback: (file, index, total) { Debug.Log($解压进度: {index}/{total} - {file}); } ); if (extractSuccess) { Debug.Log(所有操作成功完成); // 可以在这里加载解压出来的资源... } } } // 同步方法示例不建议在主线程调用 void TestSyncMethod() { // 注意在移动端或WebGL主线程调用此方法会导致卡顿 // ZipUtility.CreateZip(sourceFolderPath, SyncTest.zip); // ZipUtility.ExtractZip(SyncTest.zip, SyncExtract); } }这个示例展示了如何在Unity中异步调用我们的工具并显示进度。关键是将输入输出路径与Application.persistentDataPath结合使用以确保在各个平台都有正确的写入权限。经过以上八个部分的详细拆解我们从需求分析、库选型、环境搭建到核心的压缩解压实现、中文乱码解决、异步化、性能优化最后到问题排查和完整封装完成了一个面向生产环境的Unity跨平台Zip工具。这套方案已经在我参与的多个手游和PC项目中稳定运行有效解决了资源更新、数据存档等场景下的文件打包需求。记住工具的价值在于稳定和易用封装好一次处处受益。