GUIをステートマシンとして実装する

Swingをグラフィカルインターフェイスとして使用する大規模なプロジェクトに来て、JDialogまたはJFrameコンポーネント間のロジックと接続を理解するのはまだ簡単ではないことに気付きました。 そして、このコードを扱っている間はいつでも、インターフェイスの要素間の接続の複雑さを回避する何らかの普遍的なソリューションを見つけようとしていました。







私が遭遇した主な問題:各要素には1つ以上のPropertyChangeListenerがあり、状況(他の要素の状態から読み取られる)に応じて、何らかの方法で他の要素に影響を与えます。 これらの要素には独自のPropertyChangeListenerもあり、コンテキストに応じて他の要素にも影響します。 ボタンを押すことが他のすべての要素にどのように影響するかを理解することは非常に困難な場合があり、それが発生するコードの一部を見つけることすらなおさらです。 また、依存関係の変更を複雑にしました(論理規則を変更するために、異なるコンポーネントの異なる場所ですべての接続を検索する必要があり、おそらく何かが見逃されていました)。



このような状況では、Mediatorパターンが役立ちますが、すべての接続と相互作用のロジックをトレースすることも困難になります。 各JDialogを特定の論理的な場所をカプセル化する小さなJPanelに分割することは可能ですが、これらのJPanelの相互作用を維持する必要があります。 JDialogが既に複雑でない場合は、何も破ることはありません。



ある時点で、JDialogは常に厳密に定義された状態にあるという考えに至りました。 たとえば、ダイアログが作成され、「作成したばかり」状態にある場合、ユーザーまたはプログラムロジック自体がダイアログを「処理中」状態にし、ユーザーが「処理中」状態の間にキャンセルボタンをクリックすると、ダイアログは「中断されました。」



なぜ推論はそんなに良いのですか? 個々の時点で、1つの論理状態のレンダリングに集中できます。 したがって、要素の状態を担当するすべてのコードは、論理的および物理的な1つの場所に配置されます。 特定のユーザーインタラクション後にJButtonがクリックされたときにJButtonの「キャンセル」がJLabelステータスにどのように影響するかを理解するために、すべてのPropertyChangeListenersを検索する必要はありません。 変更についても同じことが言えます。 たとえば、バックグラウンドスレッドで何かを実行し、その進捗状況を公開するダイアログがあります。 ダイアログには、現在の進行状況を示す進行状況バーが表示されます。 ユーザーが[キャンセル]ボタンをクリックすると、JProgressBarがリセットされます。 また、バックグラウンドプロセスがキャンセルされた場合は、バックグラウンドプロセスのスタートボタンを無効にすることにしました。 インターフェイスが状態を使用して記述されている場合、単純に「I was interrupted」という状態を見つけ、そこにstartButton.setEnabled(false)を追加します。変更に自信があり、論理状態「I cancelled」にのみ影響することがわかります。



そのため、ダイアログが開始および終了する一連の状態としてユーザーインターフェイスをプログラムする必要があります。 言葉を減らせばコードも増えます!



例として、メインの質問に対する答えを見つけることができるダイアログを考えます。



このクラスは、答えを知っている人を表します。

/** * class that can answer to the maing question * @author __nocach */ public class MeaningOfLifeAnswerer { public int answer(){ return 42; } }
      
      







答えはそれほど単純ではないので、主要な質問に答えることができるクラスを準備する必要もあります。 準備には長い時間がかかり、SwingWorkerを使用して行う必要があります。

 /** * worker that prepares MeaningOfLifeAnswerer * @author __nocach */ public class PrepareToAnswerMeaningOfLife extends SwingWorker<MeaningOfLifeAnswerer, Void>{ @Override protected MeaningOfLifeAnswerer doInBackground() throws Exception { Thread.sleep(1500); return new MeaningOfLifeAnswerer(); } }
      
      







答え自体を見つけることもかなり長い操作であり、SwingWorkerを使用して実行する必要があります。

 /** * worker that will retrieve answer to the main question using passed Answerer * @author __nocach */ public class RetrieveMeaningOfLife extends SwingWorker<Integer, Integer>{ private final MeaningOfLifeAnswerer answerer; public RetrieveMeaningOfLife(MeaningOfLifeAnswerer answerer){ if (answerer == null){ throw new NullPointerException("prepareProvider can't be null"); } this.answerer = answerer; } @Override protected Integer doInBackground() throws Exception { for(int i = 0; i < 100; i++){ Thread.sleep(10); setProgress(i); } return answerer.answer(); } }
      
      







インターフェースからの要件:ダイアログを作成して表示した後、ワーカーを起動してMeaningOfLifeAnswererを初期化し、検索用のボタンを無効にして、MeaningOfLifeAnswererを準備している状態を書き込みます。 MeaningOfLifeAnswererが初期化されたらすぐに、検索用のボタンをオンにします。 検索ボタンを押すことにより、RetrieveMeaningOfLifeワーカーを開始し、検索ボタンをオフにして、検索中であることを書き込みます。 答えが見つかったら、検索ボタンをもう一度オンにして、もう一度検索する準備ができていることをボタンに書きます。



典型的なアプローチは次のようになります。

 public class StandardWay extends javax.swing.JFrame { private Logger logger = Logger.getLogger(StandardWay.class.getName()); private class FindAnswerAction extends AbstractAction{ private final MeaningOfLifeAnswerer answerer; public FindAnswerAction(MeaningOfLifeAnswerer answerer){ super("Find"); this.answerer = answerer; } @Override public void actionPerformed(ActionEvent e) { RetrieveMeaningOfLife retrieveWorker = new RetrieveMeaningOfLife(answerer); retrieveWorker.addPropertyChangeListener(new PropertyChangeListener() { @Override public void propertyChange(PropertyChangeEvent evt) { if ("progress".equals(evt.getPropertyName())){ progressBar.setValue((Integer)evt.getNewValue()); } if ("state".equals(evt.getPropertyName())){ if (StateValue.STARTED.equals(evt.getNewValue())){ //    doButton.setText("In Search"); doButton.setEnabled(false); labelStatus.setText("searching..."); } if (StateValue.DONE.equals(evt.getNewValue())){ RetrieveMeaningOfLife worker = (RetrieveMeaningOfLife)evt.getSource(); try{ Integer answer = worker.get(); //   logger.info("got the answer"); JOptionPane.showMessageDialog(rootPane, "THE ANSWER IS " + answer); } catch(Exception ex){ //     logger.info("error while retrieving the answer"); JOptionPane.showMessageDialog(rootPane, "Error while searching for meaning of life"); } labelStatus.setText("answer was found"); doButton.setText("Find again"); doButton.setEnabled(true); } } } }); retrieveWorker.execute(); } } /** * listener that updates gui state by progress of PrepareToAnswerMeaningOfLife worker * @author __nocach * */ private class PrepareToAnswerMeaningOfLifeListener implements PropertyChangeListener{ @Override public void propertyChange(PropertyChangeEvent evt) { if ("state".equals(evt.getPropertyName())){ if (StateValue.STARTED.equals(evt.getNewValue())){ //      MeaningOfLifeAnswerer labelStatus.setText("Prepearing... "); doButton.setEnabled(false); logger.info("preparing..."); } if (StateValue.DONE.equals(evt.getNewValue())){ //           labelStatus.setText("I am prepared to answer the meaning of life"); doButton.setEnabled(true); PrepareToAnswerMeaningOfLife worker = (PrepareToAnswerMeaningOfLife)evt.getSource(); try{ doButton.setAction(new FindAnswerAction(worker.get())); logger.info("prepared"); } catch(Exception ex){ //    MeaningOfLifeAnswerer JOptionPane.showMessageDialog(rootPane, "failed to find answerer to the question"); dispose(); logger.severe("failed to prepare"); } } } } } /** Creates new form StandardWay */ public StandardWay() { initComponents(); PrepareToAnswerMeaningOfLife prepareWorker = new PrepareToAnswerMeaningOfLife(); prepareWorker.addPropertyChangeListener(new PrepareToAnswerMeaningOfLifeListener()); prepareWorker.execute(); } //... //     JFrame  //... private javax.swing.JButton doButton; private javax.swing.JLabel labelStatus; private javax.swing.JProgressBar progressBar; }
      
      







そのため、状態を変更するためのすべてのコードは、2つのPropertyChangeListener(PrepareToAnswerMeaningOfLifeワーカーとRetrieveMeaningOfLifeワーカー)にハードコーディングされています。 PrepareToAnswerMeaningOfLifeListenerとのダイアログのロジックが開始され、起動されたPrepareToAnswerMeaningOfLifeの進行状況を監視します。初期化が成功すると、検索ボタンはFindAnswerActionを受け取り、FindAnswerActionが押されるとRetrieveMeaningOfLifeワーカーを起動します。 そこで、メインの質問に対する答えを検索しながら、インターフェースの状態を同期するために、匿名のPropertyChangeListenerを追加します。 実際、論理的に全体的な状態の変化がそれぞれsetViewToPreparing()という形式の別のメソッドで取り出される場合、上記のリストは受け入れ可能な形式になります。

 private void setViewToPreparing(){ labelStatus.setText("Prepearing... "); doButton.setEnabled(false); logger.info("preparing..."); }
      
      





ただし、ダイアログでそのような各論理部分が20行を超える場合、レンダリングされたメソッドをさらに小さなメソッドに分割する必要があります。そのため、JFrameクラスには、あまり関係のないプライベートメソッドが多数詰め込まれます。



有限オートマトンを使用したアプローチは次のようになります。

 public class StateMachineWay extends javax.swing.JFrame { private Logger logger = Logger.getLogger(StandardWay.class.getName()); /** * controlls switching between gui states */ private GuiStateManager stateManager = new GuiStateManager(); private class PreparingAnswererState extends BaseGuiState{ @Override public void enterState() { labelStatus.setText("Prepearing... "); doButton.setEnabled(false); } } private class ReadyToFindTheAnswer extends BaseGuiState{ private final MeaningOfLifeAnswerer answerer; public ReadyToFindTheAnswer(MeaningOfLifeAnswerer answerer){ this.answerer = answerer; } @Override public void enterState() { labelStatus.setText("I am prepared to answer the meaning of life"); doButton.setEnabled(true); doButton.setAction(new FindAnswerAction(answerer)); } } private class FoundAnswerState extends BaseGuiState{ private final Integer answer; public FoundAnswerState(Integer answer){ this.answer = answer; } @Override public void enterState() { labelStatus.setText("answer was found"); doButton.setText("Find again"); doButton.setEnabled(true); JOptionPane.showMessageDialog(rootPane, "THE ANSWER IS " + answer); } } private class FailedToPrepareAnswerer extends BaseGuiState{ @Override public void enterState() { JOptionPane.showMessageDialog(rootPane, "failed to find answerer to the question"); dispose(); } } private class FailedToFoundAnswer extends BaseGuiState{ @Override public void enterState() { labelStatus.setText("failed to find answer"); doButton.setText("Try again"); doButton.setEnabled(true); JOptionPane.showMessageDialog(rootPane, "Error while searching for meaning of life"); } } private class SearchingForAnswer extends BaseGuiState{ @Override public void enterState() { labelStatus.setText("searching..."); doButton.setText("In Search"); doButton.setEnabled(false); } } /** * actions that starts worker that will find the answer to the main question * @author __nocach * */ private class FindAnswerAction extends AbstractAction{ private final MeaningOfLifeAnswerer answerer; public FindAnswerAction(MeaningOfLifeAnswerer answerer){ super("Find"); this.answerer = answerer; } @Override public void actionPerformed(ActionEvent e) { RetrieveMeaningOfLife retrieveWorker = new RetrieveMeaningOfLife(answerer); retrieveWorker.addPropertyChangeListener(new PropertyChangeListener() { @Override public void propertyChange(PropertyChangeEvent evt) { if ("progress".equals(evt.getPropertyName())){ progressBar.setValue((Integer)evt.getNewValue()); } if ("state".equals(evt.getPropertyName())){ if (StateValue.DONE.equals(evt.getNewValue())){ RetrieveMeaningOfLife worker = (RetrieveMeaningOfLife)evt.getSource(); try{ Integer answer = worker.get(); stateManager.switchTo(new FoundAnswerState(answer)); logger.info("got the answer"); } catch(Exception ex){ logger.info("error while retrieving the answer"); stateManager.switchTo(new FailedToFoundAnswer()); } } if (StateValue.STARTED.equals(evt.getNewValue())){ stateManager.switchTo(new SearchingForAnswer()); } } } }); retrieveWorker.execute(); } } /** * listener that updates gui state by progress of PrepareToAnswerMeaningOfLife worker * @author __nocach * */ private class PrepareToAnswerMeaningOfLifeListener implements PropertyChangeListener{ @Override public void propertyChange(PropertyChangeEvent evt) { if ("state".equals(evt.getPropertyName())){ if (StateValue.STARTED.equals(evt.getNewValue())){ logger.info("preparing..."); stateManager.switchTo(new PreparingAnswererState()); } if (StateValue.DONE.equals(evt.getNewValue())){ PrepareToAnswerMeaningOfLife worker = (PrepareToAnswerMeaningOfLife)evt.getSource(); try{ MeaningOfLifeAnswerer meaningOfLifeAnswerer = worker.get(); stateManager.switchTo(new ReadyToFindTheAnswer(meaningOfLifeAnswerer)); logger.info("prepared"); } catch(Exception ex){ logger.severe("failed to prepare"); stateManager.switchTo(new FailedToPrepareAnswerer()); } } } } } /** Creates new form StandardWay */ public StateMachineWay() { initComponents(); PrepareToAnswerMeaningOfLife prepareWorker = new PrepareToAnswerMeaningOfLife(); prepareWorker.addPropertyChangeListener(new PrepareToAnswerMeaningOfLifeListener()); prepareWorker.execute(); } //... //     JFrame  //... private javax.swing.JButton doButton; private javax.swing.JLabel labelStatus; private javax.swing.JProgressBar progressBar; }
      
      







主なクラスはGuiStateManagerです。

 /** * State machine of swing gui * @author __nocach */ public class GuiStateManager { private GuiState currentState = new EmptyState(); /** * makes passed state current * @param newState not null new state */ public synchronized void switchTo(GuiState newState){ if (newState == null){ throw new NullPointerException(); } currentState.leaveState(); currentState = newState; currentState.enterState(); } public GuiState current(){ return currentState; } }
      
      





状態の切り替えと、状態の開始と終了のための適切なメソッドの呼び出しを担当する状態マシンクラス。



およびクラス状態GuiState

 public interface GuiState { /** * called when entering to this state */ public void enterState(); /** * called when leaving this state */ public void leaveState(); }
      
      







ステートマシンを介してアプローチすると、最終的に次の内部クラスを思い付きました。

AnswererStateの準備、

ReadyToFindTheAnswer、

FoundAnswerState、

FailedToPrepareAnswerer、

FailedToFoundAnswer



上記の例では、ハードコーディングされたロジックの代わりに、状態への遷移がありますが、それ以外は何もありません。たとえば:

 stateManager.switchTo(new ReadyToFindTheAnswer(meaningOfLifeAnswerer));
      
      





要素を変更するすべてのロジックは、切り替えた状態に集中しています(この場合、検索ボタンをアクティブにし、JLabelのラベルとボタンを変更するReadyToFindTheAnswer)。 状態の切り替えの場所を簡単に移動したり、同じ切り替えを別の場所で使用したりできることに注意してください。



たくさんのクラスを作成したと言う人もいるかもしれませんが、この場合、コードの可読性を高めるために5つのクラスを使用します。 各クラス名は状態名です。



これで、MeaningOfLifeAnswererを準備した時点でインターフェースの動作を変更したい場合(つまり、回答検索を起動するボタンをアクティブにした場合)、ReadyToFindTheAnswer状態を見つけて、たとえば、応答する準備ができているメッセージを含むポップアップダイアログを追加するだけです質問。



不要な変数やメソッドで外部JFrameを詰まらせることなく、各状態クラスを_locally_自由にリファクタリングできます。 状態クラスは、後でテストするために別のファイルに移動できます。



また、このアプローチは、対話コードをより明確に記述し、状態ごとに論理名を持つ個別の小さなクラスを作成する_強制_します。 もちろん、ここでは膨大な数の匿名クラスですべてを台無しにすることができます。



上記のステートマシンコードは、単なるアイデアの一例です。 ユーザーインターフェイスを有限状態マシンと見なすことにしたのは私が初めてではないため、このアイデアを非常に深く考えた既製のSwingStatesフレームワークがあります。



もちろん、このアプローチは、あらゆる言語のあらゆるデスクトップアプリケーションで使用できます。



サンプルコードはこちらから入手できます。



All Articles