הנחיות לגבי שמירת נתונים במטמון בצד הלקוח של Android API

קריאות ל-Android API בדרך כלל כוללות זמן אחזור וחישוב משמעותיים לכל הפעלה. לכן, כשמתכננים ממשקי API שימושיים, מדויקים ויעילים, חשוב לקחת בחשבון את האפשרות של שמירת נתונים במטמון בצד הלקוח.

מוטיבציה

ממשקי API שמוצגים למפתחי אפליקציות ב-Android SDK מיושמים לעיתים קרובות כקוד לקוח ב-Android Framework, שמבצע קריאת Binder IPC לשירות מערכת בתהליך פלטפורמה. תפקיד השירות הוא לבצע חישוב ולהחזיר תוצאה ללקוח. זמן האחזור של הפעולה הזו מושפע בדרך כלל משלושה גורמים:

  • תקורה של IPC: זמן האחזור של קריאת IPC בסיסית הוא בדרך כלל פי 10,000 מזמן האחזור של קריאה בסיסית של שיטת in-process.
  • תחרות בצד השרת: יכול להיות שהעבודה שמתבצעת בשירות המערכת בתגובה לבקשה של הלקוח לא מתחילה באופן מיידי, למשל אם השרשור של השרת עסוק בטיפול בבקשות אחרות שהתקבלו קודם.
  • חישוב בצד השרת: יכול להיות שהעבודה עצמה לטיפול בבקשה בשרת תדרוש מאמץ משמעותי.

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

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

כדאי לשקול שמירת תוצאות של שרת במטמון בצד הלקוח

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

כדאי להשתמש ב-IpcDataCache בהטמעה:

public class BirthdayManager {
    private final IpcDataCache.QueryHandler<User, Birthday> mBirthdayQuery =
            new IpcDataCache.QueryHandler<User, Birthday>() {
                @Override
                public Birthday apply(User user) {
                    return mService.getBirthday(user);
                }
            };
    private static final int BDAY_CACHE_MAX = 8;  // Maximum birthdays to cache
    private static final String BDAY_API = "getUserBirthday";
    private final IpcDataCache<User, Birthday> mCache
            new IpcDataCache<User, Birthday>(
                BDAY_CACHE_MAX, MODULE_SYSTEM, BDAY_API,  BDAY_API, mBirthdayQuery);

    /** @hide **/
    @VisibleForTesting
    public static void clearCache() {
        IpcDataCache.invalidateCache(MODULE_SYSTEM, BDAY_API);
    }

    public Birthday getBirthday(User user) {
        return mCache.query(user);
    }
}

דוגמה מלאה מופיעה בקטע android.app.admin.DevicePolicyManager.

IpcDataCache זמין לכל קוד המערכת, כולל מודולים מרכזיים. יש גם את PropertyInvalidatedCache שהוא כמעט זהה, אבל הוא גלוי רק למסגרת. מומלץ להשתמש ב-IpcDataCache כשהדבר אפשרי.

ביטול התוקף של מטמונים בשינויים בצד השרת

אם הערך שמוחזר מהשרת יכול להשתנות לאורך זמן, צריך להטמיע קריאה חוזרת (callback) כדי לעקוב אחרי השינויים, ולרשום קריאה חוזרת כדי לבטל את התוקף של מטמון בצד הלקוח בהתאם.

ביטול תוקף של מטמונים בין מקרי בדיקה של יחידות

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

@RunWith(AndroidJUnit4.class)
public class BirthdayManagerTest {

    @Before
    public void setUp() {
        BirthdayManager.clearCache();
    }

    @After
    public void tearDown() {
        BirthdayManager.clearCache();
    }

    ...
}

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

בדיקת פגיעות במטמון והחמצות

IpcDataCache ו-PropertyInvalidatedCache יכולים להדפיס נתונים סטטיסטיים בזמן אמת:

adb shell dumpsys cacheinfo
  ...
  Cache Name: cache_key.is_compat_change_enabled
    Property: cache_key.is_compat_change_enabled
    Hits: 1301458, Misses: 21387, Skips: 0, Clears: 39
    Skip-corked: 0, Skip-unset: 0, Skip-bypass: 0, Skip-other: 0
    Nonce: 0x856e911694198091, Invalidates: 72, CorkedInvalidates: 0
    Current Size: 1254, Max Size: 2048, HW Mark: 2049, Overflows: 310
    Enabled: true
  ...

שדות

כניסות:

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

ניקוי:

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

Misses:

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

דילוגים:

  • הגדרה: מקרים שבהם לא נעשה שימוש במטמון בכלל, למרות שאפשר היה להשתמש בו.
  • הסיבות לדילוג:
    • Corking: תופעה שקשורה לעדכונים של Android Package Manager, שבה המערכת משביתה בכוונה את השמירה במטמון בגלל נפח גדול של שיחות במהלך האתחול.
    • לא מוגדר: המטמון קיים אבל לא אותחל. הערך nonce לא הוגדר, מה שאומר שהמטמון מעולם לא בוטל.
    • עקיפה: החלטה מכוונת לדלג על המטמון.
  • מספרים גבוהים מצביעים על חוסר יעילות פוטנציאלי בשימוש במטמון.

תאריך פקיעת התוקף:

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

הגודל הנוכחי:

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

גודל מקסימלי:

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

סימן מים גבוה:

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

הצגת יתר של מודעות:

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

אפשר למצוא את אותם נתונים סטטיסטיים גם בדוח על באג.

שינוי הגודל של המטמון

יש גודל מקסימלי למטמון. אם חורגים מהגודל המקסימלי של המטמון, המערכת מסירה את הרשומות לפי סדר השימוש האחרון (LRU).

  • אם המטמון מכיל מעט מדי רשומות, הדבר עלול להשפיע לרעה על שיעור הפגיעה במטמון.
  • שמירה במטמון של יותר מדי רשומות מגדילה את השימוש בזיכרון של המטמון.

חשוב למצוא את האיזון הנכון לתרחיש השימוש שלכם.

הסרת שיחות מיותרות עם לקוחות

יכול להיות שלקוחות ישלחו את אותה שאילתה לשרת כמה פעמים בפרק זמן קצר:

public void executeAll(List<Operation> operations) throws SecurityException {
    for (Operation op : operations) {
        for (Permission permission : op.requiredPermissions()) {
            if (!permissionChecker.checkPermission(permission, ...)) {
                throw new SecurityException("Missing permission " + permission);
            }
        }
        op.execute();
  }
}

כדאי לשקול שימוש חוזר בתוצאות משיחות קודמות:

public void executeAll(List<Operation> operations) throws SecurityException {
    Set<Permission> permissionsChecked = new HashSet<>();
    for (Operation op : operations) {
        for (Permission permission : op.requiredPermissions()) {
            if (!permissionsChecked.add(permission)) {
                if (!permissionChecker.checkPermission(permission, ...)) {
                    throw new SecurityException(
                            "Missing permission " + permission);
                }
            }
        }
        op.execute();
  }
}

מומלץ לשקול שימוש בממויזציה בצד הלקוח של תגובות שרת עדכניות

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

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

@UiThread
private void setStats() {
    mobileRxBytesTextView.setText(
        Long.toString(TrafficStats.getMobileRxBytes()));
    mobileRxPacketsTextView.setText(
        Long.toString(TrafficStats.getMobileRxPackages()));
    mobileTxBytesTextView.setText(
        Long.toString(TrafficStats.getMobileTxBytes()));
    mobileTxPacketsTextView.setText(
        Long.toString(TrafficStats.getMobileTxPackages()));
}

יכול להיות שהאפליקציה תצייר פריימים בקצב של 60 Hz. אבל באופן היפותטי, קוד הלקוח ב-TrafficStats יכול לבחור לשלוח שאילתה לשרת לגבי נתונים סטטיסטיים פעם אחת לכל היותר בשנייה, ואם נשלחת שאילתה תוך שנייה משאילתה קודמת, להחזיר את הערך האחרון שנראה. זה מותר כי במסמכי ה-API לא מופיע חוזה לגבי עדכניות התוצאות שמוחזרות.

participant App code as app
participant Client library as clib
participant Server as server

app->clib: request @ T=100ms
clib->server: request
server->clib: response 1
clib->app: response 1

app->clib: request @ T=200ms
clib->app: response 1

app->clib: request @ T=300ms
clib->app: response 1

app->clib: request @ T=2000ms
clib->server: request
server->clib: response 2
clib->app: response 2

מומלץ להשתמש ביצירת קוד בצד הלקוח במקום בשאילתות בצד השרת

אם תוצאות השאילתה ידועות לשרת בזמן הבנייה, כדאי לבדוק אם הן ידועות גם ללקוח בזמן הבנייה, ולשקול אם אפשר להטמיע את ה-API כולו בצד הלקוח.

כדאי לעיין בקוד האפליקציה הבא שבודק אם המכשיר הוא שעון (כלומר, אם במכשיר פועלת מערכת Wear OS):

public boolean isWatch(Context ctx) {
    PackageManager pm = ctx.getPackageManager();
    return pm.hasSystemFeature(PackageManager.FEATURE_WATCH);
}

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

ביטול כפילויות של קריאות חוזרות (callback) מהשרת בלקוח

לבסוף, לקוח ה-API יכול לרשום קריאות חוזרות (callback) בשרת ה-API כדי לקבל הודעות על אירועים.

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

digraph d_front_back {
  rankdir=RL;
  node [style=filled, shape="rectangle", fontcolor="white" fontname="Roboto"]
  server->clib
  clib->c1;
  clib->c2;
  clib->c3;

  subgraph cluster_client {
    graph [style="dashed", label="Client app process"];
    c1 [label="my.app.FirstCallback" color="#4285F4"];
    c2 [label="my.app.SecondCallback" color="#4285F4"];
    c3 [label="my.app.ThirdCallback" color="#4285F4"];
    clib [label="android.app.FooManager" color="#F4B400"];
  }

  subgraph cluster_server {
    graph [style="dashed", label="Server process"];
    server [label="com.android.server.FooManagerService" color="#0F9D58"];
  }
}