从实际项目角度出发,这种工具类几乎是 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.QueueUserWorkItem,async/await有这些优势:
- 上下文切换代码量最少:一个
Task.Run包裹核心拷贝逻辑,调用方一个await就拿到结果,不需要 manual 管理线程生命周期。 - 异常传递更自然:异步方法里的异常会包装进返回的 Task,调用方 catch 即可。
- 可组合性:多个文件可以全部放进任务队列里,配合
Task.WhenAll或逐文件 await,进行更高级的调度。
2.3 对外接口设计
我把对外接口设计成“单文件拷贝 + 多文件队列”两层。
public static class FileCopyUtility { public static async Task<CopyResult> CopyAsync( string sourceFullPath, string destDir, string newFileName = null, IProgress<CopyProgress> 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 Task<CopyResult> CopyAsync( string sourceFullPath, string destDir, string newFileName = null, ConflictPolicy policy = ConflictPolicy.Overwrite, IProgress<CopyProgress> 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(Task<CopyResult>).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 大小为什么选 512KB
byte[] buffer = new byte[512 * 1024]这个大小不是随手写的。太小(比如 4KB)会导致系统调用频繁,CPU 上下文切换开销大;太大(比如 8MB)会明显增加内存压力,尤其在移动平台上容易触发 GC,还可能导致 LOH(Large Object Heap)碎片——超过 85KB 的数组会直接进 LOH,频繁分配大数组会造成内存碎片和更频繁的 GC。
512KB 属于移动平台和桌面平台都比较安全的折中区块大小。实测下来,在 Android 真机内部存储和 PC 端机械硬盘上,这个块大小做流式读写,性能接近系统级拷贝的 80%~90%,已经足够满足绝大多数项目需求。如果你面向 PC 高端 NVMe SSD,可以适度提高到 1MB,收益也会有一点,但差距没有从 4KB 提到 256KB 那么明显。
3.3 进度上报的节流:别让回调成为新的性能瓶颈
初版实现犯过一个错误,每个 512KB 块读完都调一次progress.Report()。大文件拷贝时,IProgress<T>的实现默认会把回调投递到捕获的 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
很多第一次写进度功能的开发者会犯一个经典错误,直接把Action<float> onProgress传给后台拷贝线程,然后在回调里操作 UI,比如progressBar.value = p。这在小项目、偶发场景下运气好可能不出问题,但它本质上是线程不安全的——你在后台线程写 UI 组件的值,Unity 主线程同时可能在渲染、读这个值,轻则进度条闪烁抖动,重则内存访问异常导致崩溃。
4.1 IProgress 的内部工作原理
IProgress<T>是 .NET 提供的一个标准接口,它的典型用法是:
var progress = new Progress<CopyProgress>(p => { // 这段代码会在捕获的 SynchronizationContext 上执行 progressBar.value = p.Percentage; });Progress<T>内部会捕获创建它的那个线程的SynchronizationContext。如果你在 Unity 主线程里创建Progress实例,那么后续每次progress.Report()调用,都会把回调封装成一个消息投递到主线程的上下文队列中,由主线程在合适的时机执行。这其实就是线程调度里最常见的“生产者-消费者”模型——后台线程只负责生产进度数据,主线程消费并更新 UI。
这个机制听起来完美,但有一个极重要的前提:主线程必须存在一个正在运行的 SynchronizationContext。Unity 的UnitySynchronizationContext会在每次玩家循环(Player Loop)中泵(post)它的队列,因此主线程里调用Progress是安全的。由于我们工具的接口通过可选参数IProgress<CopyProgress> progress = null接入,Unity 主线程调用时基本不会踩到“context 为空”的坑。
如果你是在没有 SynchronizationContext 的控制台程序或者纯后台线程中调用这个工具,Progress<T>的回调就会退化为线程池调用,此时不要在里面碰 UI。写 Unity 的开发者大概率不需要担心这个,但做编辑器工具时如果从非主线程调用,还是长个心眼。
4.2 自己实现 IProgress 应对高性能场景
在编辑器工具里,动辄几千个文件的批量拷贝,每个文件都通过Progress<T>上报会产生大量跨线程投递。这时可以考虑实现一个简易的IProgress<T>,把进度数据直接写入一个共享的线程安全容器,然后在EditorApplication.update里每帧读取并绘制。这种方案减少了跨线程消息的开销,也更容易跟 EditorWindow 的生命周期结合。
public class ThreadSafeProgress<T> : IProgress<T> { private readonly object _lock = new object(); private readonly Action<T> _handler; public ThreadSafeProgress(Action<T> handler) { _handler = handler; } public void Report(T value) { lock (_lock) { _handler(value); } } }不过说句老实话,如果只是做游戏内 UI 进度条,直接用自带的Progress<T>就够了,没必要自己造轮子。自己实现的场景主要是在编辑器批量工具里,希望避免每次进度都触发一次主线程委托调用,而是把上千个文件的总进度聚合成几个关键节点,再一次性刷新 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 Task<List<CopyResult>> CopyManyAsync( IEnumerable<FileCopyRequest> requests, IProgress<BatchCopyProgress> progress = null, CancellationToken token = default)FileCopyRequest结构为:
public struct FileCopyRequest { public string SourcePath; public string DestDir; public Func<string, 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/20:update_003.pkg,当前文件 45%,整体 15%”这样信息完整的文案,用户能感知到系统在工作,不会误以为卡死。
5.4 结果汇总:List 怎么处理才算优秀
批量方法最终返回List<CopyResult>,每个元素对应一个文件的拷贝结果。封装时的关键点是让调用方可以快速判断“是否全部成功”。
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 Progress<CopyProgress>(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 Progress<BatchCopyProgress>(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 特有的坑:Progress<T>的回调虽然在主线程执行,但不一定是在同一个帧周期内。如果你在主线程的Progress回调里调用了Instantiate、加载 Asset 等重量级操作,可能造成主线程卡顿,因为回调是即时同步执行的。
正确的姿势是:进度回调里只更新数值和 UI 显示,绝不做资源加载、逻辑跳转等重量级操作。等CopyAsync返回的CopyResult之后,再统一处理跳转业务。可以理解为,进度条是“结果呈现”的一部分,而业务逻辑是“结果落地”的一部分,两者不要让它们混在同一个回调里。
7. 为什么说这个工具有资格成为团队通用基建
做 Unity 项目这些年,我越来越确信一件事:从代码复用率来看,文件操作工具类几乎可以排进前三。存档系统、热更新、资源导出、编辑器批量处理、用户生成内容(UGC)的上传下载,全都要和文件拷贝打交道。从当初那个只会File.Copy的新手,到现在用分块读写、进度上报、取消令牌、批量队列的标准工具,本质上是一次“把系统黑盒变成可控操作流”的思维转变。
这个工具类最有价值的一点是,它把三个具体诉求都变成了直接可调用的接口:拷贝进度(IProgress<CopyProgress>)、重命名(newFileName/RenameRule)、结果返回(CopyResult)。后续不管接什么系统,都是几行代码的事,而不用每个新功能都重新写一遍 FileStream 循环。
最后再说一个可扩展的方向:这个工具类天然可以扩展成“带 MD5 校验的拷贝”——拷贝完成后计算两端文件的哈希值,如果不一致则判定为拷贝失败,防止数据损坏。在存档上传或资源校验这种对完整性要求极高的场景,这是非常有价值的升级。你可以在CopyResult里加一个CheckSum字段,把扩展点留给未来。