起因

菜单程序在 emWin(ucGUI) 的 PC 模拟器上调试时出现怪现象:按一次 ENTER,菜单连跳两级——本该从一级进到二级,结果直接落在三级菜单。

需求

  • 按一次按键只响应一次,一级一级进入菜单
  • “响应 ENTER 键消息后切换菜单”的既有架构不动,只在按键判定上找原因

技术实现原理

根因:模拟器“按下、松开”各发一条消息

emWin(ucgui) 在PC端的模拟器,默认的按键机制是"按抬都发送Msg",当在按下键盘时,会收到一个key值-1,在按键没有离开时一直维持,当按键松开时还发送一个key值-0的标记。所以在你手速多快的情况下都会有一个 key,1和key,0两个操作。程序中没有对按键的状态做判定,所以在PC上不管是按下,还是按下后离开都会进行响应(两次响应)。

把这两条消息放进菜单场景推演一遍,“连跳两级”就不奇怪了:在一级菜单按下 ENTER,第一条(按下)消息触发 WIN_Enter 切到二级菜单;手指还没离开,第二条(松开)消息到达——此刻收消息的已经是二级菜单的窗口,它的回调同样响应 ENTER,于是再 WIN_Enter 一次切到三级。一次物理按键 = 两条等价消息 = 两次窗口切换,这就是 BUG 的完整因果链。

修复思路:用 PressedCnt 把“电平”变“边沿”

WM_KEY 消息携带的 WM_KEY_INFO 里,除了 Key 还有一个 PressedCnt(当前按下的键计数):按下那条消息里它是 1,松开那条里它是 0。只放行 PressedCnt == 0 的一条、忽略其余,一次物理按键就恰好响应一次——本质上是把“电平触发”改成“边沿触发”:不看按键“维持着”的状态,只认状态变化那一下。

选松开(==0)而不是按下(==1)还有个附带好处:操作系统键盘的自动重复会连发多条“按下”消息,只认松开天然免疫连发。

代码实现

假设有以下的应用场景,一级菜单->(ENTER键)->二级菜单>(ENTER键)->三级菜单,即,在一级菜单是可以用ENTER键进入二级菜单,同样在二级菜单可以用ENTER键进入三级菜单,实现的方式是:响应ENTER键的消息,然后切换菜单。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
/*******************************************************************************

* Function Name :WIN_Enter

* Description :进入下一级窗口

* Input :int id:把进入的ID号

* Output :void

* Other :

* Date :2013/06/02

*******************************************************************************/

int WIN_Enter(int id)

{

ELEM_BLOCK_TYPE *pElem;

MENU_INFO_ITEM *pInfo;

MENU_INFO_ITEM *pCur; //当前的窗口

WIN_ContextLock();

if (m_WinList.index < 1) //窗口调度没有初始化

goto ext;

if (m_WinList.index >= WIN_LEVEL_LAYER_COUNT - 1)//窗口深度超出最大范围

goto ext;

pElem = &m_WinList.elem[m_WinList.index - 1]; //获得当前窗口

pCur = pInfo = pElem->menu;

pInfo = BT_GetRightChild(pInfo); //获取下级窗口

pInfo = BT_GetLChildElem(pInfo, *(int *)id);

if (pInfo == NULL) //无下级窗口

goto ext;

if (pInfo->data == NULL) //下级窗口元素为空

goto ext;

pElem->ret.id = *(int *)id; //保存所进入的ID

if (pInfo->data->win == NULL) //下级窗口无窗口数据

goto end;

if (pCur->data->win->destroy) //销毁当前窗口

(*pCur->data->win->destroy)(NULL);

if (pInfo->data->win->create) //新窗口建立

(*pInfo->data->win->create)(pInfo);

m_WinList.elem[m_WinList.index++].menu = pInfo; //保存新窗口到窗口列表中

end:

WIN_ContextUnlock();

if (pInfo->data->enter) {

(*pInfo->data->enter)(&id);

}

return OK;

ext:

WIN_ContextUnlock();

return FALSE;

}

以上代码是通过提前构造少的菜单树结构,然后在菜单节点响应ENTER键和ESC键完成进入和退出,从代码上看不出有什么问题了,但是在PC实际仿真的过程成发现,在一级菜单按下ENTER键时进入二级菜单,紧接着进入三级菜单。

解决方案

所以需要对ENTER键进行判断响应,每次按下ENTER键就响应一次:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
case WM_KEY:

        switch (((WM_KEY_INFO *)(pMsg->Data.p))->Key){

            case GUI_KEY_ENTER:

             if(((WM_KEY_INFO *)(pMsg->Data.p))->PressedCnt == 0)

                 WIN_Enter(0);

             break;

        }

     break;

代码说明

  • ((WM_KEY_INFO *)(pMsg->Data.p))->Key 从消息里取出键值,WM_KEY 的附加数据是一个指向 WM_KEY_INFO 的指针;
  • 判断 PressedCnt == 0 是核心:只处理“松开”那条消息,把同一物理按键的另一半过滤掉;
  • WIN_Enter(0) 是业务动作(进入下级窗口),与按键过滤无关,换成任何“只该执行一次”的动作都成立。

踩坑记录与注意事项

  • 按一次响应两次 / 菜单连跳两级:先查输入层是不是“按、抬各报一条消息”、应用层有没有按 PressedCnt 过滤——这是本类 BUG 的通用根因,与菜单逻辑写得多正确无关;
  • 别用延时消抖对付这个问题:延时对付的是触点抖动(毫秒级电平抖动),按抬双报是两条合法消息,延时治标还引入卡顿;
  • 每一级窗口的回调都要过滤:本例连跳两级的第二跳,就是二级菜单的回调也响应了那条“松开”消息——过滤逻辑要在所有响应按键的窗口里生效,不能只改第一个;
  • 只认按下还是只认松开要想清楚:只认 PressedCnt==1 在键盘自动重复下会连发;只认 ==0 的响应时机是松手瞬间,交互上略有延迟感,按业务取舍;
  • PC 模拟器与真机行为可能不同:按键消息的行为取决于输入层实现,上了真机要重新验证按键手感,别假设两边一致。