C++ At Work 专栏...
用键盘操作静态链接打开应用程序中的URLs
原著:Paul DiLascia
翻译:NorthTibet
下载源代码:CATWork0503.exe (184KB)
原文出处:Making Static Links Keyboard-Capable, Launching URLs from Your App
这几年我一直在公司的商业程序中使用你的 CStaticLink
类,在 1998 年 MSJ 三月刊里,你示范了如何给超链接添加手型光标,但现在我想以另一种方式使用这个类。在微软的 IE
浏览器中,可以用Tab键遍历Web页面中的任何超链接,然后按回车键以单击该链接。我能否让 CStaticLink
做同样的事情?我是不是有点得寸进尺?
Tom Gifford
如果你要我借给你一千美元,那才是得寸进尺呢——但用键盘操作 CStaticLink
则是合理要求。我可以想象得到,对于一个习惯用键盘的人来说尤其如此。我讨厌伸手去用无聊的鼠标(这就是为什么我用 Emacs
的原因),并且我讨厌部提供足够键盘支持的应用程序。所以你的要求一点都不过分;相反,你应该获得用户界面最优奖。
对于新读者来说,CStaticLink 是我早在1997年12月写的一个类,这个类可以让你在窗体或关于对话框中添加Web链接。CStaticLink
类基于 MFC 的静态控件类 CStatic。根据读者对这个类功能特性的要求,我陆陆续续对它进行了改进和增强,看来这次又有事情做了。CStaticLink
用鼠标单击来启动
URL,还有一些其它的友好功能,比如用标准的蓝/紫色绘制未访问/访问链接文本显示颜色(你可以定制),文本下划线以及当鼠标移到链接上时显示相应的鼠标光标。CStaticLink
甚至能在资源文件查找与控件有相同ID号的串资源,从而从自动加载 URL。如果你还没用过 CStaticLink,那就用用试试吧。
现在 Tom
明智地要我做一个可用键盘操作的静态链接。有两件最基本的事情要做,一是要让链接具备Tab式样,二是必须处理链接的键盘导航。我们就从建立 Tab
式样开始吧。
当用户按对话框中的 Tab 键时,Windows 将输入焦点移到Tab顺序的下一个控件。焦点之停留在具备Tab 属性的控件上——即具备
WS_TABSTOP 式样的控件。默认情况下,对话框编辑器不会创建具备 WS_TABSTOP
式样的静态控件。因为静态(Static)控件静态的,它不做任何事情,它们压根就不会变化,也不与用户交互。
然而,现在 CStaticLink 被赋予了鼠标单击能力,所以要求 Tab
属性也是合情合理的。用户界面的一个最重要的原则(请注意了,伙计)是:无论用户能用鼠标做什么,那么也必须能用键盘做。这样不仅仅对象我这种惯于使用键盘的人友好,而且也方便习惯使用鼠标的人,以防鼠标线断掉。也许你的用户使用无线鼠标,但鼠标电池在凌晨1:00点耗尽,报告要在当日早晨一上班提交——当你知道用键盘也能完成工作,那岂不是一件很棒的事情吗。
所以第一件事情是让静态链接具备 Tab Stop 属性,在资源文件中添加 WS_TABSTOP 即可,或者在 Visual Studio
.NET 中将控件的 Tabstop 属性置为 True(如图一所示)。
Figure 1 设置静态链接的 Tab Stop 属性
一旦你设置了控件的 Tab Stop 属性,对话框运行时,用户便能用 Tab
键操作它。现在唯一的问题是当用户这样做的时候,什么也不发生。没有任何可视迹象表示控件具有了输入焦点。如果你用Tab操作一个编辑框控件,它会显示一个一闪一闪的光标。如果是列表框,Windows
会在第一项周围画一个点状矩形虚线,如果是按钮,那么 Windows 会在按钮内画一个焦点矩形。但 CStaticLink 什么都不做。
所以我必须添加一些可视化提示以告诉用户“你在这里”。对于超链接来说就是锚点(<A> 元素),IE
浏览器是在链接文本周围画焦点矩形。那为什么不如法炮制呢?尤其是 Windows 有一个很方便的函数叫做(说来也奇怪)DrawFocusRect,这个函数有着极好的使用
XOR 光栅操作特性,所以第二次调用它便擦除焦点矩形。当你的控件获得焦点时绘制焦点矩形;失去焦点时擦除之。关键代码如下:
void CStaticLink::OnSetFocus(CWnd* /*pOldWnd*/)
{
DrawFocusRect();
}
void CStaticLink::OnKillFocus(CWnd* /*pNewWnd*/)
{
DrawFocusRect();
}
是不是很简单?即便是用户因为切换到其它程序而失去焦点(与用Tab移到其它控件相对),它们都能照样工作。
眼光敏锐的读者也许会问:参数在哪里?前面代码段中的 DrawFocusRect 不是实际的 DrawFocusRect。它是我写的一个冒名顶替者,保护类型的 CStaticLink
成员函数,负责实际调用DrawFocusRect 前做一些准备工作。代码如下:
/////////////////////////////////////////////////////////////////////////
// 获得或丢失焦点: 绘制焦点矩形。对于位图,用窗口矩形;文本则用实际文本矩形。
/////////////////////////////////////////////////////////////////////////
void CStaticLink::DrawFocusRect()
{
CWnd* pParent = GetParent();
ASSERT(pParent);
// 计算在哪里绘制焦点矩形,用屏幕坐标
CRect rc;
DWORD dwStyle = GetStyle();
if (dwStyle & (SS_BITMAP|SS_ICON|SS_ENHMETAFILE|SS_OWNERDRAW)) {
GetWindowRect(&rc); // 图像使用全窗口矩形
} else {
// 文本使用文本矩形. 不要忘了选字体!
CClientDC dc(this);
CString s;
GetWindowText(s);
CFont* pOldFont = dc.SelectObject(GetFont());
rc.SetRectEmpty(); // 重要—DT_CALCRECT 展开, 以便起始是空
dc.DrawText(s, &rc, DT_CALCRECT);// 计算文本方块区
dc.SelectObject(pOldFont);
ClientToScreen(&rc); // 转换屏幕坐标
}
rc.InflateRect(1,1); // 周围添加一个像素
pParent-ScreenToClient(&rc); // 转成父窗口坐标
CClientDC dcParent(pParent); // 父窗口的 DC
dcParent.DrawFocusRect(&rc); // 绘制!
}
大多是常规的 GDI
处理——选择字体,转换坐标等等——我只列出关键代码。
实际的::DrawFocusRect(或者其等价的 MFC 函数 CDC::DrawFocusRect)需要一个矩形,当然还需要一个设备上下文(DC)来进行绘制。但是我应该使用哪一个设备上下文呢?通常,你只能在自己的空间绘制,而不能在别的地方——也就是说在你的控件的窗口中或客户DC中。但我们这里是要在窗口外绘制,因为处于美观,焦点矩形看起来需要比控件稍大一些。所以 CStaticLink::DrawFocusRect要在其父窗口的客户DC中绘制。绘制焦点矩形是少数几种直接在屏幕或父窗口上绘制即可的情况之一。一般来说,做一些临时性的
XOR 操作即可,如同在窗口中进行拖拽操作时绘制的图标或透明图像;此时你可能使用屏幕DC。如果要在另一个窗口的设备上下文上画,唯一的规则是:不管画(paintest)什么,都要进行还原(unpaintest)!
接着,我应该用什么矩形呢?当然是窗口矩形。再想想。窗口矩形对于与控件大小相同的位图来说是不错,但对于文本呢?控件常常比其上的文本大一些,宽一些。谁来负责正确调整其静态控件的大小?如果使用窗口矩形,可以用一个长矩形来装入小文本串,甚或另做一个控件,如
Figure 3 上面部分所示。这种效果使你看起来很不爽。这就是为什么对于文本,CStaticLink::DrawFocusRect 首先要以 DT_CALCRECT
来调用 CDC::DrawText 计算正确围绕该文本的矩形原因。将量好的围绕文本的偏平像素矩形转换为父窗口客户坐标,再调用 CDC::DrawFocusRect
——瞧!正确结果如 Figure 3 下面部分所示。
Figure 3 有 DT_CALCRECT 和 没有 DT_CALCRECT 的区别
现在,用户可以用Tab来定位静态控件并看到焦点矩形。最后要做的事情是处理键盘输入。哪个键负责导航链接?IE
浏览器用回车键,但我不喜欢那样,理由有两个。首先,由键盘按下某个按钮的公认的用户界面模式是按空格键(Space
键)。超链接类似一个按钮,所以我觉得空格键更好。这是对话框的方式,在 Web 窗体中,IE 是也是这么做的。我不懂微软的老大们为什么要选择回车键来做
IE 中的链接导航。回车通常意味着“我搞掂了”,与对话框中的“确认”键相同。既然 CStaticLink
是为对话框设计的,那么它就不应该与回车键的功能相冲突。其次,在对话框中捕获回车键需要做更多的工作(参见 2000 年 7 月的专栏文章)。
所以当用户用 Tab 键移到链接时,我用空格键来导航到链接。为了实现导航,你必须处理两个消息。WM_CHAR 肯定是其中之一:
void CStaticLink::OnChar(UINT nChar,...)
{
if (nChar==VK_SPACE) {
Navigate();
}
}
但是在你的静态链接能够得到 WM_CHAR 消息之前,你必须告诉对话框你对这个消息感兴趣。通常静态控件得不到 WM_CHAR
消息(记住:因为它们是静态的)。有一个特殊的消息可以告诉对话框你想得到什么——这个消息就是 WM_GETDLGCODE:
UINT CStaticLink::OnGetDlgCode()
{
// 告诉对话框我想要 chars
return DLGC_WANTCHARS;
}
完成上述工作便万事俱备。现在当用户用 Tab 键到达超链接上时,按下空格键便可以导航了。酷。
最后是一个警告:小心选择正确的静态链接Tab顺序。你的超链接通常应该在Tab顺序的最后,即使它们出现在对话框的最前面。你可能不想让你的对话框一启动输入焦点就落在公司(
ACME )标徽链接上。并且如果你在具有其它控件的窗体/对话框中使用 CStaticLink,你可能不想Tab键从某个编辑框跳过超链接到另外一个编辑框或按钮。所以我的忠告是保持所有超链接在Tab顺序的最后,除非你有充足的理由不这样做。
我写了一个例子程序 LinkTest,它使用新的具备键盘操作能力的 CStaticLink。请下载代码参考细节。
我有一个 MFC 程序,调用 ShellExecute 来打开一个
Web 页面。如:
ShellExecute "http://www.microsoft.com"
在我使用托管扩展前,运行正常,但是一使用托管扩展它就不行了。返回的错误代码是5,在 WinError.h 中是 ERROR_ACCESS_DENIED。我不懂为什么会存取失败。ShellExecute
不能与托管扩展一起用吗?
你是众多遇上这等不幸怪事的人之一。没错,只要你设置 /clr
开关来使用托管扩展,那么当你尝试用 ShellExecute 打开 Web 页面时会失败。我也曾经一度被它绊倒。托管扩展和 /clr 对 ShellExecute
做了些什么手脚呢?为什么会产生存取违例?
Windows 中常发生这种事情,错误代码提供的信息很难确定到底发生了什么。但通过搜索 MSDN 库,有一篇名为“Calling Shell
Functions and Interfaces from a Multithreaded Apartment”的文章揭示问题的答案。很多年前,ShellExecute
就谦卑地开始了其一生;其功能无非是让你运行一个程序,也就是一个 EXE 文件。随着 Windows 变得越来越复杂,ShellExecute
也成长为几乎可以“执行”任何程序——例如一个磁盘文件(用关联程序打开文件),FTP 协议或者 Web
页面——只要将文件名或URL传递给它即可。它是通过外壳扩展和 IShellExecuteHook 实现的,IShellExecuteHook 是一个
COM 接口,这个接口通过告诉它如何“执行”传递到 ShellExecute(Ex) 的串来扩展外壳。例如,有一个 HTTP
协议扩展钩,它处理以“http://”开始的串。扩展处理例程启动默认的浏览器打开给定的 URL。
问题是用 /clr 和托管扩展强制你的应用程序进入多线程模式,因为垃圾收集器拟在单独的线程中异步运行。但是按照 INFO 文章的解释,许多 IShellExecuteHook
扩展之所以在多线程环境不工作,是因为它们没有所需的用于 COM
封送参数的代理/存根(proxy/stub)以及进行同步存取的代码。如果你对此感到困惑,那么很多人和你一样。但我只想说,ShellExecute
在所有多线程环境下都无法正常工作。
所以,如果你已经使用托管扩展,为什么不用.NET框架的Process类和 Process::Start 来代替 ShellExecute
呢?有一个静态重载正好是你想要的:
Process::Start("http://www.microsoft.com");
哦,回来一试,还是不行。此调用丢出 Win32Exception 异常,它甚至在 NativeErrorCode
属性中产生更莫名其妙的错误代码:ERROR_SXS_KEY_NOT_FOUND。此错误的描述是:“请求的查找键在所有的活动上下文中未找到。”怎么回事呢?
如果你认真看一看 Process 的文档,你会发现 Process 使用一个叫 StartInfo 的东西来告诉它如何启动该进程。StartInfo
的属性之一便是 UseShellExecute。默认情况下,UseShellExecute 为 True,由此告诉框架用外壳启动进程,也就是说用 ShellExecuteEx。好了,试一下将它置成
False。结果正像文档所说的,你只能启动 EXEs,而非文件名或URLs。两种方法都行不通,你在兜圈子。
再仔细看看 Process::Start 的文档,它告诉你如果你想用 UseShellExecute,你必须保证指定 [STAThread](单线程公寓模型)作为应用程序
main 函数的特征:
// in C#
public class MyForm : Form {
[STAThread]
public static void Main(string[] args) {
...
}
}
那么,对 C# 或者是“纯”(非 MFC)C++ 程序能行得通,它们有自己的 main 函数,但 MFC
程序怎么办?如果是那样的话,main,_tmain,_tWinMain 或任何平台入口点都深藏在 MFC 内部,无法编辑源代码添加 [STAThread]。你可以使用
/ENTRY:MyMain 并编写自己的调用 CRT 启动例程的 [STAThread] MyMain,碰到这种情况太糟了。肯定有比这个简单的方法。
实际上,在 MFC 应用程序中有一种强制线程为单线程(STA)模型的方法,而不使用 [STAThread]。你只要在框架试图调用 CoInitializeEx(COINIT_MULTITHREADED)
之前调用 CoInitialize(NULL) 即可。用一个小类来做这件事情。代码如下:
//////////////////////////////////////////////////////////////////////////
// 用此类在混合模式应用程序中强制 STA (单线程公寓模型) 线程。使用方法如下:
// 在你的 main 应用模块中建一个静态实例,例如,MyApp.cpp 或在进入 CLR 之前要
// 运行构造函数的任何地方。
//////////////////////////////////////////////////////////////////////////
class CSTAThread {
public:
CSTAThread() {
CoInitialize(NULL);
}
~CSTAThread() {
CoUninitialize();
}
};
构造函数调用 CoInitialize(NULL)(STA 线程)和析构函数调用 CoUninitialize。所以只要象下面这样在 MFC
程序的 main 中插入一个实例即可:
// 这样做效果与 [STAThread] 一样
CSTAThread forceSTAThread;
真是聪明。(感谢微软的 Martyn Lovell 给我提出这个建议)。在 Visual C++ 2005
中,你可以告诉链接器你的入口点使用 STAThread,但目前你得用 CSTAThread。
还有一个方法可以在应用程序中启动 URLs,它甚至可以用于多线程模式,这个方法就是 rundll32.exe,这个程序很方便,用它可以调用任何
DLL 中的函数。你只要给它提供 DLL、函数名以及要传递的参数即可。Rundll32.exe 绝对多才多艺,你可以用它来关闭和重启
Windows,创建快捷方式以及启动控制面板程序。我见过一个专门研究 rundll32.exe
使用技巧的网站;只要知道要调用的DLLs,一切都搞掂。你可以象下面这样用 rundll32.exe 从命令行打开一个 URL:
rundll32.exe url.dll,FileProtocolHandler www.vckbase.com
url.dll 中的函数 FileProtocolHandler 负责这个工作。如果使用 ShellExecute,可以象下面这样写:
LPCTSTR url = _T("www.vckbase.com");
CString args;
args.Format(_T("url.dll,FileProtocolHandler %s"), url);
ShellExecute(NULL, _T("open"), _T("rundll32.exe"), args);
即便是在多线程应用中这都是可以行得通的,因为你赋予 ShellExecute 的是一个真正的 EXE,而不是一个外壳扩展和 IShellExecuteHook
运行必须的文件名。唯一的缺点是一旦打不开 URL,你得不到任何错误返回码。因此,我推荐使用 CSTAThread,并直接用 ShellExecute
来调用 URL,尤其是在 MFC 程序中,不管怎样,它与公寓模型线程配合得很好。
作为实践的例子,我更新了第一个问题中的 CStaticLink 类,使用 CSTAThread 和 ShellExecute,并编写了一个托管测试程序,LinkTest,为了证明它能在托管模式下正常运行。我在 StatLink.h
中包含了 CSTAThread 类。所以现在 CStaticLink 又多了一个特性:不管是本机应用还是用 /clr
编译以及托管扩展,它都能正常运行。具体细节请下载源代码。
祝编程愉快!
作者简介
Paul DiLascia 是一名自由作家,顾问和 Web/UI 设计者。他是《Writing Reusable Windows Code in
C++》书(Addison-Wesley, 1992)的作者。通过 http://www.dilascia.com 可以获得更多了解。
本文出自 MSDN Magazine 的
February 2005 期刊,可通过当地报摊获得,或者最好是
本文由 VCKBASE MTT 翻译