Pull to refresh

Реализация графического интерфейса как Конечного Автомата

Reading time11 min
Views10K
Придя в большой проект, использующий в качествe графического интерфейса Swing, я понял, что разобраться в логике и связах между компонентами JDialog или JFrame довольно таки не просто. И всё то время, что я разбирался с этим кодом я пытался найти какое-то универсальное решение, которое бы позволило избежать сложности связей между элементами интерфейса.



Основная проблема, с которой я сталкивался: каждый элемент имеет один или более PropertyChangeListener, которые в зависимости от ситуации (читай от состояния других элементов), каким-либо образом влияют на другие элементы. Те элементы в свою очередь тоже имеют свои PropertyChangeListener, которые тоже в зависимости от контекста влияют на другие. Понять, как нажатие кнопки повлияет на все остальные элементы, было иногда очень не просто, а уж найти кусок кода, где это происходит, тем более. Также это усложняло изменение зависимостей (чтобы изменить логическое правило, приходилось искать все связи в разных местах на разных компонетах, и скорее всего что-то было упущено) и дебаггинг.

В такой ситуации мог бы помочь паттерн Медиатор, но опять же проследить все связи и логику взаимодествия всё также будет сложно. Можно разбить каждый JDialog на меньшие JPanel'ы, которые бы инкапсулировали определенные логические места, но опять же, нам надо поддерживать взаимодействие этих JPanel'ов между собой. Если JDialog и так несложный, то разбивать будет не на что.

Пришла мне в голову идея о том, что JDialog всегда находится в строго определенных состояниях в определенный момент времени. К примеру, диалог создался, он в состояние «я только что создался», затем пользователь или сама логика программы переводит диалог в состояние «я обрабатываю», если пользователь во время «я обрабатываю» нажал на кнопку отменить, то диалог переходит в состояние «я был прерван».

Почему рассуждать в таком ключе хорошо? В каждый отдельный момент времени мы можем сосредоточиться на отрисовке одного логического состояния. И, следовательно, весь код, отвечающий за состояние элементов, будет в одном, логическом и физическом месте. Не надо будет искать по всем PropertyChangeListener'ам, чтобы понять как JButton «Отменить» может повлиять на JLabel статус, когда она была нажата после определенных интеракций пользователя. То же самое касается внесения изменений. Например, у нас есть диалог, который делает что-то в фоновом потоке и публикует свой прогресс. Диалог отображает прогрессбар с текущим прогрессом. Если пользователь нажимает кнопку «Отменить», мы сбрасываем JProgressBar. Теперь мы решили еще и отключить кнопку запуска фонового процесса, если он был отменен. Когда наш интерфейс написан с использованием состояний, мы просто находим состояние «я был прерван», и туда добавляем startButton.setEnabled(false), и мы уверены в своем изменении, зная, что оно повлияло только на логическое состояние «я был отменен».

Итак, надо программировать пользовательский интерфейс как множество состояний, в которые диалог входит, и из которых выходит. Less words more code!

В качестве примера мы рассмотрим диалог, который позволяет узнать ответ на главный вопрос.

этот класс представляет того, кто знает ответ:

/**
 * 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;
}



Итак, весь код для изменения состояния у нас захардкоден в двух PropertyChangeListener'ах (воркера PrepareToAnswerMeaningOfLife и воркера RetrieveMeaningOfLife). Начинается логика работы диалога с PrepareToAnswerMeaningOfLifeListener, следящего за прогрессом запущенного PrepareToAnswerMeaningOfLife, затем, после успешного инициализирования, кнопка поиска получает 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();
}


При подходе через конечный автомат у нас в итоге вышли следующие внутренные классы:
PreparingAnswererState,
ReadyToFindTheAnswer,
FoundAnswerState,
FailedToPrepareAnswerer,
FailedToFoundAnswer

В приведенном примере, вместо захардкоденной логики теперь переход в состояние и ничего более, например:

stateManager.switchTo(new ReadyToFindTheAnswer(meaningOfLifeAnswerer));

Вся логика изменения элементов сконцентрирована в состоянии, до которого мы переключились(в данном случае ReadyToFindTheAnswer, которое делает кнопку поиска активным и меняет надписи JLabel'а состояния и кнопки). Стоит заметить, что теперь мы можем легко перемещать место переключения состояния, или использовать одно и тоже переключение в разных местах.

Кто-то может сказать, что мы просто так создали кучу классов, но в данном случае мы используем 5 классов для повышения читаемости кода, т.к. каждое название класса есть название состояния.

Теперь, когда мы захотим изменить поведение интерфейса в момент, когда мы подготовили MeaningOfLifeAnswerer (т.е. сделали активной кнопку запуска поиска ответа) нам достаточно будет найти состояние ReadyToFindTheAnswer и добавить в него, например, выскакивающий диалог с сообщением, что мы готовы ответить на вопрос.

Каждый класс состояния можно свободно рефакторить _локально_, не засоряя внешний JFrame лишними переменными или методами. Классы состояния можно вынести в отдельные файлы для последующего тестирования.

Также такой подход _заставляет_ писать код диалога более понятно, создавая для каждого состояния отдельный маленький класс с логически-понятным именем. Хотя, конечно, и тут можно все испортить огромным количеством анонимных классов.

Приведенный выше код конечного автомата, является всего лишь примером реализации идеи. Я не первый, кто решил думать о пользовательском интерфейсе, как о конечном автомате, и поэтому есть готовый фреймворк SwingStates, который довольно глубоко продумал эту идею.

Ну и, очевидно, данный подход можно использовать в любом дэсктоповом приложении на любом языке.

Код примера доступен здесь
Tags:
Hubs:
Total votes 26: ↑22 and ↓4+18
Comments11

Articles