חזרה ל־021a: פרסום חדרים ורשימה בזמן אמת
ב־021a - פרסום חדרי משחק ב־Firebase RTDB פרסמנו חדרים והצגנו רשימה שמתעדכנת בזמן אמת. כעת נשלים את המשחק: היוצר יהיה X, המשתמש הראשון שיפתח חדר ממתין יהיה O, וכל משתמש נוסף שיפתח חדר פעיל יהיה צופה. כולם יקבלו את אותו לוח דרך מאזין RTDB אחד לחדר שנבחר.
בסוף השיעור נוכל לבדוק תוצאה מלאה:
- יצירת חדר פותחת אותו וממתינה לשחקן O.
- פתיחת חדר שממתין מצרפת את המשתמש בתור O.
- X ו־O משחקים בתורות ורואים מיד את אותם מהלכים.
- משתמש שלישי פותח חדר פעיל וצופה בו ללא אפשרות ללחוץ על הלוח.
- פתיחת חדר שכבר התחיל בונה מחדש את כל הלוח מן המהלכים השמורים.
לא נוסיף transactions, בדיקות תקינות בשרת, מספרי רצף, מחיקת חדרים או סימון משחק שהסתיים. מטרתנו היא הזרימה הקטנה והברורה ביותר.
נקודת ההתחלה
השיעור מתחיל מהתוצאה העובדת של 021a. בפרט, כבר קיימים:
GameRoomובוname,playerX,playerOו־moves.FBRef.refGames, שמצביע אל/TicTacToeRtdb/games.- לובי ובו שם משחק, כפתור פרסום ו־Spinner של חדרים.
ValueEventListenerשמעדכן את רשימת החדרים.- חוקי קריאה וכתיבה פתוחים לענף
TicTacToeRtdb.
בשיעור הזה לא משנים את GameRoom, את FBRef, את TicTacToeModel, את MainActivity או את קוד SignalR.
לפני הקוד - ההחלטה הפשוטה על המהלכים
נשמור את כל רצף המהלכים במחרוזת אחת:
0,0,X;1,1,O;0,1,X
כל מהלך מכיל שורה, עמודה ושחקן, מופרדים בפסיקים. נקודה־פסיק מפרידה בין המהלכים.
כאשר שחקן לוחץ על משבצת:
- הלקוח מוסיף את המהלך לסוף המחרוזת המקומית שקיבל לאחרונה.
- הוא כותב מחדש את כל
movesבעזרתsetValue. - ה־SDK מפעיל את המאזין המקומי בעקבות הכתיבה, ומסנכרן את השינוי דרך השרת אל שאר המכשירים.
- בכל מכשיר, המאזין שמקבל snapshot של החדר מנקה את הלוח ובונה אותו מחדש מתחילת המחרוזת.
העדכון במכשיר שכתב יכול להופיע לפני שהשרת אישר את הכתיבה. תרשימי הזרימה בהמשך מפרידים בין האירוע המקומי, סנכרון המכשיר השני ואישור הכתיבה.
הלוח מכיל לכל היותר תשעה מהלכים, ולכן המחרוזת קטנה מאוד. כתיבת הרצף המלא ובנייתו מחדש קלות יותר להבנה מאוסף ילדים, מאזיני ChildEventListener, מספרי רצף וטיפול במהלך שהוחמץ.
אנו סומכים לחלוטין על הלקוחות. שני שחקנים יכולים תאורטית לכתוב יחד ואחד מהם יכול לדרוס את כתיבת האחר. לקוח ששונה בזדון יכול גם לכתוב רצף לא חוקי. זה מקובל בתרגיל הזה ואינו דפוס למשחק אמיתי.
מבנה חדר פעיל ייראה כך:
/TicTacToeRtdb/games/{pushId}
name: "Class game"
playerX: "uid-x"
playerO: "uid-o"
moves: "0,0,X;1,1,O"
שלב 1 - הטקסטים החדשים
פתחו בתצוגת Android את app > res > values > strings.xml ועדכנו את אזור הטקסטים של RTDB:
<string name="rtdb_available_games">Available games</string>
<string name="rtdb_no_games">No games have been published yet.</string>
+ <string name="rtdb_open_game">Open selected game</string>
<string name="rtdb_room_waiting">waiting</string>
<string name="rtdb_room_playing">playing</string>
<string name="rtdb_room_label">%1$s — %2$s</string>
<string name="rtdb_game_name_required">Enter a game name</string>
<string name="rtdb_login_required">Log in before opening an RTDB game</string>
- <string name="rtdb_game_published">Game published</string>
+ <string name="rtdb_waiting_for_player">Waiting for player O…</string>
+ <string name="rtdb_watching_game">Watching this game</string>
+ <string name="rtdb_your_turn">You are %1$s — your turn</string>
+ <string name="rtdb_other_turn">You are %1$s — other player’s turn</string>
<string name="rtdb_read_failed">Firebase read failed: %1$s</string>
<string name="rtdb_write_failed">Firebase write failed: %1$s</string>
איננו צריכים עוד את Game published: לאחר פרסום מוצלח היוצר יעבור מיד אל החדר ויראה Waiting for player O….
שלב 2 - הפרדת הלובי מתצוגת המשחק
פתחו בתצוגת Android את app > res > layout > activity_main2.xml.
אנו צריכים להסתיר את כל הלובי ולהציג את כל אזור המשחק בבת אחת. לכן נוסיף שני LinearLayout פנימיים:
| id | מה הוא מכיל | מצב התחלתי |
|---|---|---|
lobbyLayout |
יצירת חדר, רשימת החדרים וכפתור פתיחה | גלוי |
gameLayout |
שם המשחק, מצב המשחק והלוח | gone |
בצעו את השינוי המבני הבא. כל תשעת כפתורי הלוח הקיימים נשארים ללא שינוי בתוך ה־GridLayout; הסימן ⁞ מציין קוד קיים שלא הוצג שוב.
android:padding="16dp">
- <!-- Lobby used to publish a room and see every published room. -->
+ <!-- Lobby used to publish a room or select an existing room. -->
+ <LinearLayout
+ android:id="@+id/lobbyLayout"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical">
+
<EditText
android:id="@+id/editGameName"
⁞
<TextView
android:id="@+id/textNoGames"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/rtdb_no_games" />
+
+ <Button
+ android:id="@+id/buttonOpenGame"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:text="@string/rtdb_open_game" />
+ </LinearLayout>
- <!-- The existing board remains local in 021a and becomes realtime in 021b. -->
- <GridLayout
- android:layout_width="wrap_content"
+ <!-- Selected room view shared by players and read-only spectators. -->
+ <LinearLayout
+ android:id="@+id/gameLayout"
+ android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:layout_marginTop="12dp"
- android:columnCount="3"
- android:rowCount="3">
+ android:gravity="center"
+ android:orientation="vertical"
+ android:visibility="gone">
+
+ <TextView
+ android:id="@+id/textGameName"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:textSize="22sp"
+ android:textStyle="bold" />
+
+ <TextView
+ android:id="@+id/textGameStatus"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="16dp"
+ android:layout_marginTop="4dp" />
+
+ <GridLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:columnCount="3"
+ android:rowCount="3">
<Button
android:id="@+id/button00"
⁞
<Button
android:id="@+id/button22"
⁞
- </GridLayout>
+ </GridLayout>
+ </LinearLayout>
</LinearLayout>
Android Studio יסדר בדרך כלל את ההזחה בעזרת Code > Reformat Code. ההזחה אינה משנה את מבנה ה־XML; מה שחשוב הוא שכל רכיב נמצא בתוך הקבוצה הנכונה ושכל תג פתיחה נסגר.
android:visibility="gone" פירושו שאזור המשחק אינו מצויר וגם אינו תופס מקום. ב־Java נהפוך את המצבים: נסתיר את lobbyLayout ונציג את gameLayout.
בצעו Build. ודאו ש־View Binding מזהה את buttonOpenGame, את lobbyLayout, את gameLayout, את textGameName ואת textGameStatus.
שלב 3 - הנתונים המקומיים הדרושים לבחירת חדר
פתחו בתצוגת Android:
app > kotlin+java > com.example.tictacmenu > activities > Main2Activity
עדכנו את תיעוד המחלקה והשדות. אין imports חדשים בשיעור הזה; כל הטיפוסים כבר יובאו ב־021a.
/**
- * Publishes and displays RTDB game rooms while keeping the board local in lesson 021a.
+ * Runs the minimal Firebase Realtime Database multiplayer Tic-Tac-Toe flow.
+ *
+ * <p>The activity intentionally trusts every database value. Its role and turn
+ * checks only control this client UI and are not security checks.</p>
*/
public class Main2Activity extends AppCompatActivity {
- /** Local Tic-Tac-Toe state used by the unchanged board from the previous lesson. */
+ /** Local board model rebuilt whenever Firebase publishes the room snapshot. */
private TicTacToeModel model;
- /** View Binding access to the RTDB lobby and local board. */
+ /** View Binding access to the RTDB lobby and game board. */
private ActivityMain2Binding binding;
⁞
/** Listener that keeps the room Spinner synchronized with Firebase. */
private ValueEventListener gamesListener;
+ /** Firebase reference for the room currently being played or watched. */
+ private DatabaseReference selectedRoomReference;
+
+ /** Listener that redraws the selected game after every room change. */
+ private ValueEventListener selectedRoomListener;
+
+ /** Room objects shown in the same order as the Spinner labels. */
+ private final List<GameRoom> rooms = new ArrayList<>();
+
+ /** Firebase push keys shown in the same order as the Spinner labels. */
+ private final List<String> roomIds = new ArrayList<>();
+
/** Human-readable room labels displayed by the Spinner. */
private final List<String> roomLabels = new ArrayList<>();
/** Adapter that presents the current room labels in the Spinner. */
private ArrayAdapter<String> roomsAdapter;
+ /** Most recent complete value received for the selected room. */
+ private GameRoom selectedRoom;
+
/** Firebase user ID of the person using this activity. */
private String currentUid;
+
+ /** Local role: {@code X}, {@code O}, or an empty string for a spectator. */
+ private String localPlayer = "";
ל־Spinner יש רק מחרוזות לתצוגה. כאשר המשתמש בוחר שורה אנו צריכים גם את האובייקט וגם את מפתח ה־push של אותו חדר. לכן שלוש הרשימות נשמרות באותו סדר:
| רשימה | ערך לדוגמה במקום 0 |
|---|---|
roomLabels |
Class game — waiting |
rooms |
אובייקט GameRoom של אותו חדר |
roomIds |
מפתח ה־push של אותו חדר |
זה אינו מבנה מתוחכם, אבל קל לראות כיצד בחירת position אחד מחזירה את שלושת הפרטים המתאימים.
שלב 4 - כפתור פתיחת חדר ורשימה שניתנת לבחירה
בתוך onCreate, חברו את הכפתור החדש:
setupRoomSpinner();
binding.buttonStartGame.setOnClickListener(view -> startGameAndWait());
+ binding.buttonOpenGame.setOnClickListener(view -> openSelectedGame());
listenForRooms();
בסוף setupRoomSpinner, השביתו אותו כל עוד אין חדרים:
roomsAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
binding.spinnerGames.setAdapter(roomsAdapter);
+ binding.buttonOpenGame.setEnabled(false);
כעת עדכנו את onDataChange של מאזין רשימת החדרים:
public void onDataChange(@NonNull DataSnapshot snapshot) {
+ rooms.clear();
+ roomIds.clear();
roomLabels.clear();
for (DataSnapshot roomSnapshot : snapshot.getChildren()) {
GameRoom room = roomSnapshot.getValue(GameRoom.class);
+ rooms.add(room);
+ roomIds.add(roomSnapshot.getKey());
+
String state = room.getPlayerO().isEmpty()
? getString(R.string.rtdb_room_waiting)
: getString(R.string.rtdb_room_playing);
roomLabels.add(getString(R.string.rtdb_room_label, room.getName(), state));
}
roomsAdapter.notifyDataSetChanged();
- binding.textNoGames.setVisibility(
- roomLabels.isEmpty() ? View.VISIBLE : View.GONE
- );
+ boolean hasRooms = !rooms.isEmpty();
+ binding.textNoGames.setVisibility(hasRooms ? View.GONE : View.VISIBLE);
+ binding.buttonOpenGame.setEnabled(hasRooms);
}
אנו מנקים ובונים מחדש את שלוש הרשימות יחד. כך הן נשארות מסונכרנות גם כאשר נוסף חדר או כאשר חדר עובר מ־waiting ל־playing.
שלב 5 - היוצר נכנס מיד לחדר שפרסם
בתוך startGameAndWait, החליפו את פעולת ההצלחה:
DatabaseReference newRoomReference = gamesReference.push();
GameRoom room = new GameRoom(gameName, currentUid, "", "");
newRoomReference.setValue(room)
- .addOnSuccessListener(unused -> {
- binding.editGameName.setText("");
- Toast.makeText(this, R.string.rtdb_game_published, Toast.LENGTH_SHORT).show();
- })
+ .addOnSuccessListener(unused -> openRoom(newRoomReference.getKey()))
.addOnFailureListener(error -> Toast.makeText(
newRoomReference כבר מצביע אל מפתח ה־push החדש. רק אחרי שהכתיבה הצליחה אנו מוסרים את המפתח ל־openRoom. היוצר יזוהה בהמשך לפי playerX ויראה מסך המתנה.
שלב 6 - הצטרפות או צפייה בחדר שנבחר
הוסיפו את המתודה הבאה:
/**
* Joins the selected waiting room or watches the selected playing room.
*/
private void openSelectedGame() {
int position = binding.spinnerGames.getSelectedItemPosition();
if (position < 0 || position >= rooms.size()) {
return;
}
GameRoom room = rooms.get(position);
String roomId = roomIds.get(position);
if (currentUid.equals(room.getPlayerX()) || currentUid.equals(room.getPlayerO())) {
openRoom(roomId);
} else if (room.getPlayerO().isEmpty()) {
gamesReference.child(roomId).child("playerO").setValue(currentUid)
.addOnSuccessListener(unused -> openRoom(roomId))
.addOnFailureListener(error -> Toast.makeText(
this,
getString(R.string.rtdb_write_failed, error.getMessage()),
Toast.LENGTH_LONG
).show());
} else {
openRoom(roomId);
}
}
ההחלטה נעשית בשלושה תנאים פשוטים:
- אם ה־UID כבר כתוב ב־
playerXאו ב־playerO, זה שחקן שחוזר לחדר שלו. - אחרת, אם
playerOריק, המשתמש כותב את ה־UID שלו רק לילדplayerOוהופך ל־O. - אחרת שני מקומות השחקנים תפוסים, ולכן המשתמש פותח את החדר כצופה.
הכתיבה אל:
gamesReference.child(roomId).child("playerO")
מגיעה אל /games/{roomId}/playerO. לכן setValue(currentUid) מחליף רק את השדה הזה ולא את כל החדר.
אין transaction. אם שני משתמשים ינסו להצטרף לאותו חדר ממתין בדיוק באותו רגע, הכתיבה האחרונה יכולה לנצח. זה חלק מההנחה המכוונת של אמון מלא ופשטות.
שלב 7 - מאזין לחדר הנבחר
הוסיפו:
/**
* Shows the board and subscribes to one complete game-room snapshot.
*
* @param roomId Firebase push key of the room to open
*/
private void openRoom(String roomId) {
binding.lobbyLayout.setVisibility(View.GONE);
binding.gameLayout.setVisibility(View.VISIBLE);
setBoardEnabled(false);
selectedRoomReference = gamesReference.child(roomId);
selectedRoomListener = new ValueEventListener() {
/**
* Redraws the selected board from its latest complete room snapshot.
*
* @param snapshot current contents of the selected room
*/
@Override
public void onDataChange(@NonNull DataSnapshot snapshot) {
selectedRoom = snapshot.getValue(GameRoom.class);
if (selectedRoom != null) {
showSelectedRoom();
}
}
/**
* Shows a Firebase message when the room subscription is cancelled.
*
* @param error reason Firebase cancelled the subscription
*/
@Override
public void onCancelled(@NonNull DatabaseError error) {
Toast.makeText(
Main2Activity.this,
getString(R.string.rtdb_read_failed, error.getMessage()),
Toast.LENGTH_LONG
).show();
}
};
selectedRoomReference.addValueEventListener(selectedRoomListener);
}
כעת יש שני מאזינים בעלי תפקידים שונים:
| מאזין | כתובת | תפקיד |
|---|---|---|
gamesListener |
/games |
ממשיך לעדכן את רשימת כל החדרים |
selectedRoomListener |
/games/{roomId} |
מעדכן רק את המשחק הפתוח |
לפני שמגיע snapshot ראשון אנו משביתים את הלוח. איננו יודעים עדיין אם המשתמש X, O או צופה, ואיננו רוצים לאפשר לחיצה על מצב שעדיין לא נקרא.
שלב 8 - בניית הלוח מחדש מן הרצף
הוסיפו את שתי המתודות:
/**
* Rebuilds the board and status text from the latest complete room snapshot.
*/
private void showSelectedRoom() {
binding.textGameName.setText(selectedRoom.getName());
model.resetGame();
resetBoard();
String moves = selectedRoom.getMoves();
if (!moves.isEmpty()) {
String[] moveList = moves.split(";");
for (String move : moveList) {
String[] parts = move.split(",");
int row = Integer.parseInt(parts[0]);
int col = Integer.parseInt(parts[1]);
String player = parts[2];
model.setMove(row, col, player);
buttonFor(row, col).setText(player);
model.changePlayer();
}
}
if (currentUid.equals(selectedRoom.getPlayerX())) {
localPlayer = "X";
} else if (currentUid.equals(selectedRoom.getPlayerO())) {
localPlayer = "O";
} else {
localPlayer = "";
}
showGameStatus();
}
/**
* Shows whether this client is waiting, watching, or allowed to play now.
*/
private void showGameStatus() {
if (selectedRoom.getPlayerO().isEmpty()) {
binding.textGameStatus.setText(R.string.rtdb_waiting_for_player);
setBoardEnabled(false);
} else if (localPlayer.isEmpty()) {
binding.textGameStatus.setText(R.string.rtdb_watching_game);
setBoardEnabled(false);
} else if (localPlayer.equals(model.getCurrentPlayer())) {
binding.textGameStatus.setText(getString(R.string.rtdb_your_turn, localPlayer));
setBoardEnabled(true);
} else {
binding.textGameStatus.setText(getString(R.string.rtdb_other_turn, localPlayer));
setBoardEnabled(false);
}
}
נעקוב אחרי דוגמה קצרה:
moves = "0,0,X;1,1,O;0,1,X"
split(";")יוצר שלוש מחרוזות של מהלכים.split(",")מפרק כל מהלך לשורה, עמודה ושחקן.model.setMoveמכניס את הסימן ללוח המקומי.buttonForמציג את אותו סימן בכפתור המתאים.model.changePlayerמעביר את התור. אחרי שלושה מהלכים התור הוא O.
בכל snapshot מתחילים ממצב ריק. לכן אין תלות בכך שהלקוח ראה את האירוע הקודם: גם צופה שפתח את החדר באמצע המשחק מקבל את המחרוזת המלאה ובונה מיד את אותו לוח.
לאחר בניית הלוח אנו משווים את currentUid לשני ה־UID-ים שבחדר. מחרוזת ריקה ב־localPlayer פירושה שהמשתמש אינו אחד משני השחקנים ולכן הוא צופה.
setBoardEnabled(false) הוא התנהגות של המסך בלבד. הוא אינו משנה את חוקי Firebase ואינו מונע מלקוח אחר לכתוב. כך גם בדיקת התור בהמשך.
שלב 9 - כתיבת מהלך במקום משחק מקומי
החליפו את כל onCellClick הישן במתודה הבאה:
/**
* Publishes a locally permitted move as a new complete sequence string.
*
* @param view board button clicked by the user
*/
public void onCellClick(View view) {
if (selectedRoom == null
|| selectedRoom.getPlayerO().isEmpty()
|| localPlayer.isEmpty()
|| !localPlayer.equals(model.getCurrentPlayer())) {
return;
}
Button button = (Button) view;
String[] position = button.getTag().toString().split(",");
int row = Integer.parseInt(position[0]);
int col = Integer.parseInt(position[1]);
if (!model.isLegal(row, col)) {
return;
}
String move = row + "," + col + "," + localPlayer;
String previousMoves = selectedRoom.getMoves();
String updatedMoves = previousMoves.isEmpty() ? move : previousMoves + ";" + move;
setBoardEnabled(false);
selectedRoomReference.child("moves").setValue(updatedMoves)
.addOnFailureListener(error -> {
showGameStatus();
Toast.makeText(
this,
getString(R.string.rtdb_write_failed, error.getMessage()),
Toast.LENGTH_LONG
).show();
});
}
ארבעת התנאים הראשונים חוסמים לחיצה מקומית כאשר:
- עדיין לא התקבל חדר.
- X עדיין מחכה ל־O.
- המשתמש הוא צופה.
- זהו תורו של השחקן האחר.
אחרי בדיקת המשבצת אנו בונים מהלך כגון 2,1,O ומצרפים אותו לרצף. שימו לב שאיננו קוראים כאן ל־model.setMove ואיננו משנים את הטקסט של הכפתור. כל ציור הלוח עובר דרך showSelectedRoom, בעקבות snapshot שמתקבל במאזין. במכשיר שכתב זה יכול להיות אירוע מקומי של ה־SDK לפני אישור השרת; במכשיר השני השינוי מגיע דרך הסנכרון עם RTDB.
אנו משביתים מיד את הכפתורים כדי למנוע לחיצה כפולה בזמן שהכתיבה בדרך. אם הכתיבה נכשלת, showGameStatus מחזיר את המצב המקומי המתאים ומוצגת הודעת שגיאה.
ה־TODO-ים הקיימים של ניצחון ותיקו ב־TicTacToeModel נשארים ללא שינוי. השיעור עוסק בסנכרון המהלכים, לא בהשלמת חוקי המשחק.
שלב 10 - עזרי הלוח
החליפו את resetBoard הקיים בארבע המתודות הבאות:
/**
* Finds the bound board button at a row and column.
*
* @param row board row from zero to two
* @param col board column from zero to two
* @return matching board button
*/
private Button buttonFor(int row, int col) {
if (row == 0 && col == 0) return binding.button00;
if (row == 0 && col == 1) return binding.button01;
if (row == 0 && col == 2) return binding.button02;
if (row == 1 && col == 0) return binding.button10;
if (row == 1 && col == 1) return binding.button11;
if (row == 1 && col == 2) return binding.button12;
if (row == 2 && col == 0) return binding.button20;
if (row == 2 && col == 1) return binding.button21;
if (row == 2 && col == 2) return binding.button22;
return null;
}
/**
* Returns every board button in display order.
*
* @return array containing the nine board buttons
*/
private Button[] boardButtons() {
return new Button[]{
binding.button00, binding.button01, binding.button02,
binding.button10, binding.button11, binding.button12,
binding.button20, binding.button21, binding.button22
};
}
/**
* Clears the text displayed by every board button.
*/
private void resetBoard() {
for (Button button : boardButtons()) {
button.setText("");
}
}
/**
* Enables or disables all local board buttons without changing Firebase rules.
*
* @param enabled whether this client may press board buttons
*/
private void setBoardEnabled(boolean enabled) {
for (Button button : boardButtons()) {
button.setEnabled(enabled);
}
}
buttonFor עונה על השאלה “איזה כפתור מתאים לשורה ולעמודה שקראנו מן המחרוזת?”. boardButtons מרכז את תשעת הכפתורים כדי שגם ניקוי וגם הפעלה או השבתה יוכלו לעבור עליהם בלולאה קצרה.
שלב 11 - הסרת שני המאזינים
עדכנו את onDestroy:
/**
- * Removes the Firebase rooms subscription when this activity is destroyed.
+ * Removes both Firebase subscriptions when this activity is destroyed.
*/
@Override
protected void onDestroy() {
if (gamesListener != null) {
gamesReference.removeEventListener(gamesListener);
}
+ if (selectedRoomListener != null) {
+ selectedRoomReference.removeEventListener(selectedRoomListener);
+ }
super.onDestroy();
}
המאזין הראשון שייך לרשימת החדרים, והשני לחדר שנפתח. כל אחד מוסר מן ההפניה שאליה חובר. הסרת המאזינים אינה מוחקת חדר או מהלך מ־Firebase.
הזרימה השלמה — מה מפעיל איזו מתודה בשני המכשירים?
יצירת חדר, הצטרפות וסבב מהלכים של X ו־O
התרשימים מתארים את הקוד של סיום השיעור הזה, 021b. בכל מכשיר רץ עותק נפרד של Main2Activity, עם selectedRoom, model ומאזינים משלו. העמודות של המכשירים כוללות גם את Firebase SDK המקומי; העמודה האמצעית מייצגת את השרת. חץ שחוזר לאותה עמודה הוא פעולה בתוך אותו מכשיר.
X אינו קורא למתודה במכשיר של O. הוא משנה נתון בכתובת המשותפת /TicTacToeRtdb/games/{roomId}. ה־SDK במכשיר של O מפעיל את ה־callback שנרשם שם, והקוד של O בונה את המסך שלו מן הנתון שהתקבל.
א. יצירה והצטרפות — לפני המהלך הראשון
בדוגמה שני המכשירים כבר נמצאים בלובי, listenForRooms() כבר חיברה בכל אחד מהם את gamesListener, ורק משתמש אחד מנסה להצטרף בתור O.
במסך צר אפשר לגלול את התרשימים הצדה כדי לקרוא את שמות המתודות בגודל נוח.
sequenceDiagram
autonumber
participant X as Device X<br/>Activity + SDK
participant DB as Firebase RTDB<br/>Server
participant O as Device O<br/>Activity + SDK
X->>X: startGameAndWait()<br/>push() + new GameRoom(...)
X->>X: newRoomReference.setValue(room)
X->>DB: סנכרון החדר החדש
par המשך היצירה
DB-->>X: הכתיבה אושרה<br/>addOnSuccessListener
X->>X: openRoom(roomId)<br/>addValueEventListener(...)
X->>X: onDataChange(snapshot)<br/>getValue(GameRoom.class)
X->>X: showSelectedRoom()<br/>showGameStatus()<br/>ממתין להצטרפות והלוח מושבת
and עדכון רשימת החדרים אצל המצטרף
DB-->>O: gamesListener<br/>onDataChange(snapshot)
O->>O: עדכון הרשימות והתצוגה<br/>rooms, roomIds<br/>roomLabels, Spinner
end
O->>O: openSelectedGame()<br/>playerO.isEmpty()
O->>O: child(roomId).child("playerO")<br/>.setValue(currentUid)
O->>DB: סנכרון מזהה המצטרף<br/>playerO
par פתיחת החדר אצל המצטרף לאחר האישור
DB-->>O: addOnSuccessListener
O->>O: openRoom(roomId)<br/>addValueEventListener(...)
O->>O: onDataChange(snapshot)<br/>getValue(GameRoom.class)
O->>O: showSelectedRoom()<br/>showGameStatus()<br/>localPlayer = O<br/>ממתין לתורו
and עדכון החדר אצל היוצר
DB-->>X: selectedRoomListener<br/>onDataChange(snapshot)
X->>X: getValue(GameRoom.class)<br/>showSelectedRoom()<br/>showGameStatus()
X->>X: localPlayer = X<br/>תור האיקס והלוח מופעל
end
הבלוקים המקבילים מציגים תהליכים שאינם מחכים זה לזה: אין הבטחה אם X יציג את ההצטרפות לפני או אחרי ש־O יקבל אישור כתיבה. לעומת זאת, בתוך כל מכשיר openRoom של יוצר החדר ושל המצטרף החדש נקראת בקוד רק מתוך addOnSuccessListener. שחקן שחוזר לחדר וצופה בחדר פעיל נכנסים דרך openRoom ללא כתיבת playerO.
מיד אחרי חיבור selectedRoomListener מתקבלת גם תמונת המצב ההתחלתית הזמינה ללקוח; אין צורך להמתין למהלך הבא. המאזין מקבל את כל החדר, גם כאשר רק playerO או moves השתנו. לכן שחקן חוזר יכול לבנות את הלוח גם אם לא ראה את האירועים הקודמים. קריאה באמצעות מאזין קבוע.
ב. X לוחץ, O מקבל — ואז הכיוון מתהפך
כעת שני המכשירים כבר מאזינים לאותו חדר, הלוח ריק והתור הוא X. שתי הכתיבות בדוגמה מתקבלות בשרת.
sequenceDiagram
autonumber
participant X as Device X<br/>Activity + SDK
participant DB as Firebase RTDB<br/>Server
participant O as Device O<br/>Activity + SDK
X->>X: onCellClick(view)<br/>בדיקת תפקיד, תור וחוקיות<br/>model.isLegal(row, col)
X->>X: setBoardEnabled(false)<br/>child("moves")<br/>.setValue("0,0,X")
X->>X: אירוע מקומי<br/>selectedRoomListener<br/>onDataChange(snapshot)
X->>X: getValue(GameRoom.class)<br/>showSelectedRoom()<br/>showGameStatus()
Note over X: האיקס כבר מצויר<br/>התור כעת לעיגול<br/>טרם התקבל אישור כתיבה
X->>DB: סנכרון המהלך<br/>moves = "0,0,X"
par הכתיבה מסתיימת אצל השולח
DB-->>X: הכתיבה אושרה<br/>בקוד אין פעולת הצלחה למהלך
and השינוי מגיע לשחקן השני
DB-->>O: selectedRoomListener<br/>onDataChange(snapshot)
O->>O: getValue(GameRoom.class)<br/>showSelectedRoom()<br/>showGameStatus()
Note over O: האיקס מצויר ותור העיגול<br/>הלוח מופעל
end
O->>O: onCellClick(view)<br/>צירוף המהלך לרצף הקודם
O->>O: setBoardEnabled(false)<br/>child("moves")<br/>.setValue(updatedMoves)
O->>O: אירוע מקומי<br/>selectedRoomListener<br/>onDataChange(snapshot)
O->>O: getValue(GameRoom.class)<br/>showSelectedRoom()<br/>showGameStatus()
Note over O: האיקס והעיגול מצוירים<br/>תור האיקס<br/>עדיין אין כאן אישור מהשרת
O->>DB: סנכרון רצף המהלכים המלא
par הכתיבה מסתיימת אצל השולח
DB-->>O: הכתיבה אושרה
and השינוי מגיע לשחקן השני
DB-->>X: selectedRoomListener<br/>onDataChange(snapshot)
X->>X: getValue(GameRoom.class)<br/>showSelectedRoom()<br/>showGameStatus()
Note over X: שני הסימנים מצוירים<br/>תור האיקס<br/>הלוח מופעל
end
הציור המהיר במכשיר שכתב נקרא עדכון אופטימי: ה־SDK מפעיל אירוע מקומי לפני אישור השרת. אישור הכתיבה ועדכון המכשיר השני הם שני דברים שונים; אישור אינו אומר שהמכשיר השני כבר צייר. אין להסתמך גם על אירוע onDataChange נוסף רק בגלל שאותה כתיבה אושרה. אירועים מקומיים ועבודה ללא חיבור.
ג. אותה שרשרת ציור רצה אצל כולם
| המתודה או הפעולה | מה היא עושה בכל מכשיר |
|---|---|
selectedRoomListener.onDataChange |
מקבלת snapshot של החדר ומציבה אובייקט חדש ב־selectedRoom דרך getValue(GameRoom.class) |
showSelectedRoom |
מציגה את השם, קוראת ל־model.resetGame() ול־resetBoard(), ואז מפרקת את כל moves |
model.setMove ו־buttonFor(...).setText(...) |
משחזרים כל מהלך גם בלוח הלוגי וגם בכפתור המתאים |
model.changePlayer |
מחליפה תור אחרי כל מהלך משוחזר; כך מחושב התור הבא מן הרצף |
ההשוואה ל־currentUid |
קובעת לכל מכשיר את localPlayer: X, O או מחרוזת ריקה לצופה |
showGameStatus ו־setBoardEnabled |
מציגות את המצב ומאפשרות לחיצה רק לשחקן שתורו הגיע |
הצופה משתמש באותו מאזין ובאותה שרשרת ציור. הוא מקבל גם את המהלכים שכבר נשמרו וגם שינויים בהמשך. localPlayer שלו ריק, ולכן showGameStatus משבית את הכפתורים.
ד. ארבעה אירועים שקל לבלבל
| האירוע | מה מותר להסיק ממנו |
|---|---|
onDataChange |
המצב הזמין ללקוח השתנה, או שהמאזין קיבל מצב התחלתי. אירוע מקומי אינו אישור שרת |
addOnSuccessListener של setValue |
הכתיבה הושלמה במסד. בשיעור משתמשים בזה לפתיחת חדר אחרי יצירה או הצטרפות |
addOnFailureListener של setValue |
פעולת הכתיבה נכשלה. במהלכים הקוד קורא ל־showGameStatus() ומציג הודעה |
onCancelled של ValueEventListener |
ההאזנה נכשלה או בוטלה מצד השרת, למשל עקב הרשאות קריאה; זה אינו ה־callback לכישלון של כל כתיבה |
ההפרדה בין השלמת כתיבה להאזנה מופיעה ב־תיעוד אישור הכתיבה וב־תיעוד ValueEventListener.
התרשימים מתארים מסלול מוצלח, והם אינם מוסיפים לקוד מנגנוני הבטחה:
- בניתוק זמני הכתיבה יכולה להמתין לסנכרון בעוד המכשיר שכתב כבר מציג עדכון מקומי. ניתוק אינו בהכרח כישלון כתיבה מיידי, ואין בשיעור חיווי ייעודי לכתיבה ממתינה.
- שתי כתיבות מקבילות עלולות לדרוס זו את זו. השבתת הכפתורים, ה־getters וה־setters אינם תחליף ל־transaction או לחוקי RTDB.
onDestroy()מסירה את שני המאזינים באמצעותremoveEventListener. הקוד אינו מסיר אותם רק כי ה־Activity הפסיקה להיות גלויה; הסרת מאזין גם אינה מוחקת את החדר או מבטלת כתיבה שכבר נשלחה.
בדיקת התוצאה
לבדיקה מלאה נוחים שלושה משתמשי Firebase נפרדים בשלושה מכשירים או אמולטורים.
בדיקה 1 - יצירה והצטרפות
- משתמש א יוצר חדר. הלובי נעלם ומופיע
Waiting for player O…. - ודאו שהלוח של משתמש א מושבת.
- משתמש ב בוחר את החדר הממתין ולוחץ
Open selected game. - משתמש ב רואה שהוא O וממתין לתורו.
- משתמש א רואה מיד שהוא X ושזה תורו.
- ברשימת החדרים במכשיר נוסף מצב החדר משתנה מ־
waitingל־playing.
בדיקה 2 - מהלכים בזמן אמת
- X לוחץ על משבצת. X מופיע בשני הלוחות.
- נסו ללחוץ שוב אצל X. הלוח שלו צריך להיות מושבת.
- O לוחץ על משבצת אחרת. O מופיע בשני הלוחות והתור חוזר ל־X.
- ודאו שב־Firebase השדה
movesמכיל רצף כגון0,0,X;1,1,O.
בדיקה 3 - צפייה
- משתמש ג בוחר את החדר שמסומן
playingופותח אותו. - הוא רואה
Watching this gameואת כל המהלכים שכבר שוחקו. - כל כפתורי הלוח שלו מושבתים.
- כאשר X או O משחקים, הלוח של משתמש ג מתעדכן מיד.
בדיקה 4 - בנייה מחדש
- צאו מן המסך אחרי כמה מהלכים.
- פתחו שוב את
RTDB Gameובחרו את אותו חדר. - ודאו שכל הלוח נבנה מחדש מן המחרוזת
moves. - ודאו שהחדר נשאר ברשימה גם לאחר שהמשתמשים יצאו.
כאשר שני השחקנים והצופה רואים אותו לוח, והלוח נבנה מחדש לאחר פתיחה חוזרת, הושלמה גרסת ה־RTDB הפשוטה.
מה לא ניסינו לפתור?
כדאי לדעת במפורש היכן פישטנו:
- אין אימות מהלכים או תפקידים בחוקי RTDB.
- אין transaction כאשר שני משתמשים מנסים להיות O.
- אין טיפול בהתנגשות בין שתי כתיבות
moves. - אין
finished, מחיקת חדר או ניקוי חדרים ישנים. - אין בדיקה שהמחרוזת שקיבלנו בנויה נכון.
- אין השלמה של ניצחון או תיקו.
- אין מסך חזרה מן המשחק אל הלובי.
כל אלה יכולים להיות חשובים במוצר אמיתי, אך היו מסתירים את שלוש הפעולות שאנו רוצים להבין כאן: כתיבת ערך, האזנה לערך ובניית המסך מן הערך שהתקבל.
סיכום התפקידים
| רכיב | תפקיד בסיום 021b |
|---|---|
GameRoom |
הנתונים המשותפים: שם, שני UID-ים ורצף מהלכים |
TicTacToeModel |
מצב הלוח המקומי והתור, שנבנים מחדש מן הרצף |
FBRef.refGames |
הכתובת המשותפת של חדרי המדריך |
gamesListener |
רשימת החדרים ומצב waiting או playing |
selectedRoomListener |
המשחק הפתוח אצל שחקן או צופה |
Main2Activity |
מחבר בין פעולות המשתמש, RTDB וה־Views |
התוצאה נשארת בכוונה בתוך Activity אחד: זהו שיעור ראשון וקצר ב־RTDB, ולכן אפשר לעקוב באותו קובץ אחר כל הדרך מלחיצה, דרך setValue, אל snapshot חדש ועד לציור הלוח.
המשך
מורה־עזר ב־Gemini
פתחו את Gem: מאמן Firebase ל־TicTacToe כדי לקבל רמזים, שאלות אבחון והסברים המתאימים לשלב שבו אתם נמצאים.