首先谈BHO的开发工具,我偏向使用VC++(unmanaged C++) 作为开发工具,因为Java JVM或.Net CLR的虚拟机是个很笨重的东西,也是内存杀手, 并不具备写plugin的快捷轻巧的特点.个人并不喜欢将其作为Plug-in的开发平台,不过我会有另文说明用C#开发BHO的全过程, 作为那些偏重开发效率的同学的参考.

其次是类库的选择,我倾向利用“活动模板库”(ATL) 来开发使用 C++ 的 BHO。之所以使用 ATL,是因为它方便地实现了我们可以按需进行扩展的基本样板。尽管使用“Microsoft 基础类”(MFC) 或 Win32 API 和 COM)也可以创建BHO,但 ATL 是为我们提供了自动处理许多细节的轻型库,包括建立含有 BHO 类标识符 (CLSID) 的注册表。

ATL 的另一个优势在于它的 COM智能感知指针类(例如,CComPtr 和 CComBSTR),这些类可管理 COM 对象的生命周期。例如,CComPtr 在赋值时会调用 AddRef,而在对象被销毁或超出范围时会调用 Release。智能指针简化了代码并且有助于避免内存泄漏。当在单个方法范围内使用时,它们的稳定性和可靠性尤为有用。

介绍完ATL, 我们也简单介绍一下BHO. BHO是将自定义功能添加到 Internet Explorer 的轻型 DLL 扩展,除了IE, BHO 还可以将功能添加到 Windows 资源管理器外壳程序.

BHO 通常并不提供其自身的任何用户界面 (UI)。它们而是通过在后台响应浏览器事件和用户输入数据来发挥作用。例如,BHO 可以拦截弹出窗口、自动填充窗体或为鼠标手势添加支持。

有一种常见误解认为工具栏扩展项需要 BHO.但如果将 BHO 与工具栏配合使用,则可以实现更丰富的用户体验。(关于IE工具栏的编程,在另一篇文章中说明).

BHO 的生命周期与它所交互的浏览器实例的生命周期相等。在 IE 6 和早期版本中,这意味着要为每个新的顶层窗口创建(和销毁)一个新 BHO。在IE 7中则是为每个选项卡都创建和销毁一个新 BHO。


BHO 必须实现 IObjectWithSite 接口, 该接口提供了两个方法GetSite和SetSite。根据MSDN的说明:  

GetSite:  Gets the last site set with IObjectWithSite::SetSite. If there is no known site, the object returns a failure code.

SetSite:  Provides the site's IUnknown pointer to the object.

我们主要是对后者进行调用,此方法方便了与 Internet Explorer 的初始通信,并会在其将要释放时通知 BHO。我们实现此接口,然后将 BHO 的 CLSID 添加到注册表中,就可以创建一个简单的浏览器扩展。过程如下:

1. 在Visual Studio中,选择VC++中的ATL项目, 创建一个新的项目MySolutionPlugin, 在随后的向导中,确认Server Type是Dll, Visual Studio会为我们创建程序的模板.

2. 为该项目添加我们的程序主体, (不熟悉visual studio的同学在资源浏览器里的右键菜单里选 add-->class, 可别选到New Item), 类型选ATL Simple Object ,  short name命名为RayBHO,各项属性如下: 
a) “线程模型” ---“Apartment”
b) “聚合”---“否”
c) “接口”---“双重”
d) “支持”---勾上“IobjectWithSite”。

具体的含义请参考MSDN.

一般来说,Internet Explorer 至少调用SetSite方法两次: 一次用于建立连接,另一次则是在浏览器退出时。我们 BHO 中的 SetSite 实现将执行以下操作:

•存储对站点的引用。在初始化期间,浏览器将 IUnknown 指针传递给顶层 WebBrowser 控件,然后 BHO 将对它的引用存储在一个专用成员变量中。

•释放目前被占用的站点指针。Internet Explorer 传递 NULL 时,BHO 必须释放所有接口引用并且断开与浏览器的连接。

要实现SetSite,我们需手工在添加一个public的方法:

STDMETHOD(SetSite)(IUnknown * pUnkSite);

STDMETHOD 宏是将方法标记为虚方法并且确保其具有适用于公共 COM 接口的调用约定的一个ATL 约定, 它有助于区分 COM 接口和该类中可能存在的其他公共方法。其实现成员方法时应相应使用 STDMETHODIMP 宏。同时我们需要声明一个私有变量来保存Browser的指针"

CComPtr m_spWebBrowser;//保存Browser指针的私有变量

然后是SetSite的实现 

STDMETHODIMP CRayBHO::SetSite(IUnknown*pUnkSite)
{
if(pUnkSite!=NULL)
{
//缓存指向IWebBrowser2的指针。
pUnkSite->QueryInterface(IID_IWebBrowser2,(void**)&m_spWebBrowser);
}
else
{
//在此释放缓存的指针和其他资源。
m_spWebBrowser.Release();
}
//返回基类实现
return IObjectWithSiteImpl::SetSite(pUnkSite);
}

从上面的介绍我们知道, 初始化期间,浏览器将传递一个对其顶层 IWebBrowser2 接口(我们对其进行缓存处理)的引用。浏览器关闭时将传递 NULL,为避免内存泄漏和循环引用计数,此时释放所有指针和资源非常重要。最后,我们调用基类实现以便继续执行接口合约的其余部分。

加载DLL 后,系统将通过 DLL_PROCESS_ATTACH 通知调用 DllMain 函数。由于 Internet Explorer 大量使用多线程,因此,对 DllMain 的频繁的 DLL_THREAD_ATTACH 和 DLL_THREAD_DETACH 通知会降低扩展和浏览器进程的整体性能。

如果BHO 不需要线程级的跟踪,我们可以在 DLL_PROCESS_ATTACH 通知期间调用 DisableThreadLibraryCalls 以避免新线程通知的额外开销。修改DllMain.cpp 中的DllMain函数:

extern "C" BOOL WINAPI DllMain(HINSTANCE hInstance, DWORD dwReason, LPVOID lpReserved)
{
if(dwReason==DLL_PROCESS_ATTACH)
{
DisableThreadLibraryCalls(hInstance);
}
return _AtlModule.DllMain(dwReason,lpReserved);
}

要使BHO工作,我们还需要把BHO 的 CLSID 添加到注册表中。此条目会将 此DLL 标记为浏览器帮助程序对象,并使 Internet Explorer 在启动时加载 BHO。我们可以在MySolutionPlugin.idl中找到该BHO的CLSID.幸运的,Visual Studio会帮助我们实现这些, 你看到: 

importlib("stdole2.tlb");
[
uuid(057F3E68-6C2E-40A5-A641-E8CF9D6766F3),
helpstring("RayBHO Class")
]

您的机器的CLSID可能有所不同, 接着打开RayBHO.rgs文件,添加入:

HKLM
{
NoRemove SOFTWARE
{
NoRemove Microsoft
{
NoRemove Windows
{
NoRemove CurrentVersion
{
NoRemove Explorer
{
NoRemove 'Browser Helper Objects'
{
ForceRemove {057F3E68-6C2E-40A5-A641-E8CF9D6766F3} = s 'RayBHO Class'
{
val NoExplorer = d '1'
}
}
}        
}
}
}
}
}

这一段是为了在注册表里添加一个双字节的NoExplorer=1的键,不让Windows Explorer加载该BHO,因此该BHO只能在ie中运行.

你可以编译这个BHO. 如果一切正常, 你可以在IE的管理加载项里看到这个BHO.

如果不幸报错: 该BHO无法被注册,根据我的经验,原因大概有2类,可以依次检查

1. 你是否有管理员权限以修改注册表,如不是管理员身份,可以在菜单上右击Microsoft Visual studio 2008,从右键菜单中选择"运行方式"...
2. 你的注册表条目语法是否正确,或者含有非法字符.

上一篇文章开发的RayBHO只是BHO的一个框架,根本不具备任何功能.

在这篇文章里,我们将使继续扩展这个BHO,让它具备更强的功能.首先我们学习如何让BHO接收IE的事件通知,接者学习为ie添加一个按钮,并让BHO对按钮做出响应.

要让BHO能接收事件通知, 它必须让处理函数与浏览器事件建立连接点. 为响应这些事件,它必须实现IDispEventImpl, ATL提供了一个默认实现,可以帮助简化这个事件处理逻辑。
在RayBHO.h添加:

#include "exdispid.h"
#include "shlguid.h"

我们的CRayBHO必须派生自IDispEventImpl,修改后的代码如下:

class ATL_NO_VTABLE CRayBHO :
public CComObjectRootEx,
public CComCoClass,
public IObjectWithSiteImpl,
public IDispatchImpl,
public IDispEventImpl<1,CRayBHO,&DIID_DWebBrowserEvents2,&LIBID_SHDocVw,1,1>

DispEventImpl为处理事件提供了一种简单安全的方法。

IDispEventImpl与事件路由表配合工作,可以将事件路由到相应的处理程序函数。在例子中,我们将"DocumentComplete"的事件交由OnDocumentComplete函数进行处理.

在public段添加路由表:

BEGIN_SINK_MAP(CHelloWorldBHO)
SINK_ENTRY_EX(1, DIID_DWebBrowserEvents2, DISPID_DOCUMENTCOMPLETE, OnDocumentComplete)
END_SINK_MAP()

上述声明中SINK_ENTRY_EX(1,...)中的"1"与接口声明中的IDispEventImpl<1,....>是对应的,在必要时可以用于区分来自不同接口的事件.

DocumentComplete将被路由到处理函数OnDocumentComplete:

void STDMETHODCALLTYPE OnDocumentComplete(IDispatch* pDisp, VARIANT* URL);

它的参数和参数顺序与DocumentComplete事件所定义的相同,另请注意,不要试图从事件处理程序返回值,这是因为 Internet Explorer 会忽略任何从 Invoke 返回的值.
我们还声明了一个私有变量来跟踪事件映射的处理情况

BOOL m_fAdvised; 

SetSite函数中必须处理事件派遣:

STDMETHODIMP CRayBHO::SetSite(IUnknown*pUnkSite)
{
if(pUnkSite!=NULL)
{
HRESULT hr;
CComPtr sp;

hr = pUnkSite->QueryInterface(&sp);
if(SUCCEEDED(hr) && sp)
{
//缓存指向IWebBrowser2的指针
hr = sp->QueryService(IID_IWebBrowserApp, IID_IWebBrowser2, (void**)&m_spWebBrowser);

if(SUCCEEDED(hr)&&m_spWebBrowser!=0)
{
//注册DWebBrowserEvents2事件。
hr=DispEventAdvise(m_spWebBrowser2);

if(SUCCEEDED(hr))
{
m_fAdvised=TRUE;
}
}
}
m_spUnkSite = pUnkSite;
this->m_bIsIe7=this->IsIE7();

//hr = sp->QueryInterface(IID_IOleCommandTarget,(void**)&m_spTarget);
//this->GetInternetExplorerVersion();

}
else
{
//取消注册事件。
if(m_fAdvised)
{
DispEventUnadvise(m_spWebBrowser);
m_fAdvised=FALSE;
}
//在此释放缓存的指针和其他资源。
m_spWebBrowser.Release();
//m_spTarget.Release();
}
//调用基类实现。
return IObjectWithSiteImpl::SetSite(pUnkSite);
}

我从网上找了一个OnDocumentComplete函数的例子并将之修改成范型,它对HTML Dom进行操作,将图像的属性设置为Display:None, 具体操作与javascript类似,不再赘述.

void    STDMETHODCALLTYPE    CRayBHO::OnDocumentComplete(IDispatch*pDisp,VARIANT*pvarURL)
{
HRESULT hr = S_OK; 
// 查询 IWebBrowser2 接口。 
CComQIPtr spTempWebBrowser = pDisp; 
// 此事件是否与顶级浏览器相关联? 
if (spTempWebBrowser && m_spWebBrowser && m_spWebBrowser.IsEqualObject(spTempWebBrowser)) 

// 从浏览器中获取当前文档对象…… 
CComPtr  spDispDoc; 
hr = m_spWebBrowser->get_Document(&spDispDoc); 
if (SUCCEEDED(hr)) 

// ……并查询 HTML 文档。 
CComQIPtr spHTMLDoc = spDispDoc; 
if (spHTMLDoc != NULL) { 
// 最后,删除这些图像。 
RemoveImages(spHTMLDoc); 



}

void CRayBHO::RemoveImages(IHTMLDocument2* pDocument) 

CComPtr spImages; 
// 从 DOM 中获取图像集。 
HRESULT hr = pDocument->get_p_w_picpaths(&spImages); 
if (hr == S_OK && spImages != NULL) { 
// 获取集合中的图像数。 
long cImages = 0; 
hr = spImages->get_length(&cImages); 
if (hr == S_OK && cImages > 0) 

for (int i = 0; i < cImages; i++) 

CComVariant svarItemIndex(i); 
CComVariant svarEmpty; 
CComPtr spdispImage; 
// 按索引从集合中获取图像。 
hr = spImages->item(svarItemIndex, svarEmpty, &spdispImage); 
if (hr == S_OK && spdispImage != NULL) 

// 首先,查询通用 HTML 元素接口…… 
CComQIPtr spElement = spdispImage; 
if (spElement) 

// ……然后请求样式接口。 
CComPtr spStyle; 
hr = spElement->get_style(&spStyle); 
// 设置 display="none" 以隐藏图像。 
if (hr == S_OK && spStyle != NULL) 

static const CComBSTR sbstrNone(L"none"); 
spStyle->put_display(sbstrNone);
}





}

利用VC++操作HTML并没有想象中的繁琐, 你可以开发出更有趣的东西,比如从数据库自动填表单的BHO等等.

接下来,我们要为IE增加一个按钮(注意不是toolbar,toolbar要复杂得多),基本这是一个注册表的魔术.打开RayBHO.rgs, 添加

HKLM
{
NoRemove Software
{
NoRemove Microsoft
{
NoRemove 'Internet Explorer'
{
NoRemove Extensions
{
ForceRemove '{1AC31710-6759-484f-A129-A70C55485DA1}'
{
val ButtonText = s 'Hello,World'
val Icon = s '%MODULE%,201'
val HotIcon = s '%MODULE%,202'
val CLSID = s '{1FBA04EE-3024-11d2-8F1F-0000F87ABD16}'
val ClsidExtension = s '{057F3E68-6C2E-40A5-A641-E8CF9D6766F3}'
val 'Default Visible' = s 'yes'
}
}
}
}
}
}

当然,你也可以把这一项放在HKCU(Current User)下,这样的话,该Button只对当前用户起作用.

这些注册表项说明如下:

ForceRemove '{1AC31710-6759-484f-A129-A70C55485DA1}' -- 该extersion的CLSID,请自己用GUID这个程序生成.

val ButtonText = s 'Hello,World' // 按钮上的文字说明

val Icon = s '%MODULE%,201' // 按钮的图标,可以是icon的绝对路径,也可以和我的例子一样从资源文件里加载.

val HotIcon = s '%MODULE%,202'// 鼠标悬停时按钮的图标,与Icon类似.

val CLSID = s '{1FBA04EE-3024-11d2-8F1F-0000F87ABD16}' //该CLSID意思为可执行,此值有特定含义,请小心修改.

val ClsidExtension = s '{057F3E68-6C2E-40A5-A641-E8CF9D6766F3}' // 这个是RayBHO的CLSID,即表示该按钮的动作连接到RayBHO这个com上,具体值有所不同,必须查询你自己的rgs文件得到.

val 'Default Visible' = s 'yes'//按钮可见.

当然你也可以不使用COM来响应按钮的动作,另外两个键Exec和Script,可以设置响应的程序或者脚本..这个不是重点.

现在编译,然后从IE的自定义工具栏将这个按钮拖出来...如图所示:

点点看.....结果呢? 当然是不起作用!

因为除以上步骤外,该com 对象还必须实现 IOleCommandTarget接口。

IOleCommandTarget包含QueryStatus和Exec两个方法,其中QueryStatus方法会被IE调用来获得当前菜单的状态.当工具条按钮被点击时,com 对象的 IOleCommandTarget::exec 方法被调用,此时ncmdid 的值为 1;当菜单项被点击时,ncmdid 的值为 2。这样开发者就能区分工具条按钮和菜单项这两个不同操作

首先让CRayBHO继承IOleCommandTarget接口,最后我们得到这样得一个继承树

class ATL_NO_VTABLE CRayBHO :
public CComObjectRootEx,
public CComCoClass,
public IObjectWithSiteImpl,
public IDispatchImpl,
public IDispEventImpl<1,CHelloWorldBHO,&DIID_DWebBrowserEvents2,&LIBID_SHDocVw,1,1>,
public IOleCommandTarget



再COM_MAP里增加

COM_INTERFACE_ENTRY(IOleCommandTarget)

最终得到

BEGIN_COM_MAP(CHelloWorldBHO)
COM_INTERFACE_ENTRY(IHelloWorldBHO)
COM_INTERFACE_ENTRY(IObjectWithSite)
COM_INTERFACE_ENTRY(IOleCommandTarget)
COM_INTERFACE_ENTRY(IDispatch)
END_COM_MAP()

声明IOleCommandTarget的两个方法

// IOleCommandTarget
STDMETHOD(Exec)(const GUID*, DWORD nCmdID, DWORD, VARIANTARG*, VARIANTARG* pvaOut);
STDMETHOD(QueryStatus)(const GUID* pguidCmdGroup, ULONG cCmds, OLECMD prgCmds[], OLECMDTEXT* pCmdText);

然后实现之,基本我们的重点放在Exec方法上...所以先给出QueryStatus的实现

STDMETHODIMP CRayBHO::QueryStatus(const GUID* pguidCmdGroup, ULONG cCmds, OLECMD prgCmds[], OLECMDTEXT* pCmdText)
{
if (cCmds == 0) return E_INVALIDARG;
if (prgCmds == 0) return E_POINTER;
prgCmds[0].cmdf = OLECMDF_ENABLED;

return S_OK;
}

在Exce方法中,可以放入你的响应逻辑,或者更简单的,只是一个MessageBox响应. 这里我给出一段实际的Video Player的代码,这个代码可以为按钮添加下拉菜单,而这个菜单实际是保存在资源文件中的!并且你可以看到如何在Exce响应菜单上点击事件.

STDMETHODIMP CRayBHO::Exec(const GUID*, DWORD nCmdID, DWORD, VARIANTARG*, VARIANTARG*)
{
if(m_spUnkSite == 0 || m_spWebBrowser == 0) return S_OK;
HRESULT hRes = S_OK;

CComPtr        pDocDisp;
CComQIPtr pHtmlDoc2;
hRes = m_spWebBrowser->get_Document(&pDocDisp);

if(SUCCEEDED(hRes) && pDocDisp)
{
hRes = pDocDisp->QueryInterface(IID_IHTMLDocument2, (void**)&pHtmlDoc2);
if(SUCCEEDED(hRes) && pHtmlDoc2)
{
SHANDLE_PTR nBrowser = 0;
m_spWebBrowser->get_HWND(&nBrowser);
HWND hWndParent = (HWND)nBrowser;

POINT pt;
GetCursorPos(&pt);

HINSTANCE hInstance = _AtlBaseModule.GetModuleInstance();

HMENU hMenu = LoadMenu(hInstance,MAKEINTRESOURCE(IDR_MENU_POPUP));
HMENU hMenuTrackPopup = GetSubMenu(hMenu, 0);

if(hMenuTrackPopup && hWndParent)
{
BOOL bIsChevron = FALSE;
HWND hWndMenuParent = NULL;
HWND hWndToolBar = NULL;

hWndMenuParent = hWndParent;
hWndToolBar = WindowFromPoint(pt);

if(m_bIsIe7)
{
HWND hWndIe7ActiveTab = hWndParent;
HWND hWnd = GetWindow(hWndParent, GW_CHILD);

// looking for the Internet Explorer_Server window
// this window should be a parent for TrackPopupMenu
if(hWnd)
{
TCHAR szClassName[MAX_PATH];
while(hWnd)
{
memset(szClassName,0,MAX_PATH);
GetClassName(hWnd, szClassName, MAX_PATH);
if(_tcscmp(szClassName,_T("TabWindowClass"))==0)
{
// the active tab should be visible
if(IsWindowVisible(hWnd))
{
hWnd = GetWindow(hWnd, GW_CHILD);
while(hWnd)
{
memset(szClassName,0,MAX_PATH);
GetClassName(hWnd, szClassName, MAX_PATH);

if(_tcscmp(szClassName,_T("Shell DocObject View"))==0)
{
hWnd = FindWindowEx(hWnd, NULL, _T("Internet Explorer_Server"), NULL);
if(hWnd) hWndIe7ActiveTab = hWnd;
break;
}
hWnd = GetWindow(hWnd, GW_HWNDNEXT);
}
}
}
hWnd = GetWindow(hWnd, GW_HWNDNEXT);
}
}

if(hWndIe7ActiveTab) hWndMenuParent = hWndIe7ActiveTab;

//strHWndMenuParent = _ltoa(hWndMenuParent, 10);
}

int nIDCommand = -1;
BOOL bRightAlign = FALSE;
if(hWndToolBar)
{
ScreenToClient(hWndToolBar,&pt);
int nButton = (int)::SendMessage(hWndToolBar, TB_HITTEST, 0, (LPARAM)&pt);
if(nButton>0)
{
TBBUTTON pTBBtn;
memset(&pTBBtn,0,sizeof(TBBUTTON));
if(::SendMessage(hWndToolBar, TB_GETBUTTON, nButton, (LPARAM)&pTBBtn))
{
nIDCommand = pTBBtn.idCommand;
RECT rcButton;
if(::SendMessage(hWndToolBar,TB_GETRECT,nIDCommand,(LPARAM)&rcButton))
{
pt.x = rcButton.left;
pt.y = rcButton.bottom;
ClientToScreen(hWndToolBar,&pt);

RECT rcWorkArea;
SystemParametersInfo(SPI_GETWORKAREA,0,(LPVOID)&rcWorkArea,0);
if(rcWorkArea.right-pt.x<150)
{
bRightAlign = TRUE;
pt.x = rcButton.right;
pt.y = rcButton.bottom;
ClientToScreen(hWndToolBar,&pt);
}
}
}
}
else
{
GetCursorPos(&pt);
bIsChevron = TRUE;
}
}

UINT nFlags = TPM_NONOTIFY|TPM_RETURNCMD|TPM_LEFTBUTTON;
if(bRightAlign) nFlags |= TPM_RIGHTALIGN;
else nFlags |= TPM_LEFTALIGN;

// draw pressed button
if(nIDCommand!=-1 && !bIsChevron) ::SendMessage(hWndToolBar, TB_PRESSBUTTON, nIDCommand,  MAKELPARAM(1,0));
// popup the menu
int nCommand = TrackPopupMenu(hMenuTrackPopup, nFlags, pt.x, pt.y, 0, hWndMenuParent, 0);
// release the button
if(nIDCommand!=-1 && !bIsChevron) ::SendMessage(hWndToolBar, TB_PRESSBUTTON, nIDCommand,  MAKELPARAM(0,0));

//CStringArray* m_EvNameArr = new CStringArray();
//m_EvNameArr->Add(strHWndMenuParent);

BOOL bFound = FALSE;
switch (nCommand)
{
case ID_CHIMP:
{
MessageBox(hWndParent,_T("Play Video"),_T("IEVideo --Ray"), MB_OK|MB_ICONEXCLAMATION);
}
break;
case ID_SELECT:
{
MessageBox(hWndParent,_T("Select Video"),_T("IEVideo --Ray"), MB_OK|MB_ICONEXCLAMATION);

}
break;
case ID_STOP:
{
MessageBox(hWndParent,_T("Stop Video"),_T("IEVideo --Ray"), MB_OK|MB_ICONEXCLAMATION);
}
break;
case ID_ABOUT:
{    
MessageBox(hWndParent,_T("About IEVideo"),_T("IEVideo --Ray"), MB_OK|MB_ICONEXCLAMATION);
}
break;
}
}
}
}
return S_OK;
}
最后的效果如下:
很有意思的是, 我被认为是windows的粉丝,但其实我工作里搞的是linux的driver. 论坛里的风气常常喜欢比较A比较B,或者痛斥A痛斥B, 但你深入了解了么?

原文发表于blogs.ejb.cc,版权为Ray_linn所有。