Web 应用程序 - 使用 ATL 高级托管 WebBrowser 控件





5.00/5 (16投票s)
处理托管 WebBrowser 控件的实际挑战的示例:独立创建托管窗口和控件、键盘快捷键、控件尺寸等。
目录
引言
随着现代复杂 Web 应用程序的出现,Web 浏览器已成为我们台式计算机上最常用的应用程序之一。然而,大多数现代浏览器仍然保持传统的浏览器外观:地址栏、导航按钮、网页选项卡、搜索框等。在使用许多替代现有桌面应用程序的复杂 Web 应用程序时,这些 GUI 元素并非都是必需的。通常,用户不会使用导航按钮进行来回导航,也不会使用浏览器的菜单,并且更倾向于为应用程序提供一个独立的窗口。简而言之,大多数用户希望 Web 应用程序尽可能类似于其桌面上的“大兄弟”。
不幸的是,目前只有一种浏览器在一定程度上支持模仿桌面应用程序的 Web 应用程序:那就是 Google Chrome 浏览器。使用 Google Chrome,用户可以获取任意 URL 并创建一个 Windows 快捷方式,该快捷方式可以放置在桌面、开始菜单、快速启动栏等位置。启动快捷方式会打开一个精简的浏览器窗口,其中不包含通常的浏览器 GUI 元素。以下是 Chrome 应用程序快捷方式的描述:选项卡和窗口:应用程序快捷方式。Chrome 应用程序唯一的缺点是,点击应用程序内的链接将仅在 Google Chrome 浏览器中打开。真正的桌面应用程序应在用户的默认浏览器中打开 Web 链接,无论该浏览器是什么。
第一次尝试:HTA 宿主
Microsoft Windows 提供了一项绝妙但未被充分利用的技术:HTML 应用程序(HTML Application),简称 HTA。HTA 允许以完整桌面应用程序的安全权限运行常规 HTML 页面。借助 HTA,可以像创建网页和编写 JavaScript 一样廉价地创建桌面应用程序。无需二进制代码或编译。以下是一个仅使用 HTML 和脚本编写的 HTML 编辑器示例:如何创建 HTML 编辑器应用程序。
为了让 HTA 成为任意 URL 的宿主,我们需要创建一个 HTML 页面,其中包含一个占据页面全部空间的 IFRAME
元素。为了防止框架内容访问外部系统,我们需要指定一个 HTA 特有的属性:为 IFRAME
元素设置 application="no"
。以下是 WebApp HTA 的 HTML 部分(为清晰起见省略了脚本部分)
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html>
<head>
<style>
html, body
{
height: 100%
}
</style>
<title>Web Application</title>
<hta:application
id="oHTA"
icon="http://www.microsoft.com/windows/Framework/images/favicon.ico"
innerBorder="no"
scroll="no"
/>
<script language="vbscript" type="text/vbscript">
...
</script>
</head>
<body leftmargin="0" topmargin="0"
rightmargin="0" bottommargin="0">
<iframe
id="content"
height="100%"
width="100%"
frameborder="0"
application="no"
/>
</body>
</html>
HTA WebApp 的完整源代码可以在上面的链接中找到。
HTA Web 应用程序为大多数用户提供了足够的解决方案。然而,它也有几个缺点:
- 新窗口始终在新独立的 Internet Explorer 浏览器中打开。
- 无法在运行时更改应用程序图标。
- 无法捕获内容框架的标题更改,因此我们需要从外部文档脚本轮询标题。
现有解决方案总结
以下是现有解决方案的摘要,包括 WebApp 专用客户端,以供比较:
Google Chrome | HTA | WebApp | |
---|---|---|---|
新窗口 | 在现有的 Google Chrome 浏览器实例中打开。 | 在新的 Internet Explorer 浏览器实例中打开,即使已存在运行实例。 | 根据用户的设置,在新选项卡或新进程中,在默认 Web 浏览器中打开。 |
窗口图标 | 自动使用站点的 favicon 作为窗口图标。 | 图标无法在运行时更改。如果 HTA 源文件中明确指定,可以使用站点的 favicon。 | 自动使用站点的 favicon 作为窗口图标。 |
标题更改 | 标题即时更改。 | 必须从脚本轮询标题。 | 标题即时更改。 |
状态栏 | 无状态栏。 | 无状态栏。 | 状态栏可切换开关。 |
第二次尝试:C++ ATL 宿主
为了完全控制 WebBrowser 控件,我们需要自己托管它。ATL 框架是处理大量 COM 代码的自然选择。这就是 WebApp 项目使用 ATL 作为辅助工具的原因。
ATL 如何托管 ActiveX 控件
ATL 托管 ActiveX 控件主要在两个类中实现:CAxWindow
和 CAxHostWindow
。CAxWindow
是开发人员应直接在其代码中使用的类。CAxHostWindow
是一个未公开的类,它实现了 ActiveX 宿主的底层细节。CAxHostWindow
实例由 CAxWindow
类在后台创建。用户可以通过 IAxWinHostWindow
接口控制 CAxHostWindow
实例,该接口可通过 CAxWindow::QueryHost
调用获取。以下是托管的 ActiveX 控件创建的执行流程:
- 运行
CAxWindow::CAxWindow()
构造函数- 调用
AtlAxWinInit()
函数- 注册两个特殊的窗口消息:
WM_ATLGETHOST
、WM_ATLGETCONTROL
- 注册特殊的窗口类:
AtlAxWinNN
、AtlAxWinLicNN
,其中 NN 是 ATL 版本号。例如,AtlAxWin90
。这些类的窗口过程是:AtlAxWindowProc
。
- 注册两个特殊的窗口消息:
- 调用
- 用户调用
CAxWindow::Create
函数- 创建新窗口
- 在
AtlAxWindowProc
过程的WM_CREATE
处理程序中- 创建新的
CAxHostWindow
COM 对象 CAxHostWindow
对象子类化新创建的窗口CAxHostWindow
创建托管的 ActiveX 控件实例CAxHostWindow
将自身设置为托管控件的 OLE 站点
- 创建新的
从此时开始,用户可以通过 CAxWindow::QueryControl
调用访问托管的 ActiveX 控件。
缩放问题
如果 WebBrowser 控件在调用宿主的 IOleInPlaceSite::OnPosRectChange
方法时传递了错误的参数,默认的 ATL 托管实现将会出现问题。假设网页具有以下脚本:
window.open("page.htm", null, "left=150,top=150,height=500,width=400");
现在,这些窗口坐标不相同。Left 和 Top 值是屏幕坐标,而 Height 和 Width 值是新窗口客户区的尺寸。显然,WebBrowser 控件不关心差异,而是将新坐标原样传递给其宿主。ATL 中 IOleInPlaceSite::OnPosRectChange
方法的默认实现如下:
// CAxHostWindow::OnPosRectChange
STDMETHOD(OnPosRectChange)(LPCRECT lprcPosRect)
{
ATLTRACE2(atlTraceHosting, 0, _T("IOleInPlaceSite::OnPosRectChange"));
if (lprcPosRect==NULL) { return E_POINTER; }
// Use MoveWindow() to resize the CAxHostWindow.
// The CAxHostWindow handler for OnSize() will
// take care of calling IOleInPlaceObject::SetObjectRects().
// Convert to parent window coordinates for MoveWindow().
RECT rect = *lprcPosRect;
ClientToScreen( &rect );
HWND hWnd = GetParent();
// Check to make sure it's a non-top-level window.
if(hWnd != NULL)
{
CWindow wndParent(hWnd);
wndParent.ScreenToClient(&rect);
wndParent.Detach ();
}
// Do the actual move.
MoveWindow( &rect);
return S_OK;
}
以上实现对于大多数情况都很好。然而,在我们的案例中,代码没有考虑到坐标之间的差异,因此会将 WebBrowser 控件窗口移动到其父窗口内,而不是移动父窗口本身并调整父窗口的客户区。
如何在不修改 ATL 源代码的情况下重写 IOleInPlaceSite::OnPosRectChange
方法?答案是:通过聚合 CAxHostWindow
控件并提供我们自己的 IOleInPlaceSite
接口实现。但是,我们还不能聚合 CAxHostWindow
控件,因为它是在 AtlAxWindowProc
过程的 ATL 实现深处创建的。因此,我们需要单独创建一个托管窗口和 CAxHostWindow
对象实例,并明确提供我们自己的宿主对象作为外部对象的控制器。
独立创建托管窗口和托管控件
CBrowserHost
类使用与 CAxHostWindow
类相同的方法维护 WebBrowser 控件的托管窗口:CBrowserHost
不是一个常规的 C++ 类,而是一个 COM 对象,它实现了自己的 COM 接口用于内部使用。首先,我们声明 IBrowserHost
COM 接口:
struct IBrowserHost : public IUnknown
{
public:
virtual HRESULT STDMETHODCALLTYPE CreateHostWindow(
/* [in] */ HWND hwndParent,
/* [in] */ int nShowCmd,
/* [in] */ bool bMainWindow) = 0;
virtual HRESULT STDMETHODCALLTYPE GetWebBrowser(
/* [out] */ SHDocVw::IWebBrowser2** ppWebBrowser) = 0;
virtual HRESULT STDMETHODCALLTYPE SetUrl(
/* [string][in] */ LPCWSTR pwszUrl) = 0;
virtual HRESULT STDMETHODCALLTYPE PreTranslateMessage(
/* [in] */ const MSG& msg) = 0;
};
然后,我们创建一个静态 CBrowserHost::Create
函数来创建对象实例。首先,该函数创建一个 BrowserHost
COM 控件的新实例,然后使用 IBrowserHost::CreateHostWindow
方法创建托管窗口和 WebBrowser 控件。以下是 BrowserHost
创建的执行流程:
- 用户调用
CBrowserHost::Create
静态方法。- 创建一个新的重叠窗口。
- 调用
CBrowserHost::_CreatorClass::CreateInstance
并创建一个新的BrowserHost
实例。该实例实现了重叠窗口的窗口过程。- 在
CBrowserHost::FinalConstruct
方法中,我们创建一个聚合的CAxHostWindow
对象。
- 在
- 调用
IBrowserHost::CreateHostWindow
方法。- 创建一个新的子窗口。此子窗口将托管 WebBrowser 控件。
CAxHostWindow
对象将对此窗口进行子类化。 - 调用
IAxWinHostWindow::CreateControl("Shell.Explorer", ...)
。创建 WebBrowser 实例。对子窗口进行子类化。
- 创建一个新的子窗口。此子窗口将托管 WebBrowser 控件。
以下是相关代码部分:
// static
CBrowserHostPtr CBrowserHost::Create(
HWND hwndParent,
int nShowCmd,
bool bMainWindow)
{
CComPtr<IUnknown> ptrUnk;
HRESULT hr = CBrowserHost::_CreatorClass::CreateInstance(
NULL, IID_PPV_ARGS(&ptrUnk));
ATLASSERT(SUCCEEDED(hr));
CBrowserHostPtr ptrBrwHost = ptrUnk;
if(ptrBrwHost)
{
hr = ptrBrwHost->CreateHostWindow(hwndParent, nShowCmd, bMainWindow);
ATLASSERT(SUCCEEDED(hr));
if(FAILED(hr)) ptrBrwHost.Release();
}
return ptrBrwHost;
}
HRESULT CBrowserHost::FinalConstruct()
{
ATLVERIFY(InitSettings());
HRESULT hr = CAxHostWindow::_CreatorClass::CreateInstance(
static_cast<IBrowserHost*>(this), IID_PPV_ARGS(&m_ptrUnkInner));
ATLASSERT(SUCCEEDED(hr));
if(SUCCEEDED(hr))
m_ptrOleInPlaceSite = m_ptrUnkInner;
return hr;
}
// IBrowserHost::CreateHostWindow
STDMETHODIMP CBrowserHost::CreateHostWindow(
/* [in] */ HWND hwndParent,
/* [in] */ int nShowCmd,
/* [in] */ bool bMainWindow)
{
m_bMainWindow = bMainWindow;
HRESULT hr = CreateWebBrowser(hwndParent);
if(FAILED(hr))
return hr;
ShowWindow(nShowCmd);
return hr;
}
HRESULT CBrowserHost::CreateWebBrowser(HWND hwndParent)
{
// ...
HRESULT hr = E_FAIL;
CComQIPtr<IAxWinHostWindow> ptrWinHost = m_ptrUnkInner;
if(ptrWinHost)
{
// m_webBrowser - child window
hr = ptrWinHost->CreateControl(L"Shell.Explorer", m_webBrowser, NULL);
ATLASSERT(SUCCEEDED(hr));
}
if(SUCCEEDED(hr))
hr = HookUpWebBrowser(); // subscribe to browser's events, etc.
return hr;
}
注意:AtlAxWindowProc
过程在 WM_CREATE
消息时调用 OleInitialize
,在 WM_NCDESTROY
消息时调用 OleUninitialize
。至关重要的是调用 OleInitialize
而不是 CoInitialize[Ex]
,以便启用拖放、剪贴板和其他 OLE 相关操作。为确保 OleInitialize
是第一个 COM 调用,我们需要重写 CAtlExeModuleT::InitializeCom
方法的默认实现。
class CWebAppModule : public CAtlExeModuleT<CWebAppModule>
{
public:
static HRESULT InitializeCom() throw()
{
// This is to make sure drag drop and clipboard works.
return ::OleInitialize(NULL);
}
static void UninitializeCom() throw()
{
::OleUninitialize();
}
// ...
};
聚合 CAxHostWindow 对象
我们唯一感兴趣的接口是 IOleInPlaceSite
。该接口由 BrowserHost
对象实现。该接口中唯一需要特殊处理的方法是 IOleInPlaceSite::OnPosRectChange
。所有其他方法的调用都转发到 CAxHostWindow
对象提供的实现。
class ATL_NO_VTABLE CBrowserHost :
public CComObjectRoot,
public CComCoClass<CBrowserHost, &CLSID_NULL>,
public IOleInPlaceSite,
public IBrowserHost,
...
{
DECLARE_NO_REGISTRY();
// ...
BEGIN_COM_MAP(CBrowserHost)
// ...
COM_INTERFACE_ENTRY(IBrowserHost)
COM_INTERFACE_ENTRY(IOleInPlaceSite)
COM_INTERFACE_ENTRY_AGGREGATE_BLIND(m_ptrUnkInner)
END_COM_MAP()
};
COM_INTERFACE_ENTRY_AGGREGATE_BLIND
宏将所有接口查询重定向到内部 CAxHostWindow
对象。
CBrowserHost
类对 IOleInPlaceSite::OnPosRectChange
方法的实现是空的,并始终返回 E_NOTIMPL
代码。Web 浏览器窗口的实际缩放发生在响应以下 Web 浏览器事件时:
DWebBrowserEvents2::WindowSetLeft
DWebBrowserEvents2::WindowSetTop
DWebBrowserEvents2::WindowSetWidth
DWebBrowserEvents2::WindowSetHeight
新建窗口请求
响应 WebBrowser 控件的新窗口请求的传统方法是处理 DWebBrowserEvents2::NewWindow3
(或 NewWindow2
)浏览器事件。
void DWebBrowserEvents2::NewWindow2(
IDispatch **ppDisp,
VARIANT_BOOL *Cancel
);
void DWebBrowserEvents2::NewWindow3(
IDispatch **ppDisp,
VARIANT_BOOL *Cancel,
DWORD dwFlags,
BSTR bstrUrlContext,
BSTR bstrUrl
);
应用程序有机会为新窗口提供一个新的 WebBrowser 控件实例,或者完全取消新窗口。大多数托管应用程序允许控件自行创建新窗口。WebApp 在这方面有所不同。我们需要实现以下逻辑:
- 如果新窗口在逻辑上属于当前打开的 Web 应用程序,则在我们的进程中创建一个新的 WebBrowser 控件实例并将其用于新窗口。这样,新浏览器窗口将与主应用程序窗口共享安全上下文。
- 如果新窗口不属于 Web 应用程序,则根据用户的设置在默认浏览器中打开它:新窗口或新选项卡。
ShellExecute[Ex]
函数正是这样做。
处理第一种情况很简单。我们只需创建一个新的 BrowserHost
对象实例,从中查询 IDispatch
接口,然后将其传递给 WebBrowser 控件使用。
第二种情况更棘手。如果我们使用指定的 URL 调用 ShellExecute[Ex]
函数,则需要取消托管 WebBrowser 控件的新窗口请求,这样新窗口就不会被打开两次。然而,一些 Web 应用程序会检测到这一点,并显示一个误导人的消息框,要求用户禁用弹出窗口阻止程序。该消息不正确,因为新窗口是在默认浏览器中打开的,而不是被实际阻止的。因此,在 DWebBrowserEvents2::NewWindow3/2
事件中处理新窗口请求为时已晚。
INewWindowManager 接口
幸运的是,托管应用程序可以通过实现 INewWindowManager
接口来指示 WebBrowser 控件如何创建新窗口。该接口只有一个方法:INewWindowManager::EvaluateNewWindow
。因此,是否将新窗口作为我们进程的一部分打开的实际决定在该方法中做出。
// INewWindowManager::EvaluateNewWindow
// Return values:
// S_OK - Allow display of the window.
// S_FALSE - Block display of the window.
// E_FAIL - WebBrowser control will use the default implementation.
STDMETHODIMP CBrowserHost::EvaluateNewWindow(
/* [string][in] */ LPCWSTR pszUrl,
/* [string][in] */ LPCWSTR pszName,
/* [string][in] */ LPCWSTR pszUrlContext,
/* [string][in] */ LPCWSTR pszFeatures,
/* [in] */ BOOL fReplace,
/* [in] */ DWORD dwFlags,
/* [in] */ DWORD /*dwUserActionTime*/)
{
HRESULT hr = E_FAIL; // let the WebBrowser control decide
if(::OpenAsExternal(dwFlags))
{
hr = S_FALSE;
const int res = (int)::ShellExecuteW(m_hWnd, L"open",
pszUrl, NULL, NULL, SW_SHOWNORMAL);
ATLASSERT(res > 32);
}
return hr;
}
// dwFlags - one or more of NWMF flags
bool OpenAsExternal(DWORD dwFlags)
{
const DWORD dwOwnWndFlags =
NWMF_SHOWHELP |
NWMF_HTMLDIALOG |
NWMF_SUGGESTWINDOW;
return (dwFlags & dwOwnWndFlags) == 0;
}
OpenAsExternal
函数回答了新窗口是否应属于托管应用程序还是外部浏览器的问题。通过反复试验,我发现上述 NWMF
标志的组合产生了最准确的结果。
INewWindowManager
接口必须通过 SID_SNewWindowManager
服务公开。这意味着我们的托管对象必须实现 IServiceProvider
接口。
class ATL_NO_VTABLE CBrowserHost :
public INewWindowManager,
public IServiceProviderImpl<CBrowserHost>,
...
{
BEGIN_SERVICE_MAP(CBrowserHost)
SERVICE_ENTRY(SID_SNewWindowManager)
END_SERVICE_MAP()
// ...
};
IServiceProviderImpl
实现和服务映射由 ATL 提供。
处理特殊键和快捷键,WM_FORWARDMSG 消息
为了启用页面控件之间的键盘导航,我们需要处理 Tab、PgUp、PgDown、Space 等特殊按键。这是通过调用 WebBrowser 控件的 IOleInPlaceActiveObject::TranslateAccelerator
方法来完成的。如果控件决定翻译消息,它将从该方法返回 S_OK
,否则返回 S_FALSE
。我们不需要从 WebBrowser 控件查询 IOleInPlaceActiveObject
接口,CAxHostWindow
对象已经完成了这项工作。我们只需要通过发送一个 ATL 特定的消息 WM_FORWARDMSG
将当前消息转发给 CAxHostWindow
对象。
// Return values:
// S_OK - The message is translated by the control.
// S_FALSE - The message does not require translation.
STDMETHODIMP CBrowserHost::PreTranslateMessage(
/* [in] */ const MSG& msg)
{
if((msg.message < WM_KEYFIRST || msg.message > WM_KEYLAST) &&
(msg.message < WM_MOUSEFIRST || msg.message > WM_MOUSELAST))
{
return S_FALSE;
}
if(!m_webBrowser.IsWindow())
return S_FALSE;
const LRESULT lTranslated = m_webBrowser.SendMessage(WM_FORWARDMSG, 0,
reinterpret_cast<LPARAM>(&msg));
return (lTranslated ? S_OK : S_FALSE);
}
// ATL's handling of WM_FORWARDMSG
// m_spUnknown - a pointer to hosted WebBrowser control.
LRESULT CAxHostWindow::OnForwardMsg(
UINT /*uMsg*/,
WPARAM /*wParam*/,
LPARAM lParam,
BOOL& /*bHandled*/)
{
ATLASSERT(lParam != 0);
LPMSG lpMsg = (LPMSG)lParam;
CComQIPtr<IOleInPlaceActiveObject> spInPlaceActiveObject(m_spUnknown);
if(spInPlaceActiveObject)
{
if(spInPlaceActiveObject->TranslateAccelerator(lpMsg) == S_OK)
return 1;
}
return 0;
}
CBrowserHost::PreTranslateMessage
方法在收到的窗口消息的消息循环中被调用。
学到的教训
回顾已完成的工作,可以得出以下结论:
- 聚合
CAxHostWindow
对象可能不是最佳选择。独立创建托管窗口和控件本身使周围的代码相当复杂。这种复杂性完全是为了重用CAxWindow
类,否则该类将被重写以更好地满足应用程序的需求。 - 更好的解决方案可能是:继承
CAxHostWindow
类,以重写相关的 COM 接口实现,而不涉及聚合。 - ATL 框架是 COM 开发的绝佳工具,但有时也极其缺乏灵活性。ActiveX 托管机制深埋在 ATL 内部代码中,很少有文档记录,并且需要大量工作才能进行自定义。
- 通用控件库在过去 15 年中一直存在 Bug 且文档不足。
就是这样。
历史
- 2009 年 10 月 12 日 - 修复 Bug
- 脚本错误对话框不再显示
- 2009 年 10 月 13 日 - 修复 Bug
IOleCommandTarget
接口已正确实现- 脚本错误不再显示
- 进度通知已正确处理,页面不再显示为无限加载