如何在Java中知道用户何时真正释放了按键?

18

(更清晰的编辑)

我想在Java Swing中检测用户按下和释放键,忽略键盘自动重复功能。我还希望使用纯Java方法,在Linux、Mac OS和Windows上实现。

要求:

  1. 当用户按下某个键时,我想知道那是哪个键;
  2. 当用户松开某个键时,我想知道那是哪个键;
  3. 我想忽略系统的自动重复选项:对于每个按键事件,我只想收到一个按键事件和一个释放按键事件;
  4. 如果可能,我想使用第1到3条来确定用户是否同时按住了多个键(例如,她按下'a'键,不松开该键,再按下"Enter"键)。

我在Java中面临的问题是,在Linux下,当用户按住某个键时,会触发许多keyPress和keyRelease事件(因为键盘重复特性)。

我尝试过一些方法,但没有成功

  1. 获取上次发生按键事件的时间-在Linux下,它们似乎为0表示按键重复,但在Mac OS下却不是这样;
  2. 仅在当前keyCode与上一个keyCode不同的情况下考虑事件-这样用户就不能连续两次击键相同的键;

以下是代码的基本(不起作用)部分:

import java.awt.event.KeyListener;

public class Example implements KeyListener {

public void keyTyped(KeyEvent e) {
}

public void keyPressed(KeyEvent e) {
    System.out.println("KeyPressed: "+e.getKeyCode()+", ts="+e.getWhen());
}

public void keyReleased(KeyEvent e) {
    System.out.println("KeyReleased: "+e.getKeyCode()+", ts="+e.getWhen());
}

}

当用户按住一个键(例如,'p'键),系统会显示:

KeyPressed:  80, ts=1253637271673
KeyReleased: 80, ts=1253637271923
KeyPressed:  80, ts=1253637271923
KeyReleased: 80, ts=1253637271956
KeyPressed:  80, ts=1253637271956
KeyReleased: 80, ts=1253637271990
KeyPressed:  80, ts=1253637271990
KeyReleased: 80, ts=1253637272023
KeyPressed:  80, ts=1253637272023
...

至少在Linux下,当按住某个键时,JVM会不断地重新发送所有的键事件。更加困难的是,在我的系统(Kubuntu 9.04 Core 2 Duo)上时间戳不断变化。JVM会发送一个带有相同时间戳的新键释放和新键按下。这使得很难知道何时实际释放了按键。

有任何想法吗?

谢谢


你真的会因为自动重复而得到多个keyReleased事件吗?对我来说,只有keyPressed会被重复,而不是keyReleased。我在游戏小程序中用过很多次,并没有出现问题。 - finnw
你在KDE中尝试过吗?我已经在Debian Squeeze和Kubuntu 9.04下测试过了。当你按住某些键时,X(或KDE)似乎会发送很多事件(但不是所有键-Shift、Ctrl和Alt不会重复)。这可以通过在控制台上运行“xev”来看到。 - Luis Soeiro
10个回答

5
这可能会有问题。我记不太清了(已经很久了),但很可能重复键功能(由底层操作系统处理,而不是Java)没有提供足够的信息给JVM开发人员来区分那些附加的键事件和“真正”的键事件。(顺便说一下,我在1.1.x版本中曾在OS/2 AWT上工作过这个问题)。从KeyEvent的javadoc中可以看出:“按下键”和“释放键”事件是较低级别的事件,取决于平台和键盘布局。每当按下或释放一个键时都会生成它们,并且是了解不产生字符输入的键(例如,操作键、修饰键等)的唯一方法。正在按下或释放的键由getKeyCode方法指示,该方法返回虚拟键代码。就像我在OS/2中所做的那样(当时仍然只有类似于旧版本Windows的2个事件上/下的键盘处理方式,而不是您在更现代的版本中获得的3个事件上/下/字符的方式),如果只是按住键并自动生成事件,我不会以不同的方式报告KeyReleased事件;但我怀疑OS/2甚至没有向我报告那些信息(记不太清了)。我们使用Sun的Windows参考JVM作为开发我们的AWT的指南,因此我认为如果可能在那里报告这些信息,我至少会在他们那边看到。

4

这个问题已经在这里有了重复。

在那个问题中,提供了到Sun bug parade的链接,其中提出了一些解决方法。

我制作了一个hack,它是作为一个可以在应用程序启动时安装的AWTEventListener实现的。

基本上,观察RELEASED和随后的PRESSED之间的时间很短 - 实际上,它是0毫秒。因此,您可以将其用作度量标准:保持RELEASED一段时间,如果新的PRESSED紧接着而来,则吞下RELEASED并处理PRESSED(这样您就会得到与Windows相同的逻辑,显然这是正确的方式)。但是,请注意从一毫秒到下一毫秒的包裹(我看到过这种情况发生) - 因此使用至少1毫秒进行检查。为了考虑滞后和其他事项,大约20-30毫秒可能不会有害。


3
我已经改进了stolsvik的黑客技巧,以防止KEY_PRESSED和KEY_TYPED事件的重复发生。通过这种改进,在Win7下它可以正确地工作(应该在任何地方都可以,因为它确实关注KEY_PRESSED/KEY_TYPED/KEY_RELEASED事件)。
祝好! Jakub
package com.example;

import java.awt.AWTEvent;
import java.awt.Component;
import java.awt.EventQueue;
import java.awt.Toolkit;
import java.awt.event.AWTEventListener;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.KeyEvent;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;

import javax.swing.Timer;

/**
 * This {@link AWTEventListener} tries to work around for KEY_PRESSED / KEY_TYPED/     KEY_RELEASED repeaters.
 * 
 * If you wish to obtain only one pressed / typed / released, no repeatings (i.e., when the button is hold for a long time).
 * Use new RepeatingKeyEventsFixer().install() as a first line in main() method.
 * 
 * Based on xxx
 * Which was done by Endre Stølsvik and inspired by xxx (hyperlinks stipped out due to stackoverflow policies)
 * 
 * Refined by Jakub Gemrot not only to fix KEY_RELEASED events but also KEY_PRESSED and KEY_TYPED repeatings. Tested under Win7.
 * 
 * If you wish to test the class, just uncomment all System.out.println(...)s.
 * 
 * @author Endre Stølsvik
 * @author Jakub Gemrot
 */
public class RepeatingKeyEventsFixer implements AWTEventListener {

 public static final int RELEASED_LAG_MILLIS = 5;

 private static boolean assertEDT() {
  if (!EventQueue.isDispatchThread()) {
   throw new AssertionError("Not EDT, but [" + Thread.currentThread() + "].");
  }
  return true;
 }

 private Map<Integer, ReleasedAction> _releasedMap = new HashMap<Integer, ReleasedAction>();
 private Set<Integer> _pressed = new HashSet<Integer>();
 private Set<Character> _typed = new HashSet<Character>();

 public void install() {
  Toolkit.getDefaultToolkit().addAWTEventListener(this, AWTEvent.KEY_EVENT_MASK);
 }

 public void remove() {
  Toolkit.getDefaultToolkit().removeAWTEventListener(this);
 }

 @Override
 public void eventDispatched(AWTEvent event) {
  assert event instanceof KeyEvent : "Shall only listen to KeyEvents, so no other events shall come here";
  assert assertEDT(); // REMEMBER THAT THIS IS SINGLE THREADED, so no need
       // for synch.

  // ?: Is this one of our synthetic RELEASED events?
  if (event instanceof Reposted) {
   //System.out.println("REPOSTED: " + ((KeyEvent)event).getKeyChar());
   // -> Yes, so we shalln't process it again.
   return;
  }

  final KeyEvent keyEvent = (KeyEvent) event;

  // ?: Is this already consumed?
  // (Note how events are passed on to all AWTEventListeners even though a
  // previous one consumed it)
  if (keyEvent.isConsumed()) {
   return;
  }

  // ?: KEY_TYPED event? (We're only interested in KEY_PRESSED and
  // KEY_RELEASED).
  if (event.getID() == KeyEvent.KEY_TYPED) {
   if (_typed.contains(keyEvent.getKeyChar())) {
    // we're being retyped -> prevent!
    //System.out.println("TYPED: " + keyEvent.getKeyChar() + " (CONSUMED)");
    keyEvent.consume();  
   } else {
    // -> Yes, TYPED, for a first time
    //System.out.println("TYPED: " + keyEvent.getKeyChar());
    _typed.add(keyEvent.getKeyChar());
   }
   return;
  } 

  // ?: Is this RELEASED? (the problem we're trying to fix!)
  if (keyEvent.getID() == KeyEvent.KEY_RELEASED) {
   // -> Yes, so stick in wait
   /*
    * Really just wait until "immediately", as the point is that the
    * subsequent PRESSED shall already have been posted on the event
    * queue, and shall thus be the direct next event no matter which
    * events are posted afterwards. The code with the ReleasedAction
    * handles if the Timer thread actually fires the action due to
    * lags, by cancelling the action itself upon the PRESSED.
    */
   final Timer timer = new Timer(RELEASED_LAG_MILLIS, null);
   ReleasedAction action = new ReleasedAction(keyEvent, timer);
   timer.addActionListener(action);
   timer.start();

   ReleasedAction oldAction = (ReleasedAction)_releasedMap.put(Integer.valueOf(keyEvent.getKeyCode()), action);
   if (oldAction != null) oldAction.cancel();

   // Consume the original
   keyEvent.consume();
   //System.out.println("RELEASED: " + keyEvent.getKeyChar() + " (CONSUMED)");
   return;
  }

  if (keyEvent.getID() == KeyEvent.KEY_PRESSED) {

   if (_pressed.contains(keyEvent.getKeyCode())) {
    // we're still being pressed
    //System.out.println("PRESSED: " + keyEvent.getKeyChar() + " (CONSUMED)"); 
    keyEvent.consume();
   } else {   
    // Remember that this is single threaded (EDT), so we can't have
    // races.
    ReleasedAction action = (ReleasedAction) _releasedMap.get(keyEvent.getKeyCode());
    // ?: Do we have a corresponding RELEASED waiting?
    if (action != null) {
     // -> Yes, so dump it
     action.cancel();

    }
    _pressed.add(keyEvent.getKeyCode());
    //System.out.println("PRESSED: " + keyEvent.getKeyChar());    
   }

   return;
  }

  throw new AssertionError("All IDs should be covered.");
 }

 /**
  * The ActionListener that posts the RELEASED {@link RepostedKeyEvent} if
  * the {@link Timer} times out (and hence the repeat-action was over).
  */
 protected class ReleasedAction implements ActionListener {

  private final KeyEvent _originalKeyEvent;
  private Timer _timer;

  ReleasedAction(KeyEvent originalReleased, Timer timer) {
   _timer = timer;
   _originalKeyEvent = originalReleased;
  }

  void cancel() {
   assert assertEDT();
   _timer.stop();
   _timer = null;
   _releasedMap.remove(Integer.valueOf(_originalKeyEvent.getKeyCode()));   
  }

  @Override
  public void actionPerformed(@SuppressWarnings("unused") ActionEvent e) {
   assert assertEDT();
   // ?: Are we already cancelled?
   // (Judging by Timer and TimerQueue code, we can theoretically be
   // raced to be posted onto EDT by TimerQueue,
   // due to some lag, unfair scheduling)
   if (_timer == null) {
    // -> Yes, so don't post the new RELEASED event.
    return;
   }
   //System.out.println("REPOST RELEASE: " + _originalKeyEvent.getKeyChar());
   // Stop Timer and clean.
   cancel();
   // Creating new KeyEvent (we've consumed the original).
   KeyEvent newEvent = new RepostedKeyEvent(
     (Component) _originalKeyEvent.getSource(),
     _originalKeyEvent.getID(), _originalKeyEvent.getWhen(),
     _originalKeyEvent.getModifiers(), _originalKeyEvent
       .getKeyCode(), _originalKeyEvent.getKeyChar(),
     _originalKeyEvent.getKeyLocation());
   // Posting to EventQueue.
   _pressed.remove(_originalKeyEvent.getKeyCode());
   _typed.remove(_originalKeyEvent.getKeyChar());
   Toolkit.getDefaultToolkit().getSystemEventQueue().postEvent(newEvent);
  }
 }

 /**
  * Marker interface that denotes that the {@link KeyEvent} in question is
  * reposted from some {@link AWTEventListener}, including this. It denotes
  * that the event shall not be "hack processed" by this class again. (The
  * problem is that it is not possible to state
  * "inject this event from this point in the pipeline" - one have to inject
  * it to the event queue directly, thus it will come through this
  * {@link AWTEventListener} too.
  */
 public interface Reposted {
  // marker
 }

 /**
  * Dead simple extension of {@link KeyEvent} that implements
  * {@link Reposted}.
  */
 public static class RepostedKeyEvent extends KeyEvent implements Reposted {
  public RepostedKeyEvent(@SuppressWarnings("hiding") Component source,
    @SuppressWarnings("hiding") int id, long when, int modifiers,
    int keyCode, char keyChar, int keyLocation) {
   super(source, id, when, modifiers, keyCode, keyChar, keyLocation);
  }
 }

}

1
请查看:http://stackoverflow.com/questions/8948238/why-does-my-program-recognize-a-key-as-being-released-while-its-still-held-down#question - 一个声望较低的用户无法对此答案进行评论。您是否愿意看一下? - Kev

2
我已经找到了解决这个问题的方法,而不是依赖于时间(根据一些用户的说法,时间并不总是100%一致),而是通过发出额外的按键来覆盖按键重复。
试着按住一个键,然后在中途按下另一个键。重复将停止。在我的系统上,Robot发出的按键敲击也具有这种效果。
关于一个测试过的实现例子,在Windows 7和Ubuntu上,请参见:

http://elionline.co.uk/blog/2012/07/12/ignore-key-repeats-in-java-swing-independently-of-platform/

此外,感谢Endre Stolsvik的解决方案向我展示如何设置全局事件监听器!非常感谢。

谢谢。听起来是个好的解决方案。除了尝试从语法高亮器中复制和粘贴代码之外,还有其他获取您的代码的方法吗?它会复制各种垃圾空格,这在Eclipse中会导致错误。 - Ralph Oreg
这是一个不好的空格字符,但我进行了查找替换并且它现在可以工作了。谢谢。 - Ralph Oreg
抱歉,Ralph。实际上,在sourceforge上有一个版本受源代码控制,我会尝试在博客上链接到它...很高兴听到它能运行,除了我自己外,你是第一个尝试的人,据我所知 :)。 - Elias Vasylenko

1

keyReleased中保存事件的时间戳(arg0.when())。如果下一个keyPressed事件是相同的按键并且具有相同的时间戳,则为自动重复。

如果您按住多个键,X11仅自动重复最后按下的键。因此,如果您按住'a'和'd',则会看到类似以下内容:

a down
a up
a down
d down
d up
d down
d up
a up

1

我找到了一种解决方案,可以避免等待的情况,例如游戏循环。这个想法是存储释放事件。然后你可以在游戏循环内部和按键处理程序内部检查它们。"(反)注册一个键"是指应该由应用程序处理的提取的真实按下/释放事件。执行以下操作时,请注意同步!

  • 对于释放事件:按键存储事件;否则不做任何事情!
  • 对于按下事件:如果没有存储的释放事件,则这是新的按下 -> 注册它;如果在5毫秒内存在存储的事件,则这是自动重复 -> 删除其释放事件;否则,我们有一个已经被游戏循环保留的释放事件 ->(快速用户)按照您的意愿进行操作,例如取消注册重新注册。
  • 在你的循环中:检查存储的释放事件并将超过5毫秒的事件视为真正的释放;注销它们;处理所有已注册的键。

0

你可能想要使用你感兴趣的组件的动作映射表。这里有一个处理特定按键(空格键)的示例,但我相信如果你阅读文档,你可以修改它来处理通用按键的按下和释放。

import java.awt.Dimension;
import java.awt.event.ActionEvent;
import java.beans.PropertyChangeListener;

import javax.swing.Action;
import javax.swing.JComponent;
import javax.swing.JFrame;
import javax.swing.JPanel;
import javax.swing.KeyStroke;

public class Main {
    public static void main(String[] args) {
        JFrame f = new JFrame("Test");
        JPanel c = new JPanel();

        c.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(
                KeyStroke.getKeyStroke("SPACE"), "pressed");
        c.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(
                KeyStroke.getKeyStroke("released SPACE"), "released");
        c.getActionMap().put("pressed", new Action() {
            public void addPropertyChangeListener(
                    PropertyChangeListener listener) {
            }

            public Object getValue(String key) {
                return null;
            }

            public boolean isEnabled() {
                return true;
            }

            public void putValue(String key, Object value) {
            }

            public void removePropertyChangeListener(
                    PropertyChangeListener listener) {
            }

            public void setEnabled(boolean b) {
            }

            public void actionPerformed(ActionEvent e) {
                System.out.println("Pressed space at "+System.nanoTime());
            }
        });
        c.getActionMap().put("released", new Action() {
            public void addPropertyChangeListener(
                    PropertyChangeListener listener) {
            }

            public Object getValue(String key) {
                return null;
            }

            public boolean isEnabled() {
                return true;
            }

            public void putValue(String key, Object value) {
            }

            public void removePropertyChangeListener(
                    PropertyChangeListener listener) {
            }

            public void setEnabled(boolean b) {
            }

            public void actionPerformed(ActionEvent e) {
                System.out.println("Released space at "+System.nanoTime());
            }
        });
        c.setPreferredSize(new Dimension(200,200));


        f.getContentPane().add(c);
        f.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        f.pack();
        f.setVisible(true);
    }
}

谢谢尝试。但是我得到了相同的结果:大量重复,但这次每个代码的时间戳不同。 在7623492422327释放空间 在7623492546365按下空格 在7623525621728释放空间 在7623525754217按下空格 在7623559107407释放空间 在7623559228023按下空格 在7623591715040释放空间 在7623591835237按下空格 - Luis Soeiro

0

这种方法将按键存储在HashMap中,在释放按键时重置它们。 大部分代码来自Elistthis帖子中的贡献。

import java.awt.KeyEventDispatcher;
import java.awt.KeyboardFocusManager;
import java.awt.event.KeyEvent;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Set;

public class KeyboardInput2 {
    private static HashMap<Integer, Boolean> pressed = new HashMap<Integer, Boolean>();
    public static boolean isPressed(int key) {
        synchronized (KeyboardInput2.class) {
            return pressed.get(key);
        }
    }

    public static void allPressed() {
        final Set<Integer> templist = pressed.keySet();
        if (templist.size() > 0) {
            System.out.println("Key(s) logged: ");
        }
        for (int key : templist) {
            System.out.println(KeyEvent.getKeyText(key));
        }
    }

    public static void main(String[] args) {
        KeyboardFocusManager.getCurrentKeyboardFocusManager().addKeyEventDispatcher(new KeyEventDispatcher() {

            @Override
            public boolean dispatchKeyEvent(KeyEvent ke) {
                synchronized (KeyboardInput2.class) {
                    switch (ke.getID()) {
                        case KeyEvent.KEY_PRESSED:
                            pressed.put(ke.getKeyCode(), true);
                            break;
                        case KeyEvent.KEY_RELEASED:
                            pressed.remove(ke.getKeyCode());
                            break;
                        }
                        return false;
                }
            }
        });
    }
}

你可以使用HashMap来检查某个键是否被按下,或者调用KeyboardInput2.allPressed()来打印每个按下的键。


0

我没有理解所有复杂但是有问题的建议中的关键点是什么?解决方案如此简单!(忽略了OP问题的关键部分:“在Linux下,当用户按住某个键时,会触发许多keyPress和keyRelease事件”)

在你的keyPress事件中,检查keyCode是否已经在Set<Integer>中。如果是,则必须是一个autorepeat事件。如果不是,请将其放入并处理。在你的keyRelease事件中,盲目地从集合中删除keyCode - 假设OP关于许多keyRelease事件的说法是错误的。在Windows中,我只会得到几个keyPresses,但只有一个keyRelease。

为了抽象一些,您可以创建一个包装器,可以携带KeyEvents、MouseEvents和MouseWheelEvents,并具有一个标志,已经说明keyPress只是自动重复。


当我按住键时,我会多次收到keyPress和KeyRelease信号。 - Luis Soeiro
哦,好的,我应该更仔细地阅读你的帖子。现在我明白了复杂的时间控制方法。您需要检查在过去的10ms内释放的键是否再次被按下,因为这显然超出了用户的能力范围。您需要将每个键的释放时间和其本身添加到一个Set中,并在按键时检查(并清理)该Set。看起来足够简单。 - Dreamspace President

0

好的,你说在按键重复的情况下,关键事件之间的时间可能是非负的。即便如此,这段时间很可能非常短。你可以将这段时间阈值设为非常小的值,而一切等于或小于该值的内容都被视为按键重复。


我不想依赖于特定的时间来完成这个任务,因为需要可移植性。无论如何,我会再次进行测试,看看是否可以通过测量事件之间的时间来区分真正的keyRelease事件和伪造的事件。 - Luis Soeiro

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接