MVC in JavaFX

Die Architekturmuster bleiben ohne konkrete Umsetzung in einem Framework sehr abstrakt. Zur Veranschaulichung von MVC und MVVM sollen diese beiden Muster folgend in JavaFX umgesetzt werden. JavaFX wird hier als populäres UI-Framework für Desktop-Anwendungen in Java exemplarisch ausgewählt. Auf Grundlagen von JavaFX wird an dieser Stelle nur kurz eingegangen. Eine ausführlichere Einführung in JavaFX erfolgt im Kapitel Desktop-App mit JavaFX.

Als Beispiel soll das einfache Spiel Tic-Tac-Toe implementiert werden. Das UI wird in der folgenden Abbildung dargestellt. Die View enthält folgende Attribute, um ihren Zustand zu erfassen, und Methoden zur Steuerung der Anwenderinteraktion:

Es folgt der Code einer Tic-Tac-Toe-Implementierung, die dem gemäß Dokumentation vorgeschlagen MVC-Muster in JavaFX folgt. Der Code wird anschließend erläutert.

import javafx.fxml.FXML;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.layout.GridPane;
// ...
	
public class TicTacToeController {

    private Board model; // controller is connected to model here

    @FXML
    private GridPane board;

    @FXML
    private Label winner;

    @FXML
    protected void initialize() {
        model = new Board();
        for (int row = 0; row < 3; row++) {
            for (int col = 0; col < 3; col++) {
                Button cell = new Button();
                cell.getStyleClass().add("cell");
                cell.setOnAction(event -> selectCell(event));
                board.add(cell, col, row);
            }
        }
    }

    public void selectCell(ActionEvent event) {
        Button cell = (Button) event.getSource();
        int row = GridPane.getRowIndex(cell);
        int col = GridPane.getColumnIndex(cell);

        Player player = model.markCell(row, col);
        if (player != null) {
            cell.setText(player.toString());
            if (model.getWinner() != null) {
                winner.setText("Winner is " + player.toString());
            }
        }
    }

    public void reset(ActionEvent event) {
        model.restart();
        winner.setText("");
        board.getChildren().forEach(node -> ((Button) node).setText(""));
    }
}
enum Player {X, O}
	
class Cell {
	Player value;
}
	
class Board {
    Cell[][] cells = new Cell[3][3];
    Player currentTurn;	
    Player winner;

    public Board() { restart(); }

    /**
     * Start a new game, i.e. clear the board and the game status.
     */
    public void restart() {
        clearCells();
        winner = null;
        currentTurn = Player.X;
    }

    /**
     * Mark a cell for the player in turn.
     * Nothing is done, if row or col are out of range, or the cell is already marked, or the game is finished.
     *
     * @param row 0..2
     * @param col 0..2
     * @return player who made the turn
     */
    public Player markCell(int row, int col) {
        if (isValid(row, col)) {
            Player player = currentTurn;
            cells[row][col].value = currentTurn;
            if (isWinningMoveByPlayer(currentTurn, row, col)) { // check if game is won
                winner = currentTurn;
            } else {
                currentTurn = currentTurn == Player.X ? Player.O : Player.X; // flip current turn
            }
            return player;
        }
        return null;
    }

    public Player getWinner() { return winner; }

    void clearCells() {
        for (int i = 0; i < 3; i++) {
            for (int j = 0; j < 3; j++) {
                cells[i][j] = new Cell();
            }
        }
    }

    boolean isValid(int row, int col) {
        return !(winner != null || isOutOfBounds(row) || isOutOfBounds(col) || isCellValueAlreadySet(row, col));
    }

    boolean isOutOfBounds(int i) { return i < 0 || i > 2; }

    boolean isCellValueAlreadySet(int row, int col) { return cells[row][col].value != null; }

    boolean isWinningMoveByPlayer(Player player, int row, int col) {
        return cells[row][0].value == player && cells[row][1].value == player && cells[row][2].value == player // 3 in a row
                || cells[0][col].value == player && cells[1][col].value == player && cells[2][col].value == player // 3 in a col
                || cells[0][0].value == player && cells[1][1].value == player && cells[2][2].value == player // 3 in a diagonal
                || cells[0][2].value == player && cells[1][1].value == player && cells[2][0].value == player; // other diagonal
    }
}
import javafx.application.Application;
// ...
	
public class TicTacToeApplicationMVC extends Application {

	@Override
	public void start(Stage stage) throws Exception {
		Parent view = FXMLLoader.load(getClass().getResource("view/tictactoe.fxml"));
		Scene scene = new Scene(view);
		stage.setScene(scene);
		stage.show();
	}

	public static void main(String[] args) { launch(args); }
}

MVVM in JavaFX

Die Beispielanwendung soll jetzt dahingehend umgebaut (oder auch "refaktorisiert") werden, dass sie dem MVVM-Muster entspricht. Die Grundidee von MVVM ist es, den gesamten Zustand des UI im ViewModel abzubilden. Das ViewModel enthält also Attribute für die aktuellen Werte in Textfeldern, Tabellen und anderen UI-Elementen. Auch unscheinbare Aspekte, wie z.B. ob ein Button aktiv oder inaktiv ist, werden im ViewModel erfasst. Die View präsentiert nur den Zustand des ViewModel. Wenn der Zustand durch den Anwender verändert wird, z.B. indem in einem Textfeld getippt wird, delegiert die View diese Interaktion umgehend an das ViewModel. Entgegengesetzt muss auch eine Änderung, die vom ViewModel ausgeht, umgehend in der View angezeigt werden. View und ViewModel müssen also stets synchron gehalten werden.

In JavaFX ermöglichen Implementierungen des Interface Property sowohl unidirektionales als auch bidirektionales Data Binding. Bidirektionales Binding (auch Two-Way-Binding genannt) basiert in JavaFX darauf, dass sich zwei Property-Objekte gegenseitig beobachten. Der folgende Code verdeutlicht das Prinzip anhand von zwei StringProperty-Objekten.

StringProperty a = new SimpleStringProperty();
StringProperty b = new SimpleStringProperty();

a.bindBidirectional(b); // two-way-binding

a.set("Hello");
System.out.println(b.get()); // prints "Hello"

b.set("World!");
System.out.println(a.get()); // prints "World!"
Die Aufteilung in View und ViewModel hat folgende Vorteile:

Im folgenden Code wird zur Implementierung des MMVM-Musters das Framework MvvmFX eingesetzt, das als Zusatz auf dem JavaFX-Standard aufsetzt. Es folgt zunächst wieder der Code, der anschließend erläutert wird.

 // TicTacToeView.fxml


// TicTacToeView.java
import javafx.fxml.FXML;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.layout.GridPane;
import de.saxsys.mvvmfx.FxmlView;
import de.saxsys.mvvmfx.InjectViewModel;
// ...

public class TicTacToeView implements FxmlView<TicTacToeViewModel>, Initializable {

    @InjectViewModel
    private TicTacToeViewModel viewModel;

    @FXML
    private GridPane board;

    @FXML
    private Label winner;

    @Override
    public void initialize(URL url, ResourceBundle resourceBundle) {
        winner.textProperty().bindBidirectional(viewModel.winnerMessage); // winner label is bind to view model

        for (int row = 0; row < 3; row++) {
            for (int col = 0; col < 3; col++) {
                Button cell = new Button();
                cell.getStyleClass().add("cell");
                cell.setOnAction(event -> selectCell(event));
                cell.textProperty().bindBidirectional(viewModel.cells[row][col]); // each cell button is bind to view model
                board.add(cell, col, row);
            }
        }
    }

    public void selectCell(ActionEvent event) {
        Button cell = (Button) event.getSource();
        viewModel.selectCell(GridPane.getRowIndex(cell), GridPane.getColumnIndex(cell));
    }

    public void reset(ActionEvent event) {viewModel.reset(); }
}
import javafx.beans.property.StringProperty;
import de.saxsys.mvvmfx.ViewModel;	
// ...
	
public class TicTacToeViewModel implements ViewModel {

    private Board model; // view model is connected to model here

    public StringProperty[][] cells = new StringProperty[3][3];
    public StringProperty winnerMessage = new SimpleStringProperty();

    public TicTacToeViewModel() {
        model = new Board();
        for (StringProperty[] cellsInRow : cells) {
            Arrays.setAll(cellsInRow, i -> new SimpleStringProperty());
        }
    }

    public void selectCell(int row, int col) {
        Player player = model.markCell(row, col);
        if (player != null) {
            cells[row][col].setValue(player.toString());
            if (model.getWinner() != null) {
                winnerMessage.setValue("Winner is " + player.toString());
            }
        }
    }

    public void reset() {
        model.restart();
        winnerMessage.setValue("");
        for (StringProperty[] cellsInRow : cells) {
            Arrays.stream(cellsInRow).forEach(cell -> cell.setValue(""));
        }
    }
}
enum Player {X, O}
	
class Cell {
	Player value;
}
	
class Board {
    Cell[][] cells = new Cell[3][3];
    Player currentTurn;	
    Player winner;

    public Board() { restart(); }

    /**
     * Start a new game, i.e. clear the board and the game status.
     */
    public void restart() {
        clearCells();
        winner = null;
        currentTurn = Player.X;
    }

    /**
     * Mark a cell for the player in turn.
     * Nothing is done, if row or col are out of range, or the cell is already marked, or the game is finished.
     *
     * @param row 0..2
     * @param col 0..2
     * @return player who made the turn
     */
    public Player markCell(int row, int col) {
        if (isValid(row, col)) {
            Player player = currentTurn;
            cells[row][col].value = currentTurn;
            if (isWinningMoveByPlayer(currentTurn, row, col)) { // check if game is won
                winner = currentTurn;
            } else {
                currentTurn = currentTurn == Player.X ? Player.O : Player.X; // flip current turn
            }
            return player;
        }
        return null;
    }

    public Player getWinner() { return winner; }

    void clearCells() {
        for (int i = 0; i < 3; i++) {
            for (int j = 0; j < 3; j++) {
                cells[i][j] = new Cell();
            }
        }
    }

    boolean isValid(int row, int col) {
        return !(winner != null || isOutOfBounds(row) || isOutOfBounds(col) || isCellValueAlreadySet(row, col));
    }

    boolean isOutOfBounds(int i) { return i < 0 || i > 2; }

    boolean isCellValueAlreadySet(int row, int col) { return cells[row][col].value != null; }

    boolean isWinningMoveByPlayer(Player player, int row, int col) {
        return cells[row][0].value == player && cells[row][1].value == player && cells[row][2].value == player // 3 in a row
                || cells[0][col].value == player && cells[1][col].value == player && cells[2][col].value == player // 3 in a col
                || cells[0][0].value == player && cells[1][1].value == player && cells[2][2].value == player // 3 in a diagonal
                || cells[0][2].value == player && cells[1][1].value == player && cells[2][0].value == player; // other diagonal
    }
}
import javafx.application.Application;
// ...
	
public class TicTacToeApplicationMVVM extends Application {

	@Override
	public void start(Stage stage) {
		ViewTuple<TicTacToeView, TicTacToeViewModel> viewTuple = FluentViewLoader.fxmlView(TicTacToeView.class).load();
		Scene scene = new Scene(viewTuple.getView());
		stage.setScene(scene);
		stage.show();
	}

	public static void main(String[] args) { launch(args); }
}

Die gezeigten Code-Beispiele zur Realisierung des MVC- und MVVM-Musters in JavaFX finden sich in den Verzeichnissen /architecture/mvc und /architecture/mvvm des Modul-Repository.