Unity异步文件拷贝工具类:分块读写与进度上报实战

发布时间:2026/9/24 20:57:45
Unity异步文件拷贝工具类:分块读写与进度上报实战 从实际项目角度出发这种工具类几乎是 Unity 工程里绕不开的“基建”。无论是做热更新资源准备、存档导出、编辑器批处理还是运行时把大型文件从托管目录搬到持久化目录一个稳定、不卡主线程、能反馈进度、还能处理重命名和错误回调的拷贝工具能省掉大量重复的“临时同步拷贝 手写进度逻辑”的脏活。这篇文章我会聊聊为什么我选择用 C# 原生异步方案而不是常见的协程还会把这个工具类的完整设计思路、核心代码、进度回调的线程问题、以及实际使用中踩过的几个坑一并讲清楚。如果你正准备在项目里做类似的文件管理可以直接参考里面的实现方式。1. 同步拷贝的痛点两个项目教训逼出来的异步方案先说个真实场景。上一个项目做的是端游的更新器玩家点“开始更新”后客户端要把下载到临时目录的若干个几百 MB 的 pkg 文件解包并把散文件拷贝到 StreamingAssets 对应的文件夹。最初的实现非常朴素直接在协程里调用File.Copy结果就是你猜得到的那种体验——更新界面卡到和崩溃似的尤其是机械硬盘上的大文件能卡好几秒。玩家在论坛里反馈说“点更新就黑屏怀疑死机了”。第二次碰到这个问题是做编辑器工具。当时要批量把美术同学生成的图集、Prefab 和音频资源按命名规则整理到目标目录几千个文件同步执行下来Unity Editor 直接进入 “Not Responding” 状态鼠标转圈转了半分钟。虽然最后还是能跑完但每次执行工具期间编辑器完全不可操作任何误点击都会导致一次半途而废、文件状态错乱的脏拷贝。这两个案例暴露了同步File.Copy的三大问题阻塞主线程File.Copy是同步 IO大文件拷贝期间主线程完全卡死。无进度反馈你只能看见一个“正在拷贝”的转圈具体拷了多少、还剩多少、当前拷哪个文件全是黑盒。异常难以恢复中途磁盘空间不足、文件占用、权限问题都需要自己 try-catch如果处理不好留下的半截文件比没有文件更麻烦。我把这三个问题拆成需求列表就变成了这个工具类必须支持的三个核心点拷贝过程必须异步进行不能影响 Unity 主线程其它逻辑执行必须能拿到实时进度包括已拷贝字节数、总字节数、百分比、当前文件名必须有清晰的返回结果和错误信息调用方可以据此做重试、清残留或提示用户。再加上标题里明确提到的“重命名”需求本地文件重命名这个界面的操作背后的逻辑其实等于指定目标文件名而不是死板地只允许同名拷贝。合并起来就确定了我想要的对外接口的样子接下来我会讲接口设计背后的取舍。2. 工具类接口与整体设计为什么选 async/await 而不是协程设计这个类的第一步不是写代码而是决定异步方案。Unity 开发者最熟悉的异步方式毫无疑问是协程Coroutine但做文件拷贝这种 IO 密集型任务协程并不是好选择。2.1 协程的局限它只是“分帧”不是“异步”协程的本质是迭代器方法配合yield return null可以把一段逻辑拆成多帧执行但要注意它所有代码仍然运行在主线程。所以就算你在协程里写了IEnumerator CopyFile() { File.Copy(src, dest); yield return null; }File.Copy这行依然会把主线程卡住只是你把它从“一次性卡 5 秒”变成了“某一帧卡 5 秒”用户该卡还是卡。用协程思想模拟进度只能是你先创建一个线程去拷然后每帧去查这个线程的进度值——本质上还是逃不开Thread或Task那不如直接用 C# 的async/await。2.2 async/await 方案的三个理由2018 年之后Unity 全面切换到 .NET 4.x 等价运行时C# 的async/await在编辑器和运行时都可用。相比自己 new Thread 或者用ThreadPool.QueueUserWorkItemasync/await有这些优势上下文切换代码量最少一个Task.Run包裹核心拷贝逻辑调用方一个await就拿到结果不需要 manual 管理线程生命周期。异常传递更自然异步方法里的异常会包装进返回的 Task调用方 catch 即可。可组合性多个文件可以全部放进任务队列里配合Task.WhenAll或逐文件 await进行更高级的调度。2.3 对外接口设计我把对外接口设计成“单文件拷贝 多文件队列”两层。public static class FileCopyUtility { public static async TaskCopyResult CopyAsync( string sourceFullPath, string destDir, string newFileName null, IProgressCopyProgress progress null, CancellationToken token default); }CopyResult是统一返回结果重点字段是IsSuccess、ErrorMessage、OutputPath、TotalBytes、ElapsedSeconds。之所以不直接抛Exception是因为我并不希望在调用方那儿被“必须要 catch 所有异常”的体验绑架而且大多数调用场景比如编辑器工具希望拿到一个结果对象之后去打印、弹窗而不是让异常一路炸出去。CopyProgress则是进度结构体public struct CopyProgress { public string SourceFile; public string OutputFile; public long TotalBytes; public long CompletedBytes; public float Percentage; // 0~1 public double SpeedBytesPerSec; }2.4 重命名策略与冲突处理重命名直接在参数里通过newFileName指定。调用方传了就用没传就默认保留原文件名。但这里有个容易被忽略的点目标目录可能已经存在同名文件。我提供三种冲突策略通过另一个枚举控制public enum ConflictPolicy { Overwrite, // 覆盖旧文件 RenameNew, // 自动追加 (1) (2) 后缀 Skip // 跳过当前文件 }老实说具体用哪种策略不能拍脑袋得看场景。比如做存档备份几乎必然选RenameNew生成带时间戳的副本做更新覆盖则应该用Overwrite老版本文件直接舍弃。接口里我把它做成了可选参数默认Overwrite最符合“拷贝”这个动作的直觉。2.5 为什么选择分块读写而不是 File.Copy再说一个关键实现决策——底层拷贝逻辑我没有用File.Copy而是自己用FileStream做分块循环读写。原因有两个一是File.Copy没有进度回调二是它没有取消机制真要拷到一半发生错误你能做的很有限。分块读写的原理其实特别贴近生活就好比搬家。File.Copy是一次性叫一辆巨型卡车把所有东西拉走你既不知道车开到哪了也没法让它停分块读写则是用小推车一车一车推每推一车你都能看一眼、挂个计数器想停就停想换路线也来得及。代价就是代码量多一些但这个代价换来的进度、取消、细粒度错误处理能力在多数项目场景里完全值得。下一节我把分块读写和进度上报的核心实现展开讲。3. 核心拷贝实现分块读写进度上报的完整代码3.1 主流程代码骨架先看完整的主方法实现。public static async TaskCopyResult CopyAsync( string sourceFullPath, string destDir, string newFileName null, ConflictPolicy policy ConflictPolicy.Overwrite, IProgressCopyProgress progress null, CancellationToken token default) { var result new CopyResult(); var timer Stopwatch.StartNew(); try { // 1. 参数与路径规整 if (string.IsNullOrEmpty(sourceFullPath) || !File.Exists(sourceFullPath)) return result.Fail(源文件不存在: sourceFullPath); if (string.IsNullOrEmpty(destDir)) return result.Fail(目标目录为空); var destDirectory Path.GetFullPath(destDir); if (!Directory.Exists(destDirectory)) Directory.CreateDirectory(destDirectory); var fileInfo new FileInfo(sourceFullPath); long totalBytes fileInfo.Length; if (totalBytes 0) return result.Fail(源文件为空文件: sourceFullPath); // 2. 目标文件名重命名逻辑 var targetName string.IsNullOrEmpty(newFileName) ? Path.GetFileName(sourceFullPath) : newFileName; // 3. 处理同名冲突 var outputPath Path.Combine(destDirectory, targetName); if (File.Exists(outputPath)) { switch (policy) { case ConflictPolicy.Skip: return result.Ok(outputPath, totalBytes, timer.Elapsed.TotalSeconds, skipped: true); case ConflictPolicy.Overwrite: File.Delete(outputPath); break; case ConflictPolicy.RenameNew: outputPath GenerateUniquePath(destDirectory, targetName); break; } } // 4. 分块流式拷贝后台线程执行 await Task.Run(() { using (var sourceStream new FileStream( sourceFullPath, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 81920, useAsync: false)) using (var destStream new FileStream( outputPath, FileMode.Create, FileAccess.Write, FileShare.None, bufferSize: 81920, useAsync: false)) { byte[] buffer new byte[512 * 1024]; // 512KB 大块 long completedBytes 0; int bytesRead; double lastReportTime 0; const double reportIntervalMs 100; // 每100ms上报一次 while ((bytesRead sourceStream.Read(buffer, 0, buffer.Length)) 0) { token.ThrowIfCancellationRequested(); destStream.Write(buffer, 0, bytesRead); completedBytes bytesRead; // 进度上报控制避免每块都回调造成调用方GC压力 var elapsedMs timer.Elapsed.TotalMilliseconds; if (progress ! null elapsedMs - lastReportTime reportIntervalMs) { var p new CopyProgress { SourceFile sourceFullPath, OutputFile outputPath, TotalBytes totalBytes, CompletedBytes completedBytes, Percentage (float)completedBytes / totalBytes, SpeedBytesPerSec GetSpeed(completedBytes, timer) }; progress.Report(p); lastReportTime elapsedMs; } } destStream.Flush(true); } }, token); // 5. 完成后确保上报100% progress?.Report(new CopyProgress { SourceFile sourceFullPath, OutputFile outputPath, TotalBytes totalBytes, CompletedBytes totalBytes, Percentage 1f, SpeedBytesPerSec GetSpeed(totalBytes, timer) }); return result.Ok(outputPath, totalBytes, timer.Elapsed.TotalSeconds); } catch (OperationCanceledException) { // 已取消清理半成品文件 TryDeleteFileIfExists(typeof(TaskCopyResult).GetMethod(CopyAsync) null ? null : GetHalfPath(result, destDir)); return result.Canceled(); } catch (Exception ex) { return result.Fail(拷贝失败: ex.Message, ex); } finally { timer.Stop(); } }这段代码里有几个细节值得拎出来讲3.2 buffer 大小为什么选 512KBbyte[] buffer new byte[512 * 1024]这个大小不是随手写的。太小比如 4KB会导致系统调用频繁CPU 上下文切换开销大太大比如 8MB会明显增加内存压力尤其在移动平台上容易触发 GC还可能导致 LOHLarge Object Heap碎片——超过 85KB 的数组会直接进 LOH频繁分配大数组会造成内存碎片和更频繁的 GC。512KB 属于移动平台和桌面平台都比较安全的折中区块大小。实测下来在 Android 真机内部存储和 PC 端机械硬盘上这个块大小做流式读写性能接近系统级拷贝的 80%~90%已经足够满足绝大多数项目需求。如果你面向 PC 高端 NVMe SSD可以适度提高到 1MB收益也会有一点但差距没有从 4KB 提到 256KB 那么明显。3.3 进度上报的节流别让回调成为新的性能瓶颈初版实现犯过一个错误每个 512KB 块读完都调一次progress.Report()。大文件拷贝时IProgressT的实现默认会把回调投递到捕获的 SynchronizationContext 上这意味着主线程每帧可能要处理几百次 UI 刷新事件鼠标都跟着掉帧。所以我在循环里加了一个时间闸门至少间隔 100ms 才上报一次进度。这个间隔下进度条刷新率是 10fps肉眼看很平滑同时又不会产生海量回调压力。实际项目中如果你需要更细腻的进度把 100ms 改成 50ms 也完全可行但没必要低于 30ms人眼感知的提升非常有限。3.4 支持取消通过 CancellationToken 干净地中断拷贝取消操作是我在设计接口时特意加上的。实际场景很常见——如果玩家点击“开始更新”后发现网速太慢、或者更新包版本出错了想要中止拷贝。如果没有取消机制调用方只能看着后台线程傻傻地拷完或者强行杀掉整个应用。实现里通过token.ThrowIfCancellationRequested()在每个块循环开头检查状态。抛出的OperationCanceledException会被 catch 到然后清理半成品文件。清理逻辑比想象中重要得多否则每次取消都会留下一个不完整的目标文件下次Overwrite策略会把它当作旧文件直接覆盖看起来没问题但如果是RenameNew策略取消留下的残缺文件不会被清理会一直占着磁盘空间久而久之变成垃圾文件。3.5 FileShare 和 FileMode 的正确用法开文件的模式我特意用了读端FileShare.Read——允许其它进程同时读这个文件避免源文件被别的日志进程占用时直接报错。写端FileShare.None——独占写入防止两个拷贝任务同时写同一个目标路径导致文件内容交错损坏。FileMode.Create是“覆盖已有文件并创建新文件”的标准语义。如果希望丢到覆盖策略的细节里做这里也可以改成FileMode.CreateNew配合RenameNew策略再试不过上面的代码已经把冲突策略前置处理了这里直接Create没问题。4. 进度反馈的线程模型为什么需要 IProgress 而不是直接 Action很多第一次写进度功能的开发者会犯一个经典错误直接把Actionfloat onProgress传给后台拷贝线程然后在回调里操作 UI比如progressBar.value p。这在小项目、偶发场景下运气好可能不出问题但它本质上是线程不安全的——你在后台线程写 UI 组件的值Unity 主线程同时可能在渲染、读这个值轻则进度条闪烁抖动重则内存访问异常导致崩溃。4.1 IProgress 的内部工作原理IProgressT是 .NET 提供的一个标准接口它的典型用法是var progress new ProgressCopyProgress(p { // 这段代码会在捕获的 SynchronizationContext 上执行 progressBar.value p.Percentage; });ProgressT内部会捕获创建它的那个线程的SynchronizationContext。如果你在 Unity 主线程里创建Progress实例那么后续每次progress.Report()调用都会把回调封装成一个消息投递到主线程的上下文队列中由主线程在合适的时机执行。这其实就是线程调度里最常见的“生产者-消费者”模型——后台线程只负责生产进度数据主线程消费并更新 UI。这个机制听起来完美但有一个极重要的前提主线程必须存在一个正在运行的 SynchronizationContext。Unity 的UnitySynchronizationContext会在每次玩家循环Player Loop中泵(post)它的队列因此主线程里调用Progress是安全的。由于我们工具的接口通过可选参数IProgressCopyProgress progress null接入Unity 主线程调用时基本不会踩到“context 为空”的坑。如果你是在没有 SynchronizationContext 的控制台程序或者纯后台线程中调用这个工具ProgressT的回调就会退化为线程池调用此时不要在里面碰 UI。写 Unity 的开发者大概率不需要担心这个但做编辑器工具时如果从非主线程调用还是长个心眼。4.2 自己实现 IProgress 应对高性能场景在编辑器工具里动辄几千个文件的批量拷贝每个文件都通过ProgressT上报会产生大量跨线程投递。这时可以考虑实现一个简易的IProgressT把进度数据直接写入一个共享的线程安全容器然后在EditorApplication.update里每帧读取并绘制。这种方案减少了跨线程消息的开销也更容易跟 EditorWindow 的生命周期结合。public class ThreadSafeProgressT : IProgressT { private readonly object _lock new object(); private readonly ActionT _handler; public ThreadSafeProgress(ActionT handler) { _handler handler; } public void Report(T value) { lock (_lock) { _handler(value); } } }不过说句老实话如果只是做游戏内 UI 进度条直接用自带的ProgressT就够了没必要自己造轮子。自己实现的场景主要是在编辑器批量工具里希望避免每次进度都触发一次主线程委托调用而是把上千个文件的总进度聚合成几个关键节点再一次性刷新 UI。4.3 进度上报频率和主线程卡顿的平衡再给一个实用的参考数据如果一个 1GB 的文件要拷 30 秒每 100ms 上报一次全过程会产生约 300 次回调。300 次主线程 UI 刷新在现代硬件上几乎无感。如果你把上报间隔改成 10ms回调次数直接涨到 3000 次每次都是跨线程投递UI 刷新低端安卓机上这 3000 次 UI 刷新几十帧叠加起来可能就直接把拷贝期间的主线程跑满得不偿失。所以我的经验法则是进度回调的频次必须和 UI 刷新率挂钩而不是和 IO 块大小挂钩。100ms 或者 50ms 一次足够了。5. 完整封装带重命名、批量队列和结果汇总的落地实现单文件拷贝搞定后接下来的需求是把它封装成可以直接在游戏逻辑或编辑器菜单中调用的“完整品”。我会把重命名、批量队列、结果汇总这三个能力全糅进一个工具里。5.1 重命名参数的最佳实践基于规则生成新文件名重命名并不只是传一个newFileName字符串那么简单。更常见的需求是“按规则批量重命名”比如把icon_001.png重命名为act_icon_001.png把所有Update_20240601_1234.pkg复制到持久化目录后再格式化成patch_{version}{timestamp}.pkg把存档文件save.dat备份为save_backup_20240601_1530.dat所以在批量接口里我提供了一个命名规则委托让调用方可以在拷贝前决定最终目标文件名public static async TaskListCopyResult CopyManyAsync( IEnumerableFileCopyRequest requests, IProgressBatchCopyProgress progress null, CancellationToken token default)FileCopyRequest结构为public struct FileCopyRequest { public string SourcePath; public string DestDir; public Funcstring, string RenameRule; // 输入原文件名输出新文件名 }注意RenameRule委托的用法同newFileName参数的差异它在源码层面直接映射了标题里“带重命名”这个需求支持动态计算文件名而不是硬编码。5.2 批量拷贝的正确姿势逐文件 await 还是 WhenAll批量拷贝的常见实现有两种用Task.WhenAll同时拷贝所有文件追求并发速度用for循环逐文件await保证同一时间只拷一个文件。在 Unity 项目里我强烈建议选择后者。原因很现实磁盘 IO 并发收益在机械硬盘和手机闪存上不明显——SSD 顺序读写和多文件并发确实有一定收益但真机大量小文件并发读写反而会降低整体吞吐量闪存芯片的并发通道有限太小文件时每次 IO 的固定开销反而拖慢速度。内存占用更可控——每个拷贝任务持有 512KB buffer如果同时开 20 个任务光是 buffer 就超过 10MB还不算 FileStream 内部缓冲。低端手机上这很容易触发内存压力。进度更容易理解——逐个拷贝时批量进度可以拆成“当前第 3/20 个总进度 15%”这种清晰格式。并发时你只能给一个总字节数百分比用户看着进度条慢慢挪并不知道具体在做什么。当然如果你确实需要追求并发吞吐量比如 PC 端编辑器批量转换NVMe 高性能盘可以自己再包一层并发调度但基本盘一定是逐文件拷贝这个排队模型。5.3 批量拷贝进度的上报设计批量进度的上报需要包含两个维度当前文件的进度和整体进度。public struct BatchCopyProgress { public int CurrentIndex; public int TotalCount; public string CurrentSourceFile; public string CurrentOutputFile; public float CurrentFilePercentage; // 当前文件 0~1 public float OverallPercentage; // 整体 0~1 }这个结构的价值是可以让 UI 显示成“正在复制 3/20update_003.pkg当前文件 45%整体 15%”这样信息完整的文案用户能感知到系统在工作不会误以为卡死。5.4 结果汇总List 怎么处理才算优秀批量方法最终返回ListCopyResult每个元素对应一个文件的拷贝结果。封装时的关键点是让调用方可以快速判断“是否全部成功”。var results await FileCopyUtility.CopyManyAsync(requests, progress); var failed results.Where(r !r.IsSuccess).ToList(); if (failed.Count 0) { // 统一将失败信息写入日志而非中断整个流程 foreach (var fail in failed) Debug.LogError($[FileCopy] 失败: {fail.OutputPath} --- {fail.ErrorMessage}); }我个人有个固执的认知批量工具的返回结果永远不要因为某个文件失败就抛异常中断整个队列。一批文件里单个失败太常见了权限不足、路径冲突、文件被占用、传输中损坏任何一个都不应该毁掉剩下的文件。所以我的CopyResult保留了失败现场让调用方自行决定是重试失败项还是跳过。这种“不因局部失败而中断整体”的思路在编辑器批处理工具里尤其重要。你不可能让美术同学跑了一个批量导入工具因为第 13 个图集路径不对把前面 12 个已经做好的工作全部回滚。6. 实用场景示例和坑位复盘标题里点名的“带上进度、重命名、结果返回”三个功能点都实现了接下来我用两个具体场景演示怎么把它接到实际项目里再复盘几个自己踩过的坑。6.1 场景一运行时把下载包拷贝到持久化目录并显示进度假设游戏启动后热更新流程把补丁包下载到了Application.temporaryCachePath接下来需要把它剪切到Application.persistentDataPath同时在 UI 上显示进度。public class PatchCopyPanel : MonoBehaviour { [SerializeField] private Slider progressSlider; [SerializeField] private Text progressText; public async void StartCopyPatch() { string src Path.Combine(Application.temporaryCachePath, patch_1.2.3.pkg); string destDir Application.persistentDataPath; string newName $patch_{DateTime.Now:yyyyMMdd_HHmmss}.pkg; var progress new ProgressCopyProgress(p { progressSlider.value p.Percentage; progressText.text ${p.Percentage:P1} ({FormatSize(p.CompletedBytes)}/{FormatSize(p.TotalBytes)}); }); var result await FileCopyUtility.CopyAsync(src, destDir, newName, progress: progress); if (result.IsSuccess) { // 提示更新成功 } else { // 弹出错误面板并清理残留 } } }注意这里的StartCopyPatch是async void而不是async Task。在 Unity 里由事件驱动触发的方法用async void是合理写法纯函数逻辑用async Task。但async void有个缺陷异常如果没被内部捕捉会直接炸到 Unity 的日志系统里导致应用崩溃。所以我在这类方法内部几乎总是try-catch包裹整个逻辑或者依赖工具类内部已经吞掉异常并放入CopyResult的特性。6.2 场景二编辑器菜单批量重命名并拷贝资源编辑器扩展里经常需要把临时导入目录里的一批文件按规则重命名后拷入正式资源目录。[MenuItem(Tools/Asset Pipeline/Batch Rename Copy)] public static async void BatchCopyAssets() { string srcDir Assets/__TempImport; string dstDir Assets/ArtAssets; string[] files Directory.GetFiles(srcDir, *.fbx, SearchOption.AllDirectories); var requests files.Select(f new FileCopyRequest { SourcePath f, DestDir dstDir, RenameRule oldName $char_{oldName.ToLower().Replace( , _)} }); var progress new ProgressBatchCopyProgress(p { EditorUtility.DisplayProgressBar( 批量拷贝资源, ${p.CurrentSourceFile} ({p.CurrentIndex}/{p.TotalCount}), p.OverallPercentage); }); var results await FileCopyUtility.CopyManyAsync(requests, progress); EditorUtility.ClearProgressBar(); AssetDatabase.Refresh(); // 打印汇总结果 }一个容易忽略的细节是AssetDatabase.Refresh()。Unity 编辑器里直接往Assets目录写入新文件后如果不刷新资产数据库Unity 不会自动识别这些文件。批量拷贝结束后必须手动刷新否则资源在编辑器里显示不出来要重启项目才生效。这个坑第一次用时碰到了表现是文件明明在资源管理器里但 Unity 项目视图中看不到排查了半天才想起是没刷新。6.3 坑位一提中文路径和特殊字符Windows 下路径处理时Path.Combine可以避免手动拼接的坑但中文路径在部分 Unity 版本的老 Bug 里会偶发编码问题。我的建议是所有文件操作统一用Path.GetFullPath再进 FileStream目标目录创建前先用Directory.Exists判断避免路径过深或含非法字符导致整个拷贝失败。当重命名规则生成的名称中包含Path.GetInvalidFileNameChars()中的字符时直接拼路径会抛ArgumentException所以批量接口里需要拦截非法字符替换成下划线或者直接剔除。private static string SanitizeFileName(string name) { var invalid Path.GetInvalidFileNameChars(); var builder new StringBuilder(name.Length); foreach (var c in name) builder.Append(invalid.Contains(c) ? _ : c); return builder.ToString(); }这个函数看着不起眼但它解决了一个很现实的脏问题——美术同学导出的文件叫“最终版(1).fbx”里面带括号和空格软件批量转换时这些看似无伤大雅的字符可能变成路径解析的崩溃点。6.4 坑位二Android 平台持久化目录的安全问题另一件值得提的事是 Android 平台的存储权限。Android 6.0 对应用私有目录Application.persistentDataPath的操作不需要申请存储权限但如果你要把文件拷到公共目录比如Download或者 SD 卡根目录必须在运行时动态申请WRITE_EXTERNAL_STORAGE权限。这个坑我在实现存档导出时踩过表现为导出按钮点了之后毫无反应日志里偶发 “Permission denied”但只查代码根本定位不到问题。所以工具类在拷贝前检查一下目录可写性是个好习惯private static bool IsDirectoryWritable(string dirPath) { try { using (FileStream fs File.Create( Path.Combine(dirPath, .write_test), 1, FileOptions.DeleteOnClose)) { return true; } } catch { return false; } }6.5 坑位三进度回调里别碰可以避免的必要操作前面反复强调进度回调跨线程要小心再补一个 Unity 特有的坑ProgressT的回调虽然在主线程执行但不一定是在同一个帧周期内。如果你在主线程的Progress回调里调用了Instantiate、加载 Asset 等重量级操作可能造成主线程卡顿因为回调是即时同步执行的。正确的姿势是进度回调里只更新数值和 UI 显示绝不做资源加载、逻辑跳转等重量级操作。等CopyAsync返回的CopyResult之后再统一处理跳转业务。可以理解为进度条是“结果呈现”的一部分而业务逻辑是“结果落地”的一部分两者不要让它们混在同一个回调里。7. 为什么说这个工具有资格成为团队通用基建做 Unity 项目这些年我越来越确信一件事从代码复用率来看文件操作工具类几乎可以排进前三。存档系统、热更新、资源导出、编辑器批量处理、用户生成内容UGC的上传下载全都要和文件拷贝打交道。从当初那个只会File.Copy的新手到现在用分块读写、进度上报、取消令牌、批量队列的标准工具本质上是一次“把系统黑盒变成可控操作流”的思维转变。这个工具类最有价值的一点是它把三个具体诉求都变成了直接可调用的接口拷贝进度IProgressCopyProgress、重命名newFileName/RenameRule、结果返回CopyResult。后续不管接什么系统都是几行代码的事而不用每个新功能都重新写一遍 FileStream 循环。最后再说一个可扩展的方向这个工具类天然可以扩展成“带 MD5 校验的拷贝”——拷贝完成后计算两端文件的哈希值如果不一致则判定为拷贝失败防止数据损坏。在存档上传或资源校验这种对完整性要求极高的场景这是非常有价值的升级。你可以在CopyResult里加一个CheckSum字段把扩展点留给未来。