זהו השלב האחרון. אנחנו ממשיכים מן המשחק שניתן לגרירה ב- CollectCircles 2, ומוסיפים התחלה מחדש, זמן, שיא קבוע והודעת סיום. בסוף השיעור היישום שלם.
מה נשלים
- כפתור Start שיוצר משחק ומתחיל את המדידה.
- לחיצה נוספת שמתחילה משחק חדש ומאפסת את הזמן.
- מדידת זמן בעזרת שעון מונוטוני.
- רענון תווית הזמן ב-UI בלי להשתמש במספר ה-ticks כזמן.
- עצירה כשהעיגול האחרון נאסף.
- שמירת השיא ב-
SharedPreferences. - הסרת עדכונים מתוזמנים כאשר המסך אינו פעיל.
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.
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, והפעילות אינה צריכה לבדוק
את רשימת העיגולים בכל תנועה.
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 משמש כסימן
“עדיין אין שיא”. כל זמן משחק אמיתי יהיה קטן ממנו, ולכן התוצאה הראשונה תהפוך
אוטומטית לשיא.
מדוע SystemClock.elapsedRealtime()?
System.currentTimeMillis() מייצג שעה ותאריך ויכול להשתנות אם המשתמש או הרשת
מתקנים את שעון המכשיר. למדידת משך נשתמש ב:
SystemClock.elapsedRealtime()
זהו שעון מונוטוני: הוא מתקדם קדימה מאז שהמכשיר עלה ואינו קופץ בגלל שינוי השעה. משך המשחק הוא ההפרש בין שתי קריאות:
elapsed = currentElapsedRealtime - gameStartElapsedRealtime
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 רענונים בשנייה.
ה-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() מעדכנת מיד ומתחילה שרשרת אחת חדשה.
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.
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 - אנחנו קוראים שוב
את השעון המונוטוני.
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, מודל עיגולים
עם ירושה, משחק מגע מלא, זמן מונוטוני ושיא שנשמר במכשיר.
מחשבה להמשך
אחרי שהמשחק עובד, כדאי לעצור ולשאול אם Circle היה צריך להיות View, האם Target באמת
צריך לרשת ממנו, ומה פירוש המילה Model בפרויקט כזה. המשיכו אל
מחשבה ביקורתית על עצמים, Views וירושה.