CollectCircles 13 - התקדמות בזמן שהיישום סגור


חותמת זמן, חישוב טהור, שברי עיגול ומחזור החיים

אנחנו יודעים שכל Pusher אוסף 0.20 עיגולים בשנייה ושקצב היצירה מעט גדול מקצב העובדים. לכן אין צורך לצייר את המשחק כל זמן שהיישום סגור. נשמור מתי יצאנו, ובפתיחה הבאה נחשב כמה עבודה הייתה יכולה להסתיים בזמן שעבר.

חזרה לפרק 12: ה־Pushers מתחילים לעבוד

בפרק הזה אין קוד שרץ ברקע. אין Thread,‏ Service או WorkManager שפועלים כאשר היישום סגור. הביטוי “התקדמות אופליין” מתאר חישוב שמתרחש רק בפעם הבאה שה־Activity עוברת ל־onStart. לבצע ממש את המשחק ברקע כשהוא סגור זו טעות design משמעותית.

נוסחת הייצור

קצב האיסוף האוטומטי מוגבל על־ידי הצד האיטי יותר:

offline rate = min(spawn rate, all pushers' rate)

לדוגמה, שני Pushers מסוגלים לאסוף:

2 × 0.20 = 0.40 circles/second

קצב היצירה שלהם הוא 0.44, ולכן הקצב האמיתי הוא min(0.44, 0.40) = 0.40.

1. מרכזים את הקצבים במחלקה אחת

בחלון Android, תחת app > kotlin+java > com.example.collectcircles, צרו Java Class בשם ProductionRates:

package com.example.collectcircles;

public final class ProductionRates {

    private static final double BASE_SPAWN_RATE = 0.20;
    private static final double SPAWN_RATE_PER_PUSHER =
            Pusher.CIRCLES_PER_SECOND * 1.10;

    /** Prevents creating objects from this utility class. */
    private ProductionRates() {
    }

    /**
     * Calculates how many circles the game can create per second.
     *
     * @param pusherCount number of owned Pushers
     * @return circle spawn rate per second
     */
    public static double spawnRate(int pusherCount) {
        int safeCount = Math.max(0, pusherCount);
        return Math.max(BASE_SPAWN_RATE, safeCount * SPAWN_RATE_PER_PUSHER);
    }

    /**
     * Calculates how many circles all Pushers can collect per second.
     *
     * @param pusherCount number of owned Pushers
     * @return combined Pusher collection rate per second
     */
    public static double pusherRate(int pusherCount) {
        return Math.max(0, pusherCount) * Pusher.CIRCLES_PER_SECOND;
    }

    /**
     * Returns the slower of the spawn and collection rates.
     *
     * @param pusherCount number of owned Pushers
     * @return effective offline collection rate per second
     */
    public static double offlineRate(int pusherCount) {
        return Math.min(spawnRate(pusherCount), pusherRate(pusherCount));
    }
}

כך גם המשחק המצויר וגם חישוב האופליין משתמשים באותם מספרים. אם נשנה מאוחר יותר את הקצב, לא ייווצר מצב שבו המסך מלמד כלל אחד והחישוב משתמש בכלל אחר.

ב־Game הסירו את BASE_SPAWN_RATE,‏ SPAWN_RATE_PER_PUSHER ואת getSpawnRate(). החליפו:

-   spawnProgress += elapsedSeconds * getSpawnRate();
+   spawnProgress += elapsedSeconds * ProductionRates.spawnRate(pushers.size());

2. יוצרים מחשבון שאינו תלוי ב־Android

באותו package צרו Java Class בשם OfflineProgressCalculator:

package com.example.collectcircles;

public final class OfflineProgressCalculator {

    /** Prevents creating objects from this utility class. */
    private OfflineProgressCalculator() {
    }

    /**
     * Calculates whole circles and the remaining fraction for an offline period.
     *
     * @param elapsedMillis elapsed wall-clock time, in milliseconds
     * @param pusherCount number of owned Pushers
     * @param previousFraction unfinished progress from the previous calculation
     * @return calculated whole circles and the fraction to keep
     */
    public static Result calculate(long elapsedMillis, int pusherCount,
                                   double previousFraction) {
        long safeElapsedMillis = Math.max(0, elapsedMillis);
        double safeFraction = Math.max(0, Math.min(previousFraction, 0.999999));
        double produced = safeFraction
                + safeElapsedMillis / 1000.0
                * ProductionRates.offlineRate(pusherCount);

        if (produced >= Long.MAX_VALUE) {
            return new Result(Long.MAX_VALUE, 0);
        }

        long wholeCircles = (long) Math.floor(produced);
        return new Result(wholeCircles, produced - wholeCircles);
    }

    public static final class Result {
        private final long wholeCircles;
        private final double fraction;

        /**
         * Creates an immutable calculation result.
         *
         * @param wholeCircles number of complete circles produced
         * @param fraction unfinished part of the next circle
         */
        private Result(long wholeCircles, double fraction) {
            this.wholeCircles = wholeCircles;
            this.fraction = fraction;
        }

        /** @return number of complete circles produced */
        public long getWholeCircles() {
            return wholeCircles;
        }

        /** @return unfinished part of the next circle */
        public double getFraction() {
            return fraction;
        }
    }
}

מהי המחלקה Result?

calculate() צריכה להחזיר שני ערכים יחד: מספר עיגולים שלמים ושבר שיישמר לחישוב הבא. במקום להחזיר מערך ששני תאיו חסרי שמות, המחלקה Result אורזת את הערכים בשדות בעלי משמעות ובפעולות getWholeCircles() ו־getFraction().

זו הפעם הראשונה בסדרה שבה אנו מכריזים על מחלקה כ־final. אי־אפשר לרשת ממחלקה כזאת וליצור לה תת־מחלקה. גם ProductionRates וגם OfflineProgressCalculator הן final, מפני שהן מחלקות שירות סטטיות שלא נועדו לירושה. במקרה של Result,‏ final אומר שהתוצאה היא טיפוס קטן וסגור שאיננו מתכננים להרחיב.

שלוש המילים בהכרזה ממלאות תפקידים שונים:

  • static — כל Result שייך למחלקה OfflineProgressCalculator, לא לאובייקט מסוים שלה. יוצרים אותו בשם המלא OfflineProgressCalculator.Result.
  • final — אי־אפשר להכריז class SpecialResult extends Result.
  • השדות עצמם final — כל ערך נקבע פעם אחת בבנאי ואי־אפשר להחליף אותו אחר כך. לכן אובייקט התוצאה אינו משתנה לאחר יצירתו.

הבנאי private משאיר את יצירת התוצאה בידי calculate(). קוד חיצוני יכול לקרוא את הערכים דרך ה־getters, אך אינו יכול לבנות תוצאה שאינה תואמת לכללי המחשבון.

זו פונקציה טהורה: אותם קלטים תמיד מחזירים אותה תוצאה, והיא אינה קוראת מסך, שעון, קובץ או SharedPreferences. לכן אפשר לבדוק אותה בבדיקת יחידה רגילה ולהשתמש בה בעתיד גם מתוך WorkManager.

מדוע שומרים שבר?

Pusher אחד שעבד 12 שניות ייצר:

12 × 0.20 = 2.4 circles

אפשר להוסיף עכשיו רק שני עיגולים שלמים. את 0.4 שומרים, ובחישוב האופליין הבא מוסיפים אותו לתוצאה החדשה. בלי השבר, יציאות קצרות היו מאבדות התקדמות שוב ושוב.

3. מוסיפים מצב אופליין ל־GameProgress

הוסיפו מפתחות ושדות:

private static final String LAST_PROGRESS_UPDATE_KEY = "last_progress_update";
private static final String FRACTIONAL_PROGRESS_KEY = "fractional_progress";

private long lastProgressUpdateMillis;
private double fractionalProgress;

בסוף הבנאי טענו אותם:

lastProgressUpdateMillis = preferences.getLong(
        LAST_PROGRESS_UPDATE_KEY,
        System.currentTimeMillis()
);
fractionalProgress = Double.longBitsToDouble(
        preferences.getLong(
                FRACTIONAL_PROGRESS_KEY,
                Double.doubleToRawLongBits(0)
        )
);

SharedPreferences אינו שומר double ישירות. אנחנו שומרים את הביטים שלו בתוך long, ומשחזרים אותם באותה צורה.

4. מוסיפים כמה עיגולים בפעולה אחת

שנו את פעולת האיסוף הקיימת. משמאל היא יודעת להוסיף רק עיגול אחד; מימין הפעולה הקטנה מעבירה את העבודה לפעולה כללית:

לפני

/**
 * Records one collected circle in both progress totals.
 */
public void recordCollectedCircle() {
-    if (circlesBalance < Long.MAX_VALUE) {
-        circlesBalance++;
-    }
-    if (lifetimeCircles < Long.MAX_VALUE) {
-        lifetimeCircles++;
-    }
-
-    preferences.edit()
-            .putLong(CIRCLES_BALANCE_KEY, circlesBalance)
-            .putLong(LIFETIME_CIRCLES_KEY, lifetimeCircles)
-            .apply();
}
    

אחרי

/**
 * Records one collected circle in both progress totals.
 */
public void recordCollectedCircle() {
+    recordCollectedCircles(1);
}

+/**
+ * Adds several collected circles without overflowing either total.
+ *
+ * @param count number of circles to add
+ */
+public void recordCollectedCircles(long count) {
+    if (count <= 0) {
+        return;
+    }
+
+    circlesBalance = addWithSaturation(circlesBalance, count);
+    lifetimeCircles = addWithSaturation(lifetimeCircles, count);
+    preferences.edit()
+            .putLong(CIRCLES_BALANCE_KEY, circlesBalance)
+            .putLong(LIFETIME_CIRCLES_KEY, lifetimeCircles)
+            .apply();
+}
+
+/**
+ * Adds two non-negative values and stops at the largest long value.
+ *
+ * @param current current stored value
+ * @param addition non-negative amount to add
+ * @return the sum, or Long.MAX_VALUE if the sum would overflow
+ */
+private long addWithSaturation(long current, long addition) {
+    if (Long.MAX_VALUE - current < addition) {
+        return Long.MAX_VALUE;
+    }
+    return current + addition;
+}
    

כך גם איסוף יחיד וגם מאות עיגולי אופליין עוברים באותו כלל, בלי לולאה שקוראת apply() מאות פעמים ובלי גלישה למספר שלילי.

5. מסכמים את הזמן שעבר

הוסיפו ל־GameProgress:

/**
 * Applies progress earned since the last saved wall-clock timestamp.
 *
 * @param nowMillis current wall-clock time, in milliseconds
 * @return number of whole circles added to the progress totals
 */
public long settleOfflineProgress(long nowMillis) {
    long elapsedMillis = Math.max(0, nowMillis - lastProgressUpdateMillis);
    lastProgressUpdateMillis = nowMillis;

    long collectedCircles = 0;
    if (autonomousMode && pusherCount > 0) {
        OfflineProgressCalculator.Result result =
                OfflineProgressCalculator.calculate(
                        elapsedMillis,
                        pusherCount,
                        fractionalProgress
                );
        collectedCircles = result.getWholeCircles();
        fractionalProgress = result.getFraction();
        circlesBalance = addWithSaturation(circlesBalance, collectedCircles);
        lifetimeCircles = addWithSaturation(lifetimeCircles, collectedCircles);
    }

    preferences.edit()
            .putLong(CIRCLES_BALANCE_KEY, circlesBalance)
            .putLong(LIFETIME_CIRCLES_KEY, lifetimeCircles)
            .putLong(LAST_PROGRESS_UPDATE_KEY, lastProgressUpdateMillis)
            .putLong(
                    FRACTIONAL_PROGRESS_KEY,
                    Double.doubleToRawLongBits(fractionalProgress)
            )
            .apply();
    return collectedCircles;
}

/**
 * Saves the moment from which the next offline period will be measured.
 *
 * @param nowMillis current wall-clock time, in milliseconds
 */
public void markBackgroundStarted(long nowMillis) {
    lastProgressUpdateMillis = nowMillis;
    preferences.edit()
            .putLong(LAST_PROGRESS_UPDATE_KEY, nowMillis)
            .apply();
}

אם Auto כבוי או שאין Pushers, הזמן מתעדכן אבל לא נצברת עבודה. זמן שלילי — למשל לאחר שינוי ידני של שעון המכשיר — הופך לאפס.

6. באיזה שעון משתמשים?

באנימציה השתמשנו ב־SystemClock.elapsedRealtime(). הוא מונוטוני ומתאים למדידת פריימים, אבל הערך שלו מתחיל מחדש לאחר reboot ואינו תאריך שאפשר להשוות בין הפעלות שונות.

להתקדמות אופליין משתמשים ב־System.currentTimeMillis(), מפני שהוא נשמר כתאריך מוחלט ושורד סגירה ו־reboot. יש לו מחיר: המשתמש או הרשת יכולים לשנות את שעון הקיר. במשחק לימודי אנחנו מונעים זמן שלילי, אבל איננו מנסים למנוע רמאות על־ידי הזזת השעון קדימה.

בחירת השעון היא חלק מן המודל: זמן מונוטוני למדידת משך בתוך הרצה אחת; שעון קיר להשוואה בין הרצות.

7. מחברים ל־onStart ול־onStop

ב־MainActivity הוסיפו שדה:

private boolean enteredBackground;

וב־app > res > values > strings.xml:

<string name="offline_progress_message">While you were away, your pushers collected %1$d circles.</string>

onStart לפני

if (gameProgress.isAutonomousMode()) {
    binding.gameBoard.resumeAutonomousGame();
} else if (gameRunning) {
    timerUpdate.run();
}
    

onStart אחרי

+long offlineCircles = gameProgress.settleOfflineProgress(
+        System.currentTimeMillis()
+);
+showProgress();
+showOfflineProgress(offlineCircles);
+

if (gameProgress.isAutonomousMode()) {
+    if (enteredBackground) {
+        startFreshAutonomousBoard();
+    } else {
        binding.gameBoard.resumeAutonomousGame();
+    }
} else if (gameRunning) {
    timerUpdate.run();
}
+enteredBackground = false;
    

הוסיפו:

/**
 * Shows a message when at least one whole offline circle was collected.
 *
 * @param collectedCircles number of whole circles collected offline
 */
private void showOfflineProgress(long collectedCircles) {
    if (collectedCircles <= 0) {
        return;
    }
    Toast.makeText(
            this,
            getString(R.string.offline_progress_message, collectedCircles),
            Toast.LENGTH_LONG
    ).show();
}

/** Rebuilds the autonomous board after the View has been measured. */
private void startFreshAutonomousBoard() {
    binding.gameBoard.post(() -> {
        if (gameProgress.isAutonomousMode()) {
            binding.gameBoard.startAutonomousGame(gameProgress.getPusherCount());
        }
    });
}

וב־onStop, לאחר עצירת הציור ולפני super.onStop():

gameProgress.markBackgroundStarted(System.currentTimeMillis());
enteredBackground = true;

מדוע בונים לוח חדש?

אובייקטי המסך נעצרו ברגע היציאה: ייתכן ש־Pusher היה באמצע משימה. חישוב האופליין כבר זיכה את הזמן שעבר, ולכן אסור להמשיך גם את אותה משימה ישנה כאילו היא נשמרה. לוח חדש עם שלושה עיגולים מאפס את האנימציה, בעוד השבר החשבונאי נשמר בנפרד. כך לא סופרים אותו זמן פעמיים.

הלוח החזותי עדיין מוגבל ל־12. חישוב האופליין מייצג זרימה מתמשכת של יצירה ואיסוף, לא ניסיון לשים מאות עיגולים על Canvas אחד.

8. מה באמת סינכרוני ומה אסינכרוני כאן?

  • onStart,‏ settleOfflineProgress,‏ showProgress והצגת ה־Toast רצים בזה אחר זה על UI thread.
  • החישוב הוא כמה פעולות חשבון בלבד ולכן אינו מצדיק thread רקע.
  • startFreshAutonomousBoard משתמש ב־post מפני שה־View צריך להימדד לפני יצירת הקואורדינטות. post מכניס Runnable לתור UI; הוא אינו יוצר thread חדש.
  • SharedPreferences.apply() משנה מיד את העותק בזיכרון ומתזמן כתיבה לדיסק. הקריאה אינה ממתינה ל־I/O.
  • אין Service ואין תהליך שנשאר חי לאחר סגירת היישום.

onStop נקרא ביציאה רגילה לרקע, אך Android אינו מבטיח callback אחרון אם התהליך נהרג בפתאומיות או מתרחשת קריסה. זהו אחד הגבולות של השלב הפשוט. WorkManager בפרקים הבאים ייתן נקודות ביצוע מתוזמנות נוספות, אך גם הוא אינו שעון מדויק ואינו מבטיח שתהליך יחיה תמיד.

9. בדיקה ממוקדת

הוסיפו:

/** Verifies rate limiting, whole-circle output, and saved fractions. */
@Test
public void offlineProgressUsesTheSlowerRateAndKeepsFractions() {
    OfflineProgressCalculator.Result onePusher =
            OfflineProgressCalculator.calculate(12_000, 1, 0);
    assertEquals(2, onePusher.getWholeCircles());
    assertEquals(0.4, onePusher.getFraction(), 0.0001);

    OfflineProgressCalculator.Result twoPushers =
            OfflineProgressCalculator.calculate(10_000, 2, 0);
    assertEquals(4, twoPushers.getWholeCircles());

    OfflineProgressCalculator.Result noPushers =
            OfflineProgressCalculator.calculate(60_000, 0, 0);
    assertEquals(0, noPushers.getWholeCircles());
}

הריצו פעם אחת:

.\gradlew.bat testDebugUnitTest assembleDebug

10. בדיקה ידנית

מספר ה־Pushers יכול רק לעלות. לכן מבצעים את הבדיקות לפי הסדר, לפני שקונים את העובד הבא:

  1. אם עדיין אין לכם Pusher, הפעילו Auto, עברו לרקע וחזרו. לא צריכה להיות התקדמות אופליין.
  2. קנו את ה־Pusher הראשון. עברו לרקע לכ־12 שניות וחזרו. אמורים להתווסף בערך שני עיגולים ולהופיע Toast.
  3. עם אותו Pusher, חזרו על כמה יציאות קצרות. ודאו שהשברים מצטברים ובסופו של דבר הופכים לעיגול שלם.
  4. רק לאחר שסיימתם את בדיקות העובד היחיד, קנו Pusher שני ובדקו שהקצב בערך כפול.
  5. כבו Auto כאשר שני העובדים עדיין בבעלותכם, עברו לרקע וחזרו. הפעם לא צריכה להיות התקדמות אופליין.
  6. הפעילו Auto מחדש וודאו שהלוח נבנה מחדש ואינו מציג יותר מ־12 עיגולים.
  7. סגרו ופתחו את היישום ובדקו שהיתרה, Lifetime והשבר ממשיכים באופן עקבי.

בבדיקת היחידה מותר להעביר פעם 1, פעם 2 ופעם 0, מפני שאיננו משנים שם חשבון משתמש אמיתי — כל קריאה בודקת את פונקציית החישוב עם קלט עצמאי.

המשחק יודע כעת להשלים חשבון כאשר המשתמש חוזר, בלי להשאיר קוד חי ברקע. בפרק הבא נוסיף WorkManager ונאפשר ל־Android להעיר Worker תקופתי גם כאשר ה־Activity אינה פתוחה.

המשך לפרק 14: בדיקת זכאות ראשונה עם WorkManager