זהו השלב האחרון. אנחנו ממשיכים מן המשחק שניתן לגרירה ב- CollectCircles 2, ומוסיפים התחלה מחדש, זמן, שיא קבוע והודעת סיום. בסוף השיעור היישום שלם.
חזרה ל-2: מצב משחק, עיגולים אקראיים וגרירה
מה נשלים
- כפתור Start שיוצר משחק ומתחיל את המדידה.
- לחיצה נוספת שמתחילה משחק חדש ומאפסת את הזמן.
- מדידת זמן בעזרת שעון מונוטוני.
- רענון תווית הזמן ב-UI בלי להשתמש במספר ה-ticks כזמן.
- עצירה כשהעיגול האחרון נאסף.
- שמירת השיא ב-
SharedPreferences. - הסרת עדכונים מתוזמנים כאשר המסך אינו פעיל.
עד עכשיו כללי העיגולים עבדו, אך לא הייתה מסגרת ברורה לסיבוב משחק. בפרק הזה נוסיף שלושה מצבים: לפני התחלה, משחק פעיל ומשחק שהסתיים.
| מצב | הלוח | השעון | הפעולה הבאה |
|---|---|---|---|
| לפני התחלה | ריק | 0.000 |
Start יוצר משחק |
| משחק פעיל | חמישה עיגולים שהולכים ונאספים | מתקדם | Restart מאפס, או איסוף אחרון מסיים |
| אחרי סיום | ריק | קפוא בזמן הסופי | Restart יוצר סיבוב חדש |
המשתנה gameRunning ייצג את ההבדל בין המצבים מבחינת הפעילות. רשימת העיגולים
מייצגת את התקדמות המשחק עצמו.
1. Start הופך להיות נקודת ההתחלה
בגרסה הקודמת GameBoardView יצר משחק מתוך onSizeChanged, מפני שהיינו צריכים
גרסה ניתנת למשחק לפני בניית המסך הסופי. כעת הסירו את onSizeChanged הזה והוסיפו:
public void startNewGame() {
if (getWidth() == 0 || getHeight() == 0) {
return;
}
float targetRadius = Math.min(
dp(TARGET_RADIUS_DP),
Math.min(getWidth(), getHeight()) / 5f
);
game = new Game(
getWidth(),
getHeight(),
targetRadius,
color(R.color.target_red),
color(R.color.target_cross),
dp(2f),
color(R.color.circle_green)
);
invalidate();
}
כעת פתיחת היישום מציגה את מסך הבקרה ולוח ריק. רק לחיצה על Start יוצרת חמישה עיגולים. אותה פעולה תשמש גם ל-Restart.
הבדיקה של רוחב וגובה מונעת יצירת משחק לפני ש-Android סיימה למדוד את ה-View.
איננו יוצרים עוד משחק אוטומטית ב-onSizeChanged, מפני ששינוי גודל של לוח הוא
אירוע תצוגה ולא בקשה להתחיל סיבוב. כעת רק פעולה מפורשת של המשתמש משנה את מצב
המשחק מ”לפני התחלה” ל”פעיל”.
2. מודיעים לפעילות שהמשחק הסתיים
ה-View יודע מתי הרשימה התרוקנה, אבל MainActivity היא המקום הטבעי לעדכן
תוויות, לשמור העדפות ולהציג dialog. נשמור Runnable שהפעילות תיתן ל-View:
private Runnable onGameFinished;
public void setOnGameFinishedListener(Runnable listener) {
onGameFinished = listener;
}
בתוך ACTION_UP, שמרו את התוצאה של השחרור:
boolean collected = game.releaseSelectedCircle();
אחרי invalidate() ו-performClick(), הוסיפו:
if (collected
&& game.isFinished()
&& onGameFinished != null) {
onGameFinished.run();
}
שלושת התנאים חשובים:
- העיגול אכן נאסף בשחרור הנוכחי.
- לא נשאר אף עיגול.
- הפעילות אכן מסרה פעולה להרצה.
כך ה-View אינו מכיר SharedPreferences או dialogs, והפעילות אינה צריכה לבדוק
את רשימת העיגולים בכל תנועה.
זהו callback קטן: ה-GameBoardView מפרסמת את העובדה “המשחק הסתיים” בלי
להחליט מה משמעותה בממשק. MainActivity בוחרת לעצור זמן, לשמור שיא ולהציג
הודעה. Runnable מתאים כאן מפני שאין צורך להעביר נתונים; הפעילות יכולה לחשב
את הזמן הסופי מן השעון שלה.
3. מוסיפים מחרוזות למסך הסופי
הוסיפו ל-strings.xml:
<string name="elapsed_time_format">Time: %1$.3f s</string>
<string name="best_time_format">Best: %1$.3f s</string>
<string name="restart">Restart</string>
<string name="completion_title">Game complete!</string>
<string name="completion_message">You collected every circle in %1$.3f seconds.</string>
<string name="ok">OK</string>
%1$.3f פירושו: קחו את הפרמטר הראשון והציגו אותו כמספר עשרוני עם שלוש ספרות
אחרי הנקודה. הטקסט נמצא במשאבים, ולכן אפשר לתרגם אותו בעתיד בלי לערוך Java.
4. מכינים את השעון ואת השיא
ב-MainActivity הוסיפו imports:
import android.content.SharedPreferences;
import android.os.SystemClock;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
הוסיפו קבועים ושדות:
private static final String PREFERENCES_NAME =
"collect_circles_preferences";
private static final String BEST_TIME_KEY = "best_time_millis";
private static final long TIMER_REFRESH_MILLIS = 16;
private static final long NO_BEST_TIME = Long.MAX_VALUE;
private SharedPreferences preferences;
private boolean gameRunning;
private long gameStartTimeMillis;
private long bestTimeMillis;
אנחנו שומרים זמן באלפיות שנייה בתוך long. הערך Long.MAX_VALUE משמש כסימן
“עדיין אין שיא”. כל זמן משחק אמיתי יהיה קטן ממנו, ולכן התוצאה הראשונה תהפוך
אוטומטית לשיא.
השמות מסתיימים ב-Millis בכוונה. יחידה היא חלק ממשמעות הנתון: השוואת
אלפיות שנייה לשניות הייתה מתקמפלת, אך נותנת תוצאה שגויה. ההמרה לשניות נעשית
רק בגבול התצוגה, כאשר מעצבים טקסט למשתמש.
מדוע SystemClock.elapsedRealtime()?
System.currentTimeMillis() מייצג שעה ותאריך ויכול להשתנות אם המשתמש או הרשת
מתקנים את שעון המכשיר. למדידת משך נשתמש ב:
SystemClock.elapsedRealtime()
זהו שעון מונוטוני: הוא מתקדם קדימה מאז שהמכשיר עלה ואינו קופץ בגלל שינוי השעה. משך המשחק הוא ההפרש בין שתי קריאות:
elapsed = currentElapsedRealtime - gameStartElapsedRealtime
איננו שומרים “כמה פעמים ה-Runnable רצה”. מערכת ההפעלה אינה מבטיחה שכל
רענון יתרחש בדיוק בזמן; ה-UI יכול להיות עסוק ואף להיעצר כשהיישום ברקע. הפרש
בין שתי קריאות שעון נשאר נכון גם כאשר דילגנו על רענונים.
5. מרעננים את התווית בעזרת Runnable
הוסיפו שדה:
private final Runnable timerUpdate = new Runnable() {
@Override
public void run() {
if (!gameRunning) {
return;
}
showElapsedTime(
SystemClock.elapsedRealtime() - gameStartTimeMillis
);
binding.elapsedTimeText.postDelayed(
this,
TIMER_REFRESH_MILLIS
);
}
};
ה-Runnable רצה ב-UI thread, מעדכנת את התווית ומתזמנת את עצמה שוב בעוד
כ-16ms. זה מתאים בקירוב לקצב של 60 רענונים בשנייה.
הערך 16 הוא קצב תצוגה מבוקש, לא הבטחת דיוק של 16 אלפיות שנייה. אין טעם
לעדכן TextView מאות או אלפי פעמים בשנייה, והזמן הסופי ממילא נלקח ישירות
מן השעון. כך אנחנו מפרידים בין דיוק המדידה לבין חלקות התצוגה.
ה-Runnable אינה השעון. היא רק מזכירה לנו לרענן את המסך. הזמן המדויק תמיד
מחושב מחדש מן השעון המונוטוני. לכן עיכוב קטן ברענון אינו מצטבר לשגיאת זמן.
6. מחברים את המסך ב-onCreate
אחרי קוד ה-insets הקיים הוסיפו:
preferences = getSharedPreferences(
PREFERENCES_NAME,
MODE_PRIVATE
);
bestTimeMillis = preferences.getLong(
BEST_TIME_KEY,
NO_BEST_TIME
);
showBestTime();
binding.startButton.setOnClickListener(view -> startGame());
binding.gameBoard.setOnGameFinishedListener(this::finishGame);
גם כאן View Binding נותן גישה ישירה ובטוחה לפי טיפוס ל-startButton,
ל-gameBoard ולתוויות.
this::finishGame היא method reference: כאשר ה-View מודיע שהמשחק הסתיים,
Android מפעילה את finishGame() של הפעילות.
7. מתחילים או מאתחלים משחק
הוסיפו:
private void startGame() {
binding.gameBoard.startNewGame();
gameStartTimeMillis = SystemClock.elapsedRealtime();
gameRunning = true;
binding.startButton.setText(R.string.restart);
binding.elapsedTimeText.removeCallbacks(timerUpdate);
timerUpdate.run();
}
removeCallbacks מונעת מצב שבו לחיצה מהירה על Restart משאירה שתי שרשראות
עדכון פעילות. לאחר מכן timerUpdate.run() מעדכנת מיד ומתחילה שרשרת אחת חדשה.
סדר הפעולות יוצר transaction קטן וברור: קודם מחליפים את תוכן הלוח, אחר כך קובעים זמן התחלה ומסמנים שהמשחק פעיל, ולבסוף מתחילים רענון יחיד. מאותו רגע כל חישוב זמן שייך לסיבוב החדש בלבד.
8. מסיימים, משווים ושומרים
הוסיפו:
private void finishGame() {
if (!gameRunning) {
return;
}
long finalTimeMillis =
SystemClock.elapsedRealtime() - gameStartTimeMillis;
gameRunning = false;
binding.elapsedTimeText.removeCallbacks(timerUpdate);
showElapsedTime(finalTimeMillis);
if (finalTimeMillis < bestTimeMillis) {
bestTimeMillis = finalTimeMillis;
preferences.edit()
.putLong(BEST_TIME_KEY, bestTimeMillis)
.apply();
showBestTime();
}
new MaterialAlertDialogBuilder(this)
.setTitle(R.string.completion_title)
.setMessage(getString(
R.string.completion_message,
millisecondsToSeconds(finalTimeMillis)
))
.setPositiveButton(R.string.ok, null)
.show();
}
רק תוצאה קטנה מן השיא הקודם נשמרת. SharedPreferences שומרת את הערך בקובץ
פרטי של היישום, ולכן הוא נשאר גם אחרי סגירה ופתיחה מחדש. apply() מעדכנת את
האובייקט מיד וכותבת לאחסון בלי לחסום את ה-UI thread.
הבדיקה if (!gameRunning) return הופכת את finishGame לבטוחה גם אם callback
יגיע פעמיים בטעות: רק המעבר הראשון מפעיל עצירה, שמירה ו-dialog. לאחר
gameRunning = false, שרשרת הרענון נעצרת והזמן המוצג מתקבע על התוצאה הסופית.
בחרנו ב-apply() ולא ב-commit(): אין צורך לעצור את ה-UI עד שהכתיבה לדיסק
מסתיימת. הערך בזיכרון מתעדכן מיד, והכתיבה הקטנה מתבצעת ברקע.
9. פעולות התצוגה הקטנות
הוסיפו:
private void showElapsedTime(long elapsedMillis) {
binding.elapsedTimeText.setText(getString(
R.string.elapsed_time_format,
millisecondsToSeconds(elapsedMillis)
));
}
private void showBestTime() {
if (bestTimeMillis == NO_BEST_TIME) {
binding.bestTimeText.setText(R.string.best_time_initial);
return;
}
binding.bestTimeText.setText(getString(
R.string.best_time_format,
millisecondsToSeconds(bestTimeMillis)
));
}
private double millisecondsToSeconds(long milliseconds) {
return milliseconds / 1000.0;
}
שמירת הזמן ב-long והמרתו ל-double רק לצורך תצוגה מונעת מאיתנו לערבב יחידות.
שם הפרמטר milliseconds מזכיר באיזו יחידה הפעולה מקבלת את הערך.
10. ניקוי בטוח במחזור החיים
כאשר המשתמש עובר ליישום אחר, אין טעם לעדכן תווית שאינה נראית. הוסיפו:
@Override
protected void onStart() {
super.onStart();
if (gameRunning) {
binding.elapsedTimeText.removeCallbacks(timerUpdate);
timerUpdate.run();
}
}
@Override
protected void onStop() {
binding.elapsedTimeText.removeCallbacks(timerUpdate);
super.onStop();
}
onStop מסירה את העדכון המתוזמן וכך אינה משאירה עבודה שמחזיקה את הפעילות
מאחורי הקלעים. אם חוזרים לאותה פעילות בזמן שהמשחק פעיל, onStart מפעילה את
הרענון מחדש. הזמן שחלף עדיין נכון, מפני שאיננו סופרים ticks - אנחנו קוראים שוב
את השעון המונוטוני.
זו גם החלטת משחק: הזמן ממשיך להיחשב כשהיישום ברקע, ורק ציור התווית נעצר. אם רצינו להשהות את המשחק, היינו צריכים לשמור זמן מצטבר ולעדכן אותו במעברי מחזור החיים. לא כדאי לקבל מדיניות כזו במקרה כתופעת לוואי של עצירת ה-UI.
בגרסה הלימודית איננו שומרים משחק פעיל מול יצירה מחדש של ה-Activity, למשל
לאחר שינוי תצורה. השיא נשמר מפני שהוא ב-SharedPreferences, אך מצב הסיבוב
הנוכחי הוא זמני. טיפול בכך בעזרת ViewModel או savedInstanceState הוא הרחבה
אפשרית, ולא דרישה להבנת מנגנון המשחק הבסיסי.
11. בנייה ובדיקות
הריצו את בדיקות היחידה, בניית ה-APK וגם Android Lint:
$env:JAVA_HOME = 'C:\Program Files\Android\Android Studio\jbr'
.\gradlew.bat testDebugUnitTest assembleDebug lintDebug
רשימת בדיקה ידנית
- במסך הראשוני מוצגים כותרת,
Time: 0.000 s, שיא אוBest: —, כפתור Start ולוח ריק. - Start יוצר בדיוק חמישה עיגולים ירוקים שאינם חופפים זה לזה או למטרה.
- גרירה מזיזה את העיגול לפי מקום האצבע.
- שחרור עיגול כשהוא כולו בתוך המטרה מוחק אותו.
- נגיעה או חפיפה חלקית במטרה אינה מוחקת את העיגול.
- איסוף העיגול האחרון עוצר את הזמן ומציג הודעת סיום.
- הזמן המוצג מפסיק להשתנות אחרי הסיום.
- תוצאה טובה יותר מעדכנת את השיא.
- תוצאה איטית יותר אינה מחליפה את השיא.
- לאחר סגירת היישום ופתיחתו מחדש השיא עדיין מוצג.
- Restart יוצר חמישה מיקומים חדשים ומאפס את הזמן.
הפרויקט הושלם: מסך XML שמחובר ב-View Binding, ציור ב-Canvas, מודל עיגולים
עם ירושה, משחק מגע מלא, זמן מונוטוני ושיא שנשמר במכשיר.
הזרימה המלאה כעת היא: XML מקצה שטח ללוח; Game יוצר מודל חוקי בעזרת בדיקות
מרחק; GameBoardView מתרגמת מגע לשינויים במודל ומציירת אותו; MainActivity
מנהלת את גבולות הסיבוב, הזמן והשמירה. כל שכבה יודעת רק את מה שנחוץ לתפקידה,
ולכן אפשר להסביר, לבדוק ולשנות כל חלק בלי לערבב את כל הפרויקט במחלקה אחת.
מחשבה להמשך
אחרי שהמשחק עובד, כדאי לעצור ולשאול אם Circle היה צריך להיות View, האם Target באמת
צריך לרשת ממנו, ומה פירוש המילה Model בפרויקט כזה. המשיכו אל
מחשבה ביקורתית על עצמים, Views וירושה.