获取 .NET 中的所有“特殊文件夹”
检索新用户文件夹(如下载、已保存游戏或搜索)的路径
引言
自 Windows 98 起,用户在其主目录中就有特殊的文件夹,称为“我的文档”、“我的音乐”、“我的图片”或“我的视频”,正式名称为“已知文件夹”。这些文件夹在 XP 之前几乎没有变化,并且可以通过 .NET 轻松检索它们的路径:调用 System.Environment.GetFolderPath 函数并传入 System.Environment.SpecialFolder 的 enum
值。
然而,Windows Vista 之后引入的新文件夹并未列在 SpecialFolder enum
中,也无法通过这种方式检索。 .NET 并未更新以反映用户主目录(以及其他几个“特殊”文件夹)的添加,许多人(包括我)尝试了错误且 hacky 的方法来查找其他文件夹的路径。本文详细介绍了为什么这些尝试是错误的,并最终提出了获取新的“特殊”文件夹路径的正确方法。
.NET 8 将添加对这些文件夹的支持
在引入这些文件夹 15 年后,通过扩展上述 SpecialFolder enum
来为(跨平台)查询它们添加支持正在考虑用于即将发布的 .NET 8。欢迎查看 我在 dotnet GitHub 存储库上的 API 提案,通过点赞来表示支持,或者查看具体实现方式。
背景
我通常会看到以下两种**错误**的尝试来检索例如下载文件夹的路径。
**错误**方法 #1:将文件夹名称附加到用户主目录
检索下载文件夹路径看似最简单的方法是将特殊文件夹名称附加到用户主路径(默认情况下特殊文件夹在此处找到)。
// This returns something like C:\Users\Username:
string userRoot = System.Environment.GetEnvironmentVariable("USERPROFILE");
// Now let's get C:\Users\Username\Downloads:
string downloadFolder = Path.Combine(userRoot, "Downloads");
由于 Windows Vista 及更高版本在内部使用英文文件夹名称,而在文件资源管理器中显示的名称只是它们的本地化虚拟版本,这**应该**可以工作,不是吗?
不。如果用户重定向了下载文件夹的路径,此解决方案将不起作用。可以更改下载文件夹属性中的路径,并简单地选择另一个位置。它可能根本不在用户根目录下,它可能在任何地方——例如,我的所有文件夹都在 D: 驱动器上。
**错误**方法 #2:使用“Shell Folders”注册表项
在下一次尝试中,开发人员在注册表中搜索包含重定向文件夹路径的项。Windows **必须**将此信息存储在某处,对吧?
最终,他们找到了“HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Explorer\Shell Folders”。该项看起来非常有希望,因为它包含了所有用户文件夹的路径,即使是重定向的路径(正如您所见,我的所有路径都在D:驱动器上)。
但是等等,它还包含一个带有名称“!Do not use this registry key”和值“Use the SHGetFolderPath
or SHGetKnownFolderPath
function instead”的令人不安的项。此项由 Microsoft 开发者 Raymond Chen 添加,他预感到人们将来会继续滥用此项来获取文件夹路径。他写了关于此的文章 在此 和 在此。
总而言之,使用此项仅在 Windows 95 Beta 版本中可以接受,之后则不行。Microsoft 意识到它不够灵活,无法保留有关 Shell 文件夹的所有信息,也无法尊重漫游用户配置文件等等。因此,创建了一个 WinAPI
函数,但该项被“临时”留在注册表中,以免破坏在 RTM 版本中设计的大约四个程序。之后从未删除,因为更多的开发人员发现了此项并开始依赖它,现在删除它将导致比最初的四个程序更多的不兼容性。
所以,不要使用那里的值。它们不能保证正确,甚至不保证存在,而且 Raymond Chen 可能会因为你这样做而讨厌你——而且你最好不要惹上“Microsoft 的查克·诺里斯”。我曾经也掉入过“Shell Folders”项的陷阱,并在 StackOverflow 回答中将其发布为“解决方案”(之后我已经更新了)。
正确解决方案
作为优秀开发者,我们遵循该项的建议,并 P/Invoke 了 SHGetKnownFolderPath 函数。由于 P/Invoke 可能有点棘手,让我们一起分解它。
首先,我们定义了我们最终想要如何检索路径。最简单的解决方案应该是一个接受我们自己的、扩展的“特殊”文件夹 enum
参数的 static
方法。
string downloadsFolder = KnownFolders.GetPath(KnownFolder.Downloads);
enum KnownFolder
{
Documents,
Downloads,
Music,
Pictures,
SavedGames,
// ...
}
static class KnownFolders
{
public static string GetPath(KnownFolder folder)
{
// TODO: Implement
}
}
理解原生方法
接下来,我们需要理解 SHGetKnownFolderPath
方法,如 Microsoft 文档所述。我在下面引用了最重要的部分。
HRESULT SHGetKnownFolderPath(
[in] REFKNOWNFOLDERID rfid,
[in] DWORD dwFlags,
[in, optional] HANDLE hToken,
[out] PWSTR *ppszPath
);
[in] REFKNOWNFOLDERID rfid
:*“对标识该文件夹的 KNOWNFOLDERID 的引用。”*KNOWNFOLDERID
实际上是一个 GUID。可用的 GUID 在此处。我们需要将它们映射到我们的KnownFolder
enum
值。为了简单起见,我们将只使用其中的一些,并在我们的static
类中使用一个字典。[in] DWORD dwFlags
:*“指定特殊检索选项的标志。此值可以为 0;否则,为 KNOWN_FOLDER_FLAG 值的一个或多个。”*为了简单起见,我们将使用
0
,尽管您可以根据需要调整和优化行为。如果不需要确保文件夹在不存在时创建,KF_FLAG_DONT_VERIFY
可能很有用,如果文件夹被重定向到网络驱动器,此操作可能会很慢。[in, optional] HANDLE hToken
:*“如果此参数为 NULL,这是最常见的用法,则该函数请求当前用户的已知文件夹。”*为了简单起见,我们只关心当前执行用户的文件夹路径。您可以传递
System.Security.Principal.WindowsIdentity.AccessToken
的句柄来模拟其他用户。[out] PWSTR *ppszPath
:*“当此方法返回时,包含指向指定已知文件夹路径的以 null 结尾的 Unicode 字符串的指针的地址。* *调用进程负责在不再需要此资源时通过调用 CoTaskMemFree 来释放它,无论 SHGetKnownFolderPath 是否成功。”*这将最终返回我们感兴趣的路径。
在 C# 中调用原生方法
要在 C# 中调用此方法,可以使用以下导入。下面的列表解释了如何确定这一点。如果您不关心 P/Invoke 的底层工作原理,可以跳过它。
[DllImport("shell32", CharSet = CharSet.Unicode,
ExactSpelling = true, PreserveSig = false)]
private static extern string SHGetKnownFolderPath(
[MarshalAs(UnmanagedType.LPStruct)] Guid rfid,
uint dwFlags, nint hToken = default);
- 文档 指出 该方法存在于 Shell32.dll 模块中,因此我们将此文件名提供给
DllImport
属性(您不必指定文件扩展名)。 - 由于返回给我们的路径是一个
[out]
参数,它是一个 Unicode (UTF16) 字符串,我们确保将 C# 的默认CharSet
覆盖为CharSet.Unicode
。这允许我们将PWSTR *ppszPath
直接转换为string
,封送器会释放为其分配的原生内存——请注意,这仅有效,因为封送器假定此类内存块之前已使用CoTaskMemAlloc
(WinAPI 方法所做的)分配,并始终为它们调用CoTaskMemFree
。 - 我们提供
ExactSpelling = true
,因为该方法没有A
或W
版本,并阻止运行时搜索这些版本。 - 使用
PreserveSig = false
允许我们 将方法返回的任何HRESULT
失败代码转换为 .NET 异常。它还使方法返回void
,但我们可以将其更改为返回最后一个[out]
参数,在本例中为我们的string
。 - 我们可以将
REFKNOWNFOLDERID
GUID
映射到ref Guid
,但为了不处理引用,我们可以在提供“按值”的Guid
时指示封送器为我们执行此操作,使用[MarshalAs(UnmanagedType.LPStruct)]
。 - 下一个参数是
DWORD
,在这种情况下,它可以映射到由可用标志组成的 .NETenum
,但由于我们不关心它们,因此我们只使用一个原始的uint
。 HANDLE
的大小是原生整数的大小,因此我们使用 C# 9 的新nint
—— 或者,您仍然可以使用IntPtr
。该参数是可选的,即使我们不需要这样做,我们也使用= default
来表示。
整合
填写我们 static
类的实现,我们将得到以下结果。
using System.Runtime.InteropServices;
static class KnownFolders
{
private static readonly Dictionary<KnownFolder, Guid> _knownFolderGuids = new()
{
[KnownFolder.Documents] = new("FDD39AD0-238F-46AF-ADB4-6C85480369C7"),
[KnownFolder.Downloads] = new("374DE290-123F-4565-9164-39C4925E467B"),
[KnownFolder.Music] = new("4BD8D571-6D19-48D3-BE97-422220080E43"),
[KnownFolder.Pictures] = new("33E28130-4E1E-4676-835A-98395C3BC3BB"),
[KnownFolder.SavedGames] = new("4C5C32FF-BB9D-43B0-B5B4-2D72E54EAAA4"),
};
public static string GetPath(KnownFolder folder)
{
return SHGetKnownFolderPath(_knownFolderGuids[folder], 0);
}
[DllImport("shell32", CharSet = CharSet.Unicode,
ExactSpelling = true, PreserveSig = false)]
private static extern string SHGetKnownFolderPath(
[MarshalAs(UnmanagedType.LPStruct)]
Guid rfid, uint dwFlags, nint hToken = default);
}
Using the Code
我们已经使其非常容易打印通过我们 enum
公开的所有已知文件夹路径。
foreach (KnownFolder knownFolder in Enum.GetValues<KnownFolder>())
{
try
{
Console.Write($"{knownFolder}: ");
Console.WriteLine(KnownFolders.GetPath(knownFolder));
}
catch (Exception ex)
{
Console.WriteLine($"<Exception> {ex.Message}");
}
Console.WriteLine();
}
需要一个非常宽松的 try catch
,因为 P/Invoke 将 HRESULT
转换为一系列广泛的异常。如果文件夹在系统上不可用,您可能会遇到 FileNotFoundException
,但这通常不会发生在用户文件夹上。
关注点
我们没有触及所有相关的已知文件夹功能,并提到了某些可能的优化潜力。
- 将剩余的文件夹添加到我们的
enum
。 - 将异常封装在自定义
KnownFolderException
中,以便更具体地捕获它们。 - 使用属性而不是
dictionary
将 GUID 分配给每个KnownFolder
enum
值并通过反射检索它们。 - 使用
SHSetKnownFolderPath
更改已知文件夹的路径。 - 通过传递标识访问令牌句柄来查询其他用户的路径。
- 通过
SHGetKnownFolderItem
检索IShellItem
COM 对象实例,并使用其GetDisplayName
方法提取用户友好的文件夹名称。 - 为不支持
SHGetKnownFolderPath
函数的 Windows XP 操作系统及更早版本添加兼容性,创建能够为它们检索路径的包装器,可能只需回退到System.Environment.GetFolderPath()
。 - 使用 CsWin32 从 WinAPI 元数据自动生成 P/Invoke 签名。请注意,其签名使用原始的
unsafe
语义,这与本文确定的签名不同。
如果您喜欢这篇文章,请随时在 StackOverflow 上为我的回答投票。
NuGet包
我创建了一个 NuGet 包,提供了前面段落中讨论的大部分功能。更多信息请参见其项目站点。请注意,由于增加了功能,API 略有不同,请查看 README 以获取更多详细信息。
历史
- 2022 年 4 月 19 日 - 重写代码示例和大部分段落
- 2018 年 12 月 18 日 - 更新了 NuGet 项目站点链接
- 2018 年 6 月 11 日 - 释放字符串内存,更新了示例,重写了一些句子
- 2016 年 3 月 21 日 - 添加了关于新创建的 NuGet 包的说明
- 2015 年 2 月 20 日 - 首次发布