基于.Net技术创建IE面板插件
作者:陈省
简介
IE的面板实际就是嵌入到IE浏览器中的子窗体。面板有两种显示模式,一种是垂直显示在浏览器的左侧,一种是水平显示在浏览器的下方。IE浏览器内置提供了很多的标准面板,如收藏夹和搜索面板这些是垂直的面板,而每日提示和讨论面板则是水平的面板。
一般来说,基于Win32原生代码编程的话,我们可以通过实现特定接口的COM组件来创建自定义的浏览器嵌入式面板。当自定义的面板COM组件注册后,它会被添加到浏览器的查看菜单的浏览器栏的子菜单下。当用户选中该菜单项后,该面板就会显示在浏览器中。那么,如何基于.Net技术开发IE面板组件呢?
COM和.Net的互操作性
幸运的是.Net和COM具有很好的互操作性,一个实现了COM接口的.Net组件可以被COM客户端正确的调用,COM和.Net的互操作是双向的,由两个子系统组成,RCW(Runtime Callable Wrapper)和CCW(COM Callable Wrapper)。RCW 可以使.NET程序调用已有的COM组件,RCW的主要任务就是导入COM组件的所有类型,并将其暴露为对应的托管代码类型。
反过来,CCW子系统则允许COM组件把.Net组件当成普通的COM组件进行调用。RegAsm.exe这个工具可以将托管组件导出一个类型库,并将其注册为一个普通的COM组件,接下来我们就来看看,如何使用RCW和CCW来实现.Net组件同COM组件的互操作。
实现面板COM组件
下面我们将实现一个类似于IE内置的收藏夹的面板,作为一个示例程序,它本身没有任何有价值的功能,唯一的功能就是点击上面的连接标签可以将IE导航到我的个人网页(http://delphi.sharpplus.net)等一些网站上去,下面是它的示意图:
首先,在VS.Net 2003中创建一个类库项目,命名为IEBand,如下图示意:
COM接口定义
一个标准的IE面板组件,必须要实现IDeskBand,IObjectWithSite,IDockingWindow和IPersistStream等COM接口,同时还需要用到如IOleWindow等接口。不幸的是,.Net Framework中并没有为我们事先完成这些COM接口的.Net类型定义,同时也无法通过导入COM类型库来引入这些接口定义,因此需要自己来手工完成定义,需要说明的是将COM接口转化为可互操作的.Net类型要遵循下面的规则,
COM 数据类型 |
对应的.NET数据类型 |
bool, bool * |
System.Int32 |
char, char *, small , small * |
System.SByte |
short, short * |
System.Int16 |
long, long *, int , int * |
System.Int32 |
hyper, hyper * |
System.Int64 |
unsigned char, unsigned char *, byte, byte * |
System.Byte |
wchar_t, wchar_t *
unsigned short, unsigned short * |
System.UInt16 |
unsigned long, unsigned long *
unsigned int, unsigned int * |
System.UInt32 |
unsigned hyper, unsigned hyper * |
System.UInt64 |
float, float * |
System.Single |
double, double * |
System.Double |
VARIANT_BOOL、VARIANT_BOOL * |
System.Boolean |
void *, void ** |
System.IntPtr |
HRESULT, HRESULT * |
System.Int16或System.IntPtr |
SCODE, SCODE * |
System.Int32 |
BSTR, BSTR * |
System.String |
LPSTR [string, …] char * LPSTR * |
System.String |
LPWSTR [string, …] wchar_t * LPWSTR * |
System.String |
VARIANT, VARIANT * |
System.Object |
DECIMAL, DECIMAL *, CURRENCY, CURRENCY * |
System.Decimal |
DATE, DATE * |
System.DateTime |
GUID, GUID * |
System.Guid |
IUnknown *, IUnknown ** |
System.Object |
IDispatch *, IDispatch ** |
System.Object |
SAFEARRAY(type), SAFEARRAY(type) * |
type[] |
同时,COM接口需要用InterfaceType, ComImport和Guid特性进行修饰,其中ComImport特性标示指示该化类型是以前在 COM 中定义的。IntefaceType特性则指示该COM接口是是双重接口的、还是调度接口、还是IUnknown接口的。这里使用的接口都是IUnknown接口,因此类型为InterfaceIsIUnknown,Guid特性则用来指定该接口的Guid标识符。注意,接口中的有些方法需要使用PreserveSig特性进行修饰,这个特性告诉COM互操作将方法的返回值看做是COM HRESULT,而不是一个输出值。下面就是面板组件用到的所有COM接口的定义:
[ComImport]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
[Guid("FC4801A3-2BA9-11CF-A229-00AA003D7352")]
public interface IObjectWithSite
{
void SetSite([In ,MarshalAs(UnmanagedType.IUnknown)] Object pUnkSite);
void GetSite(ref Guid riid, [MarshalAs(UnmanagedType.IUnknown)] out Object ppvSite);
}
[ComImport]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
[Guid("00000114-0000-0000-C000-000000000046")]
public interface IOleWindow
{
void GetWindow(out System.IntPtr phwnd);
void ContextSensitiveHelp([In] bool fEnterMode);
}
[ComImport]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
[Guid("012dd920-7b26-11d0-8ca9-00a0c92dbfe8")]
public interface IDockingWindow
{
void GetWindow(out System.IntPtr phwnd);
void ContextSensitiveHelp([In] bool fEnterMode);
void ShowDW([In] bool fShow);
void CloseDW([In] UInt32 dwReserved);
void ResizeBorderDW(IntPtr prcBorder,
[In, MarshalAs(UnmanagedType.IUnknown)] Object punkToolbarSite,bool fReserved);
}
[ComImport]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
[Guid("EB0FE172-1A3A-11D0-89B3-00A0C90A90AC")]
public interface IDeskBand
{
void GetWindow(out System.IntPtr phwnd);
void ContextSensitiveHelp([In] bool fEnterMode);
void ShowDW([In] bool fShow);
void CloseDW([In] UInt32 dwReserved);
void ResizeBorderDW(IntPtr prcBorder,
[In, MarshalAs(UnmanagedType.IUnknown)] Object punkToolbarSite,bool fReserved);
void GetBandInfo(UInt32 dwBandID,UInt32 dwViewMode, ref DESKBANDINFO pdbi);
}
[ComImport]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
[Guid("6d5140c1-7436-11ce-8034-00aa006009fa")]
public interface _IServiceProvider
{
void QueryService( ref Guid guid,
ref Guid riid, [MarshalAs(UnmanagedType.Interface)] out Object Obj);
}
[ComImport]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
[Guid("68284faa-6a48-11d0-8c78-00c04fd918b4")]
public interface IInputObject
{
void UIActivateIO(Int32 fActivate, ref MSG msg);
[PreserveSig]
Int32 HasFocusIO();
[PreserveSig]
Int32 TranslateAcceleratorIO(ref MSG msg);
}
[ComImport]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
[Guid("f1db8392-7331-11d0-8c99-00a0c92dbfe8")]
public interface IInputObjectSite
{
[PreserveSig]
Int32 OnFocusChangeIS( [MarshalAs(UnmanagedType.IUnknown)] Object punkObj, Int32 fSetFocus);
}
除了上面的定义的接口之外,由于我们还需要调用IE浏览器来访问我的个人网站,这需要调用IE所实现的COM接口的方法来实现,这回我们不需要手工来定义需要使用的接口,直接导入IE的COM类型库即可,VS.Net会使用tlbimp.exe生成互操作Assembly,其中就有我们需要的Web浏览器类WebBrowserClass。如下图示意:
作为一个面板组件,必须要有可以同用户进行交互的界面,这里我们使用一个用户组件来事先,接下来向项目中添加一个UserControl,起名为BandObject,创建完UserControl后,修改BandObject的类定义,使其实现相关的接口。
首先我们来看一下IObjectWithSite接口,顾名思义这个接口就是一个嵌入在COM容器中的组件需要实现的接口,当用户选中一个浏览器面板的时候,IE这个COM组件容器会调用面板组件的IObjectWithSite的SetSite方法,这个方法传递过来的punkSite参数就是COM容器的IUnknown接口,通过这个接口我们可以获得IE提供的各个COM接口,进而可以调用IE提供的所有功能。这个方法的一般实现是:
1、
如果方法传过来的punkSite参数为null,表示面板组件正在被隐藏释放。
2、
如果punkSite参数不为null,则通过punkSite获得我们所需要的COM实例(如IE的WebBrowserClass类),并将其保存起来。
下面是SetSite方法的实现代码,注意代码中对COM对象的引用记数进行了处理:
protected WebBrowserClass Explorer;
protected IInputObjectSite BandObjectSite;
public void SetSite(Object pUnkSite)
{
if( BandObjectSite != null )
Marshal.ReleaseComObject( BandObjectSite );
if( Explorer != null )
{
Marshal.ReleaseComObject( Explorer );
Explorer = null;
}
BandObjectSite = (IInputObjectSite)pUnkSite;
if( BandObjectSite != null )
{
//获取浏览器接口
_IServiceProvider sp = BandObjectSite as _IServiceProvider;
Guid guid = ExplorerGUIDs.IID_IWebBrowserApp;
Guid riid = ExplorerGUIDs.IID_IUnknown;
try
{
object w;
sp.QueryService( ref guid, ref riid, out w );
//一旦获得COM接口,就可以创建RCW以便使用
Explorer = (WebBrowserClass)Marshal.CreateWrapperOfType(
w as IWebBrowser, typeof(WebBrowserClass));
}
catch( COMException )
{
}
}
}
IObjectWithSite还有一个GetSite方法也需要实现,在这个方法中我们只需要返回在SetSite方法中保存的Site接口就可以了,代码实现如下:
public void GetSite(ref Guid riid, out Object ppvSite)
{
ppvSite = BandObjectSite; ;
}
接下来的IDeskBand接口就是我们面板组件所要实现的核心接口了。IDeskBand接口的ContextSensitiveHelp方法是用来提供上下文帮助支持的,为了简单起见,这里我们不提供上下文帮助支持,所以什么都不做就可以了。而ResizeBorderDW 方法系统也从来不会被IE调用,因此我们也无须实现。
获得面板的显示信息
对于IDeskBand接口的GetWindow方法来说,我们需要返回要嵌入到IE中UserControl的窗体句柄,以便COM容器将BandObject用户组件作为子窗体进行显示。下面是GetWindow方法的实现:
public void GetWindow(out IntPtr phwnd)
{
phwnd = Handle;
}
除了显示外,我们的窗体还需要对输入焦点进行处理,当焦点发生变化时,需要调用浏览器的IInputObjectSite的OnFocusChangeIS方法通知浏览器焦点变化情况,而获得焦点变化的通知,可以通过重载Control基类的OnGotFocus和OnLostFocus方法来实现,代码如下:
protected override void OnGotFocus(System.EventArgs e)
{
base.OnGotFocus(e);
//调用在SetSite方法中保存的IInputObjectSite接口的方法通知浏览器焦点变化
BandObjectSite.OnFocusChangeIS(this, 1);
}
protected override void OnLostFocus(System.EventArgs e)
{
base.OnLostFocus(e);
if( ActiveControl == null )
BandObjectSite.OnFocusChangeIS(this, 0);
}
系统获得子窗体的同时,因为IE本身对子窗体的显示区域是有一定限制的,所以它还需要知道面板窗体的某些属性,如最大显示尺寸、最小显示尺寸、改变大小时的尺寸变化幅度等信息,这些信息它是通过调用面板组件的IDeskBand接口的GetBandInfo方法来获得的。下面是GetBandInfo方法的声明:
void GetBandInfo(UInt32 dwBandID,UInt32 dwViewMode,ref DESKBANDINFO pdbi);
其中dwBandID参数是面板的标识符,这个标识符是COM容器赋予的,我们可以保存这个ID,在后面对面板进行操作。dwViewMode的不同值表示当前面板的显示模式:
DBIF_VIEWMODE_FLOATING |
浮动面板 |
DBIF_VIEWMODE_NORMAL |
水平面板 |
DBIF_VIEWMODE_TRANSPARENT |
面板是透明的 |
DBIF_VIEWMODE_VERTICAL |
面板是垂直面板 |
一般来说,这个参数不用考虑处理。pdbi参数则是一个TDESKBANDINFO 结构,结构定义如下:
[StructLayout(LayoutKind.Sequential,CharSet=CharSet.Unicode)]
public struct DESKBANDINFO
{
public UInt32 dwMask;
public Point ptMinSize;
public Point ptMaxSize;
public Point ptIntegral;
public Point ptActual;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst=255)]
public String wszTitle;
public DBIM dwModeFlags;
public Int32 crBkgnd;
};
下表是关于结构中各个成员的详细说明:
dwMask |
指定被请求的面板信息的掩码,是下面参数的组合。 |
DBIM_MINSIZE |
请求面板最小尺寸信息 |
DBIM_MAXSIZE |
请求面板最大尺寸信息 |
DBIM_INTEGRAL |
请求面板尺寸改变的幅度信息 |
DBIM_ACTUAL |
请求面板默认尺寸信息 |
DBIM_TITLE |
请求面板标题信息 |
DBIM_MODEFLAGS |
请求dwModeFlags参数代表的额外的面板显示信息 |
DBIM_BKCOLOR |
请求面板背景色信息 |
ptMinSize |
返回面板最小尺寸,最小宽度保存在x成员,最小高度保存在y成员 |
ptMaxSize |
返回面板最大尺寸,最大高度放在y成员中,x成员不被使用。如果最大高度没有限制,应该将y赋值为-1 |
ptIntegral |
返回面板尺寸改变幅度,垂直改变幅度放在y中,x不使用。如果dwModeFlags不包含DBIMF_VARIABLEHEIGHT,这个信息被忽略。 |
ptActual |
返回面板的默认尺寸。x保存宽度,y保存高度,容器会尽量使用这个尺寸来显示面板,但不保证一定使用这个尺寸。 |
wszTitle |
返回面板的标题 |
dwModeFlags |
返回下面显示方式的组合 |
DBIMF_NORMAL |
缺省的显示模式 |
DBIMF_VARIABLEHEIGHT |
面板的高度可以被改变。 |
DBIMF_DEBOSSED |
面板采用下陷的样式来显示 |
DBIMF_BKCOLOR |
面板将以crbkgnd指定的颜色作为自己的背景色 |
crBkgnd |
背景色 |
当IE调用GetBandInfo方法时,需要根据系统请求返回相应的面板信息,代码实现如下:
public void GetBandInfo(UInt32 dwBandID, UInt32 dwViewMode, ref DESKBANDINFO pdbi)
{
pdbi.wszTitle = ".Net面板组件";
pdbi.ptActual.X = this.Width;
pdbi.ptActual.Y = this.Height;
//最大尺寸无限制
pdbi.ptMaxSize.X = -1;
pdbi.ptMaxSize.Y = -1;
//对于最小尺寸来说,窗体的最小宽度是窗体设计时的宽度
//因为当前是一个垂直面板,因此对高度不作限制
pdbi.ptMinSize.X = this.Width;
pdbi.ptMinSize.Y = -1;
//大小改变幅度都为1
pdbi.ptIntegral.X = 1;
pdbi.ptIntegral.Y = 1;
pdbi.dwModeFlags = DBIM.TITLE | DBIM.ACTUAL | DBIM.MAXSIZE | DBIM.MINSIZE | DBIM.INTEGRAL;
}
当面板需要显示或隐藏的时候IE会调用IDeskBand接口的ShowDW方法通知面板改变窗体的显示状态,方法的fShow布尔参数表示需要显示还是隐藏窗体,代码实现如下:
public virtual void ShowDW(bool fShow)
{
if( fShow )
Show();
else
Hide();
}
当IE需要关闭面板时,会调用IDeskBand的CloseDW方法,这时我们需要销毁面板窗体,代码实现如下:
public virtual void CloseDW(UInt32 dwReserved)
{
Dispose( true );
}
用户输入支持
实现了上面三个接口方法的的面板还是仅仅能够用来显示信息,如果我们想要面板能够处理键盘输入信息的话,那么还需要实现IInputObject接口。IInputObject接口有下面三个方法UIActivateIO,HasFocusIO,TranslateAcceleratorIO。Internet浏览器会在面板被激活和失去焦点时调用UIActivateIO方法通知面板对象,这时面板应该调用Focus方法获得焦点,同时调用Select方法激活界面上的子控件。方法的fActivate表示是激活还是失活的状态,代码实现如下:
public virtual void UIActivateIO(Int32 fActivate, ref MSG Msg)
{
if( fActivate != 0 )
{
Control ctrl = GetNextControl(this,true);//first
if( ctrl != null ) ctrl.Select();
this.Focus();
}
}
当浏览器想要知道目前是哪个子窗体获得了焦点的时候,它会调用HasFocusIO方法,如果我们的面板获得了焦点,那么就应该返回S_OK,否则返回S_False。代码实现如下:
public virtual Int32 HasFocusIO()
{
return this.ContainsFocus ? 0 : 1; //S_OK : S_FALSE;
}
最后一个TranslateAcceleratorIO方法允许面板处理键盘快捷键消息,这里我们判断是否是键盘消息是否是Tab键按下的消息,如果是则选中下一个控件,代码实现如下:
public virtual Int32 TranslateAcceleratorIO(ref MSG msg)
{
if( msg.message == 0x100 )//WM_KEYDOWN
if( msg.wParam == (uint)Keys.Tab)//keys used by explorer to navigate from control to control
if( SelectNextControl(
ActiveControl, true, true, true, false ))
return 0;//S_OK
return 1;//S_FALSE
}
程序功能
COM组件基本完成之后,就该实现我们的界面的功能了。界面功能的实现非常简单,就是调用浏览器的Navigate方法来导航到某个网站。下面是具体的代码:
private void llblHubdog_LinkClicked(object sender, System.Windows.Forms.LinkLabelLinkClickedEventArgs e)
{
object Flags=null;
object TargetFrameName=null;
object PostData=null;
object Headers=null;
Explorer.Navigate("http://delphi.sharpplus.com",ref Flags, ref TargetFrameName,ref PostData,ref Headers);
}
COM组件的注册
实现完COM组件的功能后,剩下的就是注册到系统中以便IE加载了,面板COM组件首先要把自己的GUID写到注册表中HKEY_CLASS_ROOT下,下面是具体的注册表项:
HKEY_CLASSES_ROOT
CLSID
{面板组件的CLSID GUID}
(Default) = 显示在查看|浏览器栏的菜单文本
InProcServer32
(Default) = DLL路径
ThreadingModel= Apartment
同时面板还要在IE的注册表项下填写下面信息:
HKEY_LOCAL_MACHINE
Software
Microsoft
Internet Explorer
Toolbar
{面板组件的CLSID GUID}
最后,对于IE垂直面板来说,我们还要注册COM组件的类别:
HKEY_CLASSES_ROOT\CLSID\<面板GUID>\
Implemented Categories\{00021493-0000-0000-C000-000000000046}
COM组件的注册和注销的是由Register()和Unregister()方法来实现的,代码如下:
[ComRegisterFunctionAttribute]
public static void Register(Type t)
{
string guid = t.GUID.ToString("B");
RegistryKey rkClass = Registry.ClassesRoot.CreateSubKey(@"CLSID\"+guid );
RegistryKey rkCat = rkClass.CreateSubKey("Implemented Categories");
string name = "IEBand";
string help = "IEBand";
rkClass.SetValue(null, name );
rkClass.SetValue("MenuText", name );
rkClass.SetValue("HelpText", help );
rkCat.CreateSubKey("{00021493-0000-0000-C000-000000000046}");
}
[ComUnregisterFunctionAttribute]
public static void Unregister(Type t)
{
string guid = t.GUID.ToString("B");
Registry.ClassesRoot.CreateSubKey(@"CLSID").DeleteSubKeyTree(guid);
}
注意,这两个方法用Com{Un}registerFunction特性进行了修饰,这样regasm.exe工具会自动调用这两个方法来将Assemlby作为COM Server来注册或者注销。另外,别忘了为BandObject对象设定Guid特性。
此外,要特别说明的是,同一般的COM组件不同,要想让.Net Framework能够找到并加载一个实现了COM接口的装配件(Assembly),装配件必须注册到GAC或者同调用COM组件的客户端程序处于同一个目录,而Windows系统中IE浏览器和Windows资源管理器都支持嵌入式面板扩展,而这两个客户端处于不同的目录下,比如Windows资源管理器位于Windows目录下,而IE浏览器则会在Program Files目录下的某个子目录下。因为,两个COM客户端程序会处于不确定的目录下,因此将Assembly注册到GAC中通常是一个更好的选择。同时,需要注意的是注册到GAC中的Assembly必须是有强名称有密匙的Assembly。密匙可以使用.Net Framework带的sn.exe来生成。
执行Sn –k IEBand.snk将生成一个新的snk。
然后,将AssemblyInfo.cs中的密匙文件属性改为
[assembly: AssemblyKeyFile("..\\..\\IEBand.snk")]
将一个组件注册到GAC中,可以使用.Net Framework中的gacutil.exe,而将.Net Assembly注册为COM Server的话,可以使用regasm.exe程序。
gacutil /if Interop.SHDocVw.dll (因为引用了Interop.SHDocVw.dll,因此也要加到GAC中)
gacutil /if IEBand.dll
regasm IEBand.dll
最后
实际上面板对象不仅仅可以应用在IE中,外壳的任务条的快捷方式工具条实际上也是面板组件,也可以采用本文提到的类似的技术来实现,除了要填写的注册表项内容不同外,其它大同小异,因此这里就不赘述了。