张赐荣——一位视障程序员。
赐荣小站: www.prc.cx

張賜榮

张赐荣的技术博客

博客园 首页 新随笔 联系 订阅 管理

浅谈在C#中调用COM组件——以文件夹选择器为例

【文 / 张赐荣】

在现如今的这个时代,提到跨语言调用或者系统级操作,许多开发者第一时间会想到.NET、Web API等现代技术。然而,不得不说,COM组件这门技术可能在许多年轻开发者的学习清单中早已被“扫进角落”了。毕竟现如今.NET、Web API、云技术满天飞,谁还会关心什么COM组件呢?可偏偏这玩意儿不但还活着,而且在Windows系统的某些角落里依然坚挺存在。尤其是,当你需要和Windows底层API打交道时,可能绕不开它。

今天这篇文章,就让我这热爱上古技术的“老开发者”,跟大家聊聊这个略显古早的——COM组件,并通过一个简单的文件夹选择器例子,详细讲讲C#是怎么调用COM的。

COM组件,听起来就很古老?

没错,COM(Component Object Model,组件对象模型)这个东西最早出现在90年代。那时候,.NET还没影儿呢,微软推出它是为了解决跨语言调用和软件组件复用的问题。说白了,COM就是为了解决“不同语言的代码怎么互相调用”这个难题。

举个例子吧,你用C++写了一个图像处理模块,想在用VB写的程序中调用它?没问题,COM来帮你搞定跨语言调用。不仅如此,COM的伟大之处在于它还能提供跨进程调用,这在当年简直就是黑科技。

然而,时过境迁,.NET的横空出世让开发者有了托管代码的选择,COM渐渐“退居二线”。但即便如此,Windows底层依然有大量API依赖它——比如今天我们要用到的文件夹选择器就是其中之一。只要你是Windows开发者,哪怕是个年轻人,也迟早得跟它打个照面。

如今为什么还要学COM组件?

我知道你们可能会问:“现在.NET都能搞定很多事了,为什么还要学COM?” 这个问题问得好。实际上,许多Windows底层功能,尤其是那些老牌的系统级API,依然离不开COM。再比如,微软的Office套件,很多功能还是通过COM接口实现的。如果你想在Windows底层API和老系统打交道,COM就是那把钥匙。

学COM,说难不难,说简单也不简单。接下来,我用一个常见的文件夹选择器来带你了解C#如何调用COM组件。

C#调用COM组件的基本步骤

在C#中调用COM组件并不复杂,但由于COM是非托管代码,C#需要通过互操作(Interop)机制来与其交互。互操作机制主要是让托管代码(C#)能够调用非托管代码(COM)。
调用COM组件的基本流程可以总结为以下几步:

  1. 导入COM接口:使用[ComImport][Guid]等特性引入COM接口。
  2. 创建COM对象:通过接口的实现类实例化COM对象。
  3. 调用方法:通过接口调用COM的功能。
  4. 释放资源:由于COM组件是非托管代码,使用完要记得手动释放资源。

别急,我会一步步拆解这些过程,接下来我们通过一个简单的文件夹选择器示例来具体演示如何在C#中调用COM组件。

示例代码:实现文件夹选择器

这个示例的主要功能是让用户通过Windows对话框选择一个文件夹,选中的文件夹路径会返回给程序。我们使用了COM组件 IFileOpenDialog,它是Windows提供的文件对话框接口之一。

接下来,我会详细剖析这段代码。

1. 命名空间导入和声明类

using System;
using System.Runtime.InteropServices;

namespace WinApi
{
	static class FolderPicker
	{
		public static string ChooseDirectory()
		{

上面我们导入了System.Runtime.InteropServices,这是C#与非托管代码(比如COM组件)打交道的关键命名空间。FolderPicker是一个静态类,里面的ChooseDirectory方法就是用于弹出文件夹选择对话框的关键部分。

2. 创建 IFileOpenDialog 对象

IFileOpenDialog dialog = null;
try
{
	dialog = (IFileOpenDialog)new FileOpenFileDialog();

这里的IFileOpenDialog是Windows API提供的接口,用来显示文件(或文件夹)选择对话框。我们通过FileOpenFileDialog实例化IFileOpenDialog。这个FileOpenFileDialog是什么?稍后我们会看到它的定义。需要注意的是,创建COM对象的方式和我们通常用new实例化类的方式不同,COM对象需要通过接口来操作。

3. 设置对话框选项

uint options;
dialog.GetOptions(out options);
options |= (uint)FOS.FOS_PICKFOLDERS;
dialog.SetOptions(options);

这一段的意思是,首先通过GetOptions获取当前对话框的选项,然后使用FOS.FOS_PICKFOLDERS标志位将对话框设置为“文件夹选择”模式(而不是默认的选择文件)。最后通过SetOptions重新应用这些设置。

FOS_PICKFOLDERS是一个常量,它表示这次我们只关心文件夹,不需要文件。

4. 显示对话框并处理返回值

int hr = dialog.Show(IntPtr.Zero);
if (hr == (int)HRESULT.ERROR_CANCELLED)
{
	return null;
}
else if (hr != 0)
{
	Marshal.ThrowExceptionForHR(hr);
}

接下来,通过Show方法显示对话框。这里传入的IntPtr.Zero表示没有父窗口。如果用户取消选择,Show方法会返回一个HRESULT.ERROR_CANCELLED,这种情况下我们返回null。否则,如果hr返回的值不为0,我们通过Marshal.ThrowExceptionForHR将错误码转换为C#的异常,这样便于后续处理。

5. 获取用户选择的文件夹路径

IShellItem item;
dialog.GetResult(out item);

string path;
item.GetDisplayName(SIGDN.SIGDN_FILESYSPATH, out path);

return path;

用户选定文件夹后,我们通过GetResult获取用户选择的文件夹(即IShellItem对象),然后通过GetDisplayName获取文件夹的路径。这里的SIGDN.SIGDN_FILESYSPATH是说我们要获取文件夹的完整文件路径。

6. 释放COM对象

finally
{
	if (dialog != null)
	{
		Marshal.ReleaseComObject(dialog);
	}
}

COM对象与托管代码不同,C#的垃圾回收器并不能自动回收COM对象,因此我们需要手动释放它们。在finally块中,我们通过Marshal.ReleaseComObject确保即便出错也能正确释放IFileOpenDialog,防止内存泄漏。

7. 相关的COM接口定义

接下来我们看看与IFileOpenDialogIShellItem相关的COM接口定义。为了方便调用,C#通过[ComImport][Guid]特性来导入COM接口。

FileOpenFileDialog
[ComImport]
[Guid("DC1C5A9C-E88A-4DDE-A5A1-60F82A20AEF7")]
[ClassInterface(ClassInterfaceType.None)]
class FileOpenFileDialog
{
}

这是FileOpenFileDialog的定义,它是一个COM类。我们用[ComImport]告诉C#编译器这个类是从外部导入的COM对象,并通过[Guid]提供这个COM类的唯一标识符。

IFileOpenDialog 接口
[ComImport]
[Guid("D57C7288-D4AD-4768-BE02-9D969532D960")]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
interface IFileOpenDialog
{
	[PreserveSig]
	int Show([In] IntPtr parent);

	void SetOptions(uint fos);
	void GetOptions(out uint fos);
	void GetResult(out IShellItem ppsi);
}

IFileOpenDialog接口定义了文件对话框的核心操作方法。我们主要用到Show方法来显示对话框,SetOptionsGetOptions来设置和获取对话框选项,GetResult则用于获取用户选择的文件夹或文件。

IShellItem 接口
[ComImport]
[Guid("43826D1E-E718-42EE-BC55-A1E261C37BFE")]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
interface IShellItem
{
	void GetDisplayName(SIGDN sigdnName, [MarshalAs(UnmanagedType.LPWStr)] out string ppszName);
}

IShellItem是文件系统对象(如文件/文件夹)的COM接口。我们用它的GetDisplayName方法来获取文件夹的路径。

总结

最后附上完整的代码吧:

using System;
using System.Runtime.InteropServices;

namespace WinApi
{
	static class FolderPicker
	{
		public static string ChooseDirectory()
		{
			IFileOpenDialog dialog = null;
			try
			{
				dialog = (IFileOpenDialog)new FileOpenFileDialog();

				uint options;
				dialog.GetOptions(out options);
				options |= (uint)FOS.FOS_PICKFOLDERS;
				dialog.SetOptions(options);

				int hr = dialog.Show(IntPtr.Zero);
				if (hr == (int)HRESULT.ERROR_CANCELLED)
				{
					return null;
				}
				else if (hr != 0)
				{
					Marshal.ThrowExceptionForHR(hr);
				}

				IShellItem item;
				dialog.GetResult(out item);

				string path;
				item.GetDisplayName(SIGDN.SIGDN_FILESYSPATH, out path);

				return path;
			}
			finally
			{
				if (dialog != null)
				{
					Marshal.ReleaseComObject(dialog);
				}
			}
		}
	}

	[ComImport]
	[Guid("DC1C5A9C-E88A-4DDE-A5A1-60F82A20AEF7")]
	[ClassInterface(ClassInterfaceType.None)]

	class FileOpenFileDialog
	{
	}

	[ComImport]
	[Guid("D57C7288-D4AD-4768-BE02-9D969532D960")]
	[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]

	interface IFileOpenDialog
	{
		[PreserveSig]
		int Show([In] IntPtr parent);

		void SetFileTypes(uint cFileTypes, [In] IntPtr rgFilterSpec);
		void SetFileTypeIndex(uint iFileType);
		void GetFileTypeIndex(out uint piFileType);
		void Advise(IntPtr pfde, out uint pdwCookie);
		void Unadvise(uint dwCookie);
		void SetOptions(uint fos);
		void GetOptions(out uint fos);
		void SetDefaultFolder(IShellItem psi);
		void SetFolder(IShellItem psi);
		void GetFolder(out IShellItem ppsi);
		void GetCurrentSelection(out IShellItem ppsi);
		void SetFileName([MarshalAs(UnmanagedType.LPWStr)] string pszName);
		void GetFileName([MarshalAs(UnmanagedType.LPWStr)] out string pszName);
		void SetTitle([MarshalAs(UnmanagedType.LPWStr)] string pszTitle);
		void SetOkButtonLabel([MarshalAs(UnmanagedType.LPWStr)] string pszText);
		void SetFileNameLabel([MarshalAs(UnmanagedType.LPWStr)] string pszLabel);
		void GetResult(out IShellItem ppsi);
		void AddPlace(IShellItem psi, uint fdap);
		void SetDefaultExtension([MarshalAs(UnmanagedType.LPWStr)] string pszDefaultExtension);
		void Close(int hr);
		void SetClientGuid(ref Guid guid);
		void ClearClientData();
		void SetFilter(IntPtr pFilter);
		void GetResults(out IntPtr ppenum);
		void GetSelectedItems(out IntPtr ppsai);
	}

	[ComImport]
	[Guid("43826D1E-E718-42EE-BC55-A1E261C37BFE")]
	[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]

	interface IShellItem
	{
		void BindToHandler(IntPtr pbc, ref Guid bhid, ref Guid riid, out IntPtr ppv);
		void GetParent(out IShellItem ppsi);
		void GetDisplayName(SIGDN sigdnName, [MarshalAs(UnmanagedType.LPWStr)] out string ppszName);
		void GetAttributes(uint sfgaoMask, out uint psfgaoAttribs);
		void Compare(IShellItem psi, uint hint, out int piOrder);
	}

	enum SIGDN : uint
	{
		SIGDN_FILESYSPATH = 0x80058000,
	}

	enum FOS : uint
	{
		FOS_PICKFOLDERS = 0x00000020,
	}

	enum HRESULT : int
	{
		ERROR_CANCELLED = unchecked((int)0x800704C7),
	}
}

通过这个例子,我们可以看到C#调用COM组件的基本流程:导入COM接口、创建COM对象、调用接口方法、释放资源。每一步都依赖于C#和Windows系统之间的互操作机制,尤其是对COM对象的正确释放,至关重要。

实际上对于年轻开发者来说,不必对COM退避三舍,它依然是打开Windows底层世界的钥匙。掌握这门技术,你会发现,在处理一些系统级的操作或与遗留代码打交道时,COM可以让你“所向披靡”。

至于像我这样的老开发者,虽然我们热衷于这些“上古技术”,但无论是怀旧还是实用,技术本身的价值总是无可替代的。希望通过这篇文章,你能对COM有一个清晰的认识,也许在未来的某个项目中,你也会用上这门“古早”技术。

有什么问题或者想法,欢迎在评论区讨论!

posted on   张赐荣  阅读(417)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 一个费力不讨好的项目,让我损失了近一半的绩效!
· 清华大学推出第四讲使用 DeepSeek + DeepResearch 让科研像聊天一样简单!
· 实操Deepseek接入个人知识库
· CSnakes vs Python.NET:高效嵌入与灵活互通的跨语言方案对比
· 易语言 —— 开山篇

感谢访问张赐荣的技术分享博客!
博客地址:https://cnblogs.com/netlog/
知乎主页:https://www.zhihu.com/people/tzujung-chang
个人网站:https://prc.cx/

点击右上角即可分享
微信分享提示