1. 一个看似简单的需求,背后藏着多少东西
“通过 C# 代码打开指定路径下的文件夹”这句话,第一次听到的人多半会觉得这是一行代码就能解决的事情。确实,很多示例就只给了一行Process.Start("C:\\Temp")。但真正在项目里用过几次之后你会发现,同样一行代码,在 .NET Framework 下能跑,搬到 .NET 6+ 上直接抛异常;在自己的开发机上能跑,到客户那台以服务身份运行的机器上就静默失败;路径里带个空格就打开到了错误的地方;路径末尾多一个反斜杠又能让人查半天。这就是我为什么愿意单独拿一篇文章来聊这个需求——它不是难,它是“散”,坑分布在运行时差异、Shell 调用机制、路径字符串处理和权限会话四个完全不同的层面上。
先把结论摆出来:这个需求的本质不是文件系统操作,而是向操作系统桌面 Shell 发起一次“请帮我打开这个东西”的委托请求。你要打开的文件夹本身可能根本不归你的进程管,资源管理器是一个独立进程,你的代码只负责把意图传过去。想清楚这一点,后面所有的取舍就都有依据了。
这篇文章适合谁看?如果你只是想在 WinForms 里加个“打开目录”按钮,第 2 节和第 4 节的代码直接抄就行。如果你在做需要跨平台、需要单元测试、或者需要在托盘程序和服务里都稳定工作的模块,那第 3 节的路径坑位清单和第 6 节的排查表可能更值钱。全文的代码我都在 Windows 和类 Unix 桌面环境上跑过,下面提到的每个坑都是真实遇到过的。
2. 三种实现路径的选型拆解
在动手之前,得先明白 C# 里“打开文件夹”其实有不止一条路,它们走到操作系统底层的路径并不相同,行为差异也来自这里。
2.1 直接 Start 文件夹路径:把意图交给 Shell
最常见也最推荐的写法是把文件夹路径本身当作要启动的目标:
Process.Start(new ProcessStartInfo { FileName = @"C:\Temp\Reports", UseShellExecute = true });关键在于UseShellExecute = true。这个开关决定了Process.Start走的是ShellExecuteEx而不是CreateProcess。前者的语义是“请 Shell 帮我处理这个目标”,Shell 会根据文件类型、路径类型去匹配关联程序,文件夹自然就被交给资源管理器处理;后者是“直接创建一个进程”,而文件夹不是可执行文件,所以会报Win32Exception: 系统找不到指定的文件。
这就是我踩过的第一个大坑:.NET Framework里ProcessStartInfo.UseShellExecute默认是true,老代码复制过去什么都不用改;但从 .NET Core 开始,这个默认值改成了false。同一份代码从 4.8 迁移到 .NET 6,编译不报错,运行就炸。所以我的习惯是永远显式写出UseShellExecute = true,不管在哪个框架下——代码是给人看的,默认值这种东西不该靠记忆。
这种写法的好处是“行为一致”:用户在你程序里点这个按钮,和他在资源管理器里双击这个文件夹,走的是同一条系统路径,包括用户自己设置的文件夹关联、第三方文件管理器接管等,都会生效。缺点是它不能带参数,比如你想“打开文件夹并选中某个文件”,这条路就走不通。
2.2 显式调用资源管理器进程:需要精细控制的场合
第二条路是自己把explorer.exe拉起来,把路径当参数传进去:
Process.Start(new ProcessStartInfo { FileName = "explorer.exe", Arguments = $"\"{folderPath}\"", UseShellExecute = true });为什么要绕这一圈?因为只有显式调用explorer.exe,你才能用它的命令行开关:
| 开关 | 作用 | 典型用途 |
|---|---|---|
| 无 | 在已有窗口里跳转到目标目录 | 普通打开目录 |
/n | 强制开一个新窗口 | 需要并排对照查看 |
/e | 以资源管理器双栏视图打开 | 需要浏览树形结构 |
/select,<path> | 打开父目录并选中目标 | “在文件夹中显示” |
/root,<path> | 把该目录设为根节点 | 展示某个独立集合 |
这里有个必须说清楚的点:explorer.exe的进程生命周期是不可靠的。因为资源管理器通常是单实例复用的,当你传入一个目录时,它可能只是给已经存在的那个窗口发了个消息,新拉起来的进程立刻退出。所以不要用WaitForExit加ExitCode去判断成功与否,它经常返回 1 或者干脆马上结束。判断是否成功的正确姿势只有两个:调用前自己校验路径存在性,以及捕获Process.Start抛出的异常。
另外,这种写法会丢掉一部分“用户偏好”——比如用户装了第三方文件管理器并设成了默认,直接调explorer.exe就绕过了这个设置。所以我的取舍是:普通打开目录用 2.1 的方案,只有需要/select这类特殊能力时才用 2.2。
2.3 打开并选中指定文件:两种实现思路的对比
“打开文件夹并选中某个文件”是个高频衍生需求,比如导出报表后想直接定位到刚生成的那个 xlsx。除了上面的/select命令行方案,还有一条路是 P/Invoke 调用 Shell 的SHOpenFolderAndSelectItems。我把两者的差别列一下:
- 命令行
/select:写法简单,几行代码搞定,跨语言通用。缺点是需要自己拼字符串、处理引号,而且对某些特殊路径(后面会讲)表现不够稳。 - P/Invoke Shell API:需要
SHParseDisplayName把路径转成 PIDL,再调SHOpenFolderAndSelectItems,还得处理HRESULT、释放 PIDL 内存、注意 COM 单元线程模型(UI 线程要用 STA)。代码量大概是前者的三到四倍。
对一个“点按钮打开目录”的工具类程序来说,命令行方案完全够用,我几乎不选 P/Invoke——除非是在做资源管理器的深度集成插件,那里本来就已经在 Shell 进程内,用原生 API 更自然。工程上的判断标准永远是“够用就好”,不是“更底层就更专业”。
2.4 跨平台的分发策略
如果你的程序要跑在多种桌面环境上,就得按运行时分发:
- Windows:
Process.Start+UseShellExecute = true,或者explorer.exe。 - macOS:
open <path>,选中文件用open -R <path>(-R就是 Reveal)。 - Linux 桌面:
xdg-open <path>,这是各桌面环境约定的统一入口。选中文件没有统一语义,只能退化到打开父目录。
用RuntimeInformation.IsOSPlatform做判断,而不是Environment.OSVersion.Platform,后者在 .NET Core 上返回值的行为有过变化,前者语义更明确、更稳。还有一点要特别注意:在 Linux 上xdg-open会阻塞等待到被调用的程序退出,所以调用之后别在当前线程等它,也不要用WaitForExit,让它自生自灭即可。
3. 路径处理的六个高频坑
这一节是我认为最有价值的部分,因为真正让人加班的从来不是“怎么打开”,而是“为什么打开错了”。
3.1 空格、中文与引号转义
路径里有空格是最经典的翻车点。Process.Start("explorer.exe", path)这种写法在路径含空格时,explorer.exe只会把空格前的那一段当成参数,剩下的被当成别的开关。所以手动拼接Arguments时,必须用双引号把整个路径包起来。
那有没有更保险的写法?有,用ArgumentList代替Arguments:
var psi = new ProcessStartInfo { FileName = "xdg-open", UseShellExecute = false }; psi.ArgumentList.Add(folderPath);ArgumentList会按照系统的参数转义规则自动处理空格和引号,不需要你自己拼。但它有个限制:每个元素被视为一个独立参数。而explorer.exe的/select,<path>在语义上是“一个参数”还是“两个”,各个版本解释不太一样,实际测下来用ArgumentList拼/select经常不生效,还是得回到Arguments手动加引号。所以我的实用结论是:普通打开目录优先ArgumentList,/select场景用Arguments加显式引号。
中文路径本身不是问题,因为 .NET 的字符串到CreateProcess/ShellExecuteEx是宽字符传递,全程 UTF-16,不会经过 ANSI 代码页转换。真正会出问题的是那些绕了一圈走外部脚本、批处理或者cmd /c的写法——那才是编码问题的来源。所以别为了“保险”去包一层cmd.exe,那只会把问题变复杂。
3.2 结尾反斜杠与根目录的边界情况
这个问题非常隐蔽:如果你的路径以反斜杠结尾,比如C:\Temp\Reports\,当你手动拼成"C:\Temp\Reports\"传给explorer.exe时,末尾那个\"会被 Windows 的命令行解析规则理解成转义后的引号字符,而不是“引号结束符”。结果就是整个参数边界错乱,运气好打开到错误位置,运气差直接什么都不发生。
处理办法很直接:传参前把尾部多余的分隔符去掉,但要保留根目录的情况。
static string NormalizeForShell(string path) { string full = Path.GetFullPath(path); // 根目录(C:\ 或 /)不能去掉分隔符 if (string.Equals(full, Path.GetPathRoot(full), StringComparison.OrdinalIgnoreCase)) return full; return full.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); }先Path.GetFullPath再做判断很重要,因为它会把相对路径、.、..全部规范化掉,后面的判断才有意义。根目录那个特判不能省,C:\被 Trim 成C:之后,语义就从“C 盘根目录”变成了“C 盘的当前工作目录”,这是完全不同的两个东西。
3.3 路径不存在时到底会发生什么
答案是:不确定。不同 Windows 版本、不同 Shell 状态下,ShellExecuteEx对一个不存在的目录的反应可能是弹一个系统错误框、打开“此电脑”、或者干脆返回失败但不提示。对一个面向用户的程序来说,这三种结果都不能接受。
所以调用前一定自己做校验,而且校验要有明确的产品决策:是直接提示“目录不存在”,还是顺手创建出来?我的做法是给方法加一个createIfMissing参数,默认false,只有在“导出完成后打开目录”这种明确知道该目录应该存在的场景才传true,其他场景一律提示用户。
if (!Directory.Exists(fullPath)) { if (!createIfMissing) return false; Directory.CreateDirectory(fullPath); }这里还有个容易忽略的细节:Directory.Exists对无权限访问的路径返回false,而不是抛异常。所以如果返回false,提示语最好写成“目录不存在或无权访问”,别一口咬定不存在,否则用户会跟你争论“明明就在那儿”。
3.4 相对路径、环境变量与 UNC 路径
用户输入或者配置文件里读出来的路径,很可能是相对路径。相对谁?相对当前进程的工作目录,而工作目录这个东西在托盘程序、服务、快捷方式启动的场景下完全不可控。所以只要路径来自外部,第一件事就是Path.GetFullPath,把它钉死成绝对路径。
路径里带环境变量(%USERPROFILE%\Downloads)也很常见。Environment.ExpandEnvironmentVariables可以展开,但要注意它只处理%VAR%形式,遇到未定义的变量会原样保留,所以要配合Path.GetFullPath使用,或者自己写个检测提示未展开的变量名——这对排查“为什么路径看着对但打不开”特别有用。
UNC 网络路径(\\server\share\dir)本身是可以被资源管理器打开的,但有前提:当前用户有访问凭据,且网络可达。如果程序在打开前先Directory.Exists,遇到网络慢的时候会卡住主线程好几秒。这种情况我的建议是把整个打开动作放到后台线程,先给用户一个“正在打开”的反馈,别让 UI 假死。
3.5 长路径与特殊字符
超过 260 字符的路径在传统 Win32 API 下会失败,虽然新版 .NET 和较新的系统已经放宽,但explorer.exe对\\?\前缀的支持并不好——你把带前缀的路径传给它,它很可能什么都不做。所以对超长路径,我的策略是不去硬碰,直接检测长度并给出明确提示,引导用户换个更浅的目录,而不是试图用各种前缀糊过去。
至于特殊字符,Windows 文件名里不允许出现"、<、>、|、?、*、:(除盘符),所以理论上不存在引号转义问题。真正会出问题的是&、^、%这些在cmd.exe里有特殊含义的字符——只要你全程不经过cmd,它们就是普通字符。又一次印证了上一条:别为了图省事去包cmd /c start。
3.6 权限、会话与前台焦点
这一层最容易被忽略,但它在真实部署里出问题的概率极高。
- 以管理员身份运行的程序:从高完整性级别进程直接调
explorer.exe,新进程会继承高完整性,导致拖拽、右键菜单等行为异常,甚至有的系统上直接不显示。稳妥做法是走UseShellExecute = true的 Shell 委托,让系统自己去处理。 - 服务或计划任务里调用:服务跑在 Session 0,没有交互桌面,打开的资源管理器窗口用户根本看不见,而且可能报错。这类场景正确的做法是记录日志并提供路径复制按钮,让用户自己去打开,而不是硬要弹窗。
- 前台焦点抢占:资源管理器窗口打开后不一定在最前面。想让窗口置前需要调
SetForegroundWindow,但这个 API 有前台锁定限制,被系统拒绝很正常。我的经验是别死磕置前,把提示做清楚比抢焦点更有效。
提示:如果你的程序会在“管理员”和“普通用户”两种身份下都被使用,请务必把打开文件夹的逻辑单独抽出来,在两种身份下各测一遍,别只测自己顺手的那一种。
4. 一套可直接抄的完整实现
前面讲了一堆取舍,这里把它们收拢成一份能直接放进项目的代码。我特意拆了接口,因为这东西不拆接口就没法写单元测试——你总不能指望 CI 机器上弹出一堆资源管理器窗口。
4.1 接口设计与职责边界
接口只暴露两个能力:打开目录、定位文件。返回值用bool表示“请求是否已成功发出”,而不是“窗口是否已经弹出来”——后者我们根本无法可靠观测。
public interface IFolderOpener { bool OpenFolder(string folderPath, bool createIfMissing = false); bool RevealFile(string filePath); }这个语义定义很关键。如果把返回值定义成“是否打开成功”,调用方就会写出一些基于它做分支的代码,而那些分支在explorer.exe复用窗口的情况下根本不可靠。定义成“请求已发出”,调用方就明白失败只可能是参数问题或系统拒绝,逻辑清晰得多。
4.2 核心实现代码
using System; using System.Diagnostics; using System.IO; using System.Runtime.InteropServices; public sealed class ShellFolderOpener : IFolderOpener { public bool OpenFolder(string folderPath, bool createIfMissing = false) { if (string.IsNullOrWhiteSpace(folderPath)) return false; string full; try { full = NormalizeForShell(folderPath); } catch (Exception) { return false; } // 非法字符、路径过长等 if (!Directory.Exists(full)) { if (!createIfMissing) return false; try { Directory.CreateDirectory(full); } catch (Exception) { return false; } // 无权限等 } if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { Process.Start(new ProcessStartInfo { FileName = full, UseShellExecute = true, Verb = "open" })?.Dispose(); return true; } string opener = RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? "open" : "xdg-open"; var psi = new ProcessStartInfo { FileName = opener, UseShellExecute = false, CreateNoWindow = true }; psi.ArgumentList.Add(full); Process.Start(psi)?.Dispose(); return true; } public bool RevealFile(string filePath) { if (string.IsNullOrWhiteSpace(filePath)) return false; string full; try { full = Path.GetFullPath(filePath); } catch (Exception) { return false; } if (!File.Exists(full)) { string dir = Path.GetDirectoryName(full); return !string.IsNullOrEmpty(dir) && OpenFolder(dir); } if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { Process.Start(new ProcessStartInfo { FileName = "explorer.exe", Arguments = $"/select,\"{full}\"", UseShellExecute = true })?.Dispose(); return true; } if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) { Process.Start(new ProcessStartInfo { FileName = "open", Arguments = $"-R \"{full}\"", UseShellExecute = false })?.Dispose(); return true; } // Linux 桌面没有统一的“选中”语义,退化为打开父目录 return OpenFolder(Path.GetDirectoryName(full) ?? full); } private static string NormalizeForShell(string path) { string full = Path.GetFullPath(path); string root = Path.GetPathRoot(full) ?? string.Empty; if (string.Equals(full, root, StringComparison.OrdinalIgnoreCase)) return full; return full.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); } }4.3 关键行逐条说明
NormalizeForShell里先GetFullPath再 Trim,这个顺序不能反。反过来的话,相对的..\结尾会被 Trim 成..,语义就变了,得到的是一个完全不同的目录。
Process.Start(...)?.Dispose()里的空条件运算符不能省。虽然实际上很少返回 null,但Process.Start的返回值在文档里就是可空的,加上这个符号能避免静态分析告警,也顺便告诉读代码的人“我不打算管这个进程”。
UseShellExecute = false在非 Windows 分支是刻意的:xdg-open、open都是真实的可执行文件,走CreateProcess更直接、更快,也避免 Shell 再套一层。而 Windows 分支的两个调用都是true,因为目标要么是文件夹(不是可执行文件),要么需要 Shell 帮忙找explorer.exe在系统目录中的位置。
Verb = "open"这个属性大部分时候可有可无,因为默认动词就是 open。但显式写上有两个好处:一是代码意图明确,二是当路径本身是某种带多动词关联的 Shell 对象(比如库、收藏夹)时,能确保走的是打开而不是别的动作。
4.4 单元测试该怎么写
有了接口,测试就简单了:写一个RecordingFolderOpener记录被调用的参数,然后断言路径规范化结果和异常分支。
[Fact] public void OpenFolder_TrimsTrailingSlash() { var fake = new RecordingFolderOpener(); fake.OpenFolder(@"C:\Temp\Reports\", createIfMissing: true); Assert.Equal(@"C:\Temp\Reports", fake.LastOpenedPath); } [Fact] public void OpenFolder_ReturnsFalse_WhenWhitespace() { var opener = new ShellFolderOpener(); Assert.False(opener.OpenFolder(" ")); }真正调用ShellFolderOpener的测试只覆盖“参数非法返回 false”这类不会真的弹窗的路径。涉及系统调用的部分靠人工冒烟测试,别硬塞进 CI,否则一台机器上会堆满资源管理器窗口,跑一次崩一次。
5. 五个真实场景的落地拆解
代码写好了,还得看它在具体场景里怎么用。下面这些场景都是我在项目里实际遇到的,处理方式也经过了几轮调整。
5.1 导出完成后打开目录
最典型的用法:用户点了“导出报表”,进度条走完,弹一个提示框问要不要打开目录。这里有两个决策点。
第一个是要不要自动打开。我的经验是默认不自动打开,给个按钮让用户自己决定。自动弹资源管理器会打断用户手里的其他操作,尤其在批量导出场景下会连着弹好几次,很烦。
第二个是目录该由谁创建。如果导出逻辑本身已经写出了文件,目录必然存在,这时候传createIfMissing: false更安全——如果目录居然不存在,说明导出逻辑出了别的问题,此时静默创建空目录反而掩盖了 bug。只有在“用户手动指定了一个还没建好的输出目录”时才传true。
5.2 托盘程序里的调用
托盘程序的特点是窗口隐藏,调用时没有明显的用户上下文。这时候如果Process.Start抛异常,用户完全看不到任何反馈,只会觉得“点了没反应”。
处理办法是把异常全部捕获,然后用托盘气泡或者一个轻量提示窗口告知失败原因。同时要注意托盘程序的当前工作目录往往是安装目录或者系统目录,所以所有路径都必须是绝对路径,这一点前面已经强调过,在托盘场景下尤其致命。
另外,托盘程序常常以自启动方式运行,登录后立刻启动,此时 Shell 可能还没完全就绪。我的做法是在启动后延迟一小段时间再允许调用这类 Shell 操作,避开系统登录时的那个混乱窗口期。
5.3 服务或计划任务中的调用
这是唯一一个我认为不应该实现的场景。服务跑在没有交互桌面的会话里,它调用任何 Shell API 都不会把窗口呈现给用户。你在开发机上用“以管理员身份运行”的调试模式测可能看不出问题,因为那还是在你自己的桌面会话里,但一旦装成真服务就完全失效。
正确做法是把“打开目录”这个能力放在客户端 UI 里,后台只负责把目录路径写进通知、日志或者一个可复制的文本框。如果确实需要提醒用户,用系统通知或者邮件带路径,让用户自己去访问。硬在服务里调 Shell,只会给自己埋一个“在我这儿好好的”的长期争议。
5.4 WPF / MVVM 中的绑定
在 MVVM 结构里,View 不该直接调Process.Start。常规做法是把IFolderOpener注入到 ViewModel,通过命令触发。
public class ExportViewModel { private readonly IFolderOpener _opener; public string LastOutputFolder { get; private set; } public ICommand OpenFolderCommand { get; } public ExportViewModel(IFolderOpener opener) { _opener = opener; OpenFolderCommand = new RelayCommand( _ => _opener.OpenFolder(LastOutputFolder), _ => !string.IsNullOrEmpty(LastOutputFolder)); } }这样做的直接收益是那个CanExecute判断:路径为空时按钮自动置灰,用户不会点了以后得到一个“什么都没发生”的结果。这个细节看起来小,但它把“调用失败”从运行时问题提前变成了编译期可见的状态约束。
5.5 批量打开多个目录的取舍
有时候需求会变成“把这批文件所在的目录都打开”。这里必须提醒一句:如果你的方案是explorer.exe每个目录一次,那么用户的任务栏会被瞬间刷满窗口,尤其在 20 个以上的时候,基本等于给用户制造麻烦。
更合理的做法是合并——如果多个文件在同一个目录,只打开一次;如果分布在不同目录,最多只打开前 N 个并提示用户还剩多少。这种“帮用户想一步”的设计,比把功能做全更能体现实用价值。
6. 常见问题与排查速查表
下面这张表是我这些年被问过最多的几类问题,配上直接可用的排查路径。建议按“现象→可能原因→处理办法”的顺序对着看。
| 现象 | 最可能的原因 | 处理办法 |
|---|---|---|
抛Win32Exception: 找不到文件 | UseShellExecute为false | 显式设置UseShellExecute = true |
| 迁移到 .NET 6+ 后失效 | 默认值变化 | 同上,别依赖默认值 |
| 含空格路径打开错位置 | Arguments未加引号 | 手动加双引号,或改用ArgumentList |
| 路径末尾反斜杠导致失败 | 引号被转义 | 去掉尾部反斜杠,保留根目录特判 |
| 点击后毫无反应 | 服务会话或后台无桌面 | 改由 UI 层调用,日志里给路径 |
| 目录明明存在却提示不存在 | 无访问权限 | 提示语加“或无权访问” |
| 网络路径卡住 UI | Directory.Exists同步等待 | 放到后台线程,加加载反馈 |
/select不生效 | 参数拼接方式不对 | 用Arguments加显式引号 |
| 资源管理器窗口不在最前 | 前台锁定限制 | 不强求置前,改用明显提示 |
| 判断成功失败不可靠 | 用ExitCode判断 | 改为“请求已发出”语义 |
| 长路径无响应 | 超 260 字符 | 检测长度并明确提示用户 |
| 中文路径变乱码 | 中间经过cmd或外部脚本 | 全程走托管 API,别套cmd |
| macOS 上无效 | 用了explorer.exe | 按平台分发open/-R |
| Linux 上调用后卡住 | xdg-open阻塞等待 | 不等待退出,异步发起 |
表格之外再补三个不容易归类、但特别值得单独说的经验。
第一个是关于日志。打开文件夹失败时,日志里一定要打出“规范化之后的完整路径”,而不是用户输入的原始字符串。因为九成的排查都卡在“我以为传进去的是 A,实际传进去的是 B”。把Path.GetFullPath之后的结果打进日志,能省掉大量来回沟通。
第二个是关于异常类型。Process.Start在这个场景下最常抛的是Win32Exception(系统层面拒绝,比如找不到关联程序、权限不足)和InvalidOperationException(FileName为空)。捕获的时候分开处理,前者给用户看“系统拒绝打开该目录”,后者就是代码 bug,该往上报就往上报。别用一个catch (Exception)全吞了,那样线上出问题连方向都没有。
第三个是关于测试环境的伪装。有人为了跑自动化测试,会写个假的explorer.exe放进 PATH 前面来拦截调用。这个做法在 Windows 上不可靠,因为系统会用完整路径去找资源管理器,不完全走 PATH 搜索。老老实实抽接口、注入假实现,是唯一稳的路。
7. 几条踩过坑之后的个人建议
写到这里,我最想说的其实是:这类“一行代码需求”的价值,从来不在那一行代码本身,而在于你有没有把它当成一个需要设计的边界来看。我在实际项目里最后沉淀下来的做法,是把这类系统交互全部收进一个小模块,对外只暴露“打开目录”“定位文件”两个语义化方法,内部按平台分发、做路径规范化、做存在性校验,异常全部转成返回值或明确提示。这样上层业务代码里就不会散落着Process.Start,将来要换成别的实现、要加埋点、要改提示文案,都只在一个地方动。
如果你现在正准备实现这个功能,我给的最短路径建议是:先按第 4 节的代码建一个类,跑通 Windows 下的正常路径;然后立刻拿一个带空格的中文路径、一个结尾带反斜杠的路径、一个不存在的路径各试一遍——这三个用例能帮你提前发现八成问题。剩下两成,等真的上了生产环境,用户的操作习惯会替你把它们找出来。