はじめに (対象読者・この記事でわかること)

この記事は、Javaでデスクトップアプリケーション(SwingやAWT)を開発しているが、キー入力イベントで思わぬ例外が発生して困っている方を対象としています。特に「Backキー(Backspace)を押しただけなのに、なぜかエラーが出てアプリケーションが落ちる」という現象に遭遇した方に最適です。

記事を読むことで、Backキーがどのような経路でイベントとして扱われるのか、なぜ例外が発生しうるのか、そしてそれを防ぐための実装パターンを習得できます。キーイベントの仕組みからイベント消費のタイミング、finallyブロックの正しい活用まで、実装直後に使えるノウハウを網羅しました。

前提知識

この記事を読み進める上で、以下の知識があるとスムーズです。

  • Javaの基本的な文法(try-catch、メソッドのオーバーライド)
  • SwingもしくはAWTのコンポーネント(JFrame、JTextComponentなど)を使った簡単なGUIプログラミング経験
  • キーイベント(KeyListener、KeyAdapter)の存在を聞いたことがあるレベル

Backキーが引き起こす例外の仕組み

JavaのGUIフレームワークでは、Backspaceキーは「削除」を意味する特別なキーとして扱われます。たとえばJTextFieldやJTextAreaでは、デフォルトのKeyBindingが登録されており、カーソル位置の手前1文字を削除する動作が定義されています。しかし、独自のKeyListenerを実装していると、このデフォルト動作と競合し、意図しないnull参照やClassCastExceptionが発生することがあります。

原因の多くは「イベントチェーンの中で例外がスローされたにもかかわらず、それを握り潰してしまっている」か「キーコードの判定を間違えて、未定義の仮想キーコードを扱ってしまっている」ことにあります。Backspaceの仮想キーコードはKeyEvent.VK_BACK_SPACEですが、これを間違えてKeyEvent.VK_UNDEFINEDと比較してしまうと、後続の処理で想定外のnullが出現して例外が発生します。

イベントを安全に処理する実装パターン

ここでは、実際に遭遇した「Backspaceキーを押したらコンソールに大量のエラース tackが出力され、最終的にWindowがフリーズ」した事例を基に、トラブルシューティングから安定実装までをステップごとに解説します。

ステップ1:最小再現コードで現象を確認する

まず、現象を再現する最小コードを用意します。以下のように、JTextFieldにKeyListenerを登録し、キー押下時に独自の処理を実行するだけのコードです。

Java
import javax.swing.*; import java.awt.event.*; public class BackspaceTrap extends JFrame { public static void main(String[] args) { SwingUtilities.invokeLater(() -> new BackspaceTrap().setVisible(true)); } private BackspaceTrap() { setDefaultCloseOperation(EXIT_ON_CLOSE); JTextField field = new JTextField(20); field.addKeyListener(new KeyAdapter() { @Override public void keyPressed(KeyEvent e) { // 意図的に不安定な処理 if (e.getKeyCode() == KeyEvent.VK_BACK_SPACE) { String text = ((JTextField) e.getSource()).getText(); char deleted = text.charAt(text.length()); // ここでStringIndexOutOfBoundsException System.out.println("削除した文字:" + deleted); } } }); add(field); pack(); setLocationRelativeTo(null); } }

上記コードでBackspaceを押すと、例外が発生しますが、これをそのまま放置するとEDT(Event Dispatch Thread)が異常終了し、GUIがフリーズします。

ステップ2:例外をキャッチしてもイベントが連鎖しないよう設計する

KeyListener内部で発生した例外をキャッチしても、それが上位のイベントディスパッチャに伝播しないよう、次のように実装します。

Java
field.addKeyListener(new KeyAdapter() { @Override public void keyPressed(KeyEvent e) { try { if (e.getKeyCode() == KeyEvent.VK_BACK_SPACE) { JTextField src = (JTextField) e.getSource(); int caret = src.getCaretPosition(); String before = src.getText(); if (caret > 0 && caret <= before.length()) { char deleted = before.charAt(caret - 1); System.out.println("削除予定の文字:" + deleted); } } } catch (RuntimeException ex) { // ログを残して沈静化 ex.printStackTrace(); // 本番ではロギングフレームワークを使用 // イベントを消費してデフォルト動作も抑制 e.consume(); } } });

ポイントは、e.consume()を呼ぶことで、デフォルトのBackspace処理(1文字削除)も同時に抑止できることです。これにより、二重に文字が削除されるリスクも回避できます。

ステップ3:InputMap/ActionMapを使った推奨パターンへ置き換える

KeyListenerよりも、Swing推奨のInputMap/ActionMapを使った実装に置き換えると、より安全かつ拡張しやすくなります。

Java
InputMap im = field.getInputMap(JComponent.WHEN_FOCUSED); ActionMap am = field.getActionMap(); // デフォルトのBackspace削除アクションを一旦退避 KeyStroke backKey = KeyStroke.getKeyStroke("BACKSPACE"); Object actionKey = im.get(backKey); Action defaultDelete = am.get(actionKey); // 独自アクションを定義 class SafeBackspaceAction extends AbstractAction { private final Action delegate; SafeBackspaceAction(Action delegate) { this.delegate = delegate; } @Override public void actionPerformed(ActionEvent e) { try { JTextComponent tc = (JTextComponent) e.getSource(); // 独自前処理 System.out.println("SafeBackspace発動 位置:" + tc.getCaretPosition()); // デフォルト動作を委譲 delegate.actionPerformed(e); } catch (RuntimeException ex) { // ログ出力後、例外を握り潰す ex.printStackTrace(); } } } // 新しいアクションを登録 am.put(actionKey, new SafeBackspaceAction(defaultDelete));

この方法では、デフォルトの削除動作を保持したまま、前後処理を安全に介入できます。例外が発生してもActionMapレベルでキャッチされるため、EDTがクラッシュすることはありません。

ハマった点やエラー解決

実装中に「KeyEventのgetKeyChar()がなぜか'\b'ではなく''(NULL文字)を返す」「LinuxとWindowsでBackspaceのキーコードが異なる」「日本語変換中のBackspaceがKeyTypedで2回流れる」といったプラットフォーム差に悩まされました。特に、macOSのOpenJDKでは、キーバインドがネイティブのものに依存するため、同じ論理でも挙動が異なることがあります。

また、try-catchで例外をキャッチしたつもりが、別スレッドで非同期に実行されている処理が原因で、結局例外が検知できないというケースもありました。これは、SwingUtilities.invokeLaterで登録したタスク内で発生した例外が、EDTのデフォルトUncaughtExceptionHandlerに捕捉されるため、KeyListenerのcatchブロックでは届かないというものでした。

解決策

最終的に、以下の3層防御で安定動作を実現しました。

  1. 独自KeyListenerでは必ずe.consume()でイベントを終端
  2. アプリケーション起動時にThread.setDefaultUncaughtExceptionHandlerでEDTの例外を一括キャッチ
  3. 単体テストでjava.awt.Robotを使い、実際にBackspaceキーを自動入力して例外が出ないことを検証
Java
public static void main(String[] args) { Thread.setDefaultUncaughtExceptionHandler((t, ex) -> { // ログを外部ファイルに出力 Logger.getLogger("EDT").log(Level.SEVERE, "EDTで未キャッチ例外", ex); }); SwingUtilities.invokeLater(() -> { try { new BackspaceTrap().setVisible(true); } catch (Exception ex) { // 初期化例外もここで捕捉 ex.printStackTrace(); } }); }

まとめ

本記事では、JavaのSwing/AWTアプリケーションでBackspaceキーが引き金となりうる例外のメカニズムと、それを回避するための実装パターンを解説しました。

  • Backspaceはデフォルトで特別な削除動作がバインドされており、KeyListenerと競合しやすい
  • 例外が発生してもEDTがクラッシュしないよう、KeyListener内で必ずcatchしe.consume()でイベントを終端する
  • InputMap/ActionMapを使うことで、より宣言的で安全なキー処理が可能
  • プラットフォーム差を吸収するには、Robotを使った自動テストで継続的に検証する

この記事を通して、キーイベントの奥深さと、一見簡単そうで実は落とし穴が多いGUIプログラミングの一面を体験できたでしょう。次回は、JavaFXへの移行時にBackspaceイベントをどう移植するか、およびKeyEventのコードポイントとUnicodeの関係について深掘りする予定です。

参考資料