【Android】MVVMでtic-toc-toeゲームを作成する

はじめに

以下を経緯としてMVVMを使用してtic-toc-toeゲームを作成します。

MVVMとは

MVVMは以下で構成されている

Model

  • データ、状態、ビジネスロジックを担当する
  • ビューやコントローラとは関連付けられておらず、再利用可能となる

View

  • View Modelによって公開される使用可能な変数やアクションにバインドする
  • 複数のViewを1つのView Modelにバインドできる

View Model

  • モデルをラップし、ビューが必要とするデータを準備する
  • ビューからモデルへデータを渡す橋渡しにもなる
  • View ModelからはViewが見えない

アプリケーションの作成

Model

今回の場合、Modelで定義するのは以下の要素です。

  • Player
  • Cell
  • Game

共通処理

共通で使用する処理を書いておきます。 引数の文字列がnullまたは空のときにtrueを返す関数です。 utilities/StringUtility.javaを作成します。

public class StringUtility {
​
    public static boolean isNullOrEmpty(String value) {
        return value == null || value.length() == 0;
    }
}

Player

プレイヤーはプロパティとして名前と値(○か✕か)を持つ model/Player.javaを作成します。

public class Player {
​
    public String name;
    public String value;
​
    public Player(String name, String value) {
        this.name = name;
        this.value = value;
    }
}

Cell

各マス目の状態を表すクラス このセルはプロパティとして、Playerクラスのインスンタスを持ちます。 model/Cell.javaを作成します。

public class Cell {
​
    public Player player;
​
    public Cell(Player player) {
        this.player = player;
    }
​
    public boolean isEmpty() {
        return player == null || StringUtility.isNullOrEmpty(player.value);
    }
}

Game

実際のゲームを表すクラス 二人のPlayerクラス、9つのCellクラスを持つ。

switchPlayer()メソッドでプレイヤーの順番を入れ替える ゲーム終了時、currentPlayerになっているプレイヤーが勝ちとなる

model/Game.javaを作成します

public class Game {
​
    private static final String TAG = Game.class.getSimpleName();
    private static final int BOARD_SIZE = 3;
​
    public Player player1;
    public Player player2;
​
    public Player currentPlayer = player1;
    public Cell[][] cells;
​
    public MutableLiveData<Player> winner = new MutableLiveData<>();
​
    public Game(String playerOne, String playerTwo) {
        cells = new Cell[BOARD_SIZE][BOARD_SIZE];
        player1 = new Player(playerOne, "x");
        player2 = new Player(playerTwo, "o");
        currentPlayer = player1;
    }
​
    public void switchPlayer() {
        currentPlayer = currentPlayer == player1 ? player2 : player1;
    }
}

ゲーム終了の判定

以下のときにゲームが終了したと判断する

  • ボード上の水平、垂直、斜めに3つ同じマークが並んだとき
  • 全てのマスが埋まったとき

それに伴って各メソッドを定義していきます。

model/Game.javaに追記します

// Gameが終了かどうかを判定
public boolean hasGameEnded() {
    // どれか3つ揃ったら、currentPlayerが勝ち
    if (hasThreeSameHorizontalCells() || hasThreeSameVerticalCells() || hasThreeSameDiagonalCells()) {
        winner.setValue(currentPlayer);
        return true;
    }
​
    // マスが全て埋まったら引き分け
    if (isBoardFull()) {
        winner.setValue(null);
        return true;
    }
​
    return false;
}
​
// 垂直に揃っているかを確認するメソッド
public boolean hasThreeSameHorizontalCells() {
    try {
        for (int i = 0; i < BOARD_SIZE; i++)
            if (areEqual(cells[i][0], cells[i][1], cells[i][2]))
                return true;
​
        return false;
    } catch (NullPointerException e) {
        Log.e(TAG, e.getMessage());
        return false;
    }
}
​
// 水平に揃っているかを確認するメソッド
public boolean hasThreeSameVerticalCells() {
    try {
        for (int i = 0; i < BOARD_SIZE; i++)
            if (areEqual(cells[0][i], cells[1][i], cells[2][i]))
                return true;
​
        return false;
    } catch (NullPointerException e) {
        Log.e(TAG, e.getMessage());
        return false;
    }
}
​
// 斜めに揃っているかを悪忍するメソッド
public boolean hasThreeSameDiagonalCells() {
    try {
        return areEqual(cells[0][0], cells[1][1], cells[2][2]) || areEqual(cells[0][2], cells[1][1], cells[2][0]);
    } catch (NullPointerException e) {
        Log.e(TAG, e.getMessage());
        return false;
    }
}
​
// セルがnull または空かを確認
public boolean isBoardFull() {
    for (Cell[] row : cells)
        for (Cell cell : row)
            if (cell == null || cell.isEmpty())
                return false;
    return true;
}
​
// 引数で与えられたセルのplayer.valueが全て等しいかを確認
private boolean areEqual(Cell... cells) {
    if (cells == null || cells.length == 0)
        return false;
​
    for (Cell cell : cells)
        if (cell == null || cell.player.value == null || cell.player.value.length() == 0)
            return false;
​
    Cell comparisonBase = cells[0];
    for (int i = 1; i < cells.length; i++)
        if (!comparisonBase.player.value.equals(cells[i].player.value))
            return false;
​
    return true;
}

ゲーム終了時の初期化メソッド

// ゲーム終了時各値を初期化
public void reset() {
    player1 = null;
    player2 = null;
    currentPlayer = null;
    cells = null;
}

Model View

ViewModelではViewのインスタンスを持たない。 つまりViewを知らない。

では、ViewModelとViewはどのように通信するのか。 これには以下の方法がある。

  • eventBus
  • RxAndroid
  • Observerパターン
  • LiveData

ここではLiveDataを使用します。

LibeDataとは

動的なデータをラップし、それを観測できるというもの

AがLiveDataインスタンスで、Bがそれを観測している場合、 Aのデータが変更されると、Bにそれが通知され、変更後の値が送信される。

これをMVVMにあてはめると、

MVVMでLiveDataインスタンスを持っていて、それをViewから観測していることになる。 つまり、MVVMがViewを知らなくてよい。

具体的には、MVVMではゲーム終了時にViewにそれを通知する。 これによりViewはUIを更新することができる。

データバインディングの有効化

build.gradleに以下を追記します。

dataBinding {
    enabled = true
}

共通処理の追加

utilities/StringUtility.java

// ゲーム終了時各値を初期化
public void reset() {
    player1 = null;
    player2 = null;
    currentPlayer = null;
    cells = null;
}

public static String stringFromNumbers(int… numbers) {
   StringBuilder sNumbers = new StringBuilder();
   for (int number : numbers)
       sNumbers.append(number);
   return sNumbers.toString();
}

GameViewModel

viewmodel/GameViewModel.javaを作成

public class GameViewModel extends ViewModel {
​
    public ObservableArrayMap<String, String> cells;
    private Game game;
​
    public void init(String player1, String player2) {
        game = new Game(player1, player2);
        cells = new ObservableArrayMap<>();
    }
​
    public void onClickedCellAt(int row, int column) {
        if (game.cells[row][column] == null) {
            game.cells[row][column] = new Cell(game.currentPlayer);
            cells.put(stringFromNumbers(row, column), game.currentPlayer.value);
            if (game.hasGameEnded())
                game.reset();
            else
                game.switchPlayer();
        }
    }
​
    public LiveData<Player> getWinner() {
        return game.winner;
    }
}
  • onClickedCellAt()は、xmlファイル内でバインドされていて、セルをクリックするたびに実行される* getWinner()は、Viewから観測されているメソッド、ゲーム終了と勝者を通知する。