自定义 Visual Studio .NET 包:IDE 内完全集成的文档窗口






4.72/5 (18投票s)
2003 年 12 月 21 日
13分钟阅读

64966

1114
在 Visual Studio IDE 中创建完全集成的文档窗口。
引言
Visual Studio .NET 的“可扩展性”中最令人恼火且明显缺失的功能是无法将自定义窗口插入 IDE。显然,可扩展性对象模型被故意阉割了,以维护微软销售人员的利益,因为 VSIP(Visual Studio 集成产品)包是单独分发和销售的。本文及提供的代码将为您提供实现自定义 Visual Studio .NET 包所需的基本知识和方法——这个软件模块允许您在 IDE 中创建自己的集成文档窗口。这里的“集成”意味着 Visual Studio IDE 将完全感知您的组件的存在。
- 标准的 IDE 命令,如“复制”、“撤销”、“保存”等,将被路由到您的模块。
- “已更改”状态将由文档标题旁边的星号 (*) 反映。
- 如果文档已更改但尚未保存,当执行“关闭”命令时,IDE 会像处理源文件和解决方案等其他文档一样,提示您保存它。
- 您的文档将能够更改特定命令的可用状态(例如,当文档状态更改时,启用 IDE 工具栏上的“撤销”按钮)。
- 您的窗口将插入标准的 Visual Studio 标签控件中,与其他窗口一起,并且可以拖动到不同的标签组中,
- 相应的文档和窗口自动化对象将在 IDE 中创建,就像 IDE 中打开的其他“原生”窗口一样。
- 您将能够使用
DTE.Documents.Open( szFileName, 'Auto'…)
命令,通过 Visual Basic 宏打开您的文档(即显示您的窗口)。 - 您可以使用标准的 Visual Studio 调试器和 devenv.exe 作为调试目标来调试您的包。
为了演示,我实现了一个包,该包打开扩展名为 .bine 的文件,并显示文件二进制数据的直方图(即条形图,其中每个条形代表具有特定值的字节数)。上面的快照演示了一个 temp1.bine 文件,该文件与 C++ 源文件和在二进制编辑器中打开的二进制文件并排打开。
我在此向所有阅读本文的人保证,我从未获得过描述自定义包开发或任何其他 VSIP 相关信息的微软文档。除了一个例外,此处描述的所有实现细节以及源代码都是我自己的探索结果。例外是:在这个项目的早期,我发现了一个名为 VSIPPackageCreationHandout.doc 的网页文档,它极大地增进了我对 Visual Studio 内部机制的理解。该文档和其他相关信息可以在项目的 Docs 子目录中找到。
出于上述原因,我的自定义包不能成为一个独立的模块;实际上,它是一个 Visual Studio 二进制编辑器包(bined.dll)的“智能代理”。它监视 IDE 和包之间的通信,并在需要时修改 bined.dll 的响应。我在 Windows 2000 和 Visual Studio .NET 以及 Windows XP (家庭版) 和 Visual Studio 2003 上测试了该包。我预计在安装在其他 Windows 操作系统上的这两个 Visual Studio 版本或下一版本 Visual Studio 上不会出现问题。
安装步骤
要将包加载到 IDE 中,您需要一个 PKL - 包加载密钥。由于我们没有,我通过将我的包命名为 bined.dll 并替换原始 bined.dll 来绕过此问题。此外,要使包被 IDE 加载,我们需要将特定的文件扩展名与该包关联。因此,要将包安装到系统上,请执行以下三个步骤:
- 找到原始 bined.dll(通常位于 Visual Studio 根目录下的 \Vc7\vcpackages 中),并将其重命名为 orig-bined.dll。另外,在开始任何操作之前,最好将原始 bined.dll 的副本备份到其他安全的地方。
- 将自定义包 DLL 复制到上述目录,并将其命名为 bined.dll。根据我的工作区设置,结果文件将在“生成后事件”中复制到 C:\Program Files\Microsoft VisualStudio .Net\Vc7\Vcpackages,因此您可能需要根据您的机器设置进行更改。
- 要将文件扩展名与 bined.dll 关联,您需要更改系统注册表。在以下键下:[HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\VisualStudio\ 7.0\Editors\{25834150-CD7E-11D0-92DF-00A0C9138C45}\Extensions] **(针对 Visual Studio 2002)** 和/或 [HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\ VisualStudio\7.1\Editors\{25834150-CD7E-11D0-92DF-00A0C9138C45}\Extensions] **(针对 Visual Studio 2003)**,添加一个
DWORD
值,设置为 0x32。该值名称与您将使用的文件扩展名相同。例如:值名称 = xyz,类型 = DWORD,数据 = 0x32。现在,如果将扩展名为“xyz”的文件拖入 Visual Studio IDE 或通过标准的“打开文件”对话框打开,自定义包就会加载到 IDE 中。
注意 1: 这不会将您的文件与使用 Windows Explorer 的 Visual Studio 关联!
注意 2: 我的自定义包仅使用 .bine 扩展名,并且是硬编码的。要更改此设置,您需要稍微修改 CVsEditorFactory::CreateEditorInstance
方法的实现。
接下来的两个部分不一定有助于您构建组件——到目前为止,您已经拥有了所有方法;相反,它们将解释代码是如何开发的以及代码组件是如何执行的。
包生命周期动态
Visual Studio 包是一个实现 IVsPackage
接口的 COM 对象。该接口的布局是已知的(可以通过 OleView 工具从类型库中提取)。包必须公开的其他接口包括:IVsEditorFactory
、IVsPersistDocData
、IPersistFileFormat
、IVsWindowPane
和 IOleCommandTarget
。不要急于在系统注册表中搜索这些接口或使用 OleView 工具进行探索。其中一些接口,如 IVsWindowPane
和 IPersistFileFormat
,甚至没有在 [HKEY_CLASSES_ROOT\Interface] 键下注册为接口。除了这些接口之外,根据我在引言中提到的微软文档,包应该实现 IDispatch
。然而,在我开发包的过程中,IDE 从未查询过此接口,所以我将其留空未实现。
当用户在 Visual Studio 中打开一个与该包关联了扩展名的文件时,包的加载就开始了。IDE 调用 CoCreateInstance
,从包中获取 IClassFactory
接口,并创建一个 IVsPackage
对象。在 IDE 获取到包对象后,它会调用 IVsPackage::SetSite
方法,并将一个指向 IServiceProvider
对象的指针作为参数。该接口在 MSDN 中有描述,旨在为客户端(在本例中是包)提供访问提供商(IDE)可能实现的其他接口(即服务)的途径。实现 SetSite
方法的包会查询 IVsRegisterEditors
接口(服务),并在获得该接口(服务)后调用 IVsRegisterEditors::RegisterEditor
方法。包将编辑器的 GUID
和一个实现 IVsEditorFactory
接口的对象的指针作为参数传递给此调用。内部注册完成后,IDE 会调用 IVsEditorFactory::CreateEditorInstance
方法,从而获取由包创建的文档和视图对象的指针(在源代码中分别是 CDocumentData
和 CDocumentView
类)。这两个对象必须实现 IVsWindowPane
、IVsOleCommandTarget
、IVsPersistDocData
和 IPersistFileFormat
接口。它们之间的区别仅是逻辑上的,并且可以是一个实现所有接口的单个对象。CreateEditorInstance
方法是我们可以第一次看到将要打开的文件名称(作为参数传递)的地方。在下一阶段,IDE 会查询文档和视图对象的所有上述接口,并启动实际的文件打开序列:它调用 IVsWindowPane::CreatePaneWindow
来创建窗口,调用 IVsPersistDocData::Load
来加载文件数据,并调用 IOleCommandTarget::QueryCommand
来启用特定的 UI 命令。
当用户关闭文档窗口时,IDE 会调用 IVsWindowPane::Close
来关闭窗口,调用 IVsPersistDocData::Close
来关闭文档,并调用 IVsRegisterEditors::UnregisterEditor
来反初始化编辑器工厂。(最后一个方法在我源代码中被声明为 NoName()
)。请注意,在这个阶段,包对象并没有被释放。它只有在整个 Visual Studio 环境关闭时才会发生。在包生命的这个最后阶段,IDE 会调用 IVsPackage::QueryCanClose
,然后调用 IVsPackage::Close
方法。
研究 VS 包
在本节中,我将描述导致我理解和实现自定义包的过程。这个过程可以用以下算法来最好地描述:
- 对于每个已知接口
- --对于每个可能被查询以获取新接口的方法(
QueryInterface
) - ----覆盖方法。
- ----查找所有成功查询到的接口。
- ------如果没有新接口——停止。
- ------研究新接口,找出方法的数量和参数。
- ------现在知道了新接口——转到步骤 1。
我开始编写 bined.dll 的 DLL 代理,具有相同的导出函数和 IClassFactory
的实现。下一版本的代理包括 IVsPackage
接口和 IServiceProvider
包装器的实现。此时,通过常规方法无法完成算法的第六步,因为我找不到任何关于已获取接口的合适描述。
为了解决这个问题,我开发了一个名为“Spy”的工具。该工具的理念是通过修改原始对象的虚函数表(v-table)指针,将所有虚拟方法调用重定向到存根例程。显然,每个存根例程对应一个特定的接口方法。存根例程可以在调用实际方法之前执行其他任务,这些任务可以包括打印诊断消息、设置断点等。
接口的调查从实现其监视器开始。为实现提供的唯一必要参数是接口的方法数量。它可以在系统注册表中的 [HKCR\Interface\GUID\NumMethods] 键下找到,其中 GUID
表示接口 ID。否则,如果接口(其 IID)在系统注册表中找不到,我们可以通过在调试模式下分析对象的 v-table 来做出有根据的猜测(通常 v-table 是进程 .text 段中相邻地址的序列,后面跟着零或不相关的值,另外请记住前三个地址是 IUnknown
方法的指针)。
存根例程的实现取决于相应接口方法的参数数量是否已知。如果未知,则仅提供打印诊断消息的默认实现。请注意,在这种情况下,存根声明中使用 __declspec(naked)
属性是为了防止 C++ 编译器生成可能违反堆栈完整性的序言和结束语代码。此外,如果任何额外的操作(如函数调用)在存根例程中导致堆栈状态发生变化,则必须恢复初始堆栈状态。最后,必须使用 jmp
汇编指令(而不是 call
指令)来调用原始方法,因为无法正确设置堆栈来执行此操作(参数数量未知)。
尽管有这些限制,存根例程似乎毫无用处,但实际上它是第 6 步的一个很好的起点。首先,您可以看到(根据诊断消息)方法何时被调用。其次,您可以在存根中设置断点,然后单步执行到反汇编的原始方法代码。逐步执行最终会引导您找到返回的汇编指令。因为 COM 方法使用 __stdcall
约定定义,所以它们负责在方法终止时清除堆栈。相应的汇编指令是 ret X
,其中 X
表示退出时要添加到堆栈指针寄存器中的字节数。例如,如果 X
为 8,则该方法只有一个参数(通常每个参数为 4 字节,任何方法都有 this
指针(4 字节)作为隐藏参数)。返回值可以在 ret
指令执行前的 EAX
寄存器值的基础上获得。通过这种技术,我发现例如,许多方法实际上是空的,只返回 S_OK
或 E_NOTIMPLEMENTED
代码。
存根的第二种可能实现方式与相应方法的参数数量已知时相关。在这种情况下,我们可以提供更全面的存根代码,这在 IUnknown::QueryInterface
方法方面尤其适用。由于每个接口都继承自 IUnknown
,并且 QueryInterface
方法的地址占据了接口 v-table 的第一个条目,因此我们可以提供一个功能齐全的函数,该函数可以拦截监视目标对象的所有接口查询,从而能够执行调查算法的第 2 至 4 步。只需记住调用原始对象的该方法。此外,在我们对 QueryInterface
的实现中,我们可以过滤返回的接口,以找出哪些是必需的,哪些是辅助的。
我知道这一节并没有回答所有可能的问题,但我认为它为您提供了一个很好的关于最终代码为何看起来是这样的思路。此外,如果您想深入研究代码细节,我提供了使用监视器的自定义包的最终调试版本源代码。安装过程与上面描述的完全相同。
局限性和已知问题
首先,我的自定义包最明显的问题是它严重依赖于 bined.dll。但由于它是一个标准的 IDE 包,我希望它会在未来的 Visual Studio 版本中出现,否则我们可以选择另一个包来寄生。此外,我没有遇到过,也不预见到未来会有任何共存问题。唯一需要记住的是要保存原始包 DLL,并使其对自定义包可用。
其次,似乎很难添加/创建包可用的自定义命令。可能是解决方案是创建一个包含自定义包已知命令集的插件。
最后,我在调试自定义包时注意到,当鼠标指针悬停在窗口标题标签上时,IDE 会抛出异常。IDE 会在输出调试窗口中显示一条消息,然后继续正常运行,就好像什么都没发生一样。即使只打开 C++ 源文件并且我的包尚未加载,也会发生这种情况。我假设异常的根源和唯一原因是 IDE 本身。
结论:进一步开发
进一步研究的一个可能方向是使用自定义包提供的对象扩展 DTE 自动化模型。一个好的起点是讨论过的微软文档的最后一段,以及在文档和视图对象中实现 IDispatch
接口。另一个有前途的方向是研究/实现自定义命令的集成,这些命令可以在启动时注册并按稳定状态执行。当然,对当前包功能的任何补充,特别是未实现的接口方法和/或未使用的参数,都非常受欢迎。
我希望我的工作能为您提供一个必备的工具,以便为 Visual Studio IDE 编写更全面的插件。将工具窗口与文档窗口组合在一个插件中的能力,无疑可以提高可完成任务的复杂性和多样性。一如既往,唯一的限制是您的想象力。