SwingでUIアーキテクチャパターン MVC編

前回に引き続き、今回はMVCパターンです。


まずはモデルですが、BMIの計算に関するドメインオブジェクトとして実装します。
また、MVCにおけるモデルは、ビューとオブザーバパターンの関係を作る必要があるため、オブザーバを保持するためのEventListenerListや、オブザーバにイベントを通知するための発火メソッド(fireModelChanged()メソッド)を実装しています。
BMIを表示するテキストボックスの背景色を表すbmiColor変数は、本来プレゼンテーション層の状態を表すオブジェクトですが、テストのことを考えるとあまりビューにロジックなどを持たせたくないため、今回のサンプルではモデルに実装しています。
とはいえ、このモデルはほぼBMIの計算に関する処理だけになったので、かなりすっきりした感じになりました。

public class Model{

    private EventListenerList listenerList = new EventListenerList();

    private String height = "";
    private String weight = "";
    private String bmi = "";
    private Color bmiColor = Color.WHITE;

    public String getWeight(){
        return weight;
    }

    public String getHeight(){
        return height;
    }

    public String getBmi(){
        return bmi;
    }

    public Color getBmiColor(){
        return bmiColor;
    }

    public void setWeight( String weight ){
        this.weight = weight;
        updateBMI();
        fireModelChanged();
    }


    public void setHeight( String height ){
        this.height = height;
        updateBMI();
        fireModelChanged();
    }

    private void updateBMI(){

        float height;
        float weight;

        try{
            height = Float.valueOf( this.height );
            weight = Float.valueOf( this.weight );
        } catch ( NumberFormatException e ){
            return;
        }

        if( height == 0f ){
            return;
        }

        float bmi = weight / ( height * height );

        this.bmi = Float.toString( bmi );

        if( bmi < 18.5f ){
            bmiColor = Color.WHITE;
        } else if( bmi < 20.0f ){
            bmiColor = Color.YELLOW;
        } else if( bmi < 30.0f ){
            bmiColor = Color.ORANGE;
        } else{
            bmiColor = Color.RED;

        }
    }


    protected void fireModelChanged(){
        ModelListener[] listeners = listenerList.getListeners( ModelListener.class );
        ModelEvent event = new ModelEvent( this );
        for( ModelListener l : listeners ){
            l.modelChanged( event );
        }
    }


    public void addModelListener( ModelListener listener ){
        listenerList.add( ModelListener.class, listener );
    }

    public void removeModelListener( ModelListener listener ){
        listenerList.remove( ModelListener.class, listener );
    }
}


続いてビューです。
こちらは画面の表示に関する処理、モデルとコントローラの初期化、モデルへのリスナの登録だけのクラスになりました。
モデルの状態が更新されるとmodelChanged()メソッドがモデルから呼び出されるので、その中で画面の更新処理を行なっています。

public class View extends JFrame implements ModelListener{

    private final Controller controller = new Controller();

    public static void main( String args[] ){

        SwingUtilities.invokeLater( new Runnable(){
            public void run(){
                Model model = new Model();
                View view = new View( model );
                view.setVisible( true );
            }
        } );
    }

    /**
     * @param model
     */
    public View( Model model ){
        initComponents();
        setModel( model );
        addHeightChangeListener( new DocumentListener(){
            public void insertUpdate( DocumentEvent e ){
                controller.heightChanged( e );
            }

            public void removeUpdate( DocumentEvent e ){
                controller.heightChanged( e );
            }

            public void changedUpdate( DocumentEvent e ){
            }
        } );

        addWeightChangeListener( new DocumentListener(){
            public void insertUpdate( DocumentEvent e ){
                controller.weightChanged( e );
            }

            public void removeUpdate( DocumentEvent e ){
                controller.weightChanged( e );
            }

            public void changedUpdate( DocumentEvent e ){
            }
        } );

    }


    public void setModel( Model model ){
        Model oldModel = controller.getModel();
        if( oldModel != null ){
            oldModel.removeModelListener( this );
        }
        controller.setModel( model );
        model.addModelListener( this );

    }

    public void modelChanged( ModelEvent e ){
        Model model = controller.getModel();
        if( !controller.isNotifyEvent() ){
            weightField.setText( model.getWeight() );
            heightField.setText( model.getHeight() );
        }
        bmiField.setText( model.getBmi() );
        bmiField.setBackground( model.getBmiColor() );

    }

    public void addHeightChangeListener( DocumentListener l ){
        heightField.getDocument().addDocumentListener( l );
    }

    public void addWeightChangeListener( DocumentListener l ){
        weightField.getDocument().addDocumentListener( l );
    }

    /**
     * This method is called from within the constructor to
     * initialize the form.
     * WARNING: Do NOT modify this code. The content of this method is
     * always regenerated by the Form Editor.
     */
    @SuppressWarnings( "unchecked" )
    // <editor-fold defaultstate="collapsed" desc="Generated Code">//GEN-BEGIN:initComponents
    private void initComponents(){
        //省略
    }
}

モデル登録するリスナのインターフェイスです。上述の通り、モデルの更新時にはmodelChanged()が呼び出されます。

public interface ModelListener extends EventListener{

    void modelChanged( ModelEvent e );
}


最後にコントローラです。
ユーザからのイベント(ここではビューのJTextField)から受け取ったイベントを適切に加工し、モデルに渡す処理を行なっています。もっとも、今回のサンプルではほとんど何もしないクラスになってしまいました。
notifyEvent変数は、イベントの発生元となったJTextFieldが自分自身を変更しないようにするためのフラグです。正直このコードは不細工な感じがしますが、他にいい方法が思いつきませんでした。

public class Controller{

    private boolean notifyEvent;
    private Model model;

    public Controller(){
    }


    public void setModel( Model model ){
        this.model = model;
    }

    public Model getModel(){
        return model;
    }

    public boolean isNotifyEvent(){
        return notifyEvent;
    }

    public void heightChanged( DocumentEvent e ){
        String text = getTextFromDocumentEvent( e );
        heightChanged( text );
    }

    public void heightChanged( String newHeight ){
        notifyEvent = true;
        try{
            model.setHeight( newHeight );
        } finally{
            notifyEvent = false;
        }
    }

    public void weightChanged( DocumentEvent e ){
        String text = getTextFromDocumentEvent( e );
        weightChanged( text );
    }

    public void weightChanged( String newWeight ){
        notifyEvent = true;
        try{
            model.setWeight( newWeight );
        } finally{
            notifyEvent = false;
        }
    }

    private String getTextFromDocumentEvent( DocumentEvent e ){
        try{
            return e.getDocument().getText( 0, e.getDocument().getLength() );
        } catch ( BadLocationException ignored ){
            return "";
        }
    }
}

ユーザがキーを入力した時のこれらのコードの処理の流れですが、以下のようになります。

  1. イベントがビューを経由してコントローラが受け取る
  2. コントローラは受け取ったイベントを加工し、モデルを更新
  3. 更新されたモデルは、自身のオブサーバであるビューにモデルが更新されたことを通知
  4. モデルが更新された事を通知されたビューは、モデルから情報を取り出して自分自身を更新


また、同じモデルを共有するビューを二つ生成すると、同じ値を表示する二つの画面が表示することができます。

public void run(){
    Model model = new Model();
    View view = new View( model );
    view.setVisible( true );
    View view2 = new View( model );
    view2.setVisible( true );
}

さて、前回のコードと比べるとドメインオブジェクトのコードとプレゼンテーションのコードが分離されて綺麗になりましたが、依然プレゼンテーションの状態やロジックがモデル内に混在してます。
次回、監視コントローラ(Supervising Controller)パターンに続きます。

完全なコードは以下からどうぞ。
ソース一式(github)