SwingアプリケーションのMVC構造のクラス設計
Swingで、モデルとビューとコントローラを分離させたアプリケーションをどう書くか。MVCパターンクラス設計とは悩ましいもので、これぞという手順をいつまでも模索してしまいます。
現在の開発の手順を、自分のベストプラクティスをまとめます。
なにから作り始めるか
作るソフトウエアによりますが、可能ならビューから作り始めることにしています。開発前に画面のラフ書きやユーザーストーリーがある時は特に、最初に画面だけの試作型を作ることで定期的にユーザビリティのテストが行える利点があります。
それ以外にも、出来上がり図があるプログラムはイメージが湧いてきて楽しいことなども理由にあります。
View
フレームやダイアログ画面のような、ひとかたまりになるだろう構成を、1つのクラスにつめます。EclipseなどでGUIエディターを使うのが一般的だと思いますが、私はゴリゴリ書いてしまいます。
public class MainView { public final JLabel itemLabel = new JLabel("Item"); public final JTextField itemField = new JTextField("Item"); public final JTable dataListTable = new JTable(); // 続く...
使用するウィジェット(Swingコンポーネント)が変わるような複雑性の高い設計は、コントローラの設計も複雑にさせるので、finalフィールドで定義します。Java Beansになることもないのでpublicにします。もちろんアクセサメソッドを作ってもいいです。
親コンポーネントを一つ用意します(上のcontainer)。ここへ他のコンポーネントを配置します。JPanelやJFrameを継承してレイアウトする方法もありますが、親コンポーネントを隠蔽(カプセル化)できないデメリットがあるため、継承より内包構成を選びます。
// ...続き private final JPanel container = createContainer(itemLabel, itemField); private static JPanel createContainer(JLabel itemLabel, JTextField itemField) { JPanel panel = new JPanel(); panel.add(itemLabel); // その他のレイアウト return panel; } public JComponent getContainer() { return container; } }
親コンポーネントはJPanelであるときがほとんどですが、たまにJScrollPaneのような他のコンポーネントにもなり得ますので、親コンポーネント用のアクセサメソッド(getContainer)を作成します。
レイアウトは、レイアウト用メソッドを作ってイニシャライザ内で行ってしまいます。
レイアウト用メソッドはstatic宣言し、引数にコンテナー内で使用するウィジェットをとることにすると、うっかり別のウィジェットを配置してしまった、などレイアウトミスを減らすことができます。
レイアウトはすぐ確認したいので、画面を表示するmainメソッドを作成します。
public static void main(String[] args) { EventQueue.invokeLater(new Runnable() { public void run() { JFrame frame = new JFrame("MainViewTest"); MainView mainView = new MainView(); frame.setContentPane(mainView.getContentPane()); frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); frame.pack(); frame.setLocationRelativeTo(null); frame.setVisible(true); } }); }
ビュークラスにこのmainメソッドを書いてしまってもかまいませんが、運用上必要ないメソッドを作るのはおもしろくないので、Testクラス(MainViewTestなど)にmainメソッドを書きます。
ビュークラスはウィジェットの配置とデザインがほとんどであるため、操作や計算の処理が紛れ込んでMVCパターンが崩れる、という心配が少ない領域です。
Model
データ処理は全てモデルとします。いくつかの要素(例えば名前や年齢や住所など)を持つ情報の固まりクラスや、入力から出力を得る計算クラスはモデルに該当します。ResourceBundleからデータを取得したり、ネットワークから取得するような重い処理をするクラスもモデルとして書きます。ただし重い処理のモデルは、SwingWorkerのような非同期実行クラスから使うことになります。
MVC構造には上の処理モデルクラスで得た情報をを表現するためのモデルが必要になります。Swingコンポーネントの専用のモデル(TableModelやListSelectionModel)を拡張や内包することになります。
タイトルや色や表示文字列の変更など少し凝ったアプリケーションを作るときは、これら表示の情報を納めたモデルを作成することになります。
目安として一つのビューに一つの表示モデルクラスを作ります。Java Beansの命名規則に沿って作ります。
class MainViewModel { private String title = "title"; private boolean fieldEditable = true; public String getTitle() { return title; } public void setTitle(String title) { this.title = title; } public boolean isFieldEditable() { return fieldEditable; } public void setFieldEditable(boolean fieldEditable) { this.fieldEditable = fieldEditable; } // 続く...
表示に関与するモデルは、値の変化をコンポーネントに通知するために変化イベントの発行を実装することになります。ほとんどの場合PropertyChangeEventのイベント通知機構を実装することになります。
イベントの追加や発行の処理はPropertyChangeSupportを使って処理を委譲できます。IDEの委譲メソッド作成機能を利用するとより簡単です。
// ...続き private final PropertyChangeSupport pcs = new PropertyChangeSupport(this); public void addPropertyChangeListener(PropertyChangeListener listener) { pcs.addPropertyChangeListener(listener); } public void removePropertyChangeListener(PropertyChangeListener listener) { pcs.removePropertyChangeListener(listener); } protected void firePropertyChange(String propertyName, Object oldValue, Object newValue) { pcs.firePropertyChange(propertyName, oldValue, newValue); } }
モデルの変化をビューに伝えるのはコントローラの役目です。モデルがビューを保持しないように気をつけて設計します。
Controller
コントローラクラスには大きく分けて二つの役割があります。
1つめは、ボタンを押したときの反応、テーブルのセルを選択、などユーザーの入力の反応処理をうけとり、モデルに処理を伝える役割です。モデル−コントローラと呼ばれます。
2つめは、モデルやモデル−コントローラの値の変化をウィジェットに適用する役割で、ビュー−コントローラと呼ばれます。
モデル−コントローラは、多くの場合リスナーインターフェイスを実装する設計になります。
例えば「テーブルで選択した行の識別子をモデルに適用する」という処理は次のようになります。
class TableRowSelectionHandler implements ListSelectionListener { private final List<String> tableRowIdentifierList; private final CustomDataModel targetModel; public TableRowSelectionHandler(CustomDataModel targetModel, List<String> tableRowIdentifierList) { this.targetModel = targetModel; this.tableRowIdentifierList = tableRowIdentifierList; } @Override public void valueChanged(ListSelectionEvent e) { int index = ((ListSelectionModel) e.getSource()).getAnchorSelectionIndex(); String identifier = tableRowIdentifierList.get(index); setIdentifier(identifier); } void setIdentifier(String identifier) { targetModel.setIdentifier(identifier); } }
モデルを介さないときはこのコントローラクラスに値変化通知の実装をします。
void setIdentifier(String identifier) { firePropertyChange("identifier", this.identifier, this.identifier = identifier); } protected void firePropertyChange(String propertyName, Object oldValue, Object newValue) { // イベント通知の実装... }
ボタンを押してモデルを変化させる、という処理もActionインターフェイス(AbstractAction抽象クラス)を実装したモデル−コントローラとして作ります。
ユーザーからの操作の応答をとりまとめるクラスもまたコントローラクラスになります。操作に応答してモデルを変更することになるので、これも同じくモデル−コントローラとなります。
class MainViewController { private MainViewModel model; /** 内包したコントローラオブジェクト1 */ private final PropertyChangeListener propertyChangeListener = new PropertyChangeListener() { public void propertyChange(PropertyChangeEvent e) { modelPropertyChange((MainViewModel) e.getSource(), e.getPropertyName()); } }; /** 内包したコントローラオブジェクト2 */ private final TableRowSelectionHandler handler = new TableRowSelectionHandler(model, identifierList); /** 内包したコントローラオブジェクト3 */ private final Action updateAction = new AbstractAction("Update") { @Override public void actionPerformed(ActionEvent e) { model.update(); } }; /** @param model 操作されるモデル */ public MainViewController(MainViewModel model) { this.model = model; } // その他の実装...
ボタンにアクションを設定したり、リストの変化を監視する処理を行うための結合メソッドを作成します。
public void bindUpdateButton(JButton button) { button.setAction(updateAction); } public void bindListSelection(JList list) { list.addListSelectionListener(handler); } // 実装の続き...
親ビューから一気に結合してしまうメソッドも作成してしまいます。
>|java|
public void bindMainView(MainView view) {
bindUpdateButton(view.updateButton);
bindListSelection(view.favoritesList);
}
|
ビュー−コントローラは、ビューを保持します。あるプロパティ値をフレームのタイトルに表示するというときはビュー−コントローラが処理することになります。たとえばidentifier値をフレームのタイトルに表示するクラスは次のような実装になります。
class FrameTitleController implements PropertyChangeListener { private final JFrame frame; public FrameTitleController(JFrame frame) { this.frame = frame; } @Override public void propertyChange(PropertyChangeEvent e) { if ("identifier".equals(e.getPropertyName())) { frame.setTitle(String.valueOf(e.getNewValue())); } } }
この時、監視するプロパティは"identifier"とハードコーディングしていますが、これを引数で指定できるようにすると、より再利用性が高まります。
class FrameTitleController implements PropertyChangeListener { private final JFrame frame; private final String propertyName; /** * @param propertyName 対応するプロパティ名。{@code null} の時はすべてのプロパティの変化に対応。 */ public FrameTitleController(JFrame frame, String propertyName) { this.frame = frame; this.propertyName = propertyName; } @Override public void propertyChange(PropertyChangeEvent e) { if (propertyName == null || propertyName.equals(e.getPropertyName())) { frame.setTitle(String.valueOf(e.getNewValue())); } } }
ビュー−コントローラの設計のとき、保持するビューは一つに限定して考えると、結合性を弱くなり、テストもしやすいクラスになります。
このコントローラクラスを利用する親クラスは、このオブジェクトをインスタンス化し、リスナー登録メソッド(addPropertyChangeListenerなど)を用いて追加します。
コントローラが別のコントローラに値を伝達させたいときがあります。これには二つの方法があり、コントローラが内包するモデルを介して、別のコントローラにこのモデルの変化を監視させるか、先に出てきたTableRowSelectionHandlerにあるような値変化通知をコントローラ自身が実装し、その変化を別のコントローラに監視させることになります。
ところでリスナーインターフェイスを実装するコントローラクラスは上記にあるように変化通知メソッド(#valueChanged(ListSelectionEvent) や#propertyChange(PropertyChangeEvent))が公開されることになります。カプセル化を強めるために、もう一工夫必要になります。
FrameTitleControllerはTableRowSelectionHandlerへリスナーの登録することを期待しているのでメソッドを作成します。
public void listenTo(TableRowSelectionHandler target) { target.addPropertyChangeListener(this); }
リスナー登録する対象をTableRowSelectionHandler以外にもするためには、同じメソッドオーバーロードします。リフレクションを使って全てのオブジェクトを対象にすることもできますが、通常の設計ではオーバーロードで十分です。
リスナー登録メソッドがあるため、もはやコントローラクラスがPropertyChangeListenerを実装しなくてもよくなります。リスナーをこれを匿名クラスとしてフィールドへ移します。
class FrameTitleController { private final PropertyChangeListener propertyChangeListener = new PropertyChangeListener() { public void propertyChange(PropertyChangeEvent e) { processPropertyChange((TableRowSelectionHandler) e.getSource(), e.getPropertyName()); } }; protected void processPropertyChange(TableRowSelectionHandler source, String propertyName) { // 処理... } public void listenTo(TableRowSelectionHandler target) { target.addPropertyChangeListener(propertyChangeListener); } // その他の実装... }
コード量は増えてしまいますが、メソッドのおかげでこのクラスの利用方法がよりはっきりし、またリスナー登録される型が定まるので、キャスト例外の心配がなくなります。
コントローラの値変化の監視先は通常一つになりますので、完全に一つに限定させると、強い設計になります。
class FrameTitleController { // ...その他の実装のあと private TableRowSelectionHandler target; public void setListeningTarget(TableRowSelectionHandler newTarget) { if (target != null) { deafTo(target); } target = newTarget; if (newTarget != null) { listenTo(newTarget); } } void listenTo(TableRowSelectionHandler target) { target.addPropertyChangeListener(propertyChangeListener); } void deafTo(TableRowSelectionHandler target) { target.removePropertyChangeListener(propertyChangeListener); } // さらにその他の実装... }
実行
アプリケーションの実行は、
モデルの構築 -> コントローラの構築 -> ビューの構築 -> 結合
という流れになります。
public static void main(String[] args) { EventQueue.invokeLater(new Runnable() { public void run() { start(); } }); } void start() { MainViewModel model = new MainViewModel(); MainViewController controller = new MainViewController(model); MainView mainView = new MainView(); controller.bindMainView(mainView); JFrame frame = new JFrame("Application"); frame.setContentPane(mainView.getContainer()); frame.setContentPane(mainView.getContentPane()); frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); frame.pack(); frame.setLocationRelativeTo(null); frame.setVisible(true); }