2.8 键盘

2.8.1 键盘概述

用程序来表达键盘和鼠标的操作是Visual C++开发中经常会碰到的,本节先阐述键盘的操作。键盘的硬件原理是用户每次按下或释放某个键,键盘都会产生一个扫描码,以确定相应的键,并且按下的时候扫描码最高位是0,释放的时候最高位是1,因此每个键能产生两个不同的扫描码。

物理键盘产生扫描码,不同的键盘产生的扫描码不同。为了屏蔽不同的物理键盘的差异,Windows系统通过一个虚拟键盘来提供与设备无关的键盘操作。虚拟键盘不是一个物理硬件,但Windows应用程序要访问键盘都必须先通过虚拟键盘,然后再通过物理键盘的驱动程序映射到物理键盘上,如果要更换物理键盘,只需要替换其驱动程序,虚拟键盘就知道了,而上层应用程序是不需要变动的,它只和虚拟键盘打交道。

虚拟键盘上的键码通过宏定义来表示,它们在WinUser.h中定义。常见的虚拟键码如下表所示。

(续表)

要注意的是,数字键0到9并没有定义VK_开头的宏,而是用ASCII的值’0’到’9',同样键盘上的A到Z也没有定义VK_开头的宏,直接用’A’到‘Z',另外键盘上都是大写的英文字母。

2.8.2 键盘消息

用户对键盘的操作都会产生一个事件,然后向相应的程序发送消息,由键盘事件产生的消息称为键盘消息,通常键盘消息分为两种:击键消息和字符消息。两者的区别是,只要按下或放开键盘上的键就会产生击键消息,但只有按了可显示字符的键,此时不但产生击键消息,还会产生字符消息。

1.击键消息

击键消息是指用户按下键盘上的某个键或释放某个键而产生的消息。而字符消息是指按下或释放的键是可显示的字符或回车键、Esc键和Tab键时发出的消息,比如按下字符K,就会产生字符消息,当然也会产生击键消息。只要操作键盘击键消息是肯定会产生的,但字符消息不一定。

击键消息分按下和释放两类消息,按下消息分为系统键按下消息和非系统键按下消息,前者用宏WM_SYSKEYDOWN表示,后者用WM_KEYDOWN表示。而释放键消息也分为系统键释放消息和非系统键释放消息,前者用宏WM_SYSKEYUP表示,后者用WM_KEYUP表示。通常,按下和释放是成对出现的,释放消息跟在按下消息之后。

非系统键按下的消息是WM_KEYDOWN,在Visual C++中,可以可视化地为某个窗口添加消息处理函数,WM_KEYDOWN消息的消息处理函数是这样的:

        afx_msg void OnKeyDown( UINT nChar, UINT nRepCnt, UINT nFlags );

afx_msg表示这个函数是一个消息处理函数。其中参数nChar用来确定虚拟键值(注意是键值,不是字符,字符有可能是区分大小写的,但键值就是印在每个按键上的内容,比如键F的键值就是F,但该键对应的字符可能会有’f’或’F'); nRepCnt表示重复次数;nFlags用来表示扫描码、翻译键、以前键的状态等。

有时候,在处理按键消息时,还需要知道其他键的状态,比如Ctrl键或Shift键是否按着,大写键(Caps Lock)、数字键(Num Lock)、滚动键(Scroll Lock)是否打开。系统提供了两个函数GetkeyState和GetAsyncKeyState来获取这些键的信息。函数GetKeyState可以获取击键消息发生时指定键的状态,其声明如下:

        SHORT GetKeyState( int nVirtKey);

其中参数nVirtKey表示虚拟键码,如VK_SHIFT,要注意的是,如果要获取字母(‘A'—‘Z’或‘a'—‘z')和数字键(‘0'—‘9'),必须用其字符对应的ASCII码值传入参数。函数返回值是虚拟键的状态,如果键处于按下状态,则最高位为1,如果为弹起状态,最低位为0;如果最低位是1,则表示该键处于打开状态,比如大写键(Caps Lock)、数字键(Num Lock)和滚动键(Scroll Lock)。

函数GetAsyncKeyState用来获取执行该函数时指定键的状态,其声明如下:

        SHORT GetAsyncKeyState( int vKey);

参数和返回值的含义同GetKeyState。GetAsyncKeyState用得不多,大多数场合用到的是GetKeyState。

下面我们看个例子,显示用户按下的字母键值和F1键,并判断是否同时按下Shift和空格键,如不是,则退出程序。

【例2.40】 通过按键消息显示用户按键的键值

(1)打开Visual C++ 2013,新建一个单文档工程。

(2)切换到类视图,然后找到类CTestView,单击它,然后在属性视图中选择“消息”页,找到消息WM_KEYDOWN,在其右边下拉框中选择OnKeyDown,如图2-89所示。

图2-89

这就是可视化的方式添加按键消息处理函数。此时会显示代码编辑窗口,并自动定位到函数OnKeyDown处,在该函数中添加代码如下:

        void CTestView::OnKeyDown(UINT nChar, UINT nRepCnt, UINT nFlags)
        {
            // TODO:  在此添加消息处理程序代码和/或调用默认值
            CString str;
            short sh =  GetKeyState(VK_SHIFT); //获取Shift键的状态
            if (nChar >= 'A' &&nChar <= 'Z') //如果用户按下的是字母键
            {
                str.Format(_T("你按了%c"), nChar); //格式化字符串
                AfxMessageBox(str); //显示信息
            }
            else if (nChar == VK_F1) //如果按下的是F1键
                AfxMessageBox(_T("帮助"));
            else if (nChar == VK_SPACE&&(sh & 0x8000)) //如果按下的是空格键并且Shift键也同时按下了
                PostQuitMessage(0); //退出程序
            CView::OnKeyDown(nChar, nRepCnt, nFlags);
        }

因为键盘上的字母键的键值都是大写的,所以nChar只需在‘A’和‘Z’直接判断即可。PostQuitMessage是系统API,它会向窗口消息队列发送一个WM_QUIT消息,这样窗口收到后就会退出程序了。PostQuitMessage函数的声明如下:

        void PostQuitMessage( int nExitCode);

其中参数nExitCode指定应用程序退出码,该参数其实就是消息WM_QUIT的消息参数wParm,一般设0即可。如果用户按着Shift键,则sh的最高位为1,所以要和0x8000进行与操作,就判断最高位是否为0。

(3)保存工程并运行,运行结果如图2-90所示。

图2-90

2.拦截键盘消息

在MFC编程中,有关键盘编程还有个经常碰到的场合是预处理按键或释放消息,使得正常的按键或释放消息处理函数获取不到消息,以此达到定制某种控件的目的,比如我们不想让某个编辑控件能接收用户输入,就要提前拦截按键消息了。提前拦截消息的方法是重载虚拟函数CWinApp::PreTranslateMessage,该函数在消息调度前被调用,声明如下:

        virtual BOOL PreTranslateMessage( MSG* pMsg );

其中参数pMsg为指向要处理的消息结构体MSG的指针。如果消息在PreTranslateMessage中被完全处理并且不需要进一步处理,则返回非零值。如果消息还需要按照通常方式处理,则返回零。

消息结构体MSG前面已经阐述过了,里面有成员变量wParam和lParam,按键消息所附带的信息就放在这两个消息参数中,其中wParam用来存放虚拟键码;lParam用来确定重复计数、扫描码、扩展键标记、按键前的状态等,它是32位值,不同部分的内容不同,具体如下:

● 0~15:表示当前消息的重复计数

● 16~23:确定扫描码,依赖于OEM

● 24:是否为扩展键

● 25~28:保留不用

● 29:上下文键,如果是WM_KEYDOWN消息,则总为0

● 30:确定先前状态,如果消息发生前键是按着的则为1,否则为0

● 31:过渡状态标记,如果是WM_KEYDOWN消息,则总为0

下面来看个例子,在按键消息处理之前先拦截。

【例2.41】 拦截按键消息

(1)打开Visual C++ 2013,新建一个单文档工程。

(2)切换到类视图,然后找到类CTestView,单击它,然后在属性视图中选择“消息”页,找到消息WM_KEYDOWN,在其右边下拉框中选择OnKeyDown,这样为视图添加按键消息处理函数,并添加代码如下:

        void CTestView::OnKeyDown(UINT nChar, UINT nRepCnt, UINT nFlags)
        {
            // TODO:  在此添加消息处理程序代码和/或调用默认值
            CString str;
            str.Format(_T("OnKeyDown:你按下了%c"), nChar); //格式化字符串
            AfxMessageBox(str); //显示信息
            CView::OnKeyDown(nChar, nRepCnt, nFlags);
        }

(3)下面为视图重载PreTranslateMessage函数。切换到类视图,选中CTestView,单击它,然后在属性视图中选择“重写”页,然后找到虚拟函数PreTranslateMessage,在其右边下拉框中选择PreTranslateMessage,如图2-91所示。

图2-91

这样就为视图重载了虚拟函数PreTranslateMessage,此时会自动打开代码编辑窗口,然后在该函数中添加代码如下:

        BOOL CTestView::PreTranslateMessage(MSG* pMsg)
        {
            // TODO:  在此添加专用代码和/或调用基类
            if (pMsg->message == WM_KEYDOWN) //消息是否为按键消息
            {
                CString str;
                str.Format(_T("拦截到按键消息:你按下了%c"), pMsg->wParam); //格式化字符串
                AfxMessageBox(str); //显示信息
                return TRUE; //返回非0表示不再交给正常的消息处理函数处理
            }
            return CView::PreTranslateMessage(pMsg);
        }

首先判断消息是否为按键消息,如果是就获取虚拟键码并组成字符串,显示结果,然后返回TRUE,这样正常的消息处理函数OnKeyDown就接收不到了。如果返回FALSE,则会执行OnKeyDown。

(4)保存工程并运行,运行结果如图2-92所示。

图2-92

3.字符消息

上面主要讲了按键消息(WM_KEYDOWN)的使用,除此之外字符消息也会经常用到。系统会对WM_KEYDOWN或WM_SYSKEYDOWN两个按键消息中的虚拟键码进行判断,如果是可显示字符,则会把字符消息放入消息队列中,这样在按键消息的下一次消息循环中,字符消息就会发送给窗口。有4种字符消息:WM_CHAR、WM_DEADCHAR、WM_SYSCHAR和WM_SYSDEADCHAR,前两种跟随在WM_KEYDOWN后,后两种跟随在WM_SYSKEYDOWN后。WM_CHAR是非系统字符中的一般字符消息,WM_DEADCHAR是非系统字符中的死字符消息;WM_SYSCHAR是系统字符中的一般字符消息,WM_SYSDEADCHAR是系统字符中的死字符消息。所谓死字符消息,是指某些非英语键盘上,一些给字母加音标的键由于自身并不产生字符,因此把它们所产生的消息称为死字符消息。Windows已经对死字符消息进行了较好的处理,应用程序通常不需要去处理。

字符消息WM_CHAR的消息处理函数是窗口类的成员函数,即CWnd::OnChar,它的函数声明如下:

        afx_msg void OnChar(UINT nChar, UINT nRepCnt, UINT nFlags);

其中参数nChar表示键的字符代码值;nRepCnt表示重复计数,即当用户按下键时重复的击键数目;nFlags表示扫描码、键暂态码、以前的键状态以及上下文代码等,它是一个32位值,不同位部分的内容如下:

● 0~15:指定了重复计数。其值是用户按下键时重复的击键数目。

● 16~23:指定了扫描码。其值依赖于原始设备制造商(OEM)。

● 24:指明该键是否是扩展键,如增强的101或102键盘上右边的ALT或CTRL键,如果它是个扩展键,则该值为1;否则为0。

● 25~28:Windows内部使用。

● 29:指定了上下文代码。如果按键时ALT键是按下的,则该值为1;否则为0。

● 30:指定了以前的键状态。如果在发送消息前键是按下的,则值为1;如果键是弹起的,则值为0。

● 31:指定了键的暂态。如果该键正被放开,则值为1;如果键正被按下,则该值为0。

下面我们来看2个例子,第一个例子比较简单,它通过字符消息来显示用户按下了字母键和0~9的字符键,并判断是否同时按下Shift和空格键,若是则退出程序。第二个例子有一定难度,综合了几个消息的应用,对于按下可显示的字符会产生字符消息WM_CHAR,并且是紧跟在按键消息WM_KEYDOWN之后产生的,如果按键后马上弹起,WM_CHAR之后的消息应该是WM_KEYUP。该程序会以列表的形式把接收到的按键消息和字符消息及其键符、键值等内容打印出来。

【例2.42】 通过字符消息显示用户按键的字符

(1)打开Visual C++ 2013,新建一个单文档工程。

(2)切换到类视图,然后找到类CTestView,单击它,然后在属性视图中选择“消息”页,找到消息WM_CHAR,在其右边下拉列表框中选择OnChar,如图2-93所示。

图2-93

这就是可视化的方式添加字符消息处理函数。此时会显示代码编辑窗口,并自动定位到函数OnChar处,在该函数中添加代码如下:

        void CTestView::OnChar(UINT nChar, UINT nRepCnt, UINT nFlags)
        {
            // TODO:  在此添加消息处理程序代码和/或调用默认值
            CString str;
            short sh = GetKeyState(VK_SHIFT); //获取Shift键的状态
            if (nChar >= '0' &&nChar <= '9') //如果用户按下的是数字字符
            {
                str.Format(_T("你按了%c"), nChar); //格式化字符串
                AfxMessageBox(str); //显示信息
            }
            if (nChar >= 'a' &&nChar <= 'z') //如果用户按下的是小写字母字符
            {
                str.Format(_T("你按了%c"), nChar); //格式化字符串
                AfxMessageBox(str); //显示信息
            }
            else if (nChar == VK_SPACE && (sh & 0x8000)) //如果按下的是空格键并且Shift键也同时按下了
                PostQuitMessage(0); //退出程序
            CView::OnChar(nChar, nRepCnt, nFlags);
        }

程序判断了用户是否按了‘0’到‘9’的字符和小写字母的字符键,如果是,则显示按下键所产生的字符,并且判断是否同时按下Shift键和空格键,如果是则退出程序。

要注意的是,OnChar中的nChar是按键所产生的字符,而不是按键所对应的键值。前面有例子通过按键消息显示用户按键的键值,大家可以对比下两个例子的区别。

(3)保存工程并运行,运行结果如图2-94所示。

图2-94

【例2.43】 打印按键消息和字符消息的键符和键值

(1)打开Visual C++ 2013,新建一个单文档工程。

(2)切换到解决方案资源管理器,然后打开Test.cpp,在该文件开头定义几个全局变量:

        static int        cyChar; //保存字体高度
        static int        cyClient; //客户区的当前高度
        static int        cyClientMax; //客户区的最大高度
        static int        cMaxLineNum,  cLineNum;  //  cMaxLineNum  表示显示的最大行;cLineNum为当前行
        static PMSG  pmsg; //指向接收到的消息的缓冲区
        static RECT  rectScroll; //客户区中要滚动的矩形坐标
        //定义各个列头的名称
        static TCHAR szTop[] = TEXT("消息           扫描码  键符                 键值   字符   字符值     重复计数");
        //有可能要打印的消息名称
        static TCHAR *   szMessage[] = {
            TEXT("WM_KEYDOWN"), TEXT("WM_KEYUP"),
            TEXT("WM_CHAR"), TEXT("WM_DEADCHAR"),
            TEXT("WM_SYSKEYDOWN"), TEXT("WM_SYSKEYUP"),
            TEXT("WM_SYSCHAR"), TEXT("WM_SYSDEADCHAR")
        };

(3)切换到类视图,然后找到类CTestView,单击它,然后在属性视图中选择“消息”页,找到消息WM_CREATE,在其右边下拉框中选择OnCreatre,这样为视图添加按键消息处理函数,并添加代码如下:

        int CTestView::OnCreate(LPCREATESTRUCT lpCreateStruct)
        {
            HDC             hdc;
            TEXTMETRIC  tm;
            if (CView::OnCreate(lpCreateStruct) == -1)
                return -1;
            // TODO:  在此添加您专用的创建代码
            cyClientMax = GetSystemMetrics(SM_CYMAXIMIZED); //获取屏幕高度
            hdc = ::GetDC(m_hWnd); //获取设备描述表句柄
            ::SelectObject(hdc, GetStockObject(SYSTEM_FIXED_FONT)); //把系统字体选进设备描述表
            ::GetTextMetrics(hdc, &tm); //得到该字体尺寸
            cyChar = tm.tmHeight; //保存高度
            ::ReleaseDC(m_hWnd, hdc); //释放设备描述表
            cLineNum = 0;
            cMaxLineNum = cyClientMax / cyChar; //得到该字体的文本能在屏幕上显示的最大行数
            /*开辟存放消息缓冲区的空间,屏幕上最多能显示cMaxLineNum条消息,我们就开辟cMaxLineNum个MSG的空间*/
            pmsg = (PMSG)malloc(cMaxLineNum * sizeof(MSG));
            return 0;
        }

(4)同样的方法添加视图窗口尺寸改变消息WM_SIZE,在其消息函数OnSize中添加代码如下:

        void CTestView::OnSize(UINT nType, int cx, int cy)
        {
            CView::OnSize(nType, cx, cy);
            // TODO:  在此处添加消息处理程序代码
            cxClient = cx; //保存客户区宽度
            cyClient = cy; //保存客户区高度
            //窗口大小变了,客户区所容纳的内容也会变,我们要更新以后滚动时所需的客户区矩形坐标
            rectScroll.left = 0;
            rectScroll.right = cxClient;
            rectScroll.top = cyChar;
            rectScroll.bottom = cyChar * (cyClient / cyChar);
            InvalidateRect(NULL, TRUE); //重画整个客户区
        }

(5)为视图窗口重载窗口消息处理函数WindowProc,单击类TestView,然后在其属性窗口中切换到“重写”页,在该页下找到“WindowProc”,然后单击右边空白处,选择WindowProc,接着添加代码如下:

        LRESULT CTestView::WindowProc(UINT message, WPARAM wParam, LPARAM lParam)
        {
            // TODO:  在此添加专用代码和/或调用基类
            int i;
            /*判断是否是按键消息、键弹起消息、字符消息、系统按键消息、系统键弹起消息和系统字符消息*/
            if (WM_KEYDOWN == message || WM_KEYUP == message || WM_CHAR == message
            ||  WM_SYSKEYDOWN  ==  message  ||  WM_SYSKEYUP  ==  message  ||  WM_SYSCHAR  ==
    message )
            {
                for  (i  =  cMaxLineNum  -  1;  i  >  0;  i--)  //把消息往后移动一个位置,pmsg[cMaxLineNum -1]被舍弃
                    pmsg[i] = pmsg[i -1];
                //刚刚接收到的消息存放在第一个位置
                pmsg[0].hwnd = m_hWnd;
                pmsg[0].message = message;
                pmsg[0].wParam = wParam;
                pmsg[0].lParam = lParam;
                //确定要消息显示在的那一行
                cLineNum = min(cLineNum + 1, cMaxLineNum);
                //数据更新了,滚动客户区的内容,往上滚动,最新的消息显示在最下行
                ScrollWindow(0, -cyChar, &rectScroll, &rectScroll);
            }
            return CView::WindowProc(message, wParam, lParam);
        }

重载窗口消息处理函数,可以拦截我们所需要处理的消息。拦截后,把数组位置向后移动,并把最新的消息存放在数组的第一个位置,这样最后一个位置的消息被覆盖舍弃了。然后我们看当前在客户区显示的行是否到达最大行了,如果不是,则递增。最后让客户区向上滚动,滚动客户区的内容的函数为CWnd:: ScrollWindow,该函数声明如下:

        void  ScrollWindow(  int  xAmount,  int  yAmount,  LPCRECT  lpRect  =  NULL, LPCRECT
    lpClipRect = NULL );

其中,参数xAmount指定了水平滚动的量,使用设备单位,在左滚时,该参数必须为负;yAmount指定了垂直滚动的量,使用设备单位,在上滚时,该参数必须为负;lpRect指向一个CRect对象或RECT结构,指定了要滚动的客户区的部分,如果lpRect为NULL,则将滚动整个客户区,如果光标区域与滚动矩形重叠,则插字符将被重定位;lpClipRect指向一个CRect对象或RECT结构,指定了要滚动的裁剪区域。只有这个矩形中的位才会被滚动,在矩形之外的位不会被影响,即使它们是在lpRect矩形之内,如果lpClipRect为NULL,则不会在滚动矩形上进行裁剪。

(6)添加绘图消息WM_PAINT。切换到类视图,然后找到类CTestView,单击它,然后在属性视图中选择“消息”页,找到消息WM_PAINT,在其右边下拉框中选择OnPaint,并添加代码如下:

        void CTestView::OnPaint()
        {
            int             i, nType;
            TCHAR         szBuff[150], szKeyName[30]; // szKeyName保存键符的名称
            CPaintDC dc(this); // device context for painting
            // TODO:  在此处添加消息处理程序代码
            // 不为绘图消息调用CView::OnPaint()
            dc.SelectObject( GetStockObject(SYSTEM_FIXED_FONT)); //把文本字体选进设备上下文
            dc.TextOut( 0, 0, szTop, lstrlen(szTop)); //画出各个列头的名称
            //画出已拦截到的消息及其各个参数,比如键符、键值等
            for ( i = 0; i < min(cLineNum, cyClient / cyChar -1); i++)
            {
                if (pmsg[i].message == WM_CHAR || pmsg[i].message == WM_SYSCHAR)
                    nType = 1; //如果是字符消息则要标记,以便下面键所对应字符
                else
                    nType = 0;
                //获取键符,键符就是印在键盘上的字符
                if (! GetKeyNameText(pmsg[i].lParam, szKeyName, 30))
                {
                    wsprintf(szBuff, _T("GetKeyNameText failed"));
                    dc.TextOut(0,  (cyClient  /  cyChar  -  1  -  i)  *  cyChar,  szBuff,_tcslen(szBuff));
                    return;
                }
                wsprintf(szBuff,
                nType  ?  TEXT("%-13s  %4d                                       %1s   %c      0x%04X       %6u") :
                    TEXT("%-13s %4d      %-15s%c  %3d                       %6u"),
                    szMessage[pmsg[i].message - WM_KEYFIRST],  //消息
                    HIWORD(pmsg[i].lParam) & 0xFF,           //扫描码
                    (PTSTR)(nType ? TEXT(" ") : szKeyName),          //键符
                    (TCHAR)(nType ? pmsg[i].wParam : ' '),      //字符
                    pmsg[i].wParam,                              //键值或字符值
                    LOWORD(pmsg[i].lParam)                      //重复计数
                    );
                //输出当前行
                dc.TextOut(0,   (cyClient   /   cyChar   -   1   -   i)   *   cyChar,   szBuff,_tcslen(szBuff));
            }
        }

首先把系统字体选进设备描述表中,然后开始打印列头。关于设备描述表的概念在图形图像那一章会讲到,这里只需知道即可。

打印完列头,就开始一行一行打印所拦截到消息,并且通过nType来标记是否为字符消息,如果是的话,会打印出该键的字符。值得注意的是,键符和字符是不一样的,键符就是印在键盘上的字符,而字符有大写,有小写,不一定和键符相同,比如字符‘f',而键符是‘F',只有按了键符‘F',才会出现字符‘f'。

(7)保存工程并运行,运行结果如图2-95所示。

图2-95