這幾年我一直在公司的商業程序中使用你的 CStaticLink 類,在 1998 年 MSJ 三月刊裡,你示范了如何給超鏈接添加手型光標,但現在我想以另一種方式使用這個類。在微軟的 IE 浏覽器中,可以用Tab鍵遍歷Web頁面中的任何超鏈接,然後按回車鍵以單擊該鏈接。我能否讓 CStaticLink 做同樣的事情?我是不是有點得寸進尺?
如果你要我借給你一千美元,那才是得寸進尺呢——但用鍵盤操作 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 編譯以及托管擴展,它都能正常運行。具體細節請下載源代碼。