💎一站式轻松地调用各大LLM模型接口,支持GPT4、智谱、星火、月之暗面及文生图 广告
# Part VI - Hosting ActiveX Controls 原作 :[**Michael Dunn**](http://www.codeproject.com/wtl/WTL4MFC6.asp) 翻译 :[Orbit(桔皮干了)](http://www.winmsg.com/cn/orbit.htm) ## 本章内容 * [介绍](#intro) * [从使用向导开始](#appwizard) * [建立工程](#createproject) * [自动生成的代码](#appwizcode) * [使用资源编辑器添加控件](#addreseditor) * [ATL中使用控件的类](#atlhostclasses) * [CAxDialogImpl](#CAxDialogImpl) * [AtlAxWin和CAxWindow](#AtlAxWin) * [调用控件的方法](#callmethods) * [响应控件触发的事件](#sinkevents) * [CMainDlg的修改](#changemaindlg) * [填写事件映射链](#writesinkmap) * [编写事件处理函数](#writehandler) * [回顾例子工程](#sampleoverview) * [运行时创建ActiveX控件](#runtimecreating) * [键盘事件处理](#keyboard) * [继续](#upnext) * [修改记录](#revisionhistory) ## 介绍 在第六章,我将介绍ATL对在对话框中使用ActiveX控件的支持,由于ActiveX控件就是ATL的专业,所以WTL没有添加其他的辅助类。不过,在ATL中使用ActiveX控件与在MFC中有很大的不同,所以需要重点介绍。我将介绍如何包容一个控件并处理控件的事件,开发ATL应用程序相对于MFC的类向导来说有点不方便。在WTL程序中自然可以使用ATL对包容ActiveX控件的支持。 例子工程演示如何使用IE的浏览器控件,我选择浏览器控件有两个好处: 1. 每台计算机都有这个控件,并且 2. 它有很多方法和事件,是个用来做演示的好例子。 我当然无法与那些花了大量时间编写基于IE浏览器控件的定制浏览器的人相比,不过,当你读完本篇文章之后,你就知道如何开始编写自己定制的浏览器! ## 从使用向导开始 ### 创建工程 WTL的向导可以创建一个支持包容ActiveX控件的程序,我将开始一个名为IEHoster的新工程。我们像上一章一样使用无模式对话框,只是这次要选上支持ActiveX控件包容(Enable ActiveX Control Hosting),如下图: ![](https://box.kancloud.cn/2016-01-13_56962fbce93e8.png) 选上这个check box将使我们的对话框从CAxDialogImpl派生,这样就可以包容ActiveX控件。在向导的第二页还有一个名为包容ActiveX控件的check box,但是选择这个好像对最后的结果没有影响,所以在第一页就可以点击“Finish”结束向导。 ### 向导生成的代码 在这一节我将介绍一些以前没有见过的新代码(由向导生成的),下一节介绍ActiveX包容类的细节。 首先要看的文件是stdafx.h,它包含了这些文件: ``` #include <atlbase.h> #include <atlapp.h> extern CAppModule _Module; #include <atlcom.h> #include <atlhost.h> #include <atlwin.h> #include <atlctl.h> // .. other WTL headers ... ``` atlcom.h和atlhost.h是很重要的两个,它们含有一些COM相关类的定义(比如智能指针CComPtr),还有可以包容控件的窗口类。 接下来看看maindlg.h中声明的CMainDlg类: ``` class CMainDlg : public CAxDialogImpl<CMainDlg>, public CUpdateUI<CMainDlg>, public CMessageFilter, public CIdleHandler ``` CMainDlg现在是从CAxDialogImpl类派生的,这是使对话框支持包容ActiveX控件的第一步。 最后,看看WinMain()中新加的一行代码: ``` int WINAPI _tWinMain(...) { //... _Module.Init(NULL, hInstance); AtlAxWinInit(); int nRet = Run(lpstrCmdLine, nCmdShow); _Module.Term(); return nRet; } ``` AtlAxWinInit()注册了一个类名未AtlAxWin的窗口类,ATL用它创建ActiveX控件的包容窗口。 ## 使用资源编辑器添加控件 和MFC的程序一样,ATL也可以使用资源编辑器向对话框添加控件。首先,在对话框编辑器上点击鼠标右键,在弹出的菜单中选择“Insert ActiveX control”: ![](https://box.kancloud.cn/2016-01-13_56962fbd0260c.png) VC将系统安装的控件显示在一个列表中,滚动列表选择“Microsoft Web Browser”,单击Insert按钮将控件加入到对话框中。查看控件的属性,将ID设为IDC_IE。对话框中的控件显示应该是这个样子的: ![](https://box.kancloud.cn/2016-01-13_56962fbd0f9e4.png) 如果现在编译运行程序,你会看到对话框中的浏览器控件,它将显示一个空白页,因为我们还没有告诉它到哪里去。 在下一节,我将介绍与创建和包容ActiveX控件有关的ATL类,同时我们也会明白这些类是如何与浏览器交换信息的。 ## ATL中使用控件的类 在对话框中使用ActiveX控件需要两个类协同工作:CAxDialogImpl和CAxWindow。它们处理所有控件容器必须实现的接口方法,提供通用的功能函数,例如查询控件的某个特殊的COM接口。 ### CAxDialogImpl 第一个类是CAxDialogImpl,你的对话框要能够包容控件就必须从CAxDialogImpl类派生而不是从CDialogImpl类派生。CAxDialogImpl类重载了Create()和DoModal()函数,这两个函数分别被全局函数AtlAxCreateDialog()和AtlAxDialogBox()调用。既然IEHoster对话框是由Create()创建的,我们看看AtlAxCreateDialog()到底做了什么工作。 AtlAxCreateDialog()使用辅助类_DialogSplitHelper装载对话框资源,这个辅助类遍历所以对话框的控件,查找由资源编辑器创建的特殊的入口,这些特殊的入口表示这是一个ActiveX控件。例如,下面是IEHoster.rc文件中浏览器控件的入口: ``` CONTROL "",IDC_IE,"{8856F961-340A-11D0-A96B-00C04FD705A2}", WS_TABSTOP,7,7,116,85 ``` 第一个参数是窗口文字(空字符串),第二个是控件的ID,第三个是窗口的类名。_DialogSplitHelper::SplitDialogTemplate()函数找到以'{'开始的窗口类名时就知道这是一个ActiveX控件的入口。它在内存中创建了一个临时对话框模板,在这个新模板中这些特殊的控件入口被创建的AtlAxWin窗口代替,新的入口是在内存中的等价体: ``` CONTROL "{8856F961-340A-11D0-A96B-00C04FD705A2}",IDC_IE,"AtlAxWin", WS_TABSTOP,7,7,116,85 ``` 结果就是创建了一个相同ID的AtlAxWin窗口,窗口的标题是ActiveX控件的GUID。所以你调用GetDlgItem(IDC_IE)返回的值是AtlAxWin窗口的句柄而不是ActiveX控件本身。 SplitDialogTemplate()函数完成工作后,AtlAxCreateDialog()接着调用CreateDialogIndirectParam()函数使用修改后的模板创建对话框。 ### AtlAxWin and CAxWindow 正如上面讲到的,AtlAxWin实际上是ActiveX控件的宿主窗口,AtlAxWin还会用到一个特殊的窗口接口类:CAxWindow,当AtlAxWin从模板创建一个对话框后,AtlAxWin的窗口处理过程,AtlAxWindowProc(),就会处理WM_CREATE消息并创建相应的ActiveX控件。ActiveX控件还可以在运行其间动态创建,不需要对话框模板,我会在后面介绍这种方法。 WM_CREATE的消息处理函数调用全局函数AtlAxCreateControl(),将AtlAxWin窗口的窗口标题作为参数传递给该函数,大家应该记得那实际就是浏览器控件的GUID。AtlAxCreateControl()有会调用一堆其他函数,不过最终会用到CreateNormalizedObject()函数,这个函数将窗口标题转换成GUID,并最终调用CoCreateInstance()创建ActiveX控件。 由于ActiveX控件是AtlAxWin的子窗口,所以对话框不能直接访问控件,当然CAxWindow提供了这些方法通控件通信,最常用的一个是QueryControl(),这个方法调用控件的QueryInterface()方法。例如,你可以使用QueryControl()从浏览器控件得到IWebBrowser2接口,然后使用这个接口将浏览器引导到指定的URL。 ## 调用控件的方法 既然我们的对话框有一个浏览器控件,我们可以使用COM接口与之交互。我们做得第一件事情就是使用IWebBrowser2接口将其引导到一个新URL处。在OnInitDialog()函数中,我们将一个CAxWindow变量与包容控件的AtlAxWin联系起来。 ``` CAxWindow wndIE = GetDlgItem(IDC_IE); ``` 然后声明一个IWebBrowser2的接口指针并查询浏览器控件的这个接口,使用CAxWindow::QueryControl(): ``` CComPtr<IWebBrowser2> pWB2; HRESULT hr; hr = wndIE.QueryControl ( &pWB2 ); ``` QueryControl()调用浏览器控件的QueryInterface()方法,如果成功就会返回IWebBrowser2接口,我们可以调用Navigate(): ``` if ( pWB2 ) { CComVariant v; // empty variant pWB2->Navigate ( CComBSTR("http://www.codeproject.com/"), &v, &v, &v, &v ); } ``` ## 响应控件触发的事件 从浏览器控件得到接口非常简单,通过它可以单向的与控件通信。通常控件也会以事件的形式与外界通信,ATL有专用的类包装连接点和事件相应,所以我们可以从控件接收到这些事件。为使用对事件的支持需要做四件事: 1. 将CMainDlg变成COM对象 2. 添加IDispEventSimpleImpl到CMainDlg的继承列表 3. 填写事件映射链,它指示哪些事件需要处理 4. 编写事件响应函数 ### CMainDlg的修改 将CMainDlg转变成COM对象的原因是事件相应是基于IDispatch的,为了让CMainDlg暴露这个接口,它必须是个COM对象。IDispEventSimpleImpl提供了IDispatch接口的实现和建立连接点所需的处理函数,当事件发生时IDispEventSimpleImpl还调用我们想要接收的事件的处理函数。 以下的类需要添加到CMainDlg的集成列表中,同时COM_MAP列出了CMainDlg暴露的接口: ``` #include <exdisp.h> // browser control definitions #include <exdispid.h> // browser event dispatch IDs class CMainDlg : public CAxDialogImpl<CMainDlg>, public CUpdateUI<CMainDlg>, public CMessageFilter, public CIdleHandler, blic CComObjectRootEx<CComSingleThreadModel>, public CComCoClass<CMainDlg>, public IDispEventSimpleImpl<37, CMainDlg, &DIID_DWebBrowserEvents2> { ... BEGIN_COM_MAP(CMainDlg) COM_INTERFACE_ENTRY2(IDispatch, IDispEventSimpleImpl) END_COM_MAP() }; ``` CComObjectRootEx类CComCoClass共同使CMainDlg成为一个COM对象,IDispEventSimpleImpl的模板参数是事件的ID,我们的类名和连接点接口的IID。事件ID可以是任意正数,连接点对象的IID是DIID_DWebBrowserEvents2,可以在浏览器控件的相关文档中找到这些参数,也可以查看exdisp.h。 ### 填写事件映射链 下一步是给CMainDlg添加事件映射链,这个映射链将我们感兴趣的事件和我们的处理函数联系起来。我们要看的第一个事件是DownloadBegin,当浏览器开始下载一个页面时就会触发这个事件,我们响应这个事件显示“please wait”信息给用户,让用户知道浏览器正在忙。在MSDN中可以查到DWebBrowserEvents2::DownloadBegin事件的原型 ``` void DownloadBegin(); ``` 这个事件没有参数,也不需要返回值。为了将这个事件的原型转换成事件响应链,我们需要写一个_ATL_FUNC_INFO结构,它包含返回值,参数的个数和参数类型。由于事件是基于IDispatch的,所以所有的参数都用VARIANT表示,这个数据结构的描述相当长(支持很多个数据类型),以下是常用的几个: &gt; VT_EMPTY: void VT_BSTR: BSTR 格式的字符串 VT_I4: 4字节有符号整数,用于long类型的参数 VT_DISPATCH: IDispatch* VT_VARIANT&gt;: VARIANT VT_BOOL: VARIANT_BOOL (允许的取值是VARIANT_TRUE和VARIANT_FALSE) 另外,标志VT_BYREF表示将一个参数转换成相应的指针。例如,VT_VARIANT|VT_BYREF表示VARIANT*类型。下面是_ATL_FUNC_INFO的定义: ``` #define _ATL_MAX_VARTYPES 8 struct _ATL_FUNC_INFO {   CALLCONV cc; VARTYPE vtReturn; SHORT   nParams; VARTYPE pVarTypes[_ATL_MAX_VARTYPES]; }; ``` 参数: cc 我们的事件响应函数的调用方式约定,这个参数必须是CC_STDCALL,表示是__stdcall方式 vtReturn 事件响应函数的返回值类型 nParams 事件带的参数个数 pVarTypes 相应的参数类型,按从左到右的顺序 了解这些之后,我们就可以填写DownloadBegin事件处理的_ATL_FUNC_INFO结构: ``` _ATL_FUNC_INFO DownloadInfo = { CC_STDCALL, VT_EMPTY, 0 }; ``` 现在,回到事件响应链,我们为每一个我们想要处理的事件添加一个SINK_ENTRY_INFO宏,下面是处理DownloadBegin事件的宏: ``` class CMainDlg : public ... { ... BEGIN_SINK_MAP(CMainDlg) SINK_ENTRY_INFO(37, DIID_DWebBrowserEvents2, DISPID_DOWNLOADBEGIN, OnDownloadBegin, &DownloadInfo) END_SINK_MAP() }; ``` 这个宏的参数是事件的ID(37,与我们在IDispEventSimpleImpl的继承列表中使用的ID一样),事件接口的IID,事件的dispatch ID(可以在MSDN或exdispid.h头文件中查到),事件处理函数的名字和指向描述这个事件处理的_ATL_FUNC_INFO结构的指针。 ### 编写事件处理函数 好了,等了这么长时间(吹个口哨!),我们可以写事件处理函数了: ``` void __stdcall CMainDlg::OnDownloadBegin() { // show "Please wait" here... } ``` 现在来看一个复杂一点的事件,比如BeforeNavigate2,这个事件的原型是: ``` void BeforeNavigate2 ( IDispatch* pDisp, VARIANT* URL, VARIANT* Flags, VARIANT* TargetFrameName, VARIANT* PostData, VARIANT* Headers, VARIANT_BOOL* Cancel ); ``` 此方法有7个参数,对于VARIANT类型参数可以从MSDN查到它到底传递的是什么类型的数据,我们感兴趣的是URL,是一个BSTR类型的字符串。 描述BeforeNavigate2事件的_ATL_FUNC_INFO结构是这样的: ``` _ATL_FUNC_INFO BeforeNavigate2Info = { CC_STDCALL, VT_EMPTY, 7, { VT_DISPATCH, VT_VARIANT|VT_BYREF, VT_VARIANT|VT_BYREF, VT_VARIANT|VT_BYREF, VT_VARIANT|VT_BYREF, VT_VARIANT|VT_BYREF, VT_BOOL|VT_BYREF } }; ``` 和前面一样,返回值类型是VT_EMPTY表示没有返回值,nParams是7,表示有7个参数。接着是参数类型数组,这些类型前面介绍过了,例如VT_DISPATCH表示IDispatch*。 事件响应链的入口与前面的例子很相似: ``` BEGIN_SINK_MAP(CMainDlg) SINK_ENTRY_INFO(37, DIID_DWebBrowserEvents2, DISPID_DOWNLOADBEGIN, OnDownloadBegin, &DownloadInfo) SINK_ENTRY_INFO(37, DIID_DWebBrowserEvents2, DISPID_BEFORENAVIGATE2, OnBeforeNavigate2, &BeforeNavigate2Info) END_SINK_MAP() ``` 事件处理函数是这个样子: ``` void __stdcall CMainDlg::OnBeforeNavigate2 ( IDispatch* pDisp, VARIANT* URL, VARIANT* Flags, VARIANT* TargetFrameName, VARIANT* PostData, VARIANT* Headers, VARIANT_BOOL* Cancel ) { CString sURL = URL->bstrVal; // ... log the URL, or whatever you'd like ... } ``` 我打赌你现在是越来越喜欢ClassWizard了,因为当你向MFC的对话框插入一个ActiveX控件时ClassWizard自动为你完成了所有工作。 将CMainDlg转换成对象需要注意几件事情,首先必须修改全局函数Run(),现在CMainDlg是个COM对象,我们必须使用CComObject创建CMainDlg: ``` int Run(LPTSTR /*lpstrCmdLine*/ = NULL, int nCmdShow = SW_SHOWDEFAULT) { CMessageLoop theLoop; _Module.AddMessageLoop(&theLoop); CComObject<CMainDlg> dlgMain; dlgMain.AddRef(); if ( dlgMain.Create(NULL) == NULL ) { ATLTRACE(_T("Main dialog creation failed!\n")); return 0; } dlgMain.ShowWindow(nCmdShow); int nRet = theLoop.Run(); _Module.RemoveMessageLoop(); return nRet; } ``` 另一个可替代的方法是不使用CComObject,而使用CComObjectStack类,并删除dlgMain.AddRef()这一行代码,CComObjectStack对IUnknown的三个方法的实现有些微不足道(它们只是简单的从函数返回),因为它们不是必需的--这样的COM对象可以忽略对引用的计数,因为它们仅仅是创建在栈中的临时对象。 当然这并不是完美的解决方案,CComObjectStack用于短命的临时对象,不幸的是只要调用它的任何一个IUnknown方法都会引发断言错误。因为CMainDlg对象在开始监听事件时会调用AddRef,所以CComObjectStack不适用于这种情况。 解决这个问题要么坚持使用CComObject,要么从CComObjectStack派生一个CComObjectStack2类,允许对IUnknow方法调用。CComObject的那个不必要的引用计数并无大碍--人们不会注意到它的发生--但是如果你必须节省那个CPU时钟周期的话,你可以使用本章的例子工程代码中的CComObjectStack2类。 ## 回顾例子工程 现在我们已经看到事件响应如何工作了,再来看看完整的IEHoster工程,它包容了一个浏览器控件并响应了6个事件,它还显示了一个事件列表,你会对浏览器如何使用它们提供带进度条的界面有个感性的认识,程序处理了以下几个事件: * BeforeNavigate2和NavigateComplete2:这些事件让程序可以控制URL的导航,如果你响应了BeforeNavigate2事件,你可以在事件的处理函数中取消导航。 * DownloadBegin和DownloadComplete:程序使用这些事件控制“wait”消息,这表示浏览器正在工作。一个更优美的程序会像IE一样在此期间使用一段动画。 * CommandStateChange:这个事件告诉程序向前和向后导航命令何时可用,应用程序将相应的按钮变为可用或不可用。 * StatusTextChange:这个事件会在几种情况下触发,例如鼠标移到一个超链接上。这个事件发送一个字符串,应用程序响应这个事件,将这个字符串显示在浏览器窗口下的静态控件上。 程序有四个按钮控制浏览器工作:向后,向前,停止和刷新,它们分别调用IWebBrowser2相应的方法。 事件和伴随事件发送的数据都被记录在列表控件中,你可以看到事件的触发,你还可以关闭一些事件记录而仅仅观察其中的一辆个事件。为了演示事件处理的重要作用,我们在BeforeNavigate2事件处理函数中检查URL,如果发现“doubleclick.net”就取消导航。广告和弹出窗口过滤器等一些IE的插件使用的就是这个方法而不是HTTP代理,下面就是做这些检查的代码。 ``` void __stdcall CMainDlg::OnBeforeNavigate2 ( IDispatch* pDisp, VARIANT* URL, VARIANT* Flags, VARIANT* TargetFrameName, VARIANT* PostData, VARIANT* Headers, VARIANT_BOOL* Cancel ) { USES_CONVERSION; CString sURL; sURL = URL->bstrVal; // You can set *Cancel to VARIANT_TRUE to stop the // navigation from happening. For example, to stop // navigates to evil tracking companies like doubleclick.net: if ( sURL.Find ( _T("doubleclick.net") ) > 0 ) *Cancel = VARIANT_TRUE; } ``` 下面就是我们的程序工作起来的样子: ![](https://box.kancloud.cn/2016-01-13_56962fbd1e101.png) IEHoster还使用了前几章介绍过得类:CBitmapButton(用于浏览器控制按钮),CListViewCtrl(用于事件记录),DDX (跟踪checkbox的状态)和CDialogResize. ## 运行时创建ActiveX控件 出了使用资源编辑器,还可以在运行其间动态创建ActiveX控件。About对话框演示了这种技术。对话框编辑器预先放置了一个group box用于浏览器控件的定位: ![](https://box.kancloud.cn/2016-01-13_56962fbd2d02e.png) 在OnInitDialog()函数中我们使用 CAxWindow创建了一个新AtlAxWin,它定位于我们预先放置好的group box的位置上(这个group box随后被销毁): ``` LRESULT CAboutDlg::OnInitDialog(...) { CWindow wndPlaceholder = GetDlgItem ( IDC_IE_PLACEHOLDER ); CRect rc; CAxWindow wndIE; // Get the rect of the placeholder group box, then destroy // that window because we don't need it anymore. wndPlaceholder.GetWindowRect ( rc ); ScreenToClient ( rc ); wndPlaceholder.DestroyWindow(); // Create the AX host window. wndIE.Create ( *this, rc, _T(""), WS_CHILD | WS_VISIBLE | WS_CLIPCHILDREN ); ``` 接下来我们用CAxWindow方法创建一个ActiveX控件,有两个方法可以选择:CreateControl()和CreateControlEx()。CreateControlEx()用一个额外的参数返回接口指针,这样就不需要再调用QueryControl()函数。我们感兴趣的两个参数是第一个和第四个参数,第一个参数是字符串形式的浏览器控件的GUID,第四个参数是一个IUnknown*类型的指针,这个指针指向ActiveX控件的IUnknown接口。创建控件后就可以查询IWebBrowser2接口,然后就可以像前面一样控制它导航到某个URL。 ``` CComPtr<IUnknown> punkCtrl; CComQIPtr<IWebBrowser2> pWB2; CComVariant v; // Create the browser control using its GUID. wndIE.CreateControlEx ( L"{8856F961-340A-11D0-A96B-00C04FD705A2}", NULL, NULL, &punkCtrl ); // Get an IWebBrowser2 interface on the control and navigate to a page. pWB2 = punkCtrl; pWB2->Navigate ( CComBSTR("about:mozilla"), &v, &v, &v, &v ); } ``` 对于有ProgID的ActiveX控件可以传递ProgID给CreateControlEx(),代替GUID。例如,我们可以这样创建浏览器控件: ``` // 使用控件的ProgID: 创建Shell.Explorer: wndIE.CreateControlEx ( L"Shell.Explorer", NULL, NULL, &punkCtrl ); ``` CreateControl()和CreateControlEx()还有一些重载函数用于一些使用浏览器的特殊情况,如果你的应用程序使用WEb页面作为HTML资源,你可以将资源ID作为第一个参数,ATL会创建浏览器控件并导航到这个资源。IEHoster包含一个ID为IDR_ABOUTPAGE的WEB页面资源,我们在About对话框中使用这些代码显示这个页面: ``` wndIE.CreateControl ( IDR_ABOUTPAGE ); ``` 这是显示结果: ![](https://box.kancloud.cn/2016-01-13_56962fbd3d0b1.png) 例子代码对上面提到的三个方法都用到了,你可以查看CAboutDlg::OnInitDialog()中的注释和未注释的代码,看看它们分别是如何工作的。 ## 键盘事件处理 最后一个但是非常重要的细节是键盘消息。ActiveX控件的键盘处理非常复杂,因为控件和它的宿主程序必须协同工作以确保控件能够看到它感兴趣的消息。例如,浏览器控件允许你使用TAB键在链接之间切换。MFC自己处理了所有工作,所以你永远不会意识到让键盘完美并正确的工作需要多么大的工作量。 不幸的是向导没有为基于对话框的程序生成键盘处理代码,当然,如果你使用Form View作为视图类的SDI程序,你会看到必要的代码已经被添加到PreTranslateMessage()中。当程序从消息队列中得到鼠标或键盘消息时,就使用ATL的WM_FORWARDMSG消息将此消息传递给当前拥有焦点的控件。它们通常不作什么事情,但是如果是ActiveX控件,WM_FORWARDMSG消息最终被送到包容这个控件的AtlAxWin,AtlAxWin识别WM_FORWARDMSG消息并采取必要的措施看看是否控件需要亲自处理这个消息。 如果拥有焦点的窗口没有识别WM_FORWARDMSG消息,PreTranslateMessage()就会接着调用IsDialogMessage()函数,使得像TAB这样的标准对话框的导航键能正常工作。 例子工程的PreTranslateMessage()函数中含有这些必需的代码,由于PreTranslateMessage()只在无模式对话框中有效,所以如果你想在基于对话框的应用程序中正确使用键盘就必须使用无模式对话框。 ## 继续 在下一章,我们将回到框架窗口并介绍如何使用分隔窗口。 ## 修改记录 May 20, 2003: 文章第一次发布。