Windows 属性系统
在 Shell 中访问 Windows 属性系统
引言
Windows 属性系统[^]提供了用于访问引用 Windows 操作系统各个区域的元数据的通用接口。这些区域可能包括设备、窗口、文件系统等。本文档针对 Windows Shell 中可用的属性,但这里的信息可以很容易地重新用于系统的任何其他部分。该系统最容易识别的部分可能是当您在文件的上下文菜单中选择“属性”时 - 在这些页面中显示的元数据都属于*Windows 属性系统*。该系统的优点在于它允许您编辑多种文件格式的元数据,而无需了解该格式。例如,有一些属性可以从音频和视频文件的*ID3*标签、照片中的*EXIF*数据、文档(无论是开放 XML 还是专有格式)中的创作数据中读取/写入。
此类库中的代码封装了内部接口和函数,而不是直接公开它们。我这样做有两个原因;首先,接口和函数的声明对于 .NET 应用程序来说并不太友好,第二个原因是,在研究和编写此文章的过程中,我发现该系统中有许多错误,我设法通过代码规避了它们。
背景
我目前正在开发另一个涉及保存文件的类库,并且我希望能够在文件保存时收集和设置有关该文件的适当元数据。*属性*窗口仅在文件写入后可用,我觉得这就像将主要数据收集过程推迟到清理阶段。我觉得最好让代码能够提示用户在保存过程之前或期间,甚至可能在没有用户交互的情况下自行设置部分元数据。
幕后
有两个基本对象用于属性的标识和值,然后是三个主要对象,它们提供对系统的访问以检索和/或设置属性。这些对象的名称反映了*Windows 属性系统*公开的接口和结构,但为了在 .NET 环境中方便使用而进行了整理。我将逐一介绍这些对象。
在这个库中,我做的一件事是承担了结构和接口的大部分封送工作。由于封送的复杂性(也许还有系统中的错误 - 我不知道),我发现自动封送过程并不总是能正确处理,并且在它期望封送发生的方式上不一致。例如,在传递 Guid
的引用时,某些方法坚持使用
[MarshalAs(UnmanagedType.Struct)] ref Guid riid
而其他方法则喜欢
[MarshalAs(UnmanagedType.LPStruct)] Guid riid
我发现,如果您使用了错误的封送器,您要么会收到严重的*内存访问异常*,要么得到一些非常奇怪的结果(其中一个调用甚至改变了 Guid
的值)。因此,我创建了自己的 GUID
对象,自己将值封送到非托管内存中 - 这似乎解决了问题。PROPERTYKEY
和 PROPVARIANT
结构也是如此,后者非常类似于常规的 VARIANT
,但又足够不同,以至于如果它被封送到一个对象,则可能会引发异常。因此,大多数时候,当您查看代码中的函数或方法声明,其中需要 PROPVARIANT, REFIID, PROPERTYKEY
或 Interface
对象,无论是 [In]
还是 [Out]
,您都会看到传递了 IntPtr
。我会在需要时使用 Marshal.CoTaskMemAlloc
为 IntPtr
分配内存,并在可能不经常重复使用的情况下立即释放它,或者在 PropVariant
和 PropertyKey
对象(这些对象在派对上像五分之一的杰克一样被传递)的 Dispose
方法中释放它。在需要将 NULL
引用传递给方法或函数时,这也使事情变得容易得多。
在我封装 COM 接口指针的地方,我确保指针完全保留在包含类中,我从相关的 API 调用中获取这些接口的指针,作为指向 IUnknown
接口的 IntPtr
。然后,我使用*.NET*Marshal
类将该指针转换为引用我所需接口的唯一对象。最后,我释放 IntPtr
中的原始 IUnknown
。使用此模式,我可以在类的 Dispose
方法期间安全地对接口引用调用 Marshal.FinalReleaseComObject
,确保我释放了对 COM 对象的所有引用。
我遇到的一件有趣的事情是 - 当我对 pUnk 指针进行释放时,检查返回值总是有一个值。我希望这只是接口指针为它实现的每个接口添加引用 - 否则,我怀疑可能存在某种泄漏。运气好的话,FinalRealeaseComObject
调用会获取所有这些引用。
1您可能会注意到,我没有在此库中包含一个接口,那就是 IPropertySystem
。这是因为它已损坏。调用此接口定义的任何方法都会不可避免地生成严重的*内存访问异常*。即使创建 COM CoClass
并调用其上的方法,也会生成异常。我认为 COM 类的 Vtable
以某种方式出了问题,而间接访问此对象的 API 调用之所以能正常工作,是因为它们可以看到完整的本机类和相关的 Vtable
。无论如何,有足够多的 API 函数可用,可以使类库不受阻碍地运行。
如果任何内存由*属性系统*分配,则使用 CoTaskMemory
分配例程(而不是全局堆)。此类库管理所有这些,并在需要时使用 Marshal.FreeCoTaskMem
释放此内存。此库的用户不必担心这一点,但如果有人需要以任何方式修改这些类,则应牢记此信息。为了保持一致性,如果库不得不分配非托管内存,它会使用相同的分配方法。
Using the Code
要查看和/或修改特定文件类型的*Windows 属性*,需要执行两个步骤。第一步是识别适用于该特定文件类型的属性,第二步是为单个文件检索和/或设置这些属性。第一部分由 PropertyDescriptionList
处理,顾名思义,它是一个包含适用于情况的 PropertyDescription
的列表。如果您已经知道您要查找的文件属性,您可以通过将由这些属性组成的字符串传递给构造函数来创建自己的 PropertyDescriptionList
。此字符串的格式为“prop:<property1>;<property2>;....;<propertyN>
”,其中 <property1>
到 <propertyN>
是属性的规范名称(例如 System.Title
)。
但是,如果您需要发现特定文件类型可用的属性,您可以从正确类型的文件或仅通过文件扩展名构建 PropertyDescriptionList
。当使用构造函数 PropertyDescriptionList(string path, PropertyKey propListKey)
时,如果您使用现有文件,它将从该文件的处理程序生成 PropertyDescriptionList
。如果文件不存在,后台将创建一个带有指定扩展名的“Fake”文件。然后,它将使用该假项目的处理程序加载 PropertyDescriptionList
。有关创建假文件的详细信息,请参阅我之前的文章 When a File is not a File[^]。此构造函数的第二个参数是 System.PropList[^] 属性之一的 PropertyKey
。此键指示属性列表的最终用法。其中最常见的是 System.PropList.FullDetails
列表,它获取*Windows 属性对话框*的*详细信息*选项卡中显示的属性。但是,如果您想查看/编辑用于 Windows 搜索的属性,您可以选择 System.PropList.ContentViewModeForSearch
,或者如果您正在玩转文件的*InfoTip*,则选择 System.PropList.InfoTip
键。
现在可能是时候提及 PropertyKey
类中包含一个名为 Keys
的 static
IReadOnlyDictionary
属性了。此字典加载了系统中注册的每个 PropertyKey
(在我的 Windows 10 桌面上有 1531 个),并以其规范名称索引。因此,您无需每次想要特定 PropertyKey
时都创建一个新的 PropertyKey
,而是可以从该字典中引用它。例如,PropertyKey.Keys["System.PropList.FullDetails"]
返回规范名称 System.PropList.FullDetails
的 PropertyKey
对象。我习惯在模块顶部输入一个 using static WinProps.PropertyKey;
语句,然后简单地将这些项引用为 Keys[name]
。
一旦您拥有了所需的属性的 PropertyDescriptionList
,就可以确定要读取和/或设置这些属性中的哪些。您可以通过创建 PropertyStore
对象来做到这一点,并将您希望该存储所属的文件传递给它。您还可以包含您感兴趣的特定属性的键。如果您不包含键,系统将尝试检索与该文件相关联的、符合 PropertyStore.GetFlags
中指定的条件的所有属性。请注意,如果 PropertyStore
无法检索符合该标志的所有属性,则构造函数将失败,并且 PropertyStore
将不会被创建。
好的 - 快速举例说明如何将这一切结合起来。我们将以一家当地商店的投诉部门为例。收到投诉后,会进行调查,并生成一份文件通知投诉人调查结果。在进行调查时,了解该项目以前是否受到过投诉以及调查结果是什么将会很有帮助。为了协助这一点,我们将设置一些可以在 Windows Explorer 的列中显示的属性。我们还将添加一些 System.Keyword
属性,允许*Windows 搜索*快速定位相关文档。
class Complaint {
string Item { get; set; }
string Employee { get; set; }
string Customer { get; set; }
string Resolution { get; set; }
//...
string GenerateDocument() {
//...
Save();
return fileName;
}
void SetProperties(string fileName) {
//...
}
考虑上述类将完成工作。员工填写报告并单击*处理*。生成并保存信函,然后调用 SetProperties
来将元数据附加到文档。首先,我们获取我们感兴趣的属性的 PropertyDescriptionList
。
private static PropertyKey[] _complaintProperties = {
PropertyKey.Keys["System.Author"],
PropertyKey.Keys["System.Title"],
PropertyKey.Keys["System.Subject"],
PropertyKey.Keys["System.Comments"],
PropertyKey.Keys["System.Keywords"]
}
void SetProperties(string fileName) {
PropertyDescriptionList properties = new PropertyDescriptionList(_complaintProperties);
此 PropertyDescriptionList
中的 PropertyDescription
定义了将分配给文件的 PropertyValue
的类型和结构。对于此特定场景,此步骤可能不是必需的。但是,建议这样做以允许可能的扩展。
接下来是获取新创建文件的 PropertyStore
。因为我们知道哪些属性是感兴趣的,所以我们只会为这些特定属性请求处理程序。此外,由于我们将要更新,我们需要将 .PropertyStore.GetFlags.ReadWrite
标志传递给构造函数。
PropertyStore store = new PropertyStore
(fileName, _complaintProperties, PropertyStore.GetFlags.ReadWrite);
现在,对于我们传递的每个值,我们需要创建一个 PropVariant
来存储该值。请注意,如果属性被定义为多值(PropertyDescription
中的字段),那么该值需要是一个向量,即使只有一个值被插入。如果 PropVariant
的类型不正确,系统会尝试强制转换值,但依赖它真的很不明智。作者似乎为他们测试过的少数项目做对了,但并非所有项目都对。您最好的办法是首先正确设置 PropVariant
。
PropVariant vAuthor = properties[_complaintProperties[0]].TypeFlags.IsMultiValued ?
PropVariant.FromStringAsVector(Employee) : new PropVariant(Employee);
PropVariant vTitle = new PropVariant(string.Format("{0} Complaint", Item));
PropVariant vSubject = new PropVariant(string.Format
("Resolved complaint from {0} re {1}", Customer, Item));
PropVariant vComments = new PropVariant(Resolution);
PropVariant vKeyWords = PropVariant.FromStringAsVector
(string.Format({0};Complaint;Resolved;{1};{2}", Item, Customer, Resolution));
最后,我们将这些属性保存到文件中。一个重要的一点是,在设置每个值之前都会检查 IsEditable
标志。这一点很重要,因为每个属性处理程序都决定哪些属性可以更新,哪些不能。第三方应用程序可能会为与操作系统提供的不同的文件类型安装属性处理程序,因此不要假设您知道某个属性是可编辑的。如果您尝试写入处理程序认为应该由其内部设置的属性,您将收到*拒绝访问*异常。
if (store.IsEditable(_complaintProperties[0])
store.SetValue(_complaintProperties[0], vAuthor);
if (store.IsEditable(_complaintProperties[1])
store.SetValue(_complaintProperties[1], vTitle);
if (store.IsEditable(_complaintProperties[2])
store.SetValue(_complaintProperties[2], vSubject);
if (store.IsEditable(_complaintProperties[3])
store.SetValue(_complaintProperties[3], vComments);
if (store.IsEditable(_complaintProperties[4])
store.SetValue(_complaintProperties[4], vKeywords);
store.Commit();
这个小代码段还需要做一些额外的工作,例如确保所有值都不包含分号,但本文档不是关于完整的编程实践 - 它只关于*Windows 属性系统*。
本文档附带的示例比上面描述的要复杂一些,并且允许设置各种格式图像的属性。
参考
本文档附带了一个参考帮助文件,记录了此库公开的所有对象。下面是一个快速摘要,以及一些背景和额外的有用信息,以帮助处理这些对象。
该库公开了七个主要类
PropertyKey
- 用于标识单个属性PropVariant
- 一个类似于variant
的对象,包含属性的值PropertyEnumeration
- 定义属性可能包含的枚举值PropertyDescription
- 定义属性的显示方式及其包含的信息类型PropertyDescriptionList
-PropertyDescriptions
的集合PropertyStore
- 应用于项(通常是文件)的实际属性ShellItem
- 表示 Shell 中的一个对象。封装了IShellItem
和IShellItem2
接口。
属性由 PropertyKey
标识。在内部,PropertyKey
包含一个 Guid
和一个数字 Pid
。我认为最初 Guid
是用来标识相关属性组的,而 Pid
则标识该组中的单个属性。然而,根据系统中一些值来看,这种理想似乎已经破灭,因此实际上不能假定 PropertyKey
的实际内容。但是,每个 PropertyKey
还有一个规范名称,这些名称确实具有某种结构。它们通常由两个或三个标识符组成,用句点(.)分隔。第一个标识符始终是*System*,最后一个标识符则标识实际属性。如果属性直接属于 System 组,则这两个标识符构成了完整的规范名称。如果属性属于其他分组,则该名称会出现在单个标识符之前。例如,*System.Title* 和 *System.Author* 属于 *System* 组,而 *System.Image.BitDepth* 和 *System.Image.Dimensions* 属于 *Image* 组。
PropVariant
有点奇怪。像 Variant
一样,它可以包含您能想到的几乎任何值类型,并且其内部结构实际上与 COM 和 Windows API 系统中使用的 VARIANT 结构非常相似。但是,它足够不同,以至于任何尝试使用从 Variant
到 object
的内部 .NET 封送处理 PropVariant
都将彻底失败。它主要区别在于处理数组的方式 - 它不像 COM 标准那样使用指向 SafeArray
的指针,而是具有一个称为*计数数组*或*CA* 的内部结构。PROPVARIANT
的 CA_xxxx 成员(以数组类型命名)包含元素计数器以及指向元素的指针。任何 PropVariant
,如果其 VarType
成员包含 VT_VECTOR
标志,则其数据将是这些结构之一。
PropVariant
的另一个奇怪之处在于,许多(但不是全部)处理它们的函数能够将标量值视为单元素向量。由于函数如何进行这种操作似乎没有规律,因此该库已完全区分了标量操作和向量操作,因此尝试从标量 PropVariant
获取元素 0
将始终引发越界异常。
属性系统使用的枚举类型比普通的枚举类型变量要复杂得多,因为每个枚举名称都可以有一个值范围。一个很好的例子是 System.Rating[^] 属性,其实际值在 1 到 99 之间。然而,显示时,此属性用星号表示。其工作方式是,每个枚举值都包含一个 minValue
和一个 setValue
字段。如果*评级*属性的值大于或等于 minValue
,但小于下一个枚举成员的 minValue
,则将其显示为该枚举成员的文本。最后一个枚举成员的 setValue
字段包含该属性可以包含的最大值。如果用户从枚举成员之一中选择评级,则该属性将分配该枚举成员的 setValue
字段中的值。如果这一切看起来有点复杂,那么请查看上面链接的*System.Rating*页面的底部表格,它会变得更清楚。
PropertyDescriptions
和 PropertyDescriptionLists
主要由文件类型在注册时在注册表中建立和保存。这些类型通常会定义一个*属性处理程序*,该处理程序负责管理与特定文件类型相关的属性的保存和检索。本文档不探讨编写属性处理程序,因为它本身就是一个复杂的主题,而且实际上不应该在托管代码中进行。然而,这些列表是预先建立的事实意味着您可以在没有文件的情况下获取它们。这就是我最近写过的Fake File[^]发挥作用的地方。通过创建一个假文件,并要求该项目提供 PropertyDescriptionList
,它将查找所有相关的注册表项并为您提供列表。然而,这种方法对于 PropertyStore
来说效果不佳,因为 PropertyStore
实际上需要打开文件来读取和/或写入属性值。因此,创建 PropertyStore
通常会导致*文件未找到*异常,除非您传递 GetFlags.BestEffort
标志。即便如此,以这种方式创建的 PropertyStore
也只会为您提供由系统处理的属性,并且始终是只读的。任何需要实际文件的内容都不会出现在该存储中。
ShellItem
类表示*Windows Shell*中的一个项,通常是文件或文件夹。虽然这更多地与*Shell*编程相关而不是*Windows 属性系统*,但该对象确实构成了这两个 API 领域之间的链接。此类封装了 IShellItem 和 IShellItem 2 接口,并允许直接(只读)访问与该项关联的许多属性。
1勘误
我终于弄清楚了为什么 IPropertySystem
接口无法正常工作,而且是我的错。我的假设是 VTable 被搞砸了,这是正确的,但这是因为这些 shell 接口**不**实现 IDispatch
。因此,在声明接口为 ComImport
时,必须将 InterfaceTypeAttribute
设置为 ComInterfaceType.InterfaceIsIUnknown
。如果省略此项,则默认值为 ComInterfaceType.InterfaceIsDual
,假定存在 IDispatch
方法,并相应地调整 VTable。然而,因为这个接口实际上并不存在于 VTable 中,所以一切都变得混乱不堪。
我目前不打算更改库来包含这个,因为全局 API 函数正在工作 - 我可能会在以后进行。
历史
2016 年 11 月 29 日 - 初始发布
2016 年 12 月 8 日 - 添加了 ShellItem
类。IShellItem
和 IShellItem2
接口在*Windows 属性系统*的引用中被广泛使用,我决定将此类添加到库中。
2017 年 1 月 6 日 - 添加了勘误部分,描述了我犯的错误,导致 IPropertyStore
接口无法工作。