בסוף הפרק ההיוריסטיקה תפעל דרך אותה זרימה שבה יחושב גם תור המודל. המסך יישאר פנוי, וביטול משחק יפסול תוצאה ישנה גם אם החישוב כבר כמעט הסתיים.
Thread הוא רצף ביצוע. ל־Android יש thread ראשי שמטפל במסך; חישוב כבד עליו יעכב מגע וציור. ExecutorService עם worker יחיד מריץ את החישוב, ו־Handler מחזיר את התשובה ל־thread הראשי. זה אינו Android Service: ComputerMoveService היא מחלקת Java רגילה.
השינויים לפי סדר העבודה
התחילו במצב שעבד בסוף הפרק הקודם. קובץ חדש מוצג במלואו; בקובץ קיים מופיעים רק האזורים שמשתנים. בקטעי diff מסירים את שורות -, מוסיפים את שורות +, ומשאירים את שורות ההקשר. הסימנים עצמם אינם חלק מקוד Java או XML. אין למחוק קוד אחר שאינו מוצג.
ComputerMoveService.java
app > kotlin+java > com.example.connect4 > ai > ComputerMoveService.java
צרו package בשם ai. השירות בוחר פעולה, בודק שהיא ברשימת הפעולות החוקיות, ומחזיר Result עם פעולה וטקסט הסבר. הוא אינו מפעיל GameEngine.apply על המשחק החי. בביטול זורקים CancellationException; ביטול אינו כישלון שמצדיק מהלך חלופי.
package com.example.connect4.ai;
import com.example.connect4.core.ComputerPlayer;
import com.example.connect4.core.GameAction;
import com.example.connect4.core.GameState;
import com.example.connect4.core.HeuristicPlayer;
import java.util.concurrent.CancellationException;
/**
* Connects an opponent, asks for one action, and returns the action with its status.
* All methods run synchronously on the computer worker, never on the UI thread.
* Only the controller may apply the returned action to the live game.
*/
public final class ComputerMoveService {
private ComputerMoveService() { }
public static Result chooseHeuristicMove(GameState snapshot) {
return new Result(chooseLegalAction(new HeuristicPlayer(), snapshot),
"Heuristic · win, block, then center");
}
private static GameAction chooseLegalAction(ComputerPlayer player, GameState snapshot) {
throwIfInterrupted();
GameAction action = player.chooseAction(snapshot);
throwIfInterrupted();
if (!snapshot.legalActions().contains(action)) {
throw new IllegalStateException("Computer player returned an illegal action");
}
return action;
}
private static void throwIfInterrupted() {
if (Thread.currentThread().isInterrupted()) {
throw new CancellationException("Computer move cancelled");
}
}
/** One completed calculation; model scores never cross the controller boundary. */
public static final class Result {
private final GameAction action;
private final String status;
private Result(GameAction action, String status) {
this.action = action;
this.status = status;
}
public GameAction action() { return action; }
public String status() { return status; }
}
}
GameSession.java
app > kotlin+java > com.example.connect4 > GameSession.java
ה־worker שייך ל־Session. requestComputerMove שומר מספר בקשה, מפעיל את החישוב ומפרסם השלמה ל־main thread. cancelComputerMove גם מבקש interrupt וגם מגדיל את מספר הבקשה. שני הדברים נחוצים: חישוב יכול להסתיים למרות interrupt, ותשובה יכולה כבר להמתין בתור של ה־Handler.
completeComputerMove מסירה busy רק עבור הבקשה הנוכחית. כישלון פעיל מדווח למסך ומפסיק ניסיון אוטומטי חוזר. onCleared סוגרת את ה־executor כשה־ViewModel באמת מסתיים.
package com.example.connect4;
+import android.os.Handler;
+import android.os.Looper;
+import androidx.annotation.MainThread;
import androidx.lifecycle.ViewModel;
+import com.example.connect4.ai.ComputerMoveService;
import com.example.connect4.core.GameEngine;
+import com.example.connect4.core.GameState;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.function.Consumer;
+import java.util.function.Supplier;
-/** Retains the game across Activity recreation, without keeping a View or Activity. */
+/** Survives rotation. Only MainActivity updates the live engine on the main thread. */
public final class GameSession extends ViewModel {
public static final int MODE_LOCAL = 0;
public static final int MODE_HEURISTIC = 1;
+
+ public GameEngine engine = new GameEngine();
public int mode = MODE_LOCAL;
- public int humanSide = com.example.connect4.core.GameState.RED;
- public GameEngine engine = new GameEngine();
+ public int humanSide = GameState.RED;
public boolean restored;
+ public String modelStatus = "Heuristic · win, block, then center";
+ private final ExecutorService computerWorker = Executors.newSingleThreadExecutor();
+ private final Handler mainThread = new Handler(Looper.getMainLooper());
+ private Future<?> pendingComputerMove;
+ private long computerRequestId;
+ private boolean computerThinking;
+ private boolean computerMoveFailed;
+
+ @MainThread
+ public boolean isComputerThinking() { return computerThinking; }
+
+ @MainThread
+ public boolean hasComputerMoveFailed() { return computerMoveFailed; }
+
+ /**
+ * Runs one calculation on the worker and delivers its result on the main thread.
+ * Cancelling invalidates even results that have already been posted for delivery.
+ * The callback still checks the position and applies the action through GameEngine.
+ */
+ @MainThread
+ public void requestComputerMove(
+ Supplier<ComputerMoveService.Result> calculation,
+ Consumer<ComputerMoveService.Result> onMoveReady,
+ Consumer<RuntimeException> onFailure) {
+ if (computerThinking) throw new IllegalStateException("A computer move is already pending");
+ long requestId = ++computerRequestId;
+ computerThinking = true;
+ computerMoveFailed = false;
+ pendingComputerMove = computerWorker.submit(() -> {
+ try {
+ ComputerMoveService.Result result = calculation.get();
+ mainThread.post(() -> {
+ if (!completeComputerMove(requestId)) return;
+ onMoveReady.accept(result);
+ });
+ } catch (RuntimeException failure) {
+ mainThread.post(() -> {
+ if (!completeComputerMove(requestId)) return;
+ computerMoveFailed = true;
+ onFailure.accept(failure);
+ });
+ }
+ });
+ }
+
+ private boolean completeComputerMove(long requestId) {
+ if (requestId != computerRequestId) return false;
+ computerThinking = false;
+ pendingComputerMove = null;
+ return true;
+ }
+
+ /** Called before restart, configuration changes, leaving the screen, or disposal. */
+ @MainThread
+ public void cancelComputerMove() {
+ computerRequestId++;
+ computerThinking = false;
+ computerMoveFailed = false;
+ if (pendingComputerMove != null) pendingComputerMove.cancel(true);
+ pendingComputerMove = null;
+ }
+
+ @Override protected void onCleared() {
+ cancelComputerMove();
+ computerWorker.shutdownNow();
+ }
}
HeuristicPlayer.java
app > kotlin+java > com.example.connect4 > core > HeuristicPlayer.java
גם החישוב הפשוט מכבד interrupt לפני בחירת מהלך. כך אפשר לבדוק את מדיניות הביטול בלי להזדקק למודל.
package com.example.connect4.core;
import java.util.List;
+import java.util.concurrent.CancellationException;
/** Deterministic fallback: win, block, then take the closest column to center. */
public final class HeuristicPlayer implements ComputerPlayer {
private static final int[] CENTER_ORDER = {3, 2, 4, 1, 5, 0, 6};
}
List<GameAction> legal = state.legalActions();
if (legal.isEmpty()) {
throw new IllegalArgumentException("Cannot choose an action without a legal move");
+ }
+ if (Thread.currentThread().isInterrupted()) {
+ throw new CancellationException("Heuristic calculation interrupted");
}
GameAction winning = immediateWinningAction(state, state.currentPlayer());
if (winning != null) {
activity_main.xml
app > res > layout > activity_main.xml
מוסיפים שורת הסבר למחשב ומעבירים את הלוח מתחתיה. הודעת התור ושורת המצב הן תפקידים שונים.
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/gameSetup" />
+ <TextView
+ android:id="@+id/aiStatus" android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ app:layout_constraintTop_toBottomOf="@id/status"
+ app:layout_constraintStart_toStartOf="parent" />
+
<com.example.connect4.ui.BoardView
android:id="@+id/board"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintDimensionRatio="7:6"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintTop_toBottomOf="@id/status" />
+ app:layout_constraintTop_toBottomOf="@id/aiStatus" />
<Button
android:id="@+id/restart"
android:layout_width="wrap_content"
MainActivity.java
app > kotlin+java > com.example.connect4 > MainActivity.java
לוכדים את הצילום לפני שליחת העבודה. ה־callback פועל ב־main thread ובודק שהמסך התחיל ושמצב המשחק עדיין שווה לצילום. רק אז מפעילים את המהלך במנוע.
humanTurn חוסמת קלט בזמן חישוב. onStop מבטלת עבודה ו־onStart מבקשת מחדש אם עדיין תור המחשב. New game מבטל לפני החלפת המנוע. אין לולאת retry אוטומטית אחרי חריגה.
import androidx.lifecycle.ViewModelProvider;
import com.example.connect4.core.GameAction;
import com.example.connect4.core.GameEngine;
import com.example.connect4.core.GameState;
-import com.example.connect4.core.ComputerPlayer;
-import com.example.connect4.core.HeuristicPlayer;
+import com.example.connect4.ai.ComputerMoveService;
import com.example.connect4.databinding.ActivityMainBinding;
import androidx.activity.EdgeToEdge;
public class MainActivity extends AppCompatActivity {
private ActivityMainBinding binding;
private GameSession session;
private GameSetupFragment setup;
+ private boolean started;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
render();
}
/** Resets the game after changing a player or pressing New game. */
private void newGame() {
+ session.cancelComputerMove();
session.engine = new GameEngine();
persist();
render();
}
private boolean humanTurn() {
- return !session.engine.snapshot().isTerminal()
+ return started && !session.isComputerThinking() && !session.engine.snapshot().isTerminal()
&& (session.mode == GameSession.MODE_LOCAL
|| session.engine.snapshot().currentPlayer() == session.humanSide);
}
binding.board.show(state);
if (state.isDraw()) binding.status.setText("Draw — board full");
else if (state.isTerminal()) binding.status.setText(state.winner() == GameState.RED ? "Red wins!" : "Yellow wins!");
else binding.status.setText(state.currentPlayer() == GameState.RED ? "Red to play" : "Yellow to play");
- if (session.mode == GameSession.MODE_HEURISTIC && !state.isTerminal() && !humanTurn()) {
- ComputerPlayer player = new HeuristicPlayer();
- GameAction action = player.chooseAction(state);
- if (session.engine.apply(action)) { persist(); render(); }
- }
+ if (session.isComputerThinking()) binding.status.setText("Computer is thinking…");
+ binding.aiStatus.setText(session.modelStatus);
+ requestComputerMoveIfNeeded();
}
/** Saves only accepted column history; the engine reconstructs the rest. */
private void persist() {
getPreferences(MODE_PRIVATE).edit().putString("moves", session.engine.snapshot().moves())
.putInt("mode", session.mode).putInt("side", session.humanSide).apply();
}
+ @Override protected void onStart() { super.onStart(); started = true; render(); }
+
@Override protected void onStop() {
+ started = false;
+ session.cancelComputerMove();
persist();
super.onStop();
}
ActivityMainBinding viewBinding() { return binding; }
com.example.connect4.databinding.FragmentGameSetupBinding setupBinding() { return setup.viewBinding(); }
+
+ /** Captures one position; the worker never receives the live engine. */
+ private void requestComputerMoveIfNeeded() {
+ GameState requestedState = session.engine.snapshot();
+ if (!started || session.mode == GameSession.MODE_LOCAL || requestedState.isTerminal()
+ || session.isComputerThinking() || session.hasComputerMoveFailed()
+ || requestedState.currentPlayer() == session.humanSide) return;
+ binding.status.setText("Computer is thinking…");
+ session.requestComputerMove(
+ () -> ComputerMoveService.chooseHeuristicMove(requestedState),
+ result -> applyComputerMove(requestedState, result),
+ failure -> { session.modelStatus = "Computer move failed · " + failure.getMessage(); render(); });
+ }
+
+ /** Accepts only a result for the still-current position, on the main thread. */
+ private void applyComputerMove(GameState requestedState, ComputerMoveService.Result result) {
+ if (!started || !requestedState.equals(session.engine.snapshot())) return;
+ if (!session.engine.apply(result.action())) throw new IllegalStateException("Illegal computer action");
+ session.modelStatus = result.status();
+ persist();
+ render();
+ }
}
מריצים ובודקים
- שחקו מול המחשב בשני הצבעים; כל תור מניב בדיוק פעולה אחת.
- עברו לרקע, סובבו, או התחילו משחק חדש סביב תור המחשב. אין דיסקית שנוספת למשחק החדש מתוך החישוב הישן.
- החישוב ההיוריסטי קצר, ולכן הודעת thinking עשויה להופיע לזמן קצר מאוד. בדיקת המורה מחזיקה חישוב פתוח במכוון, מבטלת אותו, ומוכיחה שהתוצאה המאוחרת אינה נמסרת.
- בדיקת המורה מוודאת שהחישוב מחוץ ל־main thread, שהמסירה עליו, ושכישלון מנקה busy וניתן לאיפוס.
בדיקת הבנה
נניח שהחישוב הסתיים והתשובה כבר בתור של Handler. מה יגן על המשחק אם המשתמש לוחץ New game לפני שה־callback רץ? מדוע interrupt לבדו לא מספיק?