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

    Придя в большой проект, использующий в качеств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, который довольно глубоко продумал эту идею.

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

    Код примера доступен здесь
    Поделиться публикацией
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама
    Комментарии 11
    • 0
      Много кода.
      Мне лично хватило одного упоминания связки состояний компонентов с состояниями конечного автомата, дальше реализация практически очевидна.
      P.S. Также хотелось бы заметить, что диалог удобно закрыть интерфейсом для вызывающего кода, в итоге вызывающий код вообще отвязан от диалога. Что правильно.
      • 0
        У вас же на каком-то этапе генерируются резанные пдфки. Почему нельзя склеивать их? Или это технически невозможно? И почему надо превращать в тяжеловесные джипеги, а не в, например, пнгшки?
        • +1
          Зачем изобретать велосипед, если есть MVP?
          Вот вам безупречный с точки зрения архитектуры пример реализации MVP паттерна:
          www.logicdevelopment.net/blog/?p=16
          • 0
            По ссылке пример почти польностью статического диалога без каких-либо сложных связей между компонентами. Не вижу как бы MVP помогло упростить приведенный мной пример. Был бы рад, если бы вы могли привести код реализации с использованием MVP.
          • 0
            Да, такой забавный способ реализовать контроллер, который бы мог просто состоять из нескольких методов, содержащих тела методов enterState всех Стейтов. А чем он удобнее, чем простой MVC?

            Просто когда слышишь слово «стейты», сразу ожидаешь увидеть возможность описывать все события в системе каким-нибудь простым стандартным образом, типа xml-файла, или, как сумашедшие немцы у нас сделали — в базе данных хранить. И типа тестировать сразу станет удобнее, потому что тестировщикам будет просто смотреть на описание требуемого поведения системы, если спеки так писать. Или вообще тесты адские делать, которые просто берут, требуемые переходы вызывают и отслеживают состояние модели.

            Как говорил герой фильма «Револьвер», а что это дает мне? В чем радость?
            • +1
              Мне кажется, что автор просто привлекает внимание к подходу/парадигме программирования, которую я называю автоматное программирование или программирование с явно выделенным состоянием. Плюсы мышления в подобном стиле велики, но не все знают о таких способах борьбы со сложностью, потому такие статьи надо приветствовать.

              Этот подход может реализоваться разными способами: от банального switch до полноценного фреймворка — что лучше зависит от контекста.

              • 0
                Всё верно. Поэтому в конце статьи и привел ссылку на SwingStates для всех желающих, которые хотели бы более глубоко изучить такой подход к написанию пользовательских интерфейсов.
              • 0
                Например, можно параллельно поддерживать в одном диалоге несколько разных View. Достаточно одновременно иметь несколько конечных автоматов, каждый из которых будет помнить своё состояние. Здесь есть замечательный пример как это выглядет.
                Я пока еще сам полностью не разобрался с SwingStates фреймворком, но в некоторых ситуациях он действительно может помочь при написании сложных диалогов.
                Опять же в SwingStates есть специальные дебаг-конечные-автоматы, с помощью которых можно очень наглядно увидеть, что у вас происходит в диалоге.
                P.S. Добавил в код примера реализацию с помощью MVC.
              • +1
                Поздравляю! Вы придумали шаблон состояние. Честно говоря, назвать это конечным автоматом я бы не решился. Хотя в некотором смысле можно так полагать.

                Использовал подбный подход в дипломе — сложной распрееленной системе, с помощью автомата/состояние описывалось поведение каждой ноды в замисимости от внешних факторов.
                  • –1
                    А если смотреть в сторону предметно-ориентированных языков (DSL), вот еще две недавние публикации на Хабре:

                    habrahabr.ru/blogs/Flash_Platform/128725/
                    habrahabr.ru/blogs/Flash_Platform/129092/

                    И не надо смущаться, что оба упомянутых поста находятся в блоге Flash_Platform, поскольку речь идет о Jetbrains MPS-based IDE.

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